From 1c5a38ad73b10f8699b86211508f0b4563592eb2 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 24 Jun 2026 17:00:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E7=99=BB=E5=BD=95=E5=92=8C?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=BA=94=E6=94=B6=E5=92=8C=E5=BA=94=E4=BB=98?= =?UTF-8?q?=E8=B4=B9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db.sql | 5 + oms表结构与接口设计2026040802.md | 3440 ---------- oms表结构与接口设计20260427.md | 5933 ----------------- sql/parcel_billing.sql | 11 + sql/wechat_login.sql | 5 + .../Channels/ServiceChannelGroupListInput.cs | 22 + .../Channels/ServiceChannelGroupListOutput.cs | 37 + .../Dtos/Orders/OrderParcelCreateInput.cs | 5 + .../Dtos/Orders/OrderPriceCheckOutput.cs | 20 + .../Dtos/Orders/ProductBillingSnapshotDtos.cs | 41 + src/Oms.Application/Oms.Application.csproj | 4 + .../Channels/ServiceChannelService.Helpers.cs | 13 + .../Channels/ServiceChannelService.cs | 34 + .../Services/Orders/OrderService.Helpers.cs | 206 - .../Orders/OrderService.ParcelBilling.cs | 693 ++ .../Orders/OrderService.ParcelQuote.cs | 206 + .../Services/Orders/OrderService.cs | 5 + .../Orders/ParcelBillingCalculator.cs | 597 ++ .../Services/Orders/ParcelQuoteContext.cs | 25 + .../Services/Orders/ParcelQuoteResult.cs | 29 + .../Repositories/IKpiAreaRepository.cs | 2 + .../Repositories/ISalesProductRepository.cs | 6 + .../Repositories/IWeightRoundingRepository.cs | 2 + .../Repositories/IWeightRuleRepository.cs | 2 + .../Consts/OrderFeeTypeCodes.cs | 34 + .../OrdersAggregate/Entities/Order.cs | 8 + .../OrdersAggregate/Entities/OrderFee.cs | 2 + .../Repositories/IOrderRepository.cs | 6 + .../Channels/KpiAreaRepository.cs | 9 + .../Channels/SalesProductRepository.cs | 18 + .../Channels/WeightRoundingRepository.cs | 9 + .../Channels/WeightRuleRepository.cs | 9 + .../Orders/OrderRepository.cs | 61 + src/Oms.Tests/ParcelBillingCalculatorTests.cs | 258 + .../Support/ParcelBillingTestData.cs | 114 + src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs | 6 + src/SimpleAdmin.Api/Auth/AuthService.cs | 54 +- src/SimpleAdmin.Api/SimpleAdmin.Api.csproj | 1 + src/SimpleAdmin.Api/SimpleAdminApiModule.cs | 7 + src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs | 82 + .../WeChat/Options/WeChatMpOptions.cs | 22 + .../Services/IWeChatLoginSessionStore.cs | 13 + .../WeChat/Services/WeChatEventHandler.cs | 90 + .../Services/WeChatLoginSessionStore.cs | 29 + .../WeChat/Services/WeChatMpApiClient.cs | 293 + .../WeChat/Services/WeChatSignatureHelper.cs | 24 + .../WeChat/WeChatCustomerAppService.cs | 297 + .../WeChat/WeChatMpAppService.cs | 130 + .../WeChat/WeChatServiceExtensions.cs | 32 + .../appsettings.Development.json | 13 + 50 files changed, 3384 insertions(+), 9580 deletions(-) delete mode 100644 oms表结构与接口设计2026040802.md delete mode 100644 oms表结构与接口设计20260427.md create mode 100644 sql/parcel_billing.sql create mode 100644 sql/wechat_login.sql create mode 100644 src/Oms.Application/Dtos/Channels/ServiceChannelGroupListInput.cs create mode 100644 src/Oms.Application/Dtos/Channels/ServiceChannelGroupListOutput.cs create mode 100644 src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs create mode 100644 src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs create mode 100644 src/Oms.Application/Services/Orders/ParcelQuoteContext.cs create mode 100644 src/Oms.Application/Services/Orders/ParcelQuoteResult.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs create mode 100644 src/Oms.Tests/ParcelBillingCalculatorTests.cs create mode 100644 src/Oms.Tests/Support/ParcelBillingTestData.cs create mode 100644 src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs create mode 100644 src/SimpleAdmin.Api/WeChat/Options/WeChatMpOptions.cs create mode 100644 src/SimpleAdmin.Api/WeChat/Services/IWeChatLoginSessionStore.cs create mode 100644 src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs create mode 100644 src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionStore.cs create mode 100644 src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs create mode 100644 src/SimpleAdmin.Api/WeChat/Services/WeChatSignatureHelper.cs create mode 100644 src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs create mode 100644 src/SimpleAdmin.Api/WeChat/WeChatMpAppService.cs create mode 100644 src/SimpleAdmin.Api/WeChat/WeChatServiceExtensions.cs diff --git a/db.sql b/db.sql index 7141047..3931b2b 100644 --- a/db.sql +++ b/db.sql @@ -60,6 +60,10 @@ CREATE TABLE `t_order` ( `ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码', `ConsigneeCountry` VARCHAR(8) NULL COMMENT '国家二字码', `CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive', + `ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式', + `PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因', + `SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本', + `SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本', `OrderStatus` VARCHAR(32) NOT NULL COMMENT '订单状态', `WarehouseId` INT NULL COMMENT '仓库ID', `CustomerId` INT NULL COMMENT '客户ID', @@ -280,6 +284,7 @@ CREATE TABLE `t_order_fee` ( `DownstreamProvider` VARCHAR(128) NULL COMMENT '下游服务商', `PriceNo` VARCHAR(64) NULL COMMENT '价格编号', `PriceEffectiveTime` DATETIME NULL COMMENT '价格编号生效时间', + `ChargingStage` VARCHAR(16) NULL COMMENT '计费阶段:forecast/inbound/outbound', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', diff --git a/oms表结构与接口设计2026040802.md b/oms表结构与接口设计2026040802.md deleted file mode 100644 index 180549b..0000000 --- a/oms表结构与接口设计2026040802.md +++ /dev/null @@ -1,3440 +0,0 @@ -# oms 表结构与接口设计文档 - ----------------------------------------------------------------------------------------- - -## OMS 系统说明 - -OMS(Order Management System,订单管理系统)作为独立服务运行和admin权限管理服务合并,面向跨境物流/仓储业务场景,提供**订单**、**渠道**、**操作**、**客户**四大核心模块,支撑从客户注册、充值、下单、定价、支付到货物入库出库的全流程管理。 - -### 核心模块 - -| 模块 | 说明 | -| --- | --- | -| **订单** | 订单全生命周期管理,包括创建、预报、支付、发货、跟踪等 | -| **渠道** | 销售产品与物流渠道的配置、定价及买价/卖价计算 | -| **操作** | 入库、出库、打印等仓储操作 | -| **客户** | 客户注册、认证、账户余额、额度及权限管理 | - - ----------------------------------------------------------------------------------------- - -### 1.2 表结构设计规范 - -#### 表的字段命名规范 - -| 规范 | 说明 | 示例 | -| --- | --- | --- | -| 驼峰命名 | 字段名开头大写+单词驼峰 | `ServiceProviderId` | -| 单数名词 | 表名用单数,表示实体 | `customer` 而非 `customers` | -| 业务前缀 | 表名按业务模块加前缀 | `t_order`、`t_customer` | - - -#### 必备字段(所有表) - -| 字段名 | 字段类型 | 说明 | -| --- | --- | --- | -| Id | BIGINT | 主键,自增,步长 1 | -| IsDeleted | TINYINT | 软删除:0-否,1-是,默认 0 | -| CreateUserId | INT | 创建人 | -| CreateTime | DATETIME | 创建时间 | -| UpdateUserId | INT | 更新人 | -| UpdateTime | DATETIME | 更新时间 | - -#### 字段命名规范 - -| 规范 | 说明 | 示例 | -| --- | --- | --- | -| 布尔字段 | 用 `IsXxx` 或 `HasXxx` | `IsDeleted`、`IsActive` | -| 外键字段 | `关联表名Id` 或 `XxxId` | `CustomerId`、`WarehouseId` | -| 时间字段 | `XxxTime` 或 `XxxAt` | `CreateTime`、`PayTime` | -| 禁用保留字 | 不用 desc、range、match 等 | 用 `Description` 替代 `Desc` | - -#### 数据类型规范 - -| 类型 | 说明 | -| --- | --- | -| 主键 | BIGINT,单表自增 | -| 金额/重量 | DECIMAL,禁止 float/double | -| 字符串 | varchar 长度 ≤ 5000,超出用 TEXT | -| 枚举 | 小数据量用 TINYINT,可读性要求高用 VARCHAR | -| 状态标志 | TINYINT | - - - ----------------------------------------------------------------------------------------- - -## 二、订单模块 - -### 1.1 核心需求说明 - -- **我的订单**:支持订单全流程管理,包括分页查询、详情查看、支付、预报、拦截/取消拦截、删除、打印、导出;订单状态覆盖草稿→已预报→已收货→待支付→待发货→运输中→派送成功/失败等全生命周期。 -- **我要查价**:根据目的国、运输方式、重量尺寸、货物类型等条件查价,返回可选渠道及费用明细。 -- **我要下单**:支持下载下单模板、上传 Excel 批量下单、查看任务进度与详情;模板分为标准模板与自定义模板。 - -### 1.2 表结构设计 - -> **命名约定**:表名按业务模块加 t_ 前缀;字段名驼峰命名(开头大写+单词驼峰)。 - -#### 订单表 `t_order` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| ServiceNo | VARCHAR | 64 | - | 是 | 运单号 | -| ClientNo | VARCHAR | 64 | - | 否 | 客户单号 | -| TrackNo | VARCHAR | 64 | - | 否 | 跟踪号 | -| ProductCode | VARCHAR | 128 | - | 否 | 产品code | -| ChannelCode | VARCHAR | 128 | - | 否 | 渠道code | -| ConsigneeCountry | VARCHAR | 8 | - | 否 | 国家二字码 | -| CargoAttribute | VARCHAR | 32 | - | 否 | 货物属性:general-普货,electronics-3c&带电,sensitive-敏感货 | -| OrderStatus | VARCHAR | 32 | - | 是 | 订单状态 | -| WarehouseId | BIGINT | - | 外键 | 否 | 仓库ID | -| CustomerId | BIGINT | - | 外键 | 否 | 客户ID | -| SenderId | BIGINT | - | 外键 | 否 | 发件人ID → t_order_sender | -| RecipientId | BIGINT | - | 外键 | 否 | 收件人ID → t_order_recipient | -| DeclarationId | BIGINT | - | 外键 | 否 | 申报信息ID → t_order_declaration | -| ReceiveTime | DATETIME | - | - | 否 | 收货时间 | -| ShipTime | DATETIME | - | - | 否 | 发货时间 | -| PayTime | DATETIME | - | - | 否 | 支付时间 | -| SignTime | DATETIME | - | - | 否 | 签收时间 | -| IsCod | TINYINT | - | - | 否 | 是否COD:0-否,1-是 | -| CodCurrency | VARCHAR | 16 | - | 否 | COD币种 | -| CodSum | DECIMAL | 18,4 | - | 否 | COD金额 | -| IsIntercept | TINYINT | - | - | 否 | 是否拦截:0-否,1-是 | -| IsRemote | TINYINT | - | - | 否 | 是否偏远:0-否,1-是 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -**OrderStatus 枚举**:draft, predeclared, received, pendingPayment, pendingShip, pendingOnline, shipping, deliverySuccess, deliveryFailed, problematic, deleted, claimed - -**CargoAttribute 枚举**:general-普货,electronics-3c&带电,sensitive-敏感货 - -#### 下单模板表 `t_order_template` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| Name | VARCHAR | 64 | - | 是 | 模板名称 | -| Type | VARCHAR | 16 | - | 是 | standard/custom | -| TemplateKey | VARCHAR | 64 | - | 否 | 模板标识 | -| CustomerId | BIGINT | - | 外键 | 否 | 客户ID(自定义模板) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 下单任务表 `t_order_task` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| TaskName | VARCHAR | 128 | - | 是 | 任务名称 | -| Quantity | INT | - | - | 否 | 数量 | -| Status | VARCHAR | 16 | - | 是 | 处理中/已完成/待处理 | -| TemplateId | BIGINT | - | 外键 | 否 | 模板ID | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | -| CustomerId | BIGINT | - | 外键 | 否 | 客户ID | - -#### 订单发件人表 `t_order_sender` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| OrderId | BIGINT | - | 外键 | 是 | 订单ID | -| SenderName | VARCHAR | 50 | - | 否 | 姓名 | -| Company | VARCHAR | 255 | - | 否 | 公司 | -| Country | INT | - | - | 否 | 国家 | -| Province | VARCHAR | 50 | - | 否 | 州省 | -| City | VARCHAR | 50 | - | 否 | 城市 | -| ZipCode | VARCHAR | 50 | - | 否 | 邮编 | -| StreetAddress1 | VARCHAR | 500 | - | 否 | 地址1 | -| StreetAddress2 | VARCHAR | 500 | - | 否 | 地址2 | -| StreetAddress3 | VARCHAR | 500 | - | 否 | 地址3 | -| HouseNumber | VARCHAR | 50 | - | 否 | 门牌号 | -| Phone | VARCHAR | 50 | - | 否 | 手机 | -| TelePhone | VARCHAR | 50 | - | 否 | 电话 | -| Email | VARCHAR | 60 | - | 否 | 邮箱 | -| IsDefault | TINYINT | - | - | 否 | 是否默认:0-否,1-是 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 订单收件人表 `t_order_recipient` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| OrderId | BIGINT | - | 外键 | 是 | 订单ID | -| RecipientsName | VARCHAR | 30 | - | 否 | 收件人名称 | -| CompanyName | VARCHAR | 60 | - | 否 | 收件人公司名称 | -| Country | INT | - | - | 否 | 国家 | -| Phone | VARCHAR | 20 | - | 否 | 收件人手机 | -| TelePhone | VARCHAR | 20 | - | 否 | 收件人电话 | -| ZipCode | VARCHAR | 15 | - | 否 | 收件人邮编 | -| Province | VARCHAR | 25 | - | 否 | 收件人省/州 | -| City | VARCHAR | 25 | - | 否 | 收件人城市 | -| StreetNumber | VARCHAR | 20 | - | 否 | 收件人门牌号 | -| StreetAddress1 | VARCHAR | 255 | - | 否 | 收件人地址1 | -| StreetAddress2 | VARCHAR | 255 | - | 否 | 收件人地址2 | -| StreetAddress3 | VARCHAR | 255 | - | 否 | 收件人地址3 | -| Email | VARCHAR | 60 | - | 否 | 收件人邮箱 | -| TaxType | INT | - | - | 否 | 税号类型 | -| TaxNo | VARCHAR | 35 | - | 否 | 税号 | -| CertificateType | INT | - | - | 否 | 证件类型 | -| CertificateNo | VARCHAR | 30 | - | 否 | 证件号码 | -| Eori | VARCHAR | 255 | - | 否 | EORI | -| Vat | VARCHAR | 255 | - | 否 | Vat | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 订单申报信息表 `t_order_declaration` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| OrderId | BIGINT | - | 外键 | 是 | 订单ID | -| CnName | VARCHAR | 500 | - | 否 | 品名--中 | -| EnName | VARCHAR | 500 | - | 否 | 品名--英 | -| Sku | VARCHAR | 255 | - | 否 | SKU | -| Unit | VARCHAR | 5 | - | 否 | 单位 | -| UnitPrice | DECIMAL | 10,2 | - | 否 | 单价 | -| UnitWeight | DECIMAL | 11,3 | - | 否 | 单重 | -| Quantity | INT | - | - | 否 | 数量 | -| Amount | DECIMAL | 11,2 | - | 否 | 总金额 | -| Weight | DECIMAL | 11,3 | - | 否 | 总重量 | -| HsCode | VARCHAR | 50 | - | 否 | HsCode | -| SkuImage | VARCHAR | 255 | - | 否 | 图片 | -| Category | VARCHAR | 20 | - | 否 | 类别 | -| Brand | VARCHAR | 50 | - | 否 | 品牌 | -| BrandType | VARCHAR | 50 | - | 否 | 品牌类型 | -| IsMagnetic | BIT | - | - | 否 | 是否带磁带电 | -| Distribution | VARCHAR | 255 | - | 否 | 配货信息 | -| Material | VARCHAR | 50 | - | 否 | 材质 | -| EnMaterial | VARCHAR | 64 | - | 否 | 英文材质 | -| Purpose | VARCHAR | 50 | - | 否 | 用途 | -| Attributes | VARCHAR | 50 | - | 否 | 属性 | -| OriginCountry | VARCHAR | 50 | - | 否 | 原产国 | -| SalesLink | VARCHAR | 255 | - | 否 | 销售链接 | -| Model | VARCHAR | 64 | - | 否 | 型号 | -| Specifications | VARCHAR | 50 | - | 否 | 规格 | -| Comment | VARCHAR | 500 | - | 否 | 备注 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 订单件重尺表 `t_order_weight` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| OrderId | BIGINT | - | 外键 | 是 | 订单ID | -| Type | VARCHAR | 16 | - | 是 | 类型:forecast/receive/ship/downstream | -| Length | DECIMAL | 10,2 | - | 否 | 长(cm) | -| Width | DECIMAL | 10,2 | - | 否 | 宽(cm) | -| Height | DECIMAL | 10,2 | - | 否 | 高(cm) | -| ActualWeight | DECIMAL | 10,3 | - | 否 | 实重(kg) | -| VolumetricWeight | DECIMAL | 10,3 | - | 否 | 体积重(kg) | -| ChargeableWeight | DECIMAL | 10,3 | - | 否 | 计费重(kg) | -| Cubic | DECIMAL | 12,6 | - | 否 | 立方(m³) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -**Type 枚举**:forecast(预报)、receive(收货)、ship(发货)、downstream(下游) - -#### 订单费用表 `t_order_fee`(子表,关联 t_order) - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| OrderId | BIGINT | - | 外键 | 是 | 订单ID | -| Type | VARCHAR | 16 | - | 是 | 类型:receivable(应收)/payable(应付) | -| CostItem | VARCHAR | 64 | - | 是 | 费用项 | -| Amount | DECIMAL | 18,4 | - | 是 | 金额 | -| Currency | VARCHAR | 16 | - | 是 | 币种:RMB/USD/EUR | -| DownstreamProvider | VARCHAR | 128 | - | 否 | 下游服务商 | -| PriceNo | VARCHAR | 64 | - | 否 | 价格编号 | -| PriceEffectiveTime | DATETIME | - | - | 否 | 价格编号生效时间 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -**Type 枚举**:receivable(应收)、payable(应付) - -### 1.3 核心接口设计 - -#### 我的订单 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 分页查询订单列表 | POST | /order/list | 支持 status、keyword、createTimeStart/End 筛选 | -| 订单详情 | GET | /order/detail | 根据订单ID获取详情 | -| 支付 | POST | /order/pay | 订单支付 | -| 预报 | POST | /order/pre-declare | 订单预报 | -| 拦截 | POST | /order/intercept | 订单拦截 | -| 取消拦截 | POST | /order/cancel-intercept | 取消订单拦截 | -| 删除 | POST | /order/delete | 删除订单 | -| 打印 | POST | /order/print | 打印订单 | -| 导出 | POST | /order/export | 导出订单 | - -**请求 /order/list**(POST,分页参数放 Body) - -```json -{ - "page": 1, // 页码,从1开始 - "pageSize": 20, // 每页条数 - "status": "all|draft|predeclared|received|...", // 订单状态筛选 - "keyword": "", // 关键词(运单号/客户单号/跟踪号) - "createTimeStart": "", // 创建时间起 - "createTimeEnd": "" // 创建时间止 -} -``` - -**响应**(用于 order/myOrders 列表及订单详情抽屉,字段与 parcel/create 对应,创建时传啥返回啥 + weightList) - -```json -{ - "items": [ - { - "id": 1, - "clientNo": "CUST...", - "trackNo": "TRK...", - "serviceNo": "LP...", - "productCode": "US_AIR", - "channelCode": "CH001", - "cargoAttribute": "general", - "warehouseId": 1, - "warehouse": "深圳仓", - "customerId": 1, - "isCod": false, - "codCurrency": "", - "codSum": "", - "orderStatus": "待支付", - "status": "pendingPayment", - "receiveTime": "", - "shipTime": "", - "payTime": "", - "signTime": "", - "isIntercept": false, - "isRemote": false, - "createTime": "2024-01-01 12:00:00", - "createBy": "", - "updateTime": "", - "updateBy": "", - "sender": { - "senderName": "发件人", - "company": "示例公司", - "phone": "13800138000", - "telePhone": "", - "email": "sender@example.com", - "countryCode": 0, - "province": "广东省", - "city": "深圳市", - "zipCode": "518000", - "streetAddress1": "示例地址1", - "streetAddress2": "", - "streetAddress3": "", - "houseNumber": "" - }, - "receiver": { - "recipientsName": "收件人1", - "companyName": "", - "phone": "13800138000", - "telePhone": "", - "email": "", - "countryCode": 0, - "province": "California", - "city": "Los Angeles", - "streetNumber": "", - "streetAddress1": "123 Main St", - "streetAddress2": "", - "streetAddress3": "", - "zipCode": "90001", - "taxType": 0, - "taxNo": "", - "certificateType": 0, - "certificateNo": "", - "eori": "", - "vat": "" - }, - "items": [ - { - "cnName": "示例商品", - "enName": "Sample Product", - "sku": "SKU001", - "unit": "件", - "unitPrice": 10, - "unitWeight": 0.5, - "quantity": 1, - "amount": 10, - "weight": 0.5, - "hsCode": "1234567890", - "skuImage": "", - "category": "普货", - "brand": "示例品牌", - "brandType": "自有", - "isMagnetic": false, - "distribution": "", - "material": "塑料", - "enMaterial": "Plastic", - "purpose": "日用", - "attributes": "", - "originCountry": "中国", - "salesLink": "", - "model": "", - "specifications": "", - "comment": "" - } - ], - "weightList": [ - { - "type": "forecast", - "length": "20", - "width": "15", - "height": "10", - "actualWeight": "2.50", - "volumetricWeight": "2.00", - "chargeableWeight": "2.50", - "cubic": "0.003" - } - ] - } - ], - "total": 100 -} -``` - -**请求 /order/detail**(GET,Query 参数) - -``` -?id=1 -``` - -**响应** - -```json -{ - "id": 1, - "clientNo": "CUST...", - "trackNo": "TRK...", - "serviceNo": "LP...", - "productCode": "US_AIR", - "channelCode": "CH001", - "cargoAttribute": "general", - "warehouseId": 1, - "warehouse": "深圳仓", - "customerId": 1, - "sender": {}, - "receiver": {}, - "items": [], - "weightList": [] -} -``` - -> data 结构与 list 单条 item 一致,含 sender、receiver、items、weightList。 - -**请求 /order/pay**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -> requestId 幂等,可选。 - -**响应** - -```json -null -``` - -**请求 /order/pre-declare**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -**响应** - -```json -null -``` - -**请求 /order/intercept**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -**响应** - -```json -null -``` - -**请求 /order/cancel-intercept**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -**响应** - -```json -null -``` - -**请求 /order/delete**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -**响应** - -```json -null -``` - -**请求 /order/print**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -**响应** - -```json -{ - "printUrl": "" -} -``` - -**请求 /order/export**(POST) - -```json -{ - "status": "all|draft|...", - "keyword": "", - "createTimeStart": "", - "createTimeEnd": "" -} -``` - -**响应** - -```json -{ - "downloadUrl": "", - "expireAt": "" -} -``` - -#### 我要查价 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 查价 | POST | /order/price-check | 根据目的国、重量尺寸等查价 | - -**请求 /order/price-check**(POST) - -```json -{ - "destinationCountry": "US", // 目的国二字码 - "shippingMethod": "", // 运输方式 - "cargoWeight": "2", // 货物重量(kg) - "length": "20", // 长(cm) - "width": "15", // 宽(cm) - "height": "10", // 高(cm) - "cargoType": "普货", // 货物类型 - "destinationProvince": "", // 目的省州 - "postalCode": "" // 邮编 -} -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "shippingMethod": "美国空运专线(普货)", - "warehouse": "一代深圳仓", - "deliveryTime": "3-7", - "volumetricWeight": "2", - "chargeableWeight": "2", - "costFormula": "(62*2) +5+7", - "freight": "62", - "otherFees": "5", - "totalCost": "136", - "maxDeliveryDays": 7, - "totalCostNum": 136 - } - ] -} -``` - -#### 我要下单 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 模板列表 | GET | /order/template/list | 获取下单模板列表 | -| 下载模板 | GET | /order/template/download | 下载 Excel 模板 | -| 上传下单 | POST | /order/upload | multipart/form-data,file + templateId | -| 任务进度列表 | GET | /order/task/list | 批量下单任务列表 | -| 任务详情 | GET | /order/task/detail | 任务详情 | -| 创建小包 | POST | /order/parcel/create | 创建小包订单 | - -**请求 /order/parcel/create**(POST) - -> 字段与 t_order、t_order_sender、t_order_recipient、t_order_declaration、t_order_weight 表对应,采用 camelCase。 - -```json -{ - "clientNo": "string", // 客户单号 t_order.ClientNo - "trackNo": "string", // 跟踪号 t_order.TrackNo - "serviceNo": "string", // 服务商单号/运单号 t_order.ServiceNo - "productCode": "string", // 产品代码 t_order.ProductCode - "channelCode": "string", // 渠道代码 t_order.ChannelCode - "cargoAttribute": "string", // 货物属性 t_order.CargoAttribute:general-普货,electronics-3c&带电,sensitive-敏感货 - "warehouseId": 0, // 仓库ID t_order.WarehouseId - "customerId": 0, // 客户ID t_order.CustomerId - "length": 3, // 长(cm) t_order_weight.Length - "width": 3, // 宽(cm) t_order_weight.Width - "height": 3, // 高(cm) t_order_weight.Height - "actualWeight": 3, // 实重(kg) t_order_weight.ActualWeight - "volumetricWeight": 3, // 体积重(kg) t_order_weight.VolumetricWeight - "chargeableWeight": 3, // 计费重(kg) t_order_weight.ChargeableWeight - "cubic": 0, // 立方(m³) t_order_weight.Cubic - "isCod": false, // 是否COD - "codCurrency": "string", // COD币种 - "codSum": "string", // COD金额 - "declaredCurrency": "string", // 申报币种 - "declaredSum": "string", // 申报金额 - "quantity": "string", // 包裹数量 - "receiver": { // 收件人 t_order_recipient - "recipientsName": "string", // 收件人名称 - "companyName": "string", // 收件人公司名称 - "phone": "string", // 收件人手机 - "telePhone": "string", // 收件人电话 - "email": "string", // 收件人邮箱 - "countryCode": 0, // 国家 - "province": "string", // 收件人省/州 - "city": "string", // 收件人城市 - "streetNumber": "string", // 收件人门牌号 - "streetAddress1": "string", // 收件人地址1 - "streetAddress2": "string", // 收件人地址2 - "streetAddress3": "string", // 收件人地址3 - "zipCode": "string", // 收件人邮编 - "taxType": 0, // 税号类型 - "taxNo": "string", // 税号 - "certificateType": 0, // 证件类型 - "certificateNo": "string", // 证件号码 - "eori": "string", // EORI - "vat": "string" // Vat - }, - "sender": { // 发件人 t_order_sender - "senderName": "string", // 发件人姓名 - "company": "string", // 发件人公司 - "phone": "string", // 发件人手机 - "telePhone": "string", // 发件人电话 - "email": "string", // 发件人邮箱 - "countryCode": 0, // 国家 - "province": "string", // 州省 - "city": "string", // 城市 - "zipCode": "string", // 邮编 - "streetAddress1": "string", // 地址1 - "streetAddress2": "string", // 地址2 - "streetAddress3": "string", // 地址3 - "houseNumber": "string" // 门牌号 - }, - "items": [ // 申报明细 t_order_declaration - { - "cnName": "string", // 品名-中 - "enName": "string", // 品名-英 - "sku": "string", // SKU - "unit": "string", // 单位 - "unitPrice": 0, // 单价 - "unitWeight": 0, // 单重 - "quantity": 0, // 数量 - "amount": 0, // 总金额 - "weight": 0, // 总重量 - "imHsCode": "string", // 进口HsCode - "exHsCode": "string", // 出口HsCode - "skuImage": "string", // 图片 - "category": "string", // 类别 - "brand": "string", // 品牌 - "brandType": "string", // 品牌类型 - "isMagnetic": false, // 是否带磁带电 - "distribution": "string", // 配货信息 - "material": "string", // 材质 - "enMaterial": "string", // 英文材质 - "purpose": "string", // 用途 - "attributes": "string", // 属性 - "originCountry": "string", // 原产国 - "salesLink": "string", // 销售链接 - "model": "string", // 型号 - "specifications": "string", // 规格 - "comment": "string" // 备注 - } - ] -} -``` - -**响应** - -```json -{ - "clientNo": "", - "serviceNo": "", - "trackNo": "", - "labelUrl": "" -} -``` - -> 幂等时可选传 requestId。 - -**请求 /order/template/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&type=standard|custom&keyword= -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "name": "标准模板", - "type": "standard", - "templateKey": "standard_v1", - "customerId": 0, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 10 -} -``` - -**请求 /order/template/download**(GET,Query 参数) - -``` -?templateId=1 -``` - -**响应** - -> 直接返回 Excel 文件流,Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - -**请求 /order/upload**(POST,multipart/form-data) - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| file | File | Excel 文件 | -| templateId | number | 模板ID | - -**响应** - -```json -{ - "taskId": 1 -} -``` - -**请求 /order/task/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&status=&keyword= -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "taskName": "批量下单_20240101", - "quantity": 100, - "status": "处理中", - "templateId": 1, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 10 -} -``` - -**请求 /order/task/detail**(GET,Query 参数) - -``` -?taskId=1 -``` - -**响应** - -```json -{ - "id": 1, - "taskName": "批量下单_20240101", - "quantity": 100, - "successCount": 80, - "failCount": 20, - "status": "已完成", - "templateId": 1, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00", - "failList": [] -} -``` - - - -## 三、渠道模块 - -### 2.1 核心需求说明 - -- **销售产品**:管理对外销售的产品(专线/快递/邮政),配置时效、黑名单区、偏远分区、计重规则、计重进位等;支持价格配置。 - - **产品配置**:产品基础信息(编码、名称、分组、时效、面单、黑名单区、偏远分区、计重规则、计重进位等)。 - - **产品卖价**:为每个产品配置多条价格项(运费、保险费、燃油附加费等);每条价格项通过弹窗配置: - - **基础信息**:费用项、费用单位、币种、收费时机、收费条件、计费因素(因素+因素值,如货物类型、分区、重量、仓库、客户等)。 - - **价格表**:根据计费因素动态生成价格表,支持分区/货物类型/重量等组合、计算公式(总价/单价/续单价)、计算值、客户组。 - - **排货规则**:选择排货方案(成本最低/时效最快/综合最优),配置服务商列表(服务商名称、服务渠道、权重、指定客户)。 - - **发布记录**:价格与规则的发布历史,支持查看版本内容、回滚到指定版本。 -- **服务渠道**:管理下游服务渠道(支持渠道组+渠道层级),配置预报单号类型、时效、买价等。 - - **渠道配置**:渠道基础信息(渠道代码、名称、时效、预报配置、描述、预报前脚本等)。 - - **渠道买价**:为每个渠道配置多条买价项(运费、保险费、燃油附加费等);每条买价项通过弹窗配置: - - **基础信息**:费用项、费用单位、币种、收费时机、收费条件、计费因素(因素+因素值)。 - - **价格表**:根据计费因素动态生成价格表,支持分区/货物类型/重量等组合、计算公式、计算值、客户组。 - - **发布记录**:买价与规则的发布历史,支持查看版本内容、回滚到指定版本。 -- **黑名单区/偏远分区/预警分区**:共用 KpiArea 表,通过 appKey/areaType 区分;支持分区及明细(国家/省/市/邮编区间)的增删改查。 -- **计重规则/计重进位**:配置计费重量规则与进位方式,明细结构与分区类似。 -- **自动触发**:配置下单/入库/出库时的自动拦截、警告、问题件等触发规则。 -- **标签设计**:保存 vg-print 导出的标签模板 JSON,支持尾程/客户/入库/出库/sku 等类型。 - -### 2.2 表结构设计 - -#### 销售产品表 `t_sales_product` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| ProductCode | VARCHAR | 64 | - | 是 | 产品编码 | -| ProductName | VARCHAR | 128 | - | 是 | 产品名称 | -| ProductGroup | VARCHAR | 32 | - | 否 | 分组(专线/快递/邮政) | -| MinTimeliness | INT | - | - | 否 | 最小时效(天) | -| MaxTimeliness | INT | - | - | 否 | 最大时效(天) | -| CustomerWaybill | VARCHAR | 32 | - | 否 | 系统面单/客户面单 | -| BlacklistAreaId | BIGINT | - | 外键 | 否 | 黑名单区ID | -| RemoteAreaId | BIGINT | - | 外键 | 否 | 偏远分区ID | -| WeightRuleId | BIGINT | - | 外键 | 否 | 计重规则ID | -| WeightRoundingId | BIGINT | - | 外键 | 否 | 计重进位ID | -| ProductDescription | TEXT | - | - | 否 | 产品描述 | -| PreStorageScript | TEXT | - | - | 否 | 入库前脚本 | -| IsActive | TINYINT | - | - | 是 | 是否启用 | -| Scheme | VARCHAR | 32 | - | 是 | 排货方案:costLowest/timeFastest/overallBest | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 产品卖价表 `t_sales_product_price`(子表,关联 t_sales_product) - -> 产品卖价主表,每个产品可配置多条价格项(运费、保险费、燃油附加费等)。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| SalesProductId | BIGINT | - | 外键 | 是 | 销售产品ID | -| SerialNumber | INT | - | - | 是 | 序号 | -| Status | TINYINT | - | - | 是 | 状态:0-禁用,1-启用 | -| PriceName | VARCHAR | 64 | - | 是 | 价格名称(如运费、保险费) | -| CostItem | VARCHAR | 64 | - | 是 | 费用项 | -| CostUnit | VARCHAR | 16 | - | 是 | 费用单位:kg/票/件 | -| Currency | VARCHAR | 16 | - | 是 | 币种:RMB/USD/EUR | -| ChargingTime | VARCHAR | 32 | - | 否 | 收费时机:预报/出库/签收 | -| ChargingCondition | VARCHAR | 64 | - | 否 | 收费条件 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 产品卖价-计费因素表 `t_sales_product_price_factor`(子表,关联 t_sales_product_price) - -> 弹窗「基础信息」中的计费因素配置,支持货物类型、分区、重量、仓库、客户等。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| PriceId | BIGINT | - | 外键 | 是 | 产品卖价ID | -| SerialNumber | INT | - | - | 是 | 序号(决定因素排序) | -| Factor | VARCHAR | 64 | - | 是 | 因素:货物类型/分区/重量/仓库/客户 | -| FactorValues | JSON | - | - | 否 | 因素值数组,如 ["普货","敏感货"] | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 产品卖价-价格表明细表 `t_sales_product_price_detail`(子表,关联 t_sales_product_price) - -> 弹窗「价格表」中的价格明细,表头由计费因素动态生成,存储各组合的价格值。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| PriceId | BIGINT | - | 外键 | 是 | 产品卖价ID | -| FactorValuesJson | JSON | - | - | 否 | 非客户因素组合,如 {"分区":"欧洲","货物类型":"普货","重量":"0-0.5"} | -| CalculationFormula | VARCHAR | 32 | - | 是 | 计算公式:总价/单价/续单价 | -| CalculatedValue | DECIMAL | 18,4 | - | 否 | 计算值 | -| CustomerGroupValues | JSON | - | - | 否 | 客户组对应价格,如 {"客户组1":10,"客户组2":12} | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - - -#### 排货规则-服务商表 `t_sales_product_allocation_provider`(子表,关联 t_sales_product_allocation_rule) - -> 服务商列表:服务商名称、服务渠道、权重、指定客户。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| SalesProductId | BIGINT | - | 外键 | 是 | 销售产品ID | -| SerialNumber | INT | - | - | 是 | 序号 | -| ProviderName | VARCHAR | 128 | - | 是 | 服务商名称 | -| ServiceChannelId | BIGINT | - | 外键 | 否 | 服务渠道ID | -| Weight | INT | - | - | 是 | 权重(评分用) | -| CustomerIds | JSON | - | - | 否 | 指定客户ID数组,空表示全部 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -**Scheme 枚举**:costLowest(成本最低)、timeFastest(时效最快)、overallBest(综合最优) - -#### 发布记录表 `t_sales_product_publish_record`(子表,关联 t_sales_product) - -> 价格与规则的发布历史,支持查看版本内容、回滚。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| SalesProductId | BIGINT | - | 外键 | 是 | 销售产品ID | -| Version | VARCHAR | 32 | - | 是 | 版本号,如 20231227112339 | -| OperatorType | VARCHAR | 16 | - | 是 | 操作类型:price(价格)/rules(规则) | -| Status | VARCHAR | 16 | - | 是 | 状态:生效/回滚/无效 | -| PublishUser | VARCHAR | 64 | - | 是 | 发布人 | -| PublishTime | DATETIME | - | - | 是 | 发布时间 | -| Description | VARCHAR | 512 | - | 否 | 版本备注 | -| Content | JSON | - | - | 否 | 发布内容(价格或规则配置的JSON快照) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 服务渠道组表 `t_service_channel_group`(可选,用于渠道分组) - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| Name | VARCHAR | 128 | - | 是 | 渠道组名称 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 服务渠道表 `t_service_channel` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| GroupId | BIGINT | - | 外键 | 否 | 渠道组ID(可选) | -| ChannelCode | VARCHAR | 64 | - | 是 | 渠道编码 | -| ChannelName | VARCHAR | 128 | - | 是 | 渠道名称 | -| MinTime | INT | - | - | 否 | 最小时效(天) | -| MaxTime | INT | - | - | 否 | 最大时效(天) | -| ForecastType | VARCHAR | 32 | - | 否 | 预报单号类型(customer/waybill/tracking) | -| ForecastCode | VARCHAR | 128 | - | 否 | 预报单号 | -| Description | TEXT | - | - | 否 | 描述 | -| PreScript | TEXT | - | - | 否 | 入库前脚本 | -| IsActive | TINYINT | - | - | 是 | 是否启用 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 渠道买价表 `t_service_channel_price`(子表,关联 t_service_channel) - -> 渠道买价主表,每个渠道可配置多条买价项(运费、保险费、燃油附加费等)。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| ServiceChannelId | BIGINT | - | 外键 | 是 | 服务渠道ID | -| SerialNumber | INT | - | - | 是 | 序号 | -| Status | TINYINT | - | - | 是 | 状态:0-禁用,1-启用 | -| PriceName | VARCHAR | 64 | - | 是 | 价格名称 | -| CostItem | VARCHAR | 64 | - | 是 | 费用项 | -| CostUnit | VARCHAR | 16 | - | 是 | 费用单位:kg/票/件 | -| Currency | VARCHAR | 16 | - | 是 | 币种:RMB/USD/EUR | -| ChargingTime | VARCHAR | 32 | - | 否 | 收费时机:预报/出库/签收 | -| ChargingCondition | VARCHAR | 64 | - | 否 | 收费条件 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 渠道买价-计费因素表 `t_service_channel_price_factor`(子表,关联 t_service_channel_price) - -> 弹窗「基础信息」中的计费因素配置。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| PriceId | BIGINT | - | 外键 | 是 | 渠道买价ID | -| SerialNumber | INT | - | - | 是 | 序号 | -| Factor | VARCHAR | 64 | - | 是 | 因素:货物类型/分区/重量/仓库/客户 | -| FactorValues | JSON | - | - | 否 | 因素值数组 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 渠道买价-价格表明细表 `t_service_channel_price_detail`(子表,关联 t_service_channel_price) - -> 弹窗「价格表」中的价格明细。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| PriceId | BIGINT | - | 外键 | 是 | 渠道买价ID | -| FactorValuesJson | JSON | - | - | 否 | 非客户因素组合 | -| CalculationFormula | VARCHAR | 32 | - | 是 | 计算公式:总价/单价/续单价 | -| CalculatedValue | DECIMAL | 18,4 | - | 否 | 计算值 | -| CustomerGroupValues | JSON | - | - | 否 | 客户组对应价格 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 服务渠道发布记录表 `t_service_channel_publish_record`(子表,关联 t_service_channel) - -> 买价与规则的发布历史。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| ServiceChannelId | BIGINT | - | 外键 | 是 | 服务渠道ID | -| Version | VARCHAR | 32 | - | 是 | 版本号 | -| OperatorType | VARCHAR | 16 | - | 是 | 操作类型:price(价格)/rules(规则) | -| Status | VARCHAR | 16 | - | 是 | 状态:生效/回滚/无效 | -| PublishUser | VARCHAR | 64 | - | 是 | 发布人 | -| PublishTime | DATETIME | - | - | 是 | 发布时间 | -| Description | VARCHAR | 512 | - | 否 | 版本备注 | -| Content | JSON | - | - | 否 | 发布内容JSON快照 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 分区表(黑名单区/偏远分区/预警分区共用)`t_kpi_area` - -> 与轨迹下预警分区共用,通过 `AreaType` 区分。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| AppKey | VARCHAR | 64 | - | 是 | 应用标识 | -| AreaType | VARCHAR | 32 | - | 是 | 类型: blacklist/remote/warning | -| Name | VARCHAR | 128 | - | 是 | 分区名称 | -| SystemName | VARCHAR | 128 | - | 否 | 系统名称 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -**AreaType 枚举**:blacklist(黑名单区)、remote(偏远分区)、warning(预警分区) - -#### 分区明细表 `t_kpi_area_info` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| AreaId | BIGINT | - | 外键 | 是 | 分区ID | -| CountryCode | VARCHAR | 8 | - | 否 | 国家二字码 | -| CountryName | VARCHAR | 64 | - | 否 | 国家名称 | -| Province | VARCHAR | 128 | - | 否 | 省份 | -| City | VARCHAR | 128 | - | 否 | 城市 | -| LeftPostcode | VARCHAR | 32 | - | 否 | 左侧邮编 | -| RightPostcode | VARCHAR | 32 | - | 否 | 右侧邮编 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 计重规则表 `t_weight_rule` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| AppKey | VARCHAR | 64 | - | 是 | 应用标识 | -| Name | VARCHAR | 128 | - | 是 | 规则名称 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 计重规则明细表 `t_weight_rule_info`(子表,关联 t_weight_rule) - -> 计重规则子页面,配置客户组对应的计费规则(条件表达式、计费重取值、材积系数)。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| RuleId | BIGINT | - | 外键 | 是 | 计重规则ID | -| Client | VARCHAR | 128 | - | 是 | 客户/客户组(如:公共、客户组1、客户组2) | -| Condition | VARCHAR | 256 | - | 是 | 条件表达式(如:gw>vw) | -| BillableWeight | VARCHAR | 64 | - | 是 | 计费重取值(如:vw、gw) | -| VolumetricFactor | VARCHAR | 32 | - | 是 | 材积系数(如:6000) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 计重进位表 `t_weight_rounding` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| AppKey | VARCHAR | 64 | - | 是 | 应用标识 | -| Name | VARCHAR | 128 | - | 是 | 进位名称 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 计重进位明细表 `t_weight_rounding_info`(子表,关联 t_weight_rounding) - -> 计重进位子页面,配置重量区间的进位值(起始值、结束值、公共/客户组进位值)。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| RoundingId | BIGINT | - | 外键 | 是 | 计重进位ID | -| StartValue | DECIMAL | 18,4 | - | 是 | 起始值(kg) | -| EndValue | DECIMAL | 18,4 | - | 是 | 结束值(kg) | -| Public | VARCHAR | 32 | - | 否 | 公共进位值 | -| CustomerGroup1 | VARCHAR | 32 | - | 否 | 客户组1进位值 | -| CustomerGroup2 | VARCHAR | 32 | - | 否 | 客户组2进位值 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 自动触发表 `t_auto_trigger` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| Status | TINYINT | - | - | 是 | 是否启用 | -| Operation | VARCHAR | 32 | - | 否 | 操作: 拦截/警告/问题件 | -| TriggerTiming | VARCHAR | 32 | - | 否 | 触发时机: 下单/入库/出库 | -| TriggerCondition | TEXT | - | - | 否 | 触发条件 | -| PromptMessage | VARCHAR | 512 | - | 否 | 提示信息 | -| Notifier | VARCHAR | 128 | - | 否 | 通知人 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 标签模板表 `t_label_template` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| Name | VARCHAR | 128 | - | 是 | 模板名称 | -| TemplateJson | JSON | - | - | 否 | vg-print 模板 JSON | -| TemplateType | VARCHAR | 32 | - | 否 | 尾程/客户/入库/出库/sku | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -### 2.3 核心接口设计 - -#### 销售产品 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 产品列表 | GET | /channel/sales-product/list | 产品列表 | -| 产品详情 | GET | /channel/sales-product/detail | 产品详情 | -| 新增产品 | POST | /channel/sales-product/create | 新增产品 | -| 更新产品 | POST | /channel/sales-product/update | 更新产品 | -| 删除产品 | POST | /channel/sales-product/delete | 删除产品 | -| 保存产品配置 | POST | /channel/sales-product/config/save | 保存产品配置(产品配置 tab) | - -**请求 /channel/sales-product/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&productGroup=专线&keyword=&isActive=1 -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "productCode": "US-AIR-P", - "productName": "美国空运专线(普货)", - "productGroup": "专线", - "minTimeliness": 3, - "maxTimeliness": 7, - "customerWaybill": "系统面单", - "blacklistAreaId": 1, - "remoteAreaId": 2, - "weightRuleId": 1, - "weightRoundingId": 1, - "productDescription": "美国空运普货专线", - "isActive": 1, - "scheme": "costLowest", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 10 -} -``` - -**请求 /channel/sales-product/detail**(GET,Query 参数) - -``` -?id=1 -``` - -**响应** - -```json -{ - "id": 1, - "productCode": "US-AIR-P", - "productName": "美国空运专线(普货)", - "productGroup": "专线", - "minTimeliness": 3, - "maxTimeliness": 7, - "customerWaybill": "系统面单", - "blacklistAreaId": 1, - "remoteAreaId": 2, - "weightRuleId": 1, - "weightRoundingId": 1, - "productDescription": "美国空运普货专线", - "preStorageScript": "", - "isActive": 1, - "scheme": "costLowest", - "createTime": "2024-01-01 12:00:00", - "createBy": "admin", - "updateTime": "2024-01-01 12:00:00", - "updateBy": "admin" -} -``` - -**请求 /channel/sales-product/create**(POST) - -```json -{ - "productCode": "US-AIR-P", - "productName": "美国空运专线(普货)", - "productGroup": "专线", - "minTimeliness": 3, - "maxTimeliness": 7, - "customerWaybill": "系统面单", - "blacklistAreaId": 1, - "remoteAreaId": 2, - "weightRuleId": 1, - "weightRoundingId": 1, - "productDescription": "美国空运普货专线", - "preStorageScript": "", - "isActive": 1, - "scheme": "costLowest" -} -``` - -**请求 /channel/sales-product/update**(POST) - -```json -{ - "id": 1, - "productCode": "US-AIR-P", - "productName": "美国空运专线(普货)", - "productGroup": "专线", - "minTimeliness": 3, - "maxTimeliness": 7, - "customerWaybill": "系统面单", - "blacklistAreaId": 1, - "remoteAreaId": 2, - "weightRuleId": 1, - "weightRoundingId": 1, - "productDescription": "美国空运普货专线", - "preStorageScript": "", - "isActive": 1, - "scheme": "costLowest" -} -``` - -**请求 /channel/sales-product/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**请求 /channel/sales-product/config/save**(POST) - -```json -{ - "id": 1, - "productCode": "US-AIR-P", - "productName": "美国空运专线(普货)", - "productGroup": "专线", - "minTimeliness": 3, - "maxTimeliness": 7, - "customerWaybill": "系统面单", - "blacklistAreaId": 1, - "remoteAreaId": 2, - "weightRuleId": 1, - "weightRoundingId": 1, - "productDescription": "美国空运普货专线", - "preStorageScript": "", - "isActive": 1, - "scheme": "costLowest" -} -``` - -**响应**(create/update/delete/config/save) - -```json -null -``` - -#### 产品卖价(子表接口) - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 价格项列表 | GET | /channel/sales-product/price/list | 按产品ID获取价格项列表 | -| 新增价格项 | POST | /channel/sales-product/price/create | 新增价格项(含基础信息+计费因素+价格表) | -| 更新价格项 | POST | /channel/sales-product/price/update | 更新价格项 | -| 删除价格项 | POST | /channel/sales-product/price/delete | 删除价格项 | -| 切换价格项状态 | POST | /channel/sales-product/price/toggleStatus | 启用/禁用价格项 | -| 保存价格配置 | POST | /channel/sales-product/price/save | 批量保存产品卖价(保存并可选发布) | - -**请求 /channel/sales-product/price/list**(GET,Query 参数) - -``` -?salesProductId=1 -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "salesProductId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [ - { - "id": 1, - "priceId": 1, - "serialNumber": 1, - "factor": "货物类型", - "factorValues": [ - "普货", - "敏感货" - ] - } - ], - "details": [ - { - "id": 1, - "priceId": 1, - "factorValuesJson": { - "分区": "欧洲", - "货物类型": "普货", - "重量": "0-0.5" - }, - "calculationFormula": "单价", - "calculatedValue": 62, - "customerGroupValues": { - "客户组1": 60, - "客户组2": 65 - } - } - ], - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ] -} -``` - -**请求 /channel/sales-product/price/create**(POST) - -```json -{ - "salesProductId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [ - { - "serialNumber": 1, - "factor": "货物类型", - "factorValues": ["普货", "敏感货"] - }, - { - "serialNumber": 2, - "factor": "分区", - "factorValues": ["欧洲", "美国"] - } - ], - "details": [ - { - "factorValuesJson": {"分区": "欧洲", "货物类型": "普货", "重量": "0-0.5"}, - "calculationFormula": "单价", - "calculatedValue": 62, - "customerGroupValues": {"客户组1": 60, "客户组2": 65} - } - ] -} -``` - -**请求 /channel/sales-product/price/update**(POST) - -```json -{ - "id": 1, - "salesProductId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [], - "details": [] -} -``` - -**请求 /channel/sales-product/price/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**请求 /channel/sales-product/price/toggleStatus**(POST) - -```json -{ - "id": 1, - "status": 0 -} -``` - -**请求 /channel/sales-product/price/save**(POST) - -```json -{ - "salesProductId": 1, - "publish": false, - "prices": [ - { - "id": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [], - "details": [] - } - ] -} -``` - -**响应**(create/update/delete/toggleStatus/save) - -```json -null -``` - -#### 排货规则(子表接口) - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 获取排货规则 | GET | /channel/sales-product/allocation/get | 按产品ID获取排货方案及服务商列表 | -| 保存排货规则 | POST | /channel/sales-product/allocation/save | 保存排货方案及服务商(保存并可选发布) | - -**请求 /channel/sales-product/allocation/get**(GET,Query 参数) - -``` -?salesProductId=1 -``` - -**响应** - -```json -{ - "salesProductId": 1, - "scheme": "costLowest", - "providers": [ - { - "id": 1, - "salesProductId": 1, - "serialNumber": 1, - "providerName": "一代物流", - "serviceChannelId": 10, - "serviceChannelName": "DHL美国专线", - "weight": 100, - "customerIds": [ - 1, - 2 - ], - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ] -} -``` - -**请求 /channel/sales-product/allocation/save**(POST) - -```json -{ - "salesProductId": 1, - "publish": false, - "scheme": "costLowest", - "providers": [ - { - "id": 0, - "serialNumber": 1, - "providerName": "一代物流", - "serviceChannelId": 10, - "weight": 100, - "customerIds": [1, 2] - } - ] -} -``` - -**响应** - -```json -null -``` - -#### 发布记录(子表接口) - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 价格发布记录列表 | GET | /channel/sales-product/publish/price/list | 按产品ID获取价格发布历史 | -| 规则发布记录列表 | GET | /channel/sales-product/publish/rules/list | 按产品ID获取规则发布历史 | -| 发布 | POST | /channel/sales-product/publish | 发布当前配置(价格或规则) | -| 回滚到版本 | POST | /channel/sales-product/publish/rollback | 回滚到指定版本 | - -**请求 /channel/sales-product/publish/price/list**(GET,Query 参数) - -``` -?salesProductId=1&page=1&pageSize=20 -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "salesProductId": 1, - "version": "20231227112339", - "operatorType": "price", - "status": "生效", - "publishUser": "admin", - "publishTime": "2023-12-27 11:23:39", - "description": "价格调整", - "content": {}, - "createTime": "2023-12-27 11:23:39" - } - ], - "total": 5 -} -``` - -**请求 /channel/sales-product/publish/rules/list**(GET,Query 参数) - -``` -?salesProductId=1&page=1&pageSize=20 -``` - -**响应**(结构同 price/list,operatorType 为 rules) - -**请求 /channel/sales-product/publish**(POST) - -```json -{ - "salesProductId": 1, - "operatorType": "price", - "description": "价格调整" -} -``` - -**请求 /channel/sales-product/publish/rollback**(POST) - -```json -{ - "salesProductId": 1, - "recordId": 1, - "operatorType": "price" -} -``` - -**响应**(publish/rollback) - -```json -null -``` - -#### 服务渠道 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 渠道列表 | GET | /channel/service-channel/list | 渠道列表(含渠道组层级) | -| 渠道详情 | GET | /channel/service-channel/detail | 渠道详情 | -| 新增渠道 | POST | /channel/service-channel/create | 新增渠道 | -| 更新渠道 | POST | /channel/service-channel/update | 更新渠道 | -| 删除渠道 | POST | /channel/service-channel/delete | 删除渠道 | -| 保存渠道配置 | POST | /channel/service-channel/config/save | 保存渠道配置(渠道配置 tab) | - -**请求 /channel/service-channel/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&groupId=&keyword=&isActive=1 -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "groupId": 1, - "groupName": "DHL渠道组", - "channelCode": "DHL-US", - "channelName": "DHL美国专线", - "minTime": 3, - "maxTime": 7, - "forecastType": "waybill", - "forecastCode": "", - "description": "DHL美国专线渠道", - "isActive": 1, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 10 -} -``` - -**请求 /channel/service-channel/detail**(GET,Query 参数) - -``` -?id=1 -``` - -**响应** - -```json -{ - "id": 1, - "groupId": 1, - "groupName": "DHL渠道组", - "channelCode": "DHL-US", - "channelName": "DHL美国专线", - "minTime": 3, - "maxTime": 7, - "forecastType": "waybill", - "forecastCode": "", - "description": "DHL美国专线渠道", - "preScript": "", - "isActive": 1, - "createTime": "2024-01-01 12:00:00", - "createBy": "admin", - "updateTime": "2024-01-01 12:00:00", - "updateBy": "admin" -} -``` - -**请求 /channel/service-channel/create**(POST) - -```json -{ - "groupId": 1, - "channelCode": "DHL-US", - "channelName": "DHL美国专线", - "minTime": 3, - "maxTime": 7, - "forecastType": "waybill", - "forecastCode": "", - "description": "DHL美国专线渠道", - "preScript": "", - "isActive": 1 -} -``` - -**请求 /channel/service-channel/update**(POST) - -```json -{ - "id": 1, - "groupId": 1, - "channelCode": "DHL-US", - "channelName": "DHL美国专线", - "minTime": 3, - "maxTime": 7, - "forecastType": "waybill", - "forecastCode": "", - "description": "DHL美国专线渠道", - "preScript": "", - "isActive": 1 -} -``` - -**请求 /channel/service-channel/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**请求 /channel/service-channel/config/save**(POST) - -```json -{ - "id": 1, - "groupId": 1, - "channelCode": "DHL-US", - "channelName": "DHL美国专线", - "minTime": 3, - "maxTime": 7, - "forecastType": "waybill", - "forecastCode": "", - "description": "DHL美国专线渠道", - "preScript": "", - "isActive": 1 -} -``` - -**响应**(create/update/delete/config/save) - -```json -null -``` - -#### 渠道买价(子表接口) - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 买价项列表 | GET | /channel/service-channel/price/list | 按渠道ID获取买价项列表 | -| 新增买价项 | POST | /channel/service-channel/price/create | 新增买价项(含基础信息+计费因素+价格表) | -| 更新买价项 | POST | /channel/service-channel/price/update | 更新买价项 | -| 删除买价项 | POST | /channel/service-channel/price/delete | 删除买价项 | -| 切换买价项状态 | POST | /channel/service-channel/price/toggleStatus | 启用/禁用买价项 | -| 保存买价配置 | POST | /channel/service-channel/price/save | 批量保存渠道买价(保存并可选发布) | - -**请求 /channel/service-channel/price/list**(GET,Query 参数) - -``` -?serviceChannelId=1 -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "serviceChannelId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [ - { - "id": 1, - "priceId": 1, - "serialNumber": 1, - "factor": "货物类型", - "factorValues": [ - "普货", - "敏感货" - ] - } - ], - "details": [ - { - "id": 1, - "priceId": 1, - "factorValuesJson": { - "分区": "欧洲", - "货物类型": "普货", - "重量": "0-0.5" - }, - "calculationFormula": "单价", - "calculatedValue": 45, - "customerGroupValues": {} - } - ], - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ] -} -``` - -**请求 /channel/service-channel/price/create**(POST) - -```json -{ - "serviceChannelId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [ - { - "serialNumber": 1, - "factor": "货物类型", - "factorValues": ["普货", "敏感货"] - } - ], - "details": [ - { - "factorValuesJson": {"分区": "欧洲", "货物类型": "普货", "重量": "0-0.5"}, - "calculationFormula": "单价", - "calculatedValue": 45, - "customerGroupValues": {} - } - ] -} -``` - -**请求 /channel/service-channel/price/update**(POST) - -```json -{ - "id": 1, - "serviceChannelId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [], - "details": [] -} -``` - -**请求 /channel/service-channel/price/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**请求 /channel/service-channel/price/toggleStatus**(POST) - -```json -{ - "id": 1, - "status": 0 -} -``` - -**请求 /channel/service-channel/price/save**(POST) - -```json -{ - "serviceChannelId": 1, - "publish": false, - "prices": [ - { - "id": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [], - "details": [] - } - ] -} -``` - -**响应**(create/update/delete/toggleStatus/save) - -```json -null -``` - -#### 服务渠道发布记录(子表接口) - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 价格发布记录列表 | GET | /channel/service-channel/publish/price/list | 按渠道ID获取买价发布历史 | -| 规则发布记录列表 | GET | /channel/service-channel/publish/rules/list | 按渠道ID获取规则发布历史 | -| 发布 | POST | /channel/service-channel/publish | 发布当前配置(买价或规则) | -| 回滚到版本 | POST | /channel/service-channel/publish/rollback | 回滚到指定版本 | - -**请求 /channel/service-channel/publish/price/list**(GET,Query 参数) - -``` -?serviceChannelId=1&page=1&pageSize=20 -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "serviceChannelId": 1, - "version": "20231227112339", - "operatorType": "price", - "status": "生效", - "publishUser": "admin", - "publishTime": "2023-12-27 11:23:39", - "description": "买价调整", - "content": {}, - "createTime": "2023-12-27 11:23:39" - } - ], - "total": 5 -} -``` - -**请求 /channel/service-channel/publish/rules/list**(GET,Query 参数) - -``` -?serviceChannelId=1&page=1&pageSize=20 -``` - -**响应**(结构同 price/list,operatorType 为 rules) - -**请求 /channel/service-channel/publish**(POST) - -```json -{ - "serviceChannelId": 1, - "operatorType": "price", - "description": "买价调整" -} -``` - -**请求 /channel/service-channel/publish/rollback**(POST) - -```json -{ - "serviceChannelId": 1, - "recordId": 1, - "operatorType": "price" -} -``` - -**响应**(publish/rollback) - -```json -null -``` - -#### 黑名单区 / 偏远分区 / 预警分区(共用 KpiArea) - -> 共用 `Track/ForewarningService/KpiArea/*` 接口,通过 `appKey` 区分类型。建议网关层映射为 `/channel/kpi-area/*` 以符合 URL 规范。 - -| 类型 | appKey 建议值 | -| --- | --- | -| 黑名单区 | blacklist | -| 偏远分区 | remote | -| 预警分区 | warning | - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 创建分区 | POST | Track/ForewarningService/KpiArea/CreateKpiArea | 创建分区 | -| 修改分区 | POST | Track/ForewarningService/KpiArea/ModifyKpiArea | 修改分区 | -| 删除分区 | POST | Track/ForewarningService/KpiArea/DeleteKpiArea | 删除分区 | -| 分区列表 | POST | Track/ForewarningService/KpiArea/GetKpiAreas | 分区列表 | -| 新增分区明细 | POST | Track/ForewarningService/KpiArea/AddAreaInfo | 新增分区明细 | -| 修改分区明细 | POST | Track/ForewarningService/KpiArea/ModifyKpiAreaInfo | 修改分区明细 | -| 删除分区明细 | POST | Track/ForewarningService/KpiArea/DeleteKpiAreaInfo | 删除分区明细 | -| 分区明细列表 | POST | Track/ForewarningService/KpiArea/GetKpiAreaInfos | 分区明细列表 | - -**请求 Track/ForewarningService/KpiArea/CreateKpiArea**(POST) - -```json -{ - "appKey": "blacklist", - "areaType": "blacklist", - "name": "黑名单区1", - "systemName": "系统黑名单" -} -``` - -**请求 Track/ForewarningService/KpiArea/ModifyKpiArea**(POST) - -```json -{ - "id": 1, - "appKey": "blacklist", - "areaType": "blacklist", - "name": "黑名单区1", - "systemName": "系统黑名单" -} -``` - -**请求 Track/ForewarningService/KpiArea/DeleteKpiArea**(POST) - -```json -{ - "id": 1 -} -``` - -**请求 Track/ForewarningService/KpiArea/GetKpiAreas**(POST) - -```json -{ - "appKey": "blacklist", - "areaType": "blacklist", - "page": 1, - "pageSize": 20 -} -``` - -**响应**(GetKpiAreas) - -```json -{ - "items": [ - { - "id": 1, - "appKey": "blacklist", - "areaType": "blacklist", - "name": "黑名单区1", - "systemName": "系统黑名单", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 5 -} -``` - -**请求 Track/ForewarningService/KpiArea/AddAreaInfo**(POST) - -```json -{ - "areaId": 1, - "countryCode": "US", - "countryName": "美国", - "province": "加州", - "city": "洛杉矶", - "leftPostcode": "90001", - "rightPostcode": "90099" -} -``` - -**请求 Track/ForewarningService/KpiArea/ModifyKpiAreaInfo**(POST) - -```json -{ - "id": 1, - "areaId": 1, - "countryCode": "US", - "countryName": "美国", - "province": "加州", - "city": "洛杉矶", - "leftPostcode": "90001", - "rightPostcode": "90099" -} -``` - -**请求 Track/ForewarningService/KpiArea/DeleteKpiAreaInfo**(POST) - -```json -{ - "id": 1 -} -``` - -**请求 Track/ForewarningService/KpiArea/GetKpiAreaInfos**(POST) - -```json -{ - "areaId": 1, - "page": 1, - "pageSize": 20 -} -``` - -**响应**(GetKpiAreaInfos) - -```json -{ - "items": [ - { - "id": 1, - "areaId": 1, - "countryCode": "US", - "countryName": "美国", - "province": "加州", - "city": "洛杉矶", - "leftPostcode": "90001", - "rightPostcode": "90099", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 10 -} -``` - -**响应**(Create/Modify/Delete/Add/Modify/Delete 明细) - -```json -null -``` - -#### 计重规则 / 计重进位 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 规则列表 | POST | /channel/weight-rule/list | 规则列表 | -| 创建规则 | POST | /channel/weight-rule/create | 创建规则 | -| 更新规则 | POST | /channel/weight-rule/update | 更新规则 | -| 删除规则 | POST | /channel/weight-rule/delete | 删除规则 | -| 规则明细列表 | POST | /channel/weight-rule/info/list | 规则明细列表 | -| 新增明细 | POST | /channel/weight-rule/info/add | 新增明细 | -| 更新明细 | POST | /channel/weight-rule/info/update | 更新明细 | -| 删除明细 | POST | /channel/weight-rule/info/delete | 删除明细 | - -计重进位接口同上,路径改为 `/channel/weight-rounding/*`,请求/响应结构与计重规则一致。 - -**请求 /channel/weight-rounding/list**(POST) - -```json -{ - "appKey": "itfx", - "page": 1, - "pageSize": 20 -} -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "appKey": "itfx", - "name": "标准进位", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 5 -} -``` - -**请求 /channel/weight-rounding/create**(POST) - -```json -{ - "appKey": "itfx", - "name": "标准进位" -} -``` - -**请求 /channel/weight-rounding/update**(POST) - -```json -{ - "id": 1, - "appKey": "itfx", - "name": "标准进位" -} -``` - -**请求 /channel/weight-rounding/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应**(create/update/delete) - -```json -null -``` - -**请求 /channel/weight-rule/list**(POST) - -```json -{ - "appKey": "itfx", - "page": 1, - "pageSize": 20 -} -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "appKey": "itfx", - "name": "标准计重规则", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 5 -} -``` - -**请求 /channel/weight-rule/create**(POST) - -```json -{ - "appKey": "itfx", - "name": "标准计重规则" -} -``` - -**请求 /channel/weight-rule/update**(POST) - -```json -{ - "id": 1, - "appKey": "itfx", - "name": "标准计重规则" -} -``` - -**请求 /channel/weight-rule/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**请求 /channel/weight-rule/info/list**(POST) - -```json -{ - "ruleId": 1, - "page": 1, - "pageSize": 20 -} -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "ruleId": 1, - "client": "公共", - "condition": "gw>vw", - "billableWeight": "vw", - "volumetricFactor": "6000", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 3 -} -``` - -**请求 /channel/weight-rule/info/add**(POST) - -```json -{ - "ruleId": 1, - "client": "公共", - "condition": "gw>vw", - "billableWeight": "vw", - "volumetricFactor": "6000" -} -``` - -**请求 /channel/weight-rule/info/update**(POST) - -```json -{ - "id": 1, - "ruleId": 1, - "client": "公共", - "condition": "gw>vw", - "billableWeight": "vw", - "volumetricFactor": "6000" -} -``` - -**请求 /channel/weight-rule/info/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应**(create/update/delete/info/add/update/delete) - -```json -null -``` - -**计重进位**(/channel/weight-rounding/*)请求/响应结构与计重规则类似,规则明细改为: - -**请求 /channel/weight-rounding/info/add**(POST) - -```json -{ - "roundingId": 1, - "startValue": 0, - "endValue": 1, - "public": "0.5", - "customerGroup1": "0.5", - "customerGroup2": "0.25" -} -``` - -**响应**(info/list) - -```json -{ - "items": [ - { - "id": 1, - "roundingId": 1, - "startValue": 0, - "endValue": 1, - "public": "0.5", - "customerGroup1": "0.5", - "customerGroup2": "0.25", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 5 -} -``` - -#### 自动触发 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 列表 | GET | /channel/auto-trigger/list | 列表 | -| 新增 | POST | /channel/auto-trigger/create | 新增 | -| 更新 | POST | /channel/auto-trigger/update | 更新 | -| 删除 | POST | /channel/auto-trigger/delete | 删除 | -| 切换启用状态 | POST | /channel/auto-trigger/toggleStatus | 切换启用状态 | - -**请求 /channel/auto-trigger/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&operation=&triggerTiming= -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "status": 1, - "operation": "拦截", - "triggerTiming": "下单", - "triggerCondition": "weight>30", - "promptMessage": "重量超过30kg,请确认", - "notifier": "admin@example.com", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 5 -} -``` - -**请求 /channel/auto-trigger/create**(POST) - -```json -{ - "status": 1, - "operation": "拦截", - "triggerTiming": "下单", - "triggerCondition": "weight>30", - "promptMessage": "重量超过30kg,请确认", - "notifier": "admin@example.com" -} -``` - -**请求 /channel/auto-trigger/update**(POST) - -```json -{ - "id": 1, - "status": 1, - "operation": "拦截", - "triggerTiming": "下单", - "triggerCondition": "weight>30", - "promptMessage": "重量超过30kg,请确认", - "notifier": "admin@example.com" -} -``` - -**请求 /channel/auto-trigger/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**请求 /channel/auto-trigger/toggleStatus**(POST) - -```json -{ - "id": 1, - "status": 0 -} -``` - -**响应**(create/update/delete/toggleStatus) - -```json -null -``` - -#### 标签设计 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 保存标签模板 | POST | /channel/label-template/save | 保存标签模板 | - -**请求 /channel/label-template/save**(POST) - -```json -{ - "id": 0, - "name": "尾程标签模板1", - "templateType": "lastMile", - "templateJson": {} -} -``` - -> 参数说明:`id` 模板ID,0为新增;`templateType` 类型:lastMile(尾程)/customer(客户)/inbound(入库)/outbound(出库)/sku;`templateJson` vg-print 导出的模板 JSON。 - -**响应** - -```json -{ - "id": 1, - "name": "尾程标签模板1", - "templateType": "lastMile", - "templateJson": {}, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" -} -``` - ----------------------------------------------------------------------------------------- - ----------------------------------------------------------------------------------------- - -## 四、操作模块 - -### 3.1 核心需求说明 - -- **入库扫描**:扫描运单号完成入库,记录实重、尺寸、销售产品、目的国、仓库、操作人、操作时间;支持查看最近入库记录。 -- **出库扫描**:扫描运单号完成出库,记录仓库、操作人、操作时间;支持查看最近出库记录。 -- **标签打印**:按运单号/客户单号/跟踪号/其他单号选择标签模板打印;记录打印结果与操作人。 - -### 3.2 表结构设计 - -#### 入库记录表 `t_inbound_record` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| WaybillNo | VARCHAR | 64 | - | 是 | 运单号 | -| Weight | DECIMAL | 10,2 | - | 否 | 实重(kg) | -| Length | DECIMAL | 10,2 | - | 否 | 长(cm) | -| Width | DECIMAL | 10,2 | - | 否 | 宽(cm) | -| Height | DECIMAL | 10,2 | - | 否 | 高(cm) | -| SalesProductId | BIGINT | - | 外键 | 否 | 销售产品ID | -| DestinationCountry | VARCHAR | 8 | - | 否 | 目的国 | -| WarehouseId | BIGINT | - | 外键 | 否 | 仓库ID | -| OperateTime | DATETIME | - | - | 是 | 操作时间 | -| OperatorId | BIGINT | - | 外键 | 否 | 操作人ID | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 出库记录表 `t_outbound_record` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| WaybillNo | VARCHAR | 64 | - | 是 | 运单号 | -| WarehouseId | BIGINT | - | 外键 | 否 | 仓库ID | -| OperateTime | DATETIME | - | - | 是 | 操作时间 | -| OperatorId | BIGINT | - | 外键 | 否 | 操作人ID | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 标签打印记录表 `t_label_print_record` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| WaybillNo | VARCHAR | 64 | - | 是 | 运单号 | -| CustomerOrderNo | VARCHAR | 64 | - | 否 | 客户单号 | -| TrackingNo | VARCHAR | 64 | - | 否 | 跟踪号 | -| OtherNumber | VARCHAR | 64 | - | 否 | 其他单号 | -| LabelTemplateId | BIGINT | - | 外键 | 否 | 标签模板ID | -| PrintSuccess | TINYINT | - | - | 是 | 是否打印成功 | -| PrintTime | DATETIME | - | - | 是 | 打印时间 | -| OperatorId | BIGINT | - | 外键 | 否 | 操作人ID | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -### 3.3 核心接口设计 - -#### 入库扫描 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 入库 | POST | /operation/inbound/create | 入库 | -| 最近入库记录 | GET | /operation/inbound/recent-list | 最近入库记录 | - -**请求 /operation/inbound/create**(POST) - -```json -{ - "waybillNo": "LP1234567890", - "weight": 2.5, - "length": 20, - "width": 15, - "height": 10, - "salesProductId": 1, - "destinationCountry": "US", - "warehouseId": 1 -} -``` - -> 参数说明:`waybillNo` 运单号;`weight` 实重(kg);`length/width/height` 长宽高(cm);`salesProductId` 销售产品ID;`destinationCountry` 目的国二字码;`warehouseId` 仓库ID。操作时间、操作人由服务端从当前会话获取。 - -**响应** - -```json -{ - "id": 1, - "waybillNo": "LP1234567890", - "weight": 2.5, - "length": 20, - "width": 15, - "height": 10, - "salesProductId": 1, - "destinationCountry": "US", - "warehouseId": 1, - "operateTime": "2024-01-01 12:00:00", - "operatorId": 1, - "createTime": "2024-01-01 12:00:00" -} -``` - -**请求 /operation/inbound/recent-list**(GET,Query 参数) - -``` -?limit=20&warehouseId= -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "waybillNo": "LP1234567890", - "weight": 2.5, - "length": 20, - "width": 15, - "height": 10, - "salesProductId": 1, - "salesProductName": "美国空运专线(普货)", - "destinationCountry": "US", - "warehouseId": 1, - "warehouseName": "深圳仓", - "operateTime": "2024-01-01 12:00:00", - "operatorId": 1, - "operatorName": "张三", - "createTime": "2024-01-01 12:00:00" - } - ] -} -``` - -#### 出库扫描 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 出库 | POST | /operation/outbound/create | 出库 | -| 最近出库记录 | GET | /operation/outbound/recent-list | 最近出库记录 | - -**请求 /operation/outbound/create**(POST) - -```json -{ - "waybillNo": "LP1234567890", - "warehouseId": 1 -} -``` - -> 参数说明:`waybillNo` 运单号;`warehouseId` 仓库ID(可选,不传则从订单或默认仓库获取)。操作时间、操作人由服务端从当前会话获取。 - -**响应** - -```json -{ - "id": 1, - "waybillNo": "LP1234567890", - "warehouseId": 1, - "operateTime": "2024-01-01 12:00:00", - "operatorId": 1, - "createTime": "2024-01-01 12:00:00" -} -``` - -**请求 /operation/outbound/recent-list**(GET,Query 参数) - -``` -?limit=20&warehouseId= -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "waybillNo": "LP1234567890", - "warehouseId": 1, - "warehouseName": "深圳仓", - "operateTime": "2024-01-01 12:00:00", - "operatorId": 1, - "operatorName": "张三", - "createTime": "2024-01-01 12:00:00" - } - ] -} -``` - -#### 标签打印 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 打印标签 | POST | /operation/print-label/print | 打印标签 | -| 最近打印记录 | GET | /operation/print-label/recent-list | 最近打印记录 | -| 标签模板列表 | GET | /operation/print-label/template-list | 标签模板列表 | - -**请求 /operation/print-label/print**(POST) - -```json -{ - "number": "LP1234567890", - "numberType": "waybill", - "labelTemplateId": 1 -} -``` - -> 参数说明:`number` 运单号/客户单号/跟踪号/其他单号;`numberType` 单号类型:waybill(运单号)/customerOrder(客户单号)/tracking(跟踪号)/other(其他单号);`labelTemplateId` 标签模板ID,可选,不传则按 templateType 取默认模板。也可传 `templateType`:lastMile(尾程)/customer(客户)/inbound(入库)/outbound(出库)/sku。 - -**响应** - -```json -{ - "id": 1, - "waybillNo": "LP1234567890", - "customerOrderNo": "", - "trackingNo": "", - "otherNumber": "", - "labelTemplateId": 1, - "labelTemplateName": "尾程标签模板1", - "printSuccess": 1, - "printTime": "2024-01-01 12:00:00", - "operatorId": 1, - "createTime": "2024-01-01 12:00:00" -} -``` - -**请求 /operation/print-label/recent-list**(GET,Query 参数) - -``` -?limit=20&labelTemplateId= -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "waybillNo": "LP1234567890", - "customerOrderNo": "CUST001", - "trackingNo": "TRK001", - "otherNumber": "", - "labelTemplateId": 1, - "labelTemplateName": "尾程标签模板1", - "printSuccess": 1, - "printTime": "2024-01-01 12:00:00", - "operatorId": 1, - "operatorName": "张三", - "createTime": "2024-01-01 12:00:00" - } - ] -} -``` - -**请求 /operation/print-label/template-list**(GET,Query 参数) - -``` -?templateType=lastMile -``` - -> 参数说明:`templateType` 可选,筛选模板类型:lastMile/customer/inbound/outbound/sku。不传则返回全部可用模板。 - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "name": "尾程标签模板1", - "templateType": "lastMile", - "templateJson": {}, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ] -} -``` - ----------------------------------------------------------------------------------------- - ----------------------------------------------------------------------------------------- - -## 五、客户模块 - -### 4.1 核心需求说明 - -- **客户管理**:客户信息的增删改查,支持客户编号、手机、邮箱、余额、额度、状态等管理;支持重置密码;已有 customer API。 -- **子账号**:主客户可创建子账号,子账号共享主客户余额与额度,独立登录与权限。 - -### 4.2 表结构设计 - -#### 客户表 `t_customer` - -> 已有 customer API,表结构参考 CustomerDto。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| CustomerCode | VARCHAR | 32 | - | 是 | 客户编号,规则:前缀w+年份+序号(如W2501) | -| WechatAvatar | VARCHAR | 255 | - | 否 | 微信头像URL | -| WechatNickname | VARCHAR | 64 | - | 否 | 微信昵称 | -| Email | VARCHAR | 64 | - | 否 | 邮箱,唯一,用于登录/找回密码 | -| Phone | VARCHAR | 16 | - | 是 | 手机号,唯一,用于登录/验证 | -| Balance | DECIMAL | 18,2 | - | 是 | 账户余额,默认0 | -| TotalQuota | DECIMAL | 18,2 | - | 是 | 总额度,默认0 | -| UsedQuota | DECIMAL | 18,2 | - | 是 | 已用额度,默认0 | -| Status | TINYINT | - | - | 是 | 状态:0-禁用,1-正常,2-冻结 | -| Remark | VARCHAR | 512 | - | 否 | 备注信息 | -| CreateUserId | INT | - | - | 是 | 创建人(系统/管理员账号) | -| CreateTime | DATETIME | - | - | 是 | 创建时间,默认当前时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | -| Salesman | VARCHAR | 32 | - | 否 | 所属销售员账号 | -| CustomerService | VARCHAR | 32 | - | 否 | 所属客服账号 | -| CustomerGroup | TINYINT | - | - | 否 | 客户分组:0-普通客户,1-VIP1,2-VIP2,3-VIP3,4-VIP4,5-VIP5 | -| Role | VARCHAR | 64 | - | 否 | 角色,用于菜单权限 | -| LastLoginIp | VARCHAR | 64 | - | 否 | 最后在线IP | -| LastLoginTime | DATETIME | - | - | 否 | 最后在线时间 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| WechatOpenid | VARCHAR | 64 | - | 否 | 微信OpenID,唯一,关联微信账号 | -| Password | VARCHAR | 64 | - | 是 | 密码(MD5加密存储) | - -**CustomerGroup 枚举**:0-普通客户、1-VIP1、2-VIP2、3-VIP3、4-VIP4、5-VIP5 - -#### 子账号表 `t_customer_sub_account` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| MainCustomerId | BIGINT | - | 外键(t_customer.Id) | 是 | 主客户ID | -| SubAccountCode | VARCHAR | 32 | - | 是 | 子账号编号,规则:主客户编号+"_"+序号 | -| SubWechatAvatar | VARCHAR | 255 | - | 否 | 子账号微信头像 | -| SubWechatNickname | VARCHAR | 64 | - | 否 | 子账号微信昵称 | -| SubPhone | VARCHAR | 16 | - | 是 | 子账号手机号,唯一 | -| SubEmail | VARCHAR | 64 | - | 否 | 子账号邮箱 | -| SubPassword | VARCHAR | 64 | - | 是 | 子账号密码(MD5加密) | -| SubStatus | TINYINT | - | - | 是 | 子账号状态:0-禁用,1-正常 | -| CreateUserId | INT | - | - | 是 | 创建人(主客户/管理员) | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是 | - -### 4.3 核心接口设计 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 客户列表 | GET | /customer/list | 客户列表 | -| 客户详情 | GET | /customer/get | 客户详情 | -| 创建客户 | POST | /customer/create | 创建客户 | -| 更新客户 | POST | /customer/update | 更新客户 | -| 删除客户 | POST | /customer/delete | 删除客户 | -| 重置密码 | POST | /customer/reset-password | 重置密码 | - -> 个人中心可复用 `/customer/get`,传入当前登录客户 ID。 - -**请求 /customer/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&keyword=&status=1&customerGroup=&salesman=&customerService= -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "customerCode": "W2501", - "wechatAvatar": "", - "wechatNickname": "", - "email": "customer@example.com", - "phone": "13800138000", - "balance": 1000.0, - "totalQuota": 5000.0, - "usedQuota": 500.0, - "status": 1, - "remark": "", - "salesman": "sales1", - "customerService": "cs1", - "customerGroup": 1, - "lastLoginIp": "192.168.1.1", - "lastLoginTime": "2024-01-01 12:00:00", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 50 -} -``` - -**请求 /customer/get**(GET,Query 参数) - -``` -?id=1 -``` - -**响应** - -```json -{ - "id": 1, - "customerCode": "W2501", - "wechatAvatar": "", - "wechatNickname": "", - "email": "customer@example.com", - "phone": "13800138000", - "balance": 1000.0, - "totalQuota": 5000.0, - "usedQuota": 500.0, - "status": 1, - "remark": "", - "salesman": "sales1", - "customerService": "cs1", - "customerGroup": 1, - "lastLoginIp": "192.168.1.1", - "lastLoginTime": "2024-01-01 12:00:00", - "createTime": "2024-01-01 12:00:00", - "createBy": "admin", - "updateTime": "2024-01-01 12:00:00", - "updateBy": "admin" -} -``` - -**请求 /customer/create**(POST) - -```json -{ - "phone": "13800138000", - "email": "customer@example.com", - "password": "123456", - "totalQuota": 5000.00, - "status": 1, - "remark": "", - "salesman": "sales1", - "customerService": "cs1", - "customerGroup": 1 -} -``` - -**请求 /customer/update**(POST) - -```json -{ - "id": 1, - "email": "customer@example.com", - "totalQuota": 5000.00, - "status": 1, - "remark": "", - "salesman": "sales1", - "customerService": "cs1", - "customerGroup": 1 -} -``` - -**请求 /customer/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**请求 /customer/reset-password**(POST) - -```json -{ - "id": 1, - "newPassword": "123456" -} -``` - -**响应**(create/update/delete/reset-password) - -```json -null -``` - ----------------------------------------------------------------------------------------- - ----------------------------------------------------------------------------------------- - -## 六、充值管理 - -### 5.1 核心需求说明 - -- **充值流程**:客户提交充值金额 → 选择支付方式(微信/支付宝)→ 上传充值截图 → 提交审核 → 管理员审核(通过/驳回)→ 审核通过后更新客户余额。 -- **充值记录**:完整留存充值全流程信息,支持查询、导出;充值状态包含:待审核、审核通过、审核驳回。 - -### 5.2 表结构设计 - -#### 充值记录表 `t_recharge_record` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| CustomerId | BIGINT | - | 外键(t_customer.Id) | 是 | 客户ID(主账号/子账号关联主账号ID) | -| SerialNumber | VARCHAR | 64 | - | 是 | 流水号,规则:RECHARGE+日期+随机6位数字 | -| RechargeTime | DATETIME | - | - | 是 | 充值提交时间,默认当前时间 | -| PayType | TINYINT | - | - | 是 | 支付方式:1-微信,2-支付宝 | -| RechargeAmount | DECIMAL | 18,2 | - | 是 | 充值金额,≥0.01 | -| RechargeScreenshot | VARCHAR | 255 | - | 否 | 充值截图URL(存储文件服务器地址) | -| RechargeStatus | TINYINT | - | - | 是 | 充值状态:0-待审核,1-审核通过,2-审核驳回 | -| Remark | VARCHAR | 512 | - | 否 | 备注(客户/审核人填写) | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | -| AuditTime | DATETIME | - | - | 否 | 审核时间 | -| AuditBy | VARCHAR | 32 | - | 否 | 审核人(管理员账号) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | - -### 5.3 核心接口设计 - -| 接口名称 | 请求方式 | 请求路径 | 请求参数 | 响应参数 | 接口说明 | -| --- | --- | --- | --- | --- | --- | -| 提交充值申请 | POST | /recharge/recharge/submit | customerId, payType, rechargeAmount, screenshotUrl, remark | serialNumber | 生成流水号,保存充值记录,状态为待审核 | -| 审核充值申请 | POST | /recharge/recharge-record/approve 或 reject | id, auditStatus, auditBy, remark | null | 审核通过则更新客户余额,更新审核时间、状态、审核人 | -| 查询充值记录 | GET | /recharge/recharge-record/list | customerId, serialNumber, startTime, endTime, status | list, total | 多条件分页查询充值记录 | -| 导出充值记录 | GET | /recharge/export | customerId, startTime, endTime | 文件流 | 导出充值记录为Excel文件 | - -**请求 /recharge/recharge/submit**(POST) - -```json -{ - "customerId": 1, - "rechargeAmount": 500, - "payType": 1, - "rechargeScreenshot": "https://example.com/upload/screenshot.jpg", - "remark": "充值500元" -} -``` - -> 参数说明:`customerId` 客户ID(客户端提交时可不传,取当前登录客户);`rechargeAmount` 充值金额,≥0.01;`payType` 支付方式:1-微信,2-支付宝;`rechargeScreenshot` 充值截图URL;`remark` 备注。 - -**响应** - -```json -{ - "id": 1, - "serialNumber": "RECHARGE2024010100123456", - "customerId": 1, - "rechargeAmount": 500.0, - "payType": 1, - "rechargeScreenshot": "https://example.com/upload/screenshot.jpg", - "rechargeStatus": 0, - "remark": "充值500元", - "rechargeTime": "2024-01-01 12:00:00", - "createTime": "2024-01-01 12:00:00" -} -``` - -**请求 /recharge/recharge-record/approve**(POST) - -```json -{ - "id": 1, - "remark": "审核通过" -} -``` - -**请求 /recharge/recharge-record/reject**(POST) - -```json -{ - "id": 1, - "remark": "截图不清晰,请重新上传" -} -``` - -**响应**(approve/reject) - -```json -null -``` - -**请求 /recharge/recharge-record/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&customerId=1&serialNumber=&rechargeStatus=0&startTime=2024-01-01&endTime=2024-01-31 -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "customerId": 1, - "customerCode": "W2501", - "customerPhone": "13800138000", - "serialNumber": "RECHARGE2024010100123456", - "rechargeTime": "2024-01-01 12:00:00", - "payType": 1, - "payTypeName": "微信", - "rechargeAmount": 500.0, - "rechargeScreenshot": "https://example.com/upload/screenshot.jpg", - "rechargeStatus": 0, - "rechargeStatusName": "待审核", - "remark": "充值500元", - "auditTime": null, - "auditBy": null, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 20 -} -``` - -**请求 /recharge/export**(GET,Query 参数) - -``` -?customerId=1&startTime=2024-01-01&endTime=2024-01-31 -``` - -**响应** - -> 返回 Excel 文件流,Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - ----------------------------------------------------------------------------------------- - ----------------------------------------------------------------------------------------- - -## 七、系统模块 - -### 6.1 核心需求说明 - -- **仓库地址**:管理仓库收货地址信息,包括主仓/中转仓/退货仓/其他类型;支持收件人、联系电话、收货地址、客服电话、客服微信及二维码等配置。 - -### 6.2 表结构设计 - -#### 仓库地址表 `t_warehouse_address` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| WarehouseName | VARCHAR | 128 | - | 是 | 仓库名称 | -| WarehouseType | VARCHAR | 32 | - | 是 | main/transit/return/other | -| Recipient | VARCHAR | 64 | - | 否 | 收件人 | -| ContactPhone | VARCHAR | 32 | - | 否 | 联系电话 | -| ShippingAddress | TEXT | - | - | 否 | 收货地址 | -| ServicePhone | VARCHAR | 32 | - | 否 | 客服电话 | -| ServiceWechat | VARCHAR | 64 | - | 否 | 客服微信 | -| ServiceWechatFiles | JSON | - | - | 否 | 微信二维码图片 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -### 6.3 核心接口设计 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 仓库列表 | GET | /system/warehouse/list | 仓库列表 | -| 仓库详情 | GET | /system/warehouse/detail | 仓库详情 | -| 新增仓库 | POST | /system/warehouse/create | 新增 | -| 更新仓库 | POST | /system/warehouse/update | 更新 | -| 删除仓库 | POST | /system/warehouse/delete | 删除 | - -**请求 /system/warehouse/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&warehouseType=main&keyword= -``` - -**响应** - -```json -{ - "items": [ - { - "id": 1, - "warehouseName": "深圳主仓", - "warehouseType": "main", - "warehouseTypeName": "主仓", - "recipient": "张三", - "contactPhone": "0755-12345678", - "shippingAddress": "广东省深圳市南山区xxx路xxx号", - "servicePhone": "400-xxx-xxxx", - "serviceWechat": "itfx_service", - "serviceWechatFiles": [ - "https://example.com/qrcode1.jpg" - ], - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 5 -} -``` - -**请求 /system/warehouse/detail**(GET,Query 参数) - -``` -?id=1 -``` - -**响应** - -```json -{ - "id": 1, - "warehouseName": "深圳主仓", - "warehouseType": "main", - "recipient": "张三", - "contactPhone": "0755-12345678", - "shippingAddress": "广东省深圳市南山区xxx路xxx号", - "servicePhone": "400-xxx-xxxx", - "serviceWechat": "itfx_service", - "serviceWechatFiles": [ - "https://example.com/qrcode1.jpg" - ], - "createTime": "2024-01-01 12:00:00", - "createBy": "admin", - "updateTime": "2024-01-01 12:00:00", - "updateBy": "admin" -} -``` - -**请求 /system/warehouse/create**(POST) - -```json -{ - "warehouseName": "深圳主仓", - "warehouseType": "main", - "recipient": "张三", - "contactPhone": "0755-12345678", - "shippingAddress": "广东省深圳市南山区xxx路xxx号", - "servicePhone": "400-xxx-xxxx", - "serviceWechat": "itfx_service", - "serviceWechatFiles": ["https://example.com/qrcode1.jpg"] -} -``` - -**请求 /system/warehouse/update**(POST) - -```json -{ - "id": 1, - "warehouseName": "深圳主仓", - "warehouseType": "main", - "recipient": "张三", - "contactPhone": "0755-12345678", - "shippingAddress": "广东省深圳市南山区xxx路xxx号", - "servicePhone": "400-xxx-xxxx", - "serviceWechat": "itfx_service", - "serviceWechatFiles": ["https://example.com/qrcode1.jpg"] -} -``` - -**请求 /system/warehouse/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应**(create/update/delete) - -```json -null -``` - ----------------------------------------------------------------------------------------- - ----------------------------------------------------------------------------------------- - -## 附录 - -### 附录一:接口路径规范(kebab-case 汇总) - -| 模块 | 路径前缀 | 示例 | -| --- | --- | --- | -| 订单 | /order/ | list, detail, pay, pre-declare, cancel-intercept, delete, price-check | -| 渠道-销售产品 | /channel/sales-product/ | list, detail, create, update, delete, config/save, price/list, allocation/get | -| 渠道-服务渠道 | /channel/service-channel/ | list, detail, create, update, delete, price/list | -| 渠道-计重 | /channel/weight-rule/, weight-rounding/ | list, create, update, delete, info/list | -| 渠道-自动触发 | /channel/auto-trigger/ | list, create, update, delete, toggle-status | -| 渠道-标签 | /channel/label-template/save | | -| 操作 | /operation/ | inbound/create, inbound/recent-list, outbound/create, print-label/print | -| 客户 | /customer/ | list, get, create, update, delete, reset-password | -| 充值 | /recharge/ | recharge/submit, recharge-record/list, approve, reject | -| 系统 | /system/warehouse/ | list, detail, create, update, delete | - -### 附录二:分区类型说明(黑名单/偏远/预警) - -| 页面 | area_type / Key | 说明 | -| --- | --- | --- | -| 黑名单区 | blacklist | 黑名单分区 | -| 偏远分区 | remote | 偏远分区 | -| 预警分区 | warning | 轨迹下预警分区 | - -调用 KpiArea 接口时,传入对应 `Key` 即可区分不同业务分区,无需新建表或接口。 - -### 附录三:状态码与枚举 - -#### 订单状态 - -| status | 中文 | -| --- | --- | -| draft | 草稿 | -| predeclared | 已预报 | -| received | 已收货 | -| pendingPayment | 待支付 | -| pendingShip | 待发货 | -| pendingOnline | 待上网 | -| shipping | 运输中 | -| deliverySuccess | 派送成功 | -| deliveryFailed | 派送失败 | -| problematic | 问题件 | -| deleted | 已删除 | -| claimed | 已理赔 | - -#### 充值状态 - -| payStatus | 中文 | -| --- | --- | -| 0 | 待审核 | -| 1 | 成功 | -| 2 | 失败 | - -#### 仓库类型 - -| warehouseType | 中文 | -| --- | --- | -| main | 主仓 | -| transit | 中转仓 | -| return | 退货仓 | -| other | 其他 | - -#### 客户分组(客户表) - -| customerGroup | 中文 | -| --- | --- | -| 0 | 普通客户 | -| 1 | VIP1 | -| 2 | VIP2 | -| 3 | VIP3 | -| 4 | VIP4 | -| 5 | VIP5 | - ----------------------------------------------------------------------------------------- - -*文档版本:v1.0 | 最后更新:2025-03-04* - - - - diff --git a/oms表结构与接口设计20260427.md b/oms表结构与接口设计20260427.md deleted file mode 100644 index 1d07366..0000000 --- a/oms表结构与接口设计20260427.md +++ /dev/null @@ -1,5933 +0,0 @@ -# oms 表结构与接口设计文档 - ----------------------------------------------------------------------------------------- - -## OMS 系统说明 - -OMS(Order Management System,订单管理系统)作为独立服务运行和admin权限管理服务合并,面向跨境物流/仓储业务场景,提供**订单**、**渠道**、**操作**、**客户**四大核心模块,支撑从客户注册、充值、下单、定价、支付到货物入库出库的全流程管理。 - -### 核心模块 - -| 模块 | 说明 | -| --- | --- | -| **订单** | 订单全生命周期管理,包括创建、预报、支付、发货、跟踪等 | -| **渠道** | 销售产品与物流渠道的配置、定价及买价/卖价计算 | -| **操作** | 入库、出库、打印等仓储操作 | -| **客户** | 客户注册、认证、账户余额、额度及权限管理 | - - ----------------------------------------------------------------------------------------- - -### 1.2 表结构设计公共规范 - -#### 表的字段命名规范 - -| 规范 | 说明 | 示例 | -| --- | --- | --- | -| 驼峰命名 | 字段名开头大写+单词驼峰 | `ServiceProviderId` | -| 单数名词 | 表名用单数,表示实体 | `customer` 而非 `customers` | -| 业务前缀 | 表名按业务模块加前缀 | `t_order`、`t_customer` | - - -#### 必备字段(所有表) - -| 字段名 | 字段类型 | 说明 | -| --- | --- | --- | -| Id | BIGINT | 主键,自增,步长 1 | -| IsDeleted | TINYINT | 软删除:0-否,1-是,默认 0 | -| CreateUserId | INT | 创建人 | -| CreateTime | DATETIME | 创建时间 | -| UpdateUserId | INT | 更新人 | -| UpdateTime | DATETIME | 更新时间 | - -#### 字段命名规范 - -| 规范 | 说明 | 示例 | -| --- | --- | --- | -| 布尔字段 | 用 `IsXxx` 或 `HasXxx` | `IsDeleted`、`IsActive` | -| 外键字段 | `关联表名Id` 或 `XxxId` | `CustomerId`、`WarehouseId` | -| 时间字段 | `XxxTime` 或 `XxxAt` | `CreateTime`、`PayTime` | -| 禁用保留字 | 不用 desc、range、match 等 | 用 `Description` 替代 `Desc` | - -#### 数据类型规范 - -| 类型 | 说明 | -| --- | --- | -| 主键 | BIGINT,单表自增 | -| 金额/重量 | DECIMAL,禁止 float/double | -| 字符串 | varchar 长度 ≤ 5000,超出用 TEXT | -| 枚举 | 小数据量用 TINYINT,可读性要求高用 VARCHAR | -| 状态标志 | TINYINT | - -### 1.3 接口设计公共规范 - -| 规范 | 说明 | -| --- | --- | -| 操作审计必填 | 任何新增、编辑、删除操作,必须记录操作者与操作时间 | -| 新增接口 | 创建成功后需落库 `CreateUserId`、`CreateTime` | -| 编辑接口 | 更新成功后需落库 `UpdateUserId`、`UpdateTime` | -| 删除接口 | 逻辑删除时需落库 `UpdateUserId`、`UpdateTime`,并更新 `IsDeleted=1` | - -> 审计字段中的操作者信息优先从当前登录会话获取,不允许由前端直接信任覆盖。 - - ----------------------------------------------------------------------------------------- - -## 需求迭代目录(按日期管理) - -> 迭代编号建议使用 `YYYYMMDD-序号`,按日期倒序维护;不同功能迭代可使用 `-01`、`-02` 递增。 - -| 迭代日期 | 迭代编号 | 迭代功能 | 变更模块 | 备注 | -| --- | --- | --- | --- | --- | -| 2026-04-27 | 20260427-01 | 客户的账号密码接入登录接口/rbac/auth/login。入参增加一个类型,0(用户)/1(客户),客户的角色编码读取`t_customer`上的`Role`字段来划分菜单权限,默认使用编码`Customer` | 客户模块 | 已完成 | -| 2026-04-27 | 20260427-02 | 任何新增、编辑、删除操作,必须通过authorization来记录操作者与操作时间 | 全局 | 已完成 | -| 2026-04-27 | 20260427-03 | 文档版本差异汇总(20260427 vs 2026040802) | 全文档 | 已完成结构与内容差异归档,见下方“差异摘要” | - -### 20260427-03 差异明细(按表结构与接口) - -> 对比范围:`oms表结构与接口设计20260427.md` vs `oms表结构与接口设计2026040802.md` - -#### 一、表结构差异 - -**新增表(4)** - -- `t_country` -- `t_dict` -- `t_notice` -- `t_notice_read` - -**更新表(0)** - -- 无(按表名维度无同名结构替换;本次以新增为主) - -**删除表(0)** - -- 无 - -#### 二、接口差异 - -**新增接口(22)** - -- `GET /system/country/list`(国家列表) -- `GET /system/dict/list`(字典列表) -- `GET /system/notice/list`(通知列表) -- `POST /channel/service-channel/group/create`(新增服务商) -- `POST /channel/service-channel/group/delete`(删除服务商) -- `POST /channel/service-channel/group/update`(编辑服务商) -- `POST /order/parcel/batch-create`(批量创建小包) -- `POST /system/dict/create`(新增字典) -- `POST /system/dict/delete`(删除字典) -- `POST /system/dict/update`(更新字典) -- `POST /system/notice/create`(新增通知) -- `POST /system/notice/delete`(删除通知) -- `POST /system/notice/read-confirm`(确认已读) -- `POST /system/notice/update`(编辑通知) - -**更新接口(8)** - -分区的接口命名更新了,可忽略,因为已经完成了变更了,知道这个事情就行。 ----现在接口命名 -- `POST /channel/kpi-area/create`(创建分区) -- `POST /channel/kpi-area/delete`(删除分区) -- `POST /channel/kpi-area/info/add`(新增分区明细) -- `POST /channel/kpi-area/info/delete`(删除分区明细) -- `POST /channel/kpi-area/info/list`(分区明细列表) -- `POST /channel/kpi-area/info/update`(修改分区明细) -- `POST /channel/kpi-area/list`(分区列表) -- `POST /channel/kpi-area/update`(修改分区) ----原来接口命名 -- `POST Track/ForewarningService/KpiArea/AddAreaInfo`(新增分区明细) -- `POST Track/ForewarningService/KpiArea/CreateKpiArea`(创建分区) -- `POST Track/ForewarningService/KpiArea/DeleteKpiArea`(删除分区) -- `POST Track/ForewarningService/KpiArea/DeleteKpiAreaInfo`(删除分区明细) -- `POST Track/ForewarningService/KpiArea/GetKpiAreaInfos`(分区明细列表) -- `POST Track/ForewarningService/KpiArea/GetKpiAreas`(分区列表) -- `POST Track/ForewarningService/KpiArea/ModifyKpiArea`(修改分区) -- `POST Track/ForewarningService/KpiArea/ModifyKpiAreaInfo`(修改分区明细) - -**删除接口(10)** - -- `POST /channel/sales-product/config/save`(保存产品配置) -- `POST /channel/service-channel/config/save`(保存渠道配置) - - - - ----------------------------------------------------------------------------------------- - -## 二、订单模块 - -### 1.1 核心需求说明 - -- **我的订单**:支持订单全流程管理,包括分页查询、详情查看、支付、预报、拦截/取消拦截、删除、打印、导出;订单状态覆盖草稿→已预报→已收货→待支付→待发货→运输中→派送成功/失败等全生命周期。 -- **我要查价**:根据目的国、运输方式、重量尺寸、货物类型等条件查价,返回可选渠道及费用明细。 -- **我要下单**:支持下载下单模板、上传 Excel 批量下单、查看任务进度与详情;模板分为标准模板与自定义模板。 - -### 1.2 表结构设计 - -> **命名约定**:表名按业务模块加 t_ 前缀;字段名驼峰命名(开头大写+单词驼峰)。 - -#### 订单表 `t_order` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| ServiceNo | VARCHAR | 64 | - | 是 | 运单号 | -| ClientNo | VARCHAR | 64 | - | 否 | 客户单号 | -| TrackNo | VARCHAR | 64 | - | 否 | 跟踪号 | -| ProductCode | VARCHAR | 128 | - | 否 | 产品code | -| ChannelCode | VARCHAR | 128 | - | 否 | 渠道code | -| ConsigneeCountry | VARCHAR | 8 | - | 否 | 国家二字码 | -| CargoAttribute | VARCHAR | 32 | - | 否 | 货物属性:general-普货,electronics-3c&带电,sensitive-敏感货 | -| OrderStatus | VARCHAR | 32 | - | 是 | 订单状态 | -| WarehouseId | BIGINT | - | 外键 | 否 | 仓库ID | -| CustomerId | BIGINT | - | 外键 | 否 | 客户ID | -| SenderId | BIGINT | - | 外键 | 否 | 发件人ID → t_order_sender | -| RecipientId | BIGINT | - | 外键 | 否 | 收件人ID → t_order_recipient | -| DeclarationId | BIGINT | - | 外键 | 否 | 申报信息ID → t_order_declaration | -| ReceiveTime | DATETIME | - | - | 否 | 收货时间 | -| ShipTime | DATETIME | - | - | 否 | 发货时间 | -| PayTime | DATETIME | - | - | 否 | 支付时间 | -| SignTime | DATETIME | - | - | 否 | 签收时间 | -| IsCod | TINYINT | - | - | 否 | 是否COD:0-否,1-是 | -| CodCurrency | VARCHAR | 16 | - | 否 | COD币种 | -| CodSum | DECIMAL | 18,4 | - | 否 | COD金额 | -| IsIntercept | TINYINT | - | - | 否 | 是否拦截:0-否,1-是 | -| IsRemote | TINYINT | - | - | 否 | 是否偏远:0-否,1-是 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -**OrderStatus 枚举**:draft, predeclared, received, pendingPayment, pendingShip, pendingOnline, shipping, deliverySuccess, deliveryFailed, problematic, deleted, claimed - -**CargoAttribute 枚举**:general-普货,electronics-3c&带电,sensitive-敏感货 - -#### 下单模板表 `t_order_template` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| Name | VARCHAR | 64 | - | 是 | 模板名称 | -| Type | VARCHAR | 16 | - | 是 | standard/custom | -| TemplateKey | VARCHAR | 64 | - | 否 | 模板标识 | -| CustomerId | BIGINT | - | 外键 | 否 | 客户ID(自定义模板) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 下单任务表 `t_order_task` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| TaskName | VARCHAR | 128 | - | 是 | 任务名称 | -| Quantity | INT | - | - | 否 | 数量 | -| Status | VARCHAR | 16 | - | 是 | 处理中/已完成/待处理 | -| TemplateId | BIGINT | - | 外键 | 否 | 模板ID | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | -| CustomerId | BIGINT | - | 外键 | 否 | 客户ID | - -#### 订单发件人表 `t_order_sender` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| OrderId | BIGINT | - | 外键 | 是 | 订单ID | -| SenderName | VARCHAR | 50 | - | 否 | 姓名 | -| Company | VARCHAR | 255 | - | 否 | 公司 | -| Country | INT | - | - | 否 | 国家 | -| Province | VARCHAR | 50 | - | 否 | 州省 | -| City | VARCHAR | 50 | - | 否 | 城市 | -| ZipCode | VARCHAR | 50 | - | 否 | 邮编 | -| StreetAddress1 | VARCHAR | 500 | - | 否 | 地址1 | -| StreetAddress2 | VARCHAR | 500 | - | 否 | 地址2 | -| StreetAddress3 | VARCHAR | 500 | - | 否 | 地址3 | -| HouseNumber | VARCHAR | 50 | - | 否 | 门牌号 | -| Phone | VARCHAR | 50 | - | 否 | 手机 | -| TelePhone | VARCHAR | 50 | - | 否 | 电话 | -| Email | VARCHAR | 60 | - | 否 | 邮箱 | -| IsDefault | TINYINT | - | - | 否 | 是否默认:0-否,1-是 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 订单收件人表 `t_order_recipient` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| OrderId | BIGINT | - | 外键 | 是 | 订单ID | -| RecipientsName | VARCHAR | 30 | - | 否 | 收件人名称 | -| CompanyName | VARCHAR | 60 | - | 否 | 收件人公司名称 | -| Country | INT | - | - | 否 | 国家 | -| Phone | VARCHAR | 20 | - | 否 | 收件人手机 | -| TelePhone | VARCHAR | 20 | - | 否 | 收件人电话 | -| ZipCode | VARCHAR | 15 | - | 否 | 收件人邮编 | -| Province | VARCHAR | 25 | - | 否 | 收件人省/州 | -| City | VARCHAR | 25 | - | 否 | 收件人城市 | -| StreetNumber | VARCHAR | 20 | - | 否 | 收件人门牌号 | -| StreetAddress1 | VARCHAR | 255 | - | 否 | 收件人地址1 | -| StreetAddress2 | VARCHAR | 255 | - | 否 | 收件人地址2 | -| StreetAddress3 | VARCHAR | 255 | - | 否 | 收件人地址3 | -| Email | VARCHAR | 60 | - | 否 | 收件人邮箱 | -| TaxType | INT | - | - | 否 | 税号类型 | -| TaxNo | VARCHAR | 35 | - | 否 | 税号 | -| CertificateType | INT | - | - | 否 | 证件类型 | -| CertificateNo | VARCHAR | 30 | - | 否 | 证件号码 | -| Eori | VARCHAR | 255 | - | 否 | EORI | -| Vat | VARCHAR | 255 | - | 否 | Vat | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 订单申报信息表 `t_order_declaration` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| OrderId | BIGINT | - | 外键 | 是 | 订单ID | -| CnName | VARCHAR | 500 | - | 否 | 品名--中 | -| EnName | VARCHAR | 500 | - | 否 | 品名--英 | -| Sku | VARCHAR | 255 | - | 否 | SKU | -| Unit | VARCHAR | 5 | - | 否 | 单位 | -| UnitPrice | DECIMAL | 10,2 | - | 否 | 单价 | -| UnitWeight | DECIMAL | 11,3 | - | 否 | 单重 | -| Quantity | INT | - | - | 否 | 数量 | -| Amount | DECIMAL | 11,2 | - | 否 | 总金额 | -| Weight | DECIMAL | 11,3 | - | 否 | 总重量 | -| HsCode | VARCHAR | 50 | - | 否 | HsCode | -| SkuImage | VARCHAR | 255 | - | 否 | 图片 | -| Category | VARCHAR | 20 | - | 否 | 类别 | -| Brand | VARCHAR | 50 | - | 否 | 品牌 | -| BrandType | VARCHAR | 50 | - | 否 | 品牌类型 | -| IsMagnetic | BIT | - | - | 否 | 是否带磁带电 | -| Distribution | VARCHAR | 255 | - | 否 | 配货信息 | -| Material | VARCHAR | 50 | - | 否 | 材质 | -| EnMaterial | VARCHAR | 64 | - | 否 | 英文材质 | -| Purpose | VARCHAR | 50 | - | 否 | 用途 | -| Attributes | VARCHAR | 50 | - | 否 | 属性 | -| OriginCountry | VARCHAR | 50 | - | 否 | 原产国 | -| SalesLink | VARCHAR | 255 | - | 否 | 销售链接 | -| Model | VARCHAR | 64 | - | 否 | 型号 | -| Specifications | VARCHAR | 50 | - | 否 | 规格 | -| Comment | VARCHAR | 500 | - | 否 | 备注 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 订单件重尺表 `t_order_weight` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| OrderId | BIGINT | - | 外键 | 是 | 订单ID | -| Type | VARCHAR | 16 | - | 是 | 类型:forecast/receive/ship/downstream | -| Length | DECIMAL | 10,2 | - | 否 | 长(cm) | -| Width | DECIMAL | 10,2 | - | 否 | 宽(cm) | -| Height | DECIMAL | 10,2 | - | 否 | 高(cm) | -| ActualWeight | DECIMAL | 10,3 | - | 否 | 实重(kg) | -| VolumetricWeight | DECIMAL | 10,3 | - | 否 | 体积重(kg) | -| ChargeableWeight | DECIMAL | 10,3 | - | 否 | 计费重(kg) | -| Cubic | DECIMAL | 12,6 | - | 否 | 立方(m³) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -**Type 枚举**:forecast(预报)、receive(收货)、ship(发货)、downstream(下游) - -#### 订单费用表 `t_order_fee`(子表,关联 t_order) - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| OrderId | BIGINT | - | 外键 | 是 | 订单ID | -| Type | VARCHAR | 16 | - | 是 | 类型:receivable(应收)/payable(应付) | -| CostItem | VARCHAR | 64 | - | 是 | 费用项 | -| Amount | DECIMAL | 18,4 | - | 是 | 金额 | -| Currency | VARCHAR | 16 | - | 是 | 币种:RMB/USD/EUR | -| DownstreamProvider | VARCHAR | 128 | - | 否 | 下游服务商 | -| PriceNo | VARCHAR | 64 | - | 否 | 价格编号 | -| PriceEffectiveTime | DATETIME | - | - | 否 | 价格编号生效时间 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -**Type 枚举**:receivable(应收)、payable(应付) - -### 1.3 核心接口设计 - -#### 我的订单 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 分页查询订单列表 | POST | /order/list | 支持 status、keyword、createTimeStart/End 筛选 | -| 订单详情 | GET | /order/detail | 根据订单ID获取详情 | -| 支付 | POST | /order/pay | 订单支付 | -| 预报 | POST | /order/pre-declare | 订单预报 | -| 拦截 | POST | /order/intercept | 订单拦截 | -| 取消拦截 | POST | /order/cancel-intercept | 取消订单拦截 | -| 删除 | POST | /order/delete | 删除订单 | -| 打印 | POST | /order/print | 打印订单 | -| 导出 | POST | /order/export | 导出订单 | - -##### 分页查询订单列表(POST /order/list) - -###### 1 接口请求示例 - -**请求 /order/list**(POST,分页参数放 Body) - -```json -{ - "page": 1, // 页码,从1开始 - "pageSize": 20, // 每页条数 - "status": "all|draft|predeclared|received|...", // 订单状态筛选 - "keyword": "", // 关键词(运单号/客户单号/跟踪号) - "createTimeStart": "", // 创建时间起 - "createTimeEnd": "" // 创建时间止 -} -``` - -**响应**(用于 order/myOrders 列表及订单详情抽屉,字段与 parcel/create 对应,创建时传啥返回啥 + weightList) - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "clientNo": "CUST...", - "trackNo": "TRK...", - "serviceNo": "LP...", - "productCode": "US_AIR", - "channelCode": "CH001", - "cargoAttribute": "general", - "warehouseId": 1, - "warehouse": "深圳仓", - "customerId": 1, - "isCod": false, - "codCurrency": "", - "codSum": "", - "orderStatus": "待支付", - "status": "pendingPayment", - "receiveTime": "", - "shipTime": "", - "payTime": "", - "signTime": "", - "isIntercept": false, - "isRemote": false, - "createTime": "2024-01-01 12:00:00", - "createBy": "", - "updateTime": "", - "updateBy": "", - "sender": { - "senderName": "发件人", - "company": "示例公司", - "phone": "13800138000", - "telePhone": "", - "email": "sender@example.com", - "countryCode": 0, - "province": "广东省", - "city": "深圳市", - "zipCode": "518000", - "streetAddress1": "示例地址1", - "streetAddress2": "", - "streetAddress3": "", - "houseNumber": "" - }, - "receiver": { - "recipientsName": "收件人1", - "companyName": "", - "phone": "13800138000", - "telePhone": "", - "email": "", - "countryCode": 0, - "province": "California", - "city": "Los Angeles", - "streetNumber": "", - "streetAddress1": "123 Main St", - "streetAddress2": "", - "streetAddress3": "", - "zipCode": "90001", - "taxType": 0, - "taxNo": "", - "certificateType": 0, - "certificateNo": "", - "eori": "", - "vat": "" - }, - "items": [ - { - "cnName": "示例商品", - "enName": "Sample Product", - "sku": "SKU001", - "unit": "件", - "unitPrice": 10, - "unitWeight": 0.5, - "quantity": 1, - "amount": 10, - "weight": 0.5, - "hsCode": "1234567890", - "skuImage": "", - "category": "普货", - "brand": "示例品牌", - "brandType": "自有", - "isMagnetic": false, - "distribution": "", - "material": "塑料", - "enMaterial": "Plastic", - "purpose": "日用", - "attributes": "", - "originCountry": "中国", - "salesLink": "", - "model": "", - "specifications": "", - "comment": "" - } - ], - "weightList": [ - { - "type": "forecast", - "length": "20", - "width": "15", - "height": "10", - "actualWeight": "2.50", - "volumetricWeight": "2.00", - "chargeableWeight": "2.50", - "cubic": "0.003" - } - ] - } - ], - "total": 100 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 订单详情(GET /order/detail) - -###### 1 接口请求示例 - -**请求 /order/detail**(GET,Query 参数) - -``` -?id=1 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 1, - "clientNo": "CUST...", - "trackNo": "TRK...", - "serviceNo": "LP...", - "productCode": "US_AIR", - "channelCode": "CH001", - "cargoAttribute": "general", - "warehouseId": 1, - "warehouse": "深圳仓", - "customerId": 1, - "sender": {}, - "receiver": {}, - "items": [], - "weightList": [] - } -} -``` - -> data 结构与 list 单条 item 一致,含 sender、receiver、items、weightList。 - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 支付(POST /order/pay) - -###### 1 接口请求示例 - -**请求 /order/pay**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -> requestId 幂等,可选。 - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 预报(POST /order/pre-declare) - -###### 1 接口请求示例 - -**请求 /order/pre-declare**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 拦截(POST /order/intercept) - -###### 1 接口请求示例 - -**请求 /order/intercept**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 取消拦截(POST /order/cancel-intercept) - -###### 1 接口请求示例 - -**请求 /order/cancel-intercept**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 删除(POST /order/delete) - -###### 1 接口请求示例 - -**请求 /order/delete**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 打印(POST /order/print) - -###### 1 接口请求示例 - -**请求 /order/print**(POST) - -```json -{ - "id": 1, - "requestId": "" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "printUrl": "" - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 导出(POST /order/export) - -###### 1 接口请求示例 - -**请求 /order/export**(POST) - -```json -{ - "status": "all|draft|...", - "keyword": "", - "createTimeStart": "", - "createTimeEnd": "" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "downloadUrl": "", - "expireAt": "" - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 -#### 我要查价 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 查价 | POST | /order/price-check | 根据目的国、重量尺寸等查价 | - -##### 查价(POST /order/price-check) - -###### 1 接口请求示例 - -**请求 /order/price-check**(POST) - -```json -{ - "destinationCountry": "US", // 目的国二字码 - "shippingMethod": "", // 运输方式 - "cargoWeight": "2", // 货物重量(kg) - "length": "20", // 长(cm) - "width": "15", // 宽(cm) - "height": "10", // 高(cm) - "cargoType": "普货", // 货物类型 - "destinationProvince": "", // 目的省州 - "postalCode": "" // 邮编 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "shippingMethod": "美国空运专线(普货)", - "warehouse": "一代深圳仓", - "deliveryTime": "3-7", - "volumetricWeight": "2", - "chargeableWeight": "2", - "costFormula": "(62*2) +5+7", - "freight": "62", - "otherFees": "5", - "totalCost": "136", - "maxDeliveryDays": 7, - "totalCostNum": 136 - } - ] - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 -#### 我要下单 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 模板列表 | GET | /order/template/list | 获取下单模板列表 | -| 下载模板 | GET | /order/template/download | 下载 Excel 模板 | -| 上传下单 | POST | /order/upload | multipart/form-data,file + templateId | -| 任务进度列表 | GET | /order/task/list | 批量下单任务列表 | -| 任务详情 | GET | /order/task/detail | 任务详情 | -| 创建小包 | POST | /order/parcel/create | 创建小包订单 | -| 批量创建小包 | POST | /order/parcel/batch-create | 批量创建小包订单 | - -##### 创建小包(POST /order/parcel/create) - -###### 1 接口请求示例 - -**请求 /order/parcel/create**(POST) - -> 字段与 t_order、t_order_sender、t_order_recipient、t_order_declaration、t_order_weight 表对应,采用 camelCase。 - -```json -{ - "clientNo": "string", // 客户单号 t_order.ClientNo - "trackNo": "string", // 跟踪号 t_order.TrackNo - "serviceNo": "string", // 运单号 t_order.ServiceNo - "productCode": "string", // 产品代码 t_order.ProductCode - "channelCode": "string", // 渠道代码 t_order.ChannelCode - "cargoAttribute": "string", // 货物属性 t_order.CargoAttribute:general-普货,electronics-3c&带电,sensitive-敏感货 - "warehouseId": 0, // 仓库ID t_order.WarehouseId - "customerId": 0, // 客户ID t_order.CustomerId - "length": 3, // 长(cm) t_order_weight.Length - "width": 3, // 宽(cm) t_order_weight.Width - "height": 3, // 高(cm) t_order_weight.Height - "actualWeight": 3, // 实重(kg) t_order_weight.ActualWeight - "volumetricWeight": 3, // 体积重(kg) t_order_weight.VolumetricWeight - "chargeableWeight": 3, // 计费重(kg) t_order_weight.ChargeableWeight - "cubic": 0, // 立方(m³) t_order_weight.Cubic - "isCod": false, // 是否COD - "codCurrency": "string", // COD币种 - "codSum": "string", // COD金额 - "declaredCurrency": "string", // 申报币种 - "declaredSum": "string", // 申报金额 - "quantity": "string", // 包裹数量 - "receiver": { // 收件人 t_order_recipient - "recipientsName": "string", // 收件人名称 - "companyName": "string", // 收件人公司名称 - "phone": "string", // 收件人手机 - "telePhone": "string", // 收件人电话 - "email": "string", // 收件人邮箱 - "countryCode": 0, // 国家 - "province": "string", // 收件人省/州 - "city": "string", // 收件人城市 - "streetNumber": "string", // 收件人门牌号 - "streetAddress1": "string", // 收件人地址1 - "streetAddress2": "string", // 收件人地址2 - "streetAddress3": "string", // 收件人地址3 - "zipCode": "string", // 收件人邮编 - "taxType": 0, // 税号类型 - "taxNo": "string", // 税号 - "certificateType": 0, // 证件类型 - "certificateNo": "string", // 证件号码 - "eori": "string", // EORI - "vat": "string" // Vat - }, - "sender": { // 发件人 t_order_sender - "senderName": "string", // 发件人姓名 - "company": "string", // 发件人公司 - "phone": "string", // 发件人手机 - "telePhone": "string", // 发件人电话 - "email": "string", // 发件人邮箱 - "countryCode": 0, // 国家 - "province": "string", // 州省 - "city": "string", // 城市 - "zipCode": "string", // 邮编 - "streetAddress1": "string", // 地址1 - "streetAddress2": "string", // 地址2 - "streetAddress3": "string", // 地址3 - "houseNumber": "string" // 门牌号 - }, - "items": [ // 申报明细 t_order_declaration - { - "cnName": "string", // 品名-中 - "enName": "string", // 品名-英 - "sku": "string", // SKU - "unit": "string", // 单位 - "unitPrice": 0, // 单价 - "unitWeight": 0, // 单重 - "quantity": 0, // 数量 - "amount": 0, // 总金额 - "weight": 0, // 总重量 - "imHsCode": "string", // 进口HsCode - "exHsCode": "string", // 出口HsCode - "skuImage": "string", // 图片 - "category": "string", // 类别 - "brand": "string", // 品牌 - "brandType": "string", // 品牌类型 - "isMagnetic": false, // 是否带磁带电 - "distribution": "string", // 配货信息 - "material": "string", // 材质 - "enMaterial": "string", // 英文材质 - "purpose": "string", // 用途 - "attributes": "string", // 属性 - "originCountry": "string", // 原产国 - "salesLink": "string", // 销售链接 - "model": "string", // 型号 - "specifications": "string", // 规格 - "comment": "string" // 备注 - } - ] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "clientNo": "", - "serviceNo": "", - "trackNo": "", - "labelUrl": "" - } -} -``` - -> 幂等时可选传 requestId。 - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 批量创建小包(POST /order/parcel/batch-create) - -###### 1 接口请求示例 - -**请求 /order/parcel/batch-create**(POST) - -> 批量请求体为数组,数组内每一项与 `/order/parcel/create` 的单票请求结构完全一致。 - -```json -[ - { - "clientNo": "string", // 客户单号 t_order.ClientNo - "trackNo": "string", // 跟踪号 t_order.TrackNo - "serviceNo": "string", // 运单号 t_order.ServiceNo - "productCode": "string", // 产品代码 t_order.ProductCode - "channelCode": "string", // 渠道代码 t_order.ChannelCode - "cargoAttribute": "string", // 货物属性 t_order.CargoAttribute:general-普货,electronics-3c&带电,sensitive-敏感货 - "warehouseId": 0, // 仓库ID t_order.WarehouseId - "customerId": 0, // 客户ID t_order.CustomerId - "length": 3, // 长(cm) t_order_weight.Length - "width": 3, // 宽(cm) t_order_weight.Width - "height": 3, // 高(cm) t_order_weight.Height - "actualWeight": 3, // 实重(kg) t_order_weight.ActualWeight - "volumetricWeight": 3, // 体积重(kg) t_order_weight.VolumetricWeight - "chargeableWeight": 3, // 计费重(kg) t_order_weight.ChargeableWeight - "cubic": 0, // 立方(m³) t_order_weight.Cubic - "isCod": false, // 是否COD - "codCurrency": "string", // COD币种 - "codSum": "string", // COD金额 - "declaredCurrency": "string", // 申报币种 - "declaredSum": "string", // 申报金额 - "quantity": "string", // 包裹数量 - "receiver": { // 收件人 t_order_recipient - "recipientsName": "string", // 收件人名称 - "companyName": "string", // 收件人公司名称 - "phone": "string", // 收件人手机 - "telePhone": "string", // 收件人电话 - "email": "string", // 收件人邮箱 - "countryCode": 0, // 国家 - "province": "string", // 收件人省/州 - "city": "string", // 收件人城市 - "streetNumber": "string", // 收件人门牌号 - "streetAddress1": "string", // 收件人地址1 - "streetAddress2": "string", // 收件人地址2 - "streetAddress3": "string", // 收件人地址3 - "zipCode": "string", // 收件人邮编 - "taxType": 0, // 税号类型 - "taxNo": "string", // 税号 - "certificateType": 0, // 证件类型 - "certificateNo": "string", // 证件号码 - "eori": "string", // EORI - "vat": "string" // Vat - }, - "sender": { // 发件人 t_order_sender - "senderName": "string", // 发件人姓名 - "company": "string", // 发件人公司 - "phone": "string", // 发件人手机 - "telePhone": "string", // 发件人电话 - "email": "string", // 发件人邮箱 - "countryCode": 0, // 国家 - "province": "string", // 州省 - "city": "string", // 城市 - "zipCode": "string", // 邮编 - "streetAddress1": "string", // 地址1 - "streetAddress2": "string", // 地址2 - "streetAddress3": "string", // 地址3 - "houseNumber": "string" // 门牌号 - }, - "items": [ // 申报明细 t_order_declaration - { - "cnName": "string", // 品名-中 - "enName": "string", // 品名-英 - "sku": "string", // SKU - "unit": "string", // 单位 - "unitPrice": 0, // 单价 - "unitWeight": 0, // 单重 - "quantity": 0, // 数量 - "amount": 0, // 总金额 - "weight": 0, // 总重量 - "imHsCode": "string", // 进口HsCode - "exHsCode": "string", // 出口HsCode - "skuImage": "string", // 图片 - "category": "string", // 类别 - "brand": "string", // 品牌 - "brandType": "string", // 品牌类型 - "isMagnetic": false, // 是否带磁带电 - "distribution": "string", // 配货信息 - "material": "string", // 材质 - "enMaterial": "string", // 英文材质 - "purpose": "string", // 用途 - "attributes": "string", // 属性 - "originCountry": "string", // 原产国 - "salesLink": "string", // 销售链接 - "model": "string", // 型号 - "specifications": "string", // 规格 - "comment": "string" // 备注 - } - ] - } -] -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 接收批量订单数据并逐条按单票创建规则执行,下发统一批次任务号并返回逐条处理结果。 - -####### 校验与异常 - -- 校验请求数组非空、单次数量上限与单条必填字段;部分失败时返回明细错误信息。 - -##### 模板列表(GET /order/template/list) - -###### 1 接口请求示例 - -**请求 /order/template/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&type=standard|custom&keyword= -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "name": "标准模板", - "type": "standard", - "templateKey": "standard_v1", - "customerId": 0, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 10 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 下载模板(GET /order/template/download) - -###### 1 接口请求示例 - -**请求 /order/template/download**(GET,Query 参数) - -``` -?templateId=1 -``` - -**响应** - -> 直接返回 Excel 文件流,Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 上传下单(POST /order/upload) - -###### 1 接口请求示例 - -**请求 /order/upload**(POST,multipart/form-data) - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| file | File | Excel 文件 | -| templateId | number | 模板ID | - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "taskId": 1 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 任务进度列表(GET /order/task/list) - -###### 1 接口请求示例 - -**请求 /order/task/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&status=&keyword= -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "taskName": "批量下单_20240101", - "quantity": 100, - "status": "处理中", - "templateId": 1, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 10 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 任务详情(GET /order/task/detail) - -###### 1 接口请求示例 - -**请求 /order/task/detail**(GET,Query 参数) - -``` -?taskId=1 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 1, - "taskName": "批量下单_20240101", - "quantity": 100, - "successCount": 80, - "failCount": 20, - "status": "已完成", - "templateId": 1, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00", - "failList": [] - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 -## 三、渠道模块 - -### 2.1 核心需求说明 - -- **销售产品**:管理对外销售的产品(专线/快递/邮政),配置时效、黑名单区、偏远分区、价格分区、计重规则、计重进位等;支持价格配置。 - - **产品配置**:产品基础信息(编码、名称、分组、时效、面单、黑名单区、偏远分区、价格分区、计重规则、计重进位等)。 - - **产品卖价**:为每个产品配置多条价格项(运费、保险费、燃油附加费等);每条价格项通过弹窗配置: - - **基础信息**:费用项、费用单位、币种、收费时机、收费条件、计费因素(因素+因素值,如货物类型、分区、重量、仓库、客户等)。 - - **价格表**:根据计费因素动态生成价格表,支持分区/货物类型/重量等组合、计算公式(总价/单价/续单价)、计算值、客户组。 - - **排货规则**:选择排货方案(成本最低/时效最快/综合最优),配置服务商列表(服务商名称、服务渠道、权重、指定客户)。 - - **发布记录**:价格与规则的发布历史,支持查看版本内容、回滚到指定版本。 -- **服务渠道**:管理下游服务渠道(支持渠道组+渠道层级),配置预报单号类型、时效、买价等。 - - **渠道配置**:渠道基础信息(渠道代码、名称、时效、预报配置、描述、预报前脚本等)。 - - **渠道买价**:为每个渠道配置多条买价项(运费、保险费、燃油附加费等);每条买价项通过弹窗配置: - - **基础信息**:费用项、费用单位、币种、收费时机、收费条件、计费因素(因素+因素值)。 - - **价格表**:根据计费因素动态生成价格表,支持分区/货物类型/重量等组合、计算公式、计算值、客户组。 - - **发布记录**:买价与规则的发布历史,支持查看版本内容、回滚到指定版本。 -- **黑名单区/偏远分区/价格分区**:共用 KpiArea 表,仅通过 `AreaType` 区分;支持分区及明细(国家/省/市/邮编区间)的增删改查。 -- **计重规则/计重进位**:配置计费重量规则与进位方式,明细结构与分区类似。 -- **自动触发**:配置下单/入库/出库时的自动拦截、警告、问题件等触发规则。 -- **标签设计**:保存 vg-print 导出的标签模板 JSON,支持尾程/客户/入库/出库/sku 等类型。 - -### 2.2 表结构设计 - -#### 销售产品表 `t_sales_product` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| ProductCode | VARCHAR | 64 | - | 是 | 产品编码 | -| ProductName | VARCHAR | 128 | - | 是 | 产品名称 | -| ProductGroup | VARCHAR | 32 | - | 否 | 分组(专线/快递/邮政) | -| MinTimeliness | INT | - | - | 否 | 最小时效(天) | -| MaxTimeliness | INT | - | - | 否 | 最大时效(天) | -| CustomerWaybill | VARCHAR | 32 | - | 否 | 系统面单/客户面单 | -| BlacklistAreaId | BIGINT | - | 外键 | 否 | 黑名单区ID | -| RemoteAreaId | BIGINT | - | 外键 | 否 | 偏远分区ID | -| PriceAreaId | BIGINT | - | 外键 | 否 | 价格分区ID(`t_kpi_area`,`AreaType=price`) | -| WeightRuleId | BIGINT | - | 外键 | 否 | 计重规则ID | -| WeightRoundingId | BIGINT | - | 外键 | 否 | 计重进位ID | -| ProductDescription | TEXT | - | - | 否 | 产品描述 | -| PreStorageScript | TEXT | - | - | 否 | 入库前脚本 | -| IsActive | TINYINT | - | - | 是 | 是否启用 | -| Scheme | VARCHAR | 32 | - | 是 | 排货方案:costLowest/timeFastest/overallBest | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 产品卖价表 `t_sales_product_price`(子表,关联 t_sales_product) - -> 产品卖价主表,每个产品可配置多条价格项(运费、保险费、燃油附加费等)。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| SalesProductId | BIGINT | - | 外键 | 是 | 销售产品ID | -| SerialNumber | INT | - | - | 是 | 序号 | -| Status | TINYINT | - | - | 是 | 状态:0-禁用,1-启用 | -| PriceName | VARCHAR | 64 | - | 是 | 价格名称(如运费、保险费) | -| CostItem | VARCHAR | 64 | - | 是 | 费用项 | -| CostUnit | VARCHAR | 16 | - | 是 | 费用单位:kg/票/件 | -| Currency | VARCHAR | 16 | - | 是 | 币种:RMB/USD/EUR | -| ChargingTime | VARCHAR | 32 | - | 否 | 收费时机:预报/出库/签收 | -| ChargingCondition | VARCHAR | 64 | - | 否 | 收费条件 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 产品卖价-计费因素表 `t_sales_product_price_factor`(子表,关联 t_sales_product_price) - -> 弹窗「基础信息」中的计费因素配置,支持货物类型、分区、重量、仓库、客户等。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| PriceId | BIGINT | - | 外键 | 是 | 产品卖价ID | -| SerialNumber | INT | - | - | 是 | 序号(决定因素排序) | -| Factor | VARCHAR | 64 | - | 是 | 因素:货物类型/分区/重量/仓库/客户 | -| FactorValues | JSON | - | - | 否 | 因素值数组,如 ["普货","敏感货"] | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 产品卖价-价格表明细表 `t_sales_product_price_detail`(子表,关联 t_sales_product_price) - -> 弹窗「价格表」中的价格明细,表头由计费因素动态生成,存储各组合的价格值。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| PriceId | BIGINT | - | 外键 | 是 | 产品卖价ID | -| FactorValuesJson | JSON | - | - | 否 | 非客户因素组合,如 {"分区":"欧洲","货物类型":"普货","重量":"0-0.5"} | -| CalculationFormula | VARCHAR | 32 | - | 是 | 计算公式:总价/单价/续单价 | -| CalculatedValue | DECIMAL | 18,4 | - | 否 | 计算值 | -| CustomerGroupValues | JSON | - | - | 否 | 客户组对应价格,如 {"客户组1":10,"客户组2":12} | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - - -#### 排货规则-服务商表 `t_sales_product_allocation_provider`(子表,关联 t_sales_product_allocation_rule) - -> 服务商列表:服务商名称、服务渠道、权重、指定客户。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| SalesProductId | BIGINT | - | 外键 | 是 | 销售产品ID | -| SerialNumber | INT | - | - | 是 | 序号 | -| ProviderName | VARCHAR | 128 | - | 是 | 服务商名称 | -| ServiceChannelId | BIGINT | - | 外键 | 否 | 服务渠道ID | -| Weight | INT | - | - | 是 | 权重(评分用) | -| CustomerIds | JSON | - | - | 否 | 指定客户ID数组,空表示全部 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -**Scheme 枚举**:costLowest(成本最低)、timeFastest(时效最快)、overallBest(综合最优) - -#### 发布记录表 `t_sales_product_publish_record`(子表,关联 t_sales_product) - -> 价格与规则的发布历史,支持查看版本内容、回滚。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| SalesProductId | BIGINT | - | 外键 | 是 | 销售产品ID | -| Version | VARCHAR | 32 | - | 是 | 版本号,如 20231227112339 | -| OperatorType | VARCHAR | 16 | - | 是 | 操作类型:price(价格)/rules(规则) | -| Status | VARCHAR | 16 | - | 是 | 状态:生效/回滚/无效 | -| PublishUser | VARCHAR | 64 | - | 是 | 发布人 | -| PublishTime | DATETIME | - | - | 是 | 发布时间 | -| Description | VARCHAR | 512 | - | 否 | 版本备注 | -| Content | JSON | - | - | 否 | 发布内容(价格或规则配置的JSON快照) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 服务渠道组表 `t_service_channel_group`(可选,用于渠道分组) - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| Name | VARCHAR | 128 | - | 是 | 渠道组名称 | -| AppId | BIGINT | - | - | 否 | 应用ID(页面新增/编辑服务商时保存) | -| ApiServiceProviderId | BIGINT | - | - | 否 | API服务商ID(页面新增/编辑服务商时保存) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 服务渠道表 `t_service_channel` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| GroupId | BIGINT | - | 外键 | 否 | 渠道组ID(可选) | -| ChannelCode | VARCHAR | 64 | - | 是 | 渠道编码 | -| ChannelName | VARCHAR | 128 | - | 是 | 渠道名称 | -| MinTime | INT | - | - | 否 | 最小时效(天) | -| MaxTime | INT | - | - | 否 | 最大时效(天) | -| ForecastType | VARCHAR | 32 | - | 否 | 预报单号类型(customer/waybill/tracking) | -| ForecastCode | VARCHAR | 128 | - | 否 | 预报单号 | -| Description | TEXT | - | - | 否 | 描述 | -| PreScript | TEXT | - | - | 否 | 入库前脚本 | -| IsActive | TINYINT | - | - | 是 | 是否启用 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 渠道买价表 `t_service_channel_price`(子表,关联 t_service_channel) - -> 渠道买价主表,每个渠道可配置多条买价项(运费、保险费、燃油附加费等)。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| ServiceChannelId | BIGINT | - | 外键 | 是 | 服务渠道ID | -| SerialNumber | INT | - | - | 是 | 序号 | -| Status | TINYINT | - | - | 是 | 状态:0-禁用,1-启用 | -| PriceName | VARCHAR | 64 | - | 是 | 价格名称 | -| CostItem | VARCHAR | 64 | - | 是 | 费用项 | -| CostUnit | VARCHAR | 16 | - | 是 | 费用单位:kg/票/件 | -| Currency | VARCHAR | 16 | - | 是 | 币种:RMB/USD/EUR | -| ChargingTime | VARCHAR | 32 | - | 否 | 收费时机:预报/出库/签收 | -| ChargingCondition | VARCHAR | 64 | - | 否 | 收费条件 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 渠道买价-计费因素表 `t_service_channel_price_factor`(子表,关联 t_service_channel_price) - -> 弹窗「基础信息」中的计费因素配置。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| PriceId | BIGINT | - | 外键 | 是 | 渠道买价ID | -| SerialNumber | INT | - | - | 是 | 序号 | -| Factor | VARCHAR | 64 | - | 是 | 因素:货物类型/分区/重量/仓库/客户 | -| FactorValues | JSON | - | - | 否 | 因素值数组 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 渠道买价-价格表明细表 `t_service_channel_price_detail`(子表,关联 t_service_channel_price) - -> 弹窗「价格表」中的价格明细。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| PriceId | BIGINT | - | 外键 | 是 | 渠道买价ID | -| FactorValuesJson | JSON | - | - | 否 | 非客户因素组合 | -| CalculationFormula | VARCHAR | 32 | - | 是 | 计算公式:总价/单价/续单价 | -| CalculatedValue | DECIMAL | 18,4 | - | 否 | 计算值 | -| CustomerGroupValues | JSON | - | - | 否 | 客户组对应价格 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 服务渠道发布记录表 `t_service_channel_publish_record`(子表,关联 t_service_channel) - -> 买价与规则的发布历史。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| ServiceChannelId | BIGINT | - | 外键 | 是 | 服务渠道ID | -| Version | VARCHAR | 32 | - | 是 | 版本号 | -| OperatorType | VARCHAR | 16 | - | 是 | 操作类型:price(价格)/rules(规则) | -| Status | VARCHAR | 16 | - | 是 | 状态:生效/回滚/无效 | -| PublishUser | VARCHAR | 64 | - | 是 | 发布人 | -| PublishTime | DATETIME | - | - | 是 | 发布时间 | -| Description | VARCHAR | 512 | - | 否 | 版本备注 | -| Content | JSON | - | - | 否 | 发布内容JSON快照 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 分区表(黑名单区/偏远分区/价格分区共用)`t_kpi_area` - -> 三类分区共用本表,仅通过 `AreaType` 区分业务含义。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| AreaType | VARCHAR | 32 | - | 是 | 类型: blacklist/remote/price | -| Name | VARCHAR | 128 | - | 是 | 分区名称 | -| SystemName | VARCHAR | 128 | - | 否 | 系统名称 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -**AreaType 枚举**:blacklist(黑名单区)、remote(偏远分区)、price(价格分区) - -#### 分区明细表 `t_kpi_area_info` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| AreaId | BIGINT | - | 外键 | 是 | 分区ID | -| CountryCode | VARCHAR | 8 | - | 否 | 国家二字码 | -| CountryName | VARCHAR | 64 | - | 否 | 国家名称 | -| Province | VARCHAR | 128 | - | 否 | 省份 | -| City | VARCHAR | 128 | - | 否 | 城市 | -| LeftPostcode | VARCHAR | 32 | - | 否 | 左侧邮编 | -| RightPostcode | VARCHAR | 32 | - | 否 | 右侧邮编 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 计重规则表 `t_weight_rule` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| Name | VARCHAR | 128 | - | 是 | 规则名称 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 计重规则明细表 `t_weight_rule_info`(子表,关联 t_weight_rule) - -> 计重规则子页面,配置客户组对应的计费规则(条件表达式、计费重取值、材积系数)。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| RuleId | BIGINT | - | 外键 | 是 | 计重规则ID | -| Client | VARCHAR | 128 | - | 是 | 客户/客户组(如:公共、客户组1、客户组2) | -| Condition | VARCHAR | 256 | - | 是 | 条件表达式(如:gw>vw) | -| BillableWeight | VARCHAR | 64 | - | 是 | 计费重取值(如:vw、gw) | -| VolumetricFactor | VARCHAR | 32 | - | 是 | 材积系数(如:6000) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 计重进位表 `t_weight_rounding` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| Name | VARCHAR | 128 | - | 是 | 进位名称 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 计重进位明细表 `t_weight_rounding_info`(子表,关联 t_weight_rounding) - -> 计重进位子页面,配置重量区间的进位值(起始值、结束值、公共/客户组进位值)。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| RoundingId | BIGINT | - | 外键 | 是 | 计重进位ID | -| StartValue | DECIMAL | 18,4 | - | 是 | 起始值(kg) | -| EndValue | DECIMAL | 18,4 | - | 是 | 结束值(kg) | -| Public | VARCHAR | 32 | - | 否 | 公共进位值 | -| CustomerGroup1 | VARCHAR | 32 | - | 否 | 客户组1进位值 | -| CustomerGroup2 | VARCHAR | 32 | - | 否 | 客户组2进位值 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 自动触发表 `t_auto_trigger` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| Status | TINYINT | - | - | 是 | 是否启用 | -| Operation | VARCHAR | 32 | - | 否 | 操作: 拦截/警告/问题件 | -| TriggerTiming | VARCHAR | 32 | - | 否 | 触发时机: 下单/入库/出库 | -| TriggerCondition | TEXT | - | - | 否 | 触发条件 | -| PromptMessage | VARCHAR | 512 | - | 否 | 提示信息 | -| Notifier | VARCHAR | 128 | - | 否 | 通知人 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 标签模板表 `t_label_template` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| Name | VARCHAR | 128 | - | 是 | 模板名称 | -| TemplateJson | JSON | - | - | 否 | vg-print 模板 JSON | -| TemplateType | VARCHAR | 32 | - | 否 | 尾程/客户/入库/出库/sku | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -### 2.3 核心接口设计 - -#### 销售产品 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 产品列表 | GET | /channel/sales-product/list | 产品列表 | -| 产品详情 | GET | /channel/sales-product/detail | 产品详情 | -| 新增产品 | POST | /channel/sales-product/create | 新增产品 | -| 更新产品 | POST | /channel/sales-product/update | 更新产品(包含产品配置 tab 保存) | -| 删除产品 | POST | /channel/sales-product/delete | 删除产品 | - -##### 产品列表(GET /channel/sales-product/list) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&productGroup=专线&keyword=&isActive=1 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "productCode": "US-AIR-P", - "productName": "美国空运专线(普货)", - "productGroup": "专线", - "minTimeliness": 3, - "maxTimeliness": 7, - "customerWaybill": "系统面单", - "blacklistAreaId": 1, - "remoteAreaId": 2, - "priceAreaId": 3, - "weightRuleId": 1, - "weightRoundingId": 1, - "productDescription": "美国空运普货专线", - "isActive": 1, - "scheme": "costLowest", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 10 - } -} -``` - - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 接收分页与筛选参数后构建查询条件(productGroup、keyword、isActive)。 -####### 校验与异常 -- 按分页参数查询销售产品列表,返回 `items` 与 `total`。 - -##### 产品详情(GET /channel/sales-product/detail) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/detail**(GET,Query 参数) - -``` -?id=1 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 1, - "productCode": "US-AIR-P", - "productName": "美国空运专线(普货)", - "productGroup": "专线", - "minTimeliness": 3, - "maxTimeliness": 7, - "customerWaybill": "系统面单", - "blacklistAreaId": 1, - "remoteAreaId": 2, - "priceAreaId": 3, - "weightRuleId": 1, - "weightRoundingId": 1, - "productDescription": "美国空运普货专线", - "preStorageScript": "", - "isActive": 1, - "scheme": "costLowest", - "createTime": "2024-01-01 12:00:00", - "createBy": "admin", - "updateTime": "2024-01-01 12:00:00", - "updateBy": "admin" - } -} -``` - - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据 `id` 查询产品主数据及扩展配置,组装详情对象返回。 -####### 校验与异常 -- 若产品不存在或不可访问,返回业务错误码。 - -##### 新增产品(POST /channel/sales-product/create) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/create**(POST) - -```json -{ - "productCode": "US-AIR-P", - "productName": "美国空运专线(普货)", - "productGroup": "专线", - "minTimeliness": 3, - "maxTimeliness": 7, - "customerWaybill": "系统面单", - "blacklistAreaId": 1, - "remoteAreaId": 2, - "priceAreaId": 3, - "weightRuleId": 1, - "weightRoundingId": 1, - "productDescription": "美国空运普货专线", - "preStorageScript": "", - "isActive": 1, - "scheme": "costLowest" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 校验产品编码唯一性与必填字段后新增产品记录。 -####### 校验与异常 -- 初始化产品配置与默认状态,写入审计字段。 - -##### 更新产品(POST /channel/sales-product/update) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/update**(POST) - -```json -{ - "id": 1, - "productCode": "US-AIR-P", - "productName": "美国空运专线(普货)", - "productGroup": "专线", - "minTimeliness": 3, - "maxTimeliness": 7, - "customerWaybill": "系统面单", - "blacklistAreaId": 1, - "remoteAreaId": 2, - "priceAreaId": 3, - "weightRuleId": 1, - "weightRoundingId": 1, - "productDescription": "美国空运普货专线", - "preStorageScript": "", - "isActive": 1, - "scheme": "costLowest" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据 `id` 校验产品存在性后更新可变字段(包含产品配置 tab 的保存内容)。 -####### 校验与异常 -- 记录操作人和更新时间,并触发配置缓存刷新。 - -##### 删除产品(POST /channel/sales-product/delete) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 校验产品是否可删除(无有效关联配置/任务)后执行逻辑删除。 -####### 校验与异常 -- 更新删除标识及审计字段,避免物理删除带来的历史数据丢失。 - -#### 产品卖价(子表接口) - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 价格项列表 | GET | /channel/sales-product/price/list | 按产品ID获取价格项列表 | -| 新增价格项 | POST | /channel/sales-product/price/create | 新增价格项(含基础信息+计费因素+价格表) | -| 更新价格项 | POST | /channel/sales-product/price/update | 更新价格项 | -| 删除价格项 | POST | /channel/sales-product/price/delete | 删除价格项 | -| 切换价格项状态 | POST | /channel/sales-product/price/toggleStatus | 启用/禁用价格项 | -| 保存价格配置 | POST | /channel/sales-product/price/save | 批量保存产品卖价(保存并可选发布) | - -##### 价格项列表(GET /channel/sales-product/price/list) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/price/list**(GET,Query 参数) - -``` -?salesProductId=1 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "salesProductId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [ - { - "id": 1, - "priceId": 1, - "serialNumber": 1, - "factor": "货物类型", - "factorValues": ["普货", "敏感货"] - } - ], - "details": [ - { - "id": 1, - "priceId": 1, - "factorValuesJson": {"分区": "欧洲", "货物类型": "普货", "重量": "0-0.5"}, - "calculationFormula": "单价", - "calculatedValue": 62, - "customerGroupValues": {"客户组1": 60, "客户组2": 65} - } - ], - "version": "20260425143000", - "publishTime": "2026-04-25 14:30:00", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ] - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按 `salesProductId` 查询价格项主表,并聚合返回计费因素与价格明细子数据。 -- 结果按 `serialNumber` 升序组织,便于前端按配置顺序渲染。 - -####### 校验与异常 - -- 校验 `salesProductId` 必填且产品存在;不存在时返回业务错误。 -- 仅返回未删除记录,查询异常返回统一错误码。 - -##### 新增价格项(POST /channel/sales-product/price/create) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/price/create**(POST) - -```json -{ - "salesProductId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [ - { - "serialNumber": 1, - "factor": "货物类型", - "factorValues": ["普货", "敏感货"] - }, - { - "serialNumber": 2, - "factor": "分区", - "factorValues": ["欧洲", "美国"] - } - ], - "details": [ - { - "factorValuesJson": {"分区": "欧洲", "货物类型": "普货", "重量": "0-0.5"}, - "calculationFormula": "单价", - "calculatedValue": 62, - "customerGroupValues": {"客户组1": 60, "客户组2": 65} - } - ] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 新增价格项主记录,并写入对应 `factors` 与 `details` 子记录。 -- 同步写入审计字段,保持主从数据一致性。 - -####### 校验与异常 - -- 校验 `salesProductId`、`priceName`、`costItem`、`costUnit` 等必填字段。 -- 校验同产品下 `serialNumber` 不冲突;冲突时返回业务错误。 - -##### 更新价格项(POST /channel/sales-product/price/update) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/price/update**(POST) - -```json -{ - "id": 1, - "salesProductId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [], - "details": [] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按 `id` 更新价格项主数据,并按最新请求重建/同步因素与明细子数据。 -- 保留版本追溯能力,更新后可参与后续发布流程。 - -####### 校验与异常 - -- 校验 `id` 存在且归属 `salesProductId`。 -- 校验更新后配置合法(计算公式、阶梯区间、客户组取值等),不合法时返回业务错误。 - -##### 删除价格项(POST /channel/sales-product/price/delete) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/price/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按价格项 `id` 执行逻辑删除,并处理关联子表数据的可见性。 - -####### 校验与异常 - -- 校验 `id` 存在且允许删除(如未处于不可删状态)。 -- 删除失败返回统一业务错误码。 - -##### 切换价格项状态(POST /channel/sales-product/price/toggleStatus) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/price/toggleStatus**(POST) - -```json -{ - "id": 1, - "status": 0 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按 `id` 更新价格项启用状态(`status`),并刷新相关缓存。 - -####### 校验与异常 - -- 校验 `status` 仅允许合法枚举值(启用/禁用)。 -- 校验 `id` 存在,不存在时返回业务错误。 - -##### 保存价格配置(POST /channel/sales-product/price/save) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/price/save**(POST) - -```json -{ - "salesProductId": 1, - "publish": false, - "prices": [ - { - "id": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [], - "details": [] - } - ] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 批量保存 `prices`,按是否存在 `id` 自动分流新增/更新,并同步子项数据。 -- 当 `publish=true` 时,保存完成后触发发布流程并生成版本记录。 - -####### 校验与异常 - -- 校验 `salesProductId`、`prices` 必填且数据结构合法。 -- 批量保存过程中任一子项失败则整体回滚,返回统一错误信息。 -#### 排货规则(子表接口) - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 获取排货规则 | GET | /channel/sales-product/allocation/get | 按产品ID获取排货方案及服务商列表 | -| 保存排货规则 | POST | /channel/sales-product/allocation/save | 保存排货方案及服务商(保存并可选发布) | - -##### 获取排货规则(GET /channel/sales-product/allocation/get) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/allocation/get**(GET,Query 参数) - -``` -?salesProductId=1 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "salesProductId": 1, - "scheme": "costLowest", - "providers": [ - { - "id": 1, - "salesProductId": 1, - "serialNumber": 1, - "providerName": "一代物流", - "serviceChannelId": 10, - "serviceChannelName": "DHL美国专线", - "weight": 100, - "customerIds": [1, 2], - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ] - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据 `salesProductId` 查询排货方案与服务商列表并按顺序返回。 - -####### 校验与异常 - -- 校验 `salesProductId` 必填且产品存在;不存在时返回业务错误。 - -##### 保存排货规则(POST /channel/sales-product/allocation/save) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/allocation/save**(POST) - -```json -{ - "salesProductId": 1, - "publish": false, - "scheme": "costLowest", - "providers": [ - { - "id": 0, - "serialNumber": 1, - "providerName": "一代物流", - "serviceChannelId": 10, - "weight": 100, - "customerIds": [1, 2] - } - ] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 保存排货策略与服务商配置,按 `id` 判定新增或更新。 -- 当 `publish=true` 时触发规则发布并生成版本记录。 - -####### 校验与异常 - -- 校验 `salesProductId`、`scheme` 与 `providers` 合法性。 -- 保存失败回滚事务并返回统一错误信息。 - -#### 发布记录(子表接口) - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 价格发布记录列表 | GET | /channel/sales-product/publish/price/list | 按产品ID获取价格发布历史 | -| 规则发布记录列表 | GET | /channel/sales-product/publish/rules/list | 按产品ID获取规则发布历史 | -| 发布 | POST | /channel/sales-product/publish | 发布当前配置(价格或规则) | -| 回滚到版本 | POST | /channel/sales-product/publish/rollback | 回滚到指定版本 | - -##### 价格发布记录列表(GET /channel/sales-product/publish/price/list) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/publish/price/list**(GET,Query 参数) - -``` -?salesProductId=1&page=1&pageSize=20 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "salesProductId": 1, - "version": "20231227112339", - "operatorType": "price", - "status": "生效", - "publishUser": "admin", - "publishTime": "2023-12-27 11:23:39", - "description": "价格调整", - "content": {}, - "createTime": "2023-12-27 11:23:39" - } - ], - "total": 5 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按产品ID分页查询价格类发布历史,返回版本与发布人信息。 - -####### 校验与异常 - -- 校验 `salesProductId` 与分页参数合法性,异常时返回统一错误码。 - -##### 规则发布记录列表(GET /channel/sales-product/publish/rules/list) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/publish/rules/list**(GET,Query 参数) - -``` -?salesProductId=1&page=1&pageSize=20 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 2, - "salesProductId": 1, - "version": "20231228103000", - "operatorType": "rules", - "status": "生效", - "publishUser": "admin", - "publishTime": "2023-12-28 10:30:00", - "description": "规则调整", - "content": {}, - "createTime": "2023-12-28 10:30:00" - } - ], - "total": 3 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按产品ID分页查询规则类发布历史,`operatorType` 固定为 `rules`。 - -####### 校验与异常 - -- 校验 `salesProductId` 与分页参数合法性,异常时返回统一错误码。 - -##### 发布(POST /channel/sales-product/publish) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/publish**(POST) - -```json -{ - "salesProductId": 1, - "operatorType": "price", - "description": "价格调整" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 将当前配置发布为新版本并写入发布记录。 - -####### 校验与异常 - -- 校验 `salesProductId`、`operatorType` 必填且可发布。 - -##### 回滚到版本(POST /channel/sales-product/publish/rollback) - -###### 1 接口请求示例 - -**请求 /channel/sales-product/publish/rollback**(POST) - -```json -{ - "salesProductId": 1, - "recordId": 1, - "operatorType": "price" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 将配置回滚到指定发布版本并生成回滚记录。 - -####### 校验与异常 - -- 校验 `recordId` 版本存在且与产品、类型一致,不一致时返回业务错误。 - -#### 服务渠道 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 渠道列表 | GET | /channel/service-channel/list | 渠道列表(含渠道组层级) | -| 渠道详情 | GET | /channel/service-channel/detail | 渠道详情 | -| 新增渠道 | POST | /channel/service-channel/create | 新增渠道 | -| 更新渠道 | POST | /channel/service-channel/update | 更新渠道(包含渠道配置 tab 保存) | -| 删除渠道 | POST | /channel/service-channel/delete | 删除渠道 | -| 新增服务商 | POST | /channel/service-channel/group/create | 新增服务商分组(写入 `t_service_channel_group`,保存应用ID与API服务商ID) | -| 编辑服务商 | POST | /channel/service-channel/group/update | 编辑服务商分组(更新名称/应用ID/API服务商ID) | -| 删除服务商 | POST | /channel/service-channel/group/delete | 删除服务商分组(若分组下存在渠道需禁止删除) | - -##### 渠道列表(GET /channel/service-channel/list) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&groupId=&keyword=&isActive=1 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "groupId": 1, - "groupName": "DHL渠道组", - "channelCode": "DHL-US", - "channelName": "DHL美国专线", - "minTime": 3, - "maxTime": 7, - "forecastType": "waybill", - "forecastCode": "", - "description": "DHL美国专线渠道", - "isActive": 1, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 10 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按分页与筛选条件查询渠道列表,返回渠道及所属分组信息。 - -####### 校验与异常 - -- 校验分页参数合法性,异常时返回统一错误码。 - -##### 渠道详情(GET /channel/service-channel/detail) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/detail**(GET,Query 参数) - -``` -?id=1 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 1, - "groupId": 1, - "groupName": "DHL渠道组", - "channelCode": "DHL-US", - "channelName": "DHL美国专线", - "minTime": 3, - "maxTime": 7, - "forecastType": "waybill", - "forecastCode": "", - "description": "DHL美国专线渠道", - "preScript": "", - "isActive": 1, - "createTime": "2024-01-01 12:00:00", - "createBy": "admin", - "updateTime": "2024-01-01 12:00:00", - "updateBy": "admin" - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据渠道 `id` 查询渠道详情与配置信息。 - -####### 校验与异常 - -- 校验 `id` 必填且存在,不存在时返回业务错误。 - -##### 新增渠道(POST /channel/service-channel/create) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/create**(POST) - -```json -{ - "groupId": 1, - "channelCode": "DHL-US", - "channelName": "DHL美国专线", - "minTime": 3, - "maxTime": 7, - "forecastType": "waybill", - "forecastCode": "", - "description": "DHL美国专线渠道", - "preScript": "", - "isActive": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 新增渠道基础配置并写入审计字段。 - -####### 校验与异常 - -- 校验 `groupId`、`channelCode`、`channelName` 等必填字段,编码冲突时返回业务错误。 - -##### 新增服务商(POST /channel/service-channel/group/create) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/group/create**(POST) - -```json -{ - "name": "DHL服务商", - "appId": 1, - "apiServiceProviderId": 12 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 在 `t_service_channel_group` 新增服务商分组并写入应用ID与API服务商ID。 - -####### 校验与异常 - -- 校验 `name`、`appId`、`apiServiceProviderId` 必填且有效。 - -##### 编辑服务商(POST /channel/service-channel/group/update) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/group/update**(POST) - -```json -{ - "id": 1, - "name": "DHL服务商(更新)", - "appId": 1, - "apiServiceProviderId": 12 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据分组 `id` 更新服务商名称、应用ID与API服务商ID。 - -####### 校验与异常 - -- 校验 `id` 存在且可编辑,不存在时返回业务错误。 - -##### 删除服务商(POST /channel/service-channel/group/delete) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/group/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据分组 `id` 执行逻辑删除。 - -####### 校验与异常 - -- 若分组下存在渠道则禁止删除并返回业务错误。 - -##### 更新渠道(POST /channel/service-channel/update) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/update**(POST) - -```json -{ - "id": 1, - "groupId": 1, - "channelCode": "DHL-US", - "channelName": "DHL美国专线", - "minTime": 3, - "maxTime": 7, - "forecastType": "waybill", - "forecastCode": "", - "description": "DHL美国专线渠道", - "preScript": "", - "isActive": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据 `id` 更新渠道基础信息与渠道配置 tab 数据。 - -####### 校验与异常 - -- 校验 `id` 存在及字段合法性,更新失败返回业务错误。 - -##### 删除渠道(POST /channel/service-channel/delete) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 校验渠道是否可删除后执行逻辑删除并保留历史审计信息。 - -####### 校验与异常 - -- 存在关联配置或不可删除状态时返回业务错误。 -#### 渠道买价(子表接口) - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 买价项列表 | GET | /channel/service-channel/price/list | 按渠道ID获取买价项列表 | -| 新增买价项 | POST | /channel/service-channel/price/create | 新增买价项(含基础信息+计费因素+价格表) | -| 更新买价项 | POST | /channel/service-channel/price/update | 更新买价项 | -| 删除买价项 | POST | /channel/service-channel/price/delete | 删除买价项 | -| 切换买价项状态 | POST | /channel/service-channel/price/toggleStatus | 启用/禁用买价项 | -| 保存买价配置 | POST | /channel/service-channel/price/save | 批量保存渠道买价(保存并可选发布) | - -##### 买价项列表(GET /channel/service-channel/price/list) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/price/list**(GET,Query 参数) - -``` -?serviceChannelId=1 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "serviceChannelId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [], - "details": [], - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ] - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按 `serviceChannelId` 查询买价项及其因素、明细子项。 - -####### 校验与异常 - -- 校验 `serviceChannelId` 必填且渠道存在;异常返回统一错误码。 - -##### 新增买价项(POST /channel/service-channel/price/create) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/price/create**(POST) - -```json -{ - "serviceChannelId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [ - { - "serialNumber": 1, - "factor": "货物类型", - "factorValues": ["普货", "敏感货"] - } - ], - "details": [ - { - "factorValuesJson": {"分区": "欧洲", "货物类型": "普货", "重量": "0-0.5"}, - "calculationFormula": "单价", - "calculatedValue": 45, - "customerGroupValues": {} - } - ] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 新增买价主记录并写入因素、明细子表。 - -####### 校验与异常 - -- 校验必填参数与序号冲突规则,校验失败返回业务错误。 - -##### 更新买价项(POST /channel/service-channel/price/update) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/price/update**(POST) - -```json -{ - "id": 1, - "serviceChannelId": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [], - "details": [] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按 `id` 更新买价主数据并同步子项明细。 - -####### 校验与异常 - -- 校验 `id` 存在且归属渠道一致,校验失败返回业务错误。 - -##### 删除买价项(POST /channel/service-channel/price/delete) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/price/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据 `id` 逻辑删除买价项并处理关联子项可见性。 - -####### 校验与异常 - -- 校验 `id` 存在且允许删除。 - -##### 切换买价项状态(POST /channel/service-channel/price/toggleStatus) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/price/toggleStatus**(POST) - -```json -{ - "id": 1, - "status": 0 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按 `id` 更新买价项启用状态并刷新缓存。 - -####### 校验与异常 - -- 校验 `status` 枚举与 `id` 存在性,不合法时返回业务错误。 - -##### 保存买价配置(POST /channel/service-channel/price/save) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/price/save**(POST) - -```json -{ - "serviceChannelId": 1, - "publish": false, - "prices": [ - { - "id": 1, - "serialNumber": 1, - "status": 1, - "priceName": "运费", - "costItem": "freight", - "costUnit": "kg", - "currency": "RMB", - "chargingTime": "出库", - "chargingCondition": "", - "factors": [], - "details": [] - } - ] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 批量保存买价配置,按 `id` 区分新增/更新;`publish=true` 时触发发布。 - -####### 校验与异常 - -- 校验 `serviceChannelId`、`prices` 合法;任一子项失败则事务回滚。 -#### 服务渠道发布记录(子表接口) - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 价格发布记录列表 | GET | /channel/service-channel/publish/price/list | 按渠道ID获取买价发布历史 | -| 规则发布记录列表 | GET | /channel/service-channel/publish/rules/list | 按渠道ID获取规则发布历史 | -| 发布 | POST | /channel/service-channel/publish | 发布当前配置(买价或规则) | -| 回滚到版本 | POST | /channel/service-channel/publish/rollback | 回滚到指定版本 | - -##### 价格发布记录列表(GET /channel/service-channel/publish/price/list) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/publish/price/list**(GET,Query 参数) - -``` -?serviceChannelId=1&page=1&pageSize=20 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "serviceChannelId": 1, - "version": "20231227112339", - "operatorType": "price", - "status": "生效", - "publishUser": "admin", - "publishTime": "2023-12-27 11:23:39", - "description": "买价调整", - "content": {}, - "createTime": "2023-12-27 11:23:39" - } - ], - "total": 5 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按渠道ID分页查询买价发布记录并返回版本信息。 - -####### 校验与异常 - -- 校验 `serviceChannelId` 与分页参数合法性。 - -##### 规则发布记录列表(GET /channel/service-channel/publish/rules/list) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/publish/rules/list**(GET,Query 参数) - -``` -?serviceChannelId=1&page=1&pageSize=20 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 2, - "serviceChannelId": 1, - "version": "20231228103000", - "operatorType": "rules", - "status": "生效", - "publishUser": "admin", - "publishTime": "2023-12-28 10:30:00", - "description": "规则调整", - "content": {}, - "createTime": "2023-12-28 10:30:00" - } - ], - "total": 3 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按渠道ID分页查询规则发布记录,`operatorType` 固定为 `rules`。 - -####### 校验与异常 - -- 校验 `serviceChannelId` 与分页参数合法性。 - -##### 发布(POST /channel/service-channel/publish) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/publish**(POST) - -```json -{ - "serviceChannelId": 1, - "operatorType": "price", - "description": "买价调整" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 发布当前渠道配置并生成新的发布版本记录。 - -####### 校验与异常 - -- 校验 `serviceChannelId`、`operatorType` 必填且可发布。 - -##### 回滚到版本(POST /channel/service-channel/publish/rollback) - -###### 1 接口请求示例 - -**请求 /channel/service-channel/publish/rollback**(POST) - -```json -{ - "serviceChannelId": 1, - "recordId": 1, - "operatorType": "price" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 回滚到指定版本并生成回滚操作记录。 - -####### 校验与异常 - -- 校验 `recordId` 与渠道、类型匹配关系,异常时返回业务错误。 -#### 黑名单区 / 偏远分区 / 价格分区(共用 KpiArea) - -> 共用 `/channel/kpi-area/*` 接口,仅通过请求/响应中的 `areaType`(对应表字段 `AreaType`)区分分区类型。 - -| 类型 | areaType | -| --- | --- | -| 黑名单区 | blacklist | -| 偏远分区 | remote | -| 价格分区 | price | - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 创建分区 | POST | /channel/kpi-area/create | 创建分区 | -| 修改分区 | POST | /channel/kpi-area/update | 修改分区 | -| 删除分区 | POST | /channel/kpi-area/delete | 删除分区 | -| 分区列表 | POST | /channel/kpi-area/list | 分区列表 | -| 新增分区明细 | POST | /channel/kpi-area/info/add | 新增分区明细 | -| 修改分区明细 | POST | /channel/kpi-area/info/update | 修改分区明细 | -| 删除分区明细 | POST | /channel/kpi-area/info/delete | 删除分区明细 | -| 分区明细列表 | POST | /channel/kpi-area/info/list | 分区明细列表 | - -##### 创建分区(POST /channel/kpi-area/create) - -###### 1 接口请求示例 - -**请求 /channel/kpi-area/create**(POST) - -```json -{ - "areaType": "blacklist", - "name": "黑名单区1", - "systemName": "系统黑名单" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据 `areaType` 创建分区主记录。 - -####### 校验与异常 - -- 校验 `areaType`、`name` 必填且合法。 - -##### 修改分区(POST /channel/kpi-area/update) - -###### 1 接口请求示例 - -**请求 /channel/kpi-area/update**(POST) - -```json -{ - "id": 1, - "areaType": "blacklist", - "name": "黑名单区1", - "systemName": "系统黑名单" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据 `id` 更新分区名称与系统名称。 - -####### 校验与异常 - -- 校验 `id` 存在且与 `areaType` 匹配。 - -##### 删除分区(POST /channel/kpi-area/delete) - -###### 1 接口请求示例 - -**请求 /channel/kpi-area/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据 `id` 执行分区逻辑删除。 - -####### 校验与异常 - -- 分区存在明细时按业务规则拦截或级联处理。 - -##### 分区列表(POST /channel/kpi-area/list) - -###### 1 接口请求示例 - -**请求 /channel/kpi-area/list**(POST) - -```json -{ - "areaType": "blacklist", - "page": 1, - "pageSize": 20 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "areaType": "blacklist", - "name": "黑名单区1", - "systemName": "系统黑名单", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 5 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按 `areaType` 分页查询分区列表。 - -####### 校验与异常 - -- 校验分页参数与 `areaType` 合法性。 - -##### 新增分区明细(POST /channel/kpi-area/info/add) - -###### 1 接口请求示例 - -**请求 /channel/kpi-area/info/add**(POST) - -```json -{ - "areaId": 1, - "countryCode": "US", - "countryName": "美国", - "province": "加州", - "city": "洛杉矶", - "leftPostcode": "90001", - "rightPostcode": "90099" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 向指定分区新增明细记录(国家/省市/邮编区间)。 - -####### 校验与异常 - -- 校验 `areaId` 存在且区间数据合法。 - -##### 修改分区明细(POST /channel/kpi-area/info/update) - -###### 1 接口请求示例 - -**请求 /channel/kpi-area/info/update**(POST) - -```json -{ - "id": 1, - "areaId": 1, - "countryCode": "US", - "countryName": "美国", - "province": "加州", - "city": "洛杉矶", - "leftPostcode": "90001", - "rightPostcode": "90099" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据明细 `id` 更新分区明细字段。 - -####### 校验与异常 - -- 校验 `id` 存在,更新内容合法。 - -##### 删除分区明细(POST /channel/kpi-area/info/delete) - -###### 1 接口请求示例 - -**请求 /channel/kpi-area/info/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据明细 `id` 执行逻辑删除。 - -####### 校验与异常 - -- 校验 `id` 存在,不存在时返回业务错误。 - -##### 分区明细列表(POST /channel/kpi-area/info/list) - -###### 1 接口请求示例 - -**请求 /channel/kpi-area/info/list**(POST) - -```json -{ - "areaId": 1, - "page": 1, - "pageSize": 20 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "areaId": 1, - "countryCode": "US", - "countryName": "美国", - "province": "加州", - "city": "洛杉矶", - "leftPostcode": "90001", - "rightPostcode": "90099", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 10 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按 `areaId` 分页查询分区明细列表。 - -####### 校验与异常 - -- 校验 `areaId` 与分页参数合法性。 -#### 计重规则 / 计重进位 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 规则列表 | POST | /channel/weight-rule/list | 规则列表 | -| 创建规则 | POST | /channel/weight-rule/create | 创建规则 | -| 更新规则 | POST | /channel/weight-rule/update | 更新规则 | -| 删除规则 | POST | /channel/weight-rule/delete | 删除规则 | -| 规则明细列表 | POST | /channel/weight-rule/info/list | 规则明细列表 | -| 新增明细 | POST | /channel/weight-rule/info/add | 新增明细 | -| 更新明细 | POST | /channel/weight-rule/info/update | 更新明细 | -| 删除明细 | POST | /channel/weight-rule/info/delete | 删除明细 | -| 进位列表 | POST | /channel/weight-rounding/list | 进位列表 | -| 创建进位 | POST | /channel/weight-rounding/create | 创建进位 | -| 更新进位 | POST | /channel/weight-rounding/update | 更新进位 | -| 删除进位 | POST | /channel/weight-rounding/delete | 删除进位 | -| 进位明细列表 | POST | /channel/weight-rounding/info/list | 进位明细列表 | -| 新增进位明细 | POST | /channel/weight-rounding/info/add | 新增进位明细 | -| 更新进位明细 | POST | /channel/weight-rounding/info/update | 更新进位明细 | -| 删除进位明细 | POST | /channel/weight-rounding/info/delete | 删除进位明细 | - -##### 规则列表(POST /channel/weight-rule/list) - -###### 1 接口请求示例 -**请求 /channel/weight-rule/list**(POST) - -```json -{ - "page": 1, - "pageSize": 20 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { "items": [{ "id": 1, "name": "标准计重规则" }], "total": 5 } -} -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 分页查询计重规则主表。 -####### 校验与异常 -- 校验分页参数合法性。 - -##### 创建规则(POST /channel/weight-rule/create) -###### 1 接口请求示例 -**请求 /channel/weight-rule/create**(POST) - -```json -{ "name": "标准计重规则" } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 新增计重规则并写入审计信息。 -####### 校验与异常 -- 校验 `name` 必填且不重复。 - -##### 更新规则(POST /channel/weight-rule/update) -###### 1 接口请求示例 -**请求 /channel/weight-rule/update**(POST) - -```json -{ "id": 1, "name": "标准计重规则" } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 根据 `id` 更新规则信息。 -####### 校验与异常 -- 校验 `id` 存在。 - -##### 删除规则(POST /channel/weight-rule/delete) -###### 1 接口请求示例 -**请求 /channel/weight-rule/delete**(POST) - -```json -{ "id": 1 } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 对规则执行逻辑删除。 -####### 校验与异常 -- 校验是否存在关联明细。 - -##### 规则明细列表(POST /channel/weight-rule/info/list) -###### 1 接口请求示例 -**请求 /channel/weight-rule/info/list**(POST) - -```json -{ "ruleId": 1, "page": 1, "pageSize": 20 } -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { "items": [{ "id": 1, "ruleId": 1, "client": "公共" }], "total": 3 } -} -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 按 `ruleId` 分页查询规则明细。 -####### 校验与异常 -- 校验 `ruleId` 必填且有效。 - -##### 新增明细(POST /channel/weight-rule/info/add) -###### 1 接口请求示例 -**请求 /channel/weight-rule/info/add**(POST) - -```json -{ "ruleId": 1, "client": "公共", "condition": "gw>vw", "billableWeight": "vw", "volumetricFactor": "6000" } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 新增规则明细。 -####### 校验与异常 -- 校验 `ruleId` 与字段合法性。 - -##### 更新明细(POST /channel/weight-rule/info/update) -###### 1 接口请求示例 -**请求 /channel/weight-rule/info/update**(POST) - -```json -{ "id": 1, "ruleId": 1, "client": "公共", "condition": "gw>vw", "billableWeight": "vw", "volumetricFactor": "6000" } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 根据 `id` 更新规则明细。 -####### 校验与异常 -- 校验 `id` 存在且归属正确。 - -##### 删除明细(POST /channel/weight-rule/info/delete) -###### 1 接口请求示例 -**请求 /channel/weight-rule/info/delete**(POST) - -```json -{ "id": 1 } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 删除规则明细(逻辑删除)。 -####### 校验与异常 -- 校验 `id` 存在。 - -##### 进位列表(POST /channel/weight-rounding/list) -###### 1 接口请求示例 -**请求 /channel/weight-rounding/list**(POST) - -```json -{ "page": 1, "pageSize": 20 } -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { "items": [{ "id": 1, "name": "标准进位" }], "total": 5 } -} -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 分页查询计重进位主表。 -####### 校验与异常 -- 校验分页参数。 - -##### 创建进位(POST /channel/weight-rounding/create) -###### 1 接口请求示例 -**请求 /channel/weight-rounding/create**(POST) - -```json -{ "name": "标准进位" } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 新增进位规则。 -####### 校验与异常 -- 校验 `name` 必填。 - -##### 更新进位(POST /channel/weight-rounding/update) -###### 1 接口请求示例 -**请求 /channel/weight-rounding/update**(POST) - -```json -{ "id": 1, "name": "标准进位" } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 根据 `id` 更新进位规则。 -####### 校验与异常 -- 校验 `id` 存在。 - -##### 删除进位(POST /channel/weight-rounding/delete) -###### 1 接口请求示例 -**请求 /channel/weight-rounding/delete**(POST) - -```json -{ "id": 1 } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 逻辑删除进位规则。 -####### 校验与异常 -- 校验是否可删除。 - -##### 进位明细列表(POST /channel/weight-rounding/info/list) -###### 1 接口请求示例 -**请求 /channel/weight-rounding/info/list**(POST) - -```json -{ "roundingId": 1, "page": 1, "pageSize": 20 } -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { "items": [{ "id": 1, "roundingId": 1, "startValue": 0, "endValue": 1 }], "total": 5 } -} -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 按 `roundingId` 分页查询进位明细。 -####### 校验与异常 -- 校验 `roundingId` 存在。 - -##### 新增进位明细(POST /channel/weight-rounding/info/add) -###### 1 接口请求示例 -**请求 /channel/weight-rounding/info/add**(POST) - -```json -{ "roundingId": 1, "startValue": 0, "endValue": 1, "public": "0.5", "customerGroup1": "0.5", "customerGroup2": "0.25" } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 新增进位明细。 -####### 校验与异常 -- 校验区间与分组字段合法性。 - -##### 更新进位明细(POST /channel/weight-rounding/info/update) -###### 1 接口请求示例 -**请求 /channel/weight-rounding/info/update**(POST) - -```json -{ "id": 1, "roundingId": 1, "startValue": 0, "endValue": 1, "public": "0.5", "customerGroup1": "0.5", "customerGroup2": "0.25" } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 根据 `id` 更新进位明细。 -####### 校验与异常 -- 校验 `id` 存在且归属正确。 - -##### 删除进位明细(POST /channel/weight-rounding/info/delete) -###### 1 接口请求示例 -**请求 /channel/weight-rounding/info/delete**(POST) - -```json -{ "id": 1 } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 删除进位明细(逻辑删除)。 -####### 校验与异常 -- 校验 `id` 存在。 - -#### 自动触发 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 列表 | GET | /channel/auto-trigger/list | 列表 | -| 新增 | POST | /channel/auto-trigger/create | 新增 | -| 更新 | POST | /channel/auto-trigger/update | 更新 | -| 删除 | POST | /channel/auto-trigger/delete | 删除 | -| 切换启用状态 | POST | /channel/auto-trigger/toggleStatus | 切换启用状态 | - -##### 列表(GET /channel/auto-trigger/list) -###### 1 接口请求示例 -**请求 /channel/auto-trigger/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&operation=&triggerTiming= -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { "items": [{ "id": 1, "operation": "拦截", "triggerTiming": "下单" }], "total": 5 } -} -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 分页查询自动触发规则列表。 -####### 校验与异常 -- 校验分页与筛选参数合法。 - -##### 新增(POST /channel/auto-trigger/create) -###### 1 接口请求示例 -**请求 /channel/auto-trigger/create**(POST) - -```json -{ - "status": 1, - "operation": "拦截", - "triggerTiming": "下单", - "triggerCondition": "weight>30", - "promptMessage": "重量超过30kg,请确认", - "notifier": "admin@example.com" -} -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 新增自动触发规则。 -####### 校验与异常 -- 校验关键字段必填。 - -##### 更新(POST /channel/auto-trigger/update) -###### 1 接口请求示例 -**请求 /channel/auto-trigger/update**(POST) - -```json -{ - "id": 1, - "status": 1, - "operation": "拦截", - "triggerTiming": "下单", - "triggerCondition": "weight>30", - "promptMessage": "重量超过30kg,请确认", - "notifier": "admin@example.com" -} -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 根据 `id` 更新自动触发规则。 -####### 校验与异常 -- 校验 `id` 存在且可编辑。 - -##### 删除(POST /channel/auto-trigger/delete) -###### 1 接口请求示例 -**请求 /channel/auto-trigger/delete**(POST) - -```json -{ "id": 1 } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 逻辑删除自动触发规则。 -####### 校验与异常 -- 校验 `id` 存在且可删除。 - -##### 切换启用状态(POST /channel/auto-trigger/toggleStatus) -###### 1 接口请求示例 -**请求 /channel/auto-trigger/toggleStatus**(POST) - -```json -{ "id": 1, "status": 0 } -``` - -**响应** - -```json -{ "code": 0, "msg": "success", "data": null } -``` - -###### 2 接口业务逻辑 -####### 核心流程 -- 按 `id` 切换规则启用状态。 -####### 校验与异常 -- 校验 `status` 枚举合法性。 -#### 标签设计 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 保存标签模板 | POST | /channel/label-template/save | 保存标签模板 | - -###### 1 接口请求示例 - -**请求 /channel/label-template/save**(POST) - -```json -{ - "id": 0, - "name": "尾程标签模板1", - "templateType": "lastMile", - "templateJson": {} -} -``` - -> 参数说明:`id` 模板ID,0为新增;`templateType` 类型:lastMile(尾程)/customer(客户)/inbound(入库)/outbound(出库)/sku;`templateJson` vg-print 导出的模板 JSON。 - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 1, - "name": "尾程标签模板1", - "templateType": "lastMile", - "templateJson": {}, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } -} -``` - ----------------------------------------------------------------------------------------- - ----------------------------------------------------------------------------------------- - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按本节接口定义执行业务处理流程,完成参数解析、数据查询/写入与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态流转与数据存在性进行校验;异常场景返回统一错误码与错误信息。 -## 四、操作模块 - -### 3.1 核心需求说明 - -- **入库扫描**:扫描运单号完成入库,记录实重、尺寸、销售产品、目的国、仓库、操作人、操作时间;支持查看最近入库记录。 -- **出库扫描**:扫描运单号完成出库,记录仓库、操作人、操作时间;支持查看最近出库记录。 -- **标签打印**:按运单号/客户单号/跟踪号/其他单号选择标签模板打印;记录打印结果与操作人。 - -### 3.2 表结构设计 - -#### 入库记录表 `t_inbound_record` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| WaybillNo | VARCHAR | 64 | - | 是 | 运单号 | -| Weight | DECIMAL | 10,2 | - | 否 | 实重(kg) | -| Length | DECIMAL | 10,2 | - | 否 | 长(cm) | -| Width | DECIMAL | 10,2 | - | 否 | 宽(cm) | -| Height | DECIMAL | 10,2 | - | 否 | 高(cm) | -| SalesProductId | BIGINT | - | 外键 | 否 | 销售产品ID | -| DestinationCountry | VARCHAR | 8 | - | 否 | 目的国 | -| WarehouseId | BIGINT | - | 外键 | 否 | 仓库ID | -| OperateTime | DATETIME | - | - | 是 | 操作时间 | -| OperatorId | BIGINT | - | 外键 | 否 | 操作人ID | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 出库记录表 `t_outbound_record` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| WaybillNo | VARCHAR | 64 | - | 是 | 运单号 | -| WarehouseId | BIGINT | - | 外键 | 否 | 仓库ID | -| OperateTime | DATETIME | - | - | 是 | 操作时间 | -| OperatorId | BIGINT | - | 外键 | 否 | 操作人ID | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 标签打印记录表 `t_label_print_record` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| WaybillNo | VARCHAR | 64 | - | 是 | 运单号 | -| CustomerOrderNo | VARCHAR | 64 | - | 否 | 客户单号 | -| TrackingNo | VARCHAR | 64 | - | 否 | 跟踪号 | -| OtherNumber | VARCHAR | 64 | - | 否 | 其他单号 | -| LabelTemplateId | BIGINT | - | 外键 | 否 | 标签模板ID | -| PrintSuccess | TINYINT | - | - | 是 | 是否打印成功 | -| PrintTime | DATETIME | - | - | 是 | 打印时间 | -| OperatorId | BIGINT | - | 外键 | 否 | 操作人ID | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -### 3.3 核心接口设计 - -#### 入库扫描 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 入库 | POST | /operation/inbound/create | 入库 | -| 最近入库记录 | GET | /operation/inbound/recent-list | 最近入库记录 | - -##### 入库(POST /operation/inbound/create) - -###### 1 接口请求示例 - -**请求 /operation/inbound/create**(POST) - -```json -{ - "waybillNo": "LP1234567890", - "weight": 2.5, - "length": 20, - "width": 15, - "height": 10, - "salesProductId": 1, - "destinationCountry": "US", - "warehouseId": 1 -} -``` - -> 参数说明:`waybillNo` 运单号;`weight` 实重(kg);`length/width/height` 长宽高(cm);`salesProductId` 销售产品ID;`destinationCountry` 目的国二字码;`warehouseId` 仓库ID。操作时间、操作人由服务端从当前会话获取。 - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 1, - "waybillNo": "LP1234567890", - "weight": 2.5, - "length": 20, - "width": 15, - "height": 10, - "salesProductId": 1, - "destinationCountry": "US", - "warehouseId": 1, - "operateTime": "2024-01-01 12:00:00", - "operatorId": 1, - "createTime": "2024-01-01 12:00:00" - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 最近入库记录(GET /operation/inbound/recent-list) - -###### 1 接口请求示例 - -**请求 /operation/inbound/recent-list**(GET,Query 参数) - -``` -?limit=20&warehouseId= -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "waybillNo": "LP1234567890", - "weight": 2.5, - "length": 20, - "width": 15, - "height": 10, - "salesProductId": 1, - "salesProductName": "美国空运专线(普货)", - "destinationCountry": "US", - "warehouseId": 1, - "warehouseName": "深圳仓", - "operateTime": "2024-01-01 12:00:00", - "operatorId": 1, - "operatorName": "张三", - "createTime": "2024-01-01 12:00:00" - } - ] - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 -#### 出库扫描 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 出库 | POST | /operation/outbound/create | 出库 | -| 最近出库记录 | GET | /operation/outbound/recent-list | 最近出库记录 | - -##### 出库(POST /operation/outbound/create) - -###### 1 接口请求示例 - -**请求 /operation/outbound/create**(POST) - -```json -{ - "waybillNo": "LP1234567890", - "warehouseId": 1 -} -``` - -> 参数说明:`waybillNo` 运单号;`warehouseId` 仓库ID(可选,不传则从订单或默认仓库获取)。操作时间、操作人由服务端从当前会话获取。 - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 1, - "waybillNo": "LP1234567890", - "warehouseId": 1, - "operateTime": "2024-01-01 12:00:00", - "operatorId": 1, - "createTime": "2024-01-01 12:00:00" - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 最近出库记录(GET /operation/outbound/recent-list) - -###### 1 接口请求示例 - -**请求 /operation/outbound/recent-list**(GET,Query 参数) - -``` -?limit=20&warehouseId= -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "waybillNo": "LP1234567890", - "warehouseId": 1, - "warehouseName": "深圳仓", - "operateTime": "2024-01-01 12:00:00", - "operatorId": 1, - "operatorName": "张三", - "createTime": "2024-01-01 12:00:00" - } - ] - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 -#### 标签打印 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 打印标签 | POST | /operation/print-label/print | 打印标签 | -| 最近打印记录 | GET | /operation/print-label/recent-list | 最近打印记录 | -| 标签模板列表 | GET | /operation/print-label/template-list | 标签模板列表 | - -##### 打印标签(POST /operation/print-label/print) - -###### 1 接口请求示例 - -**请求 /operation/print-label/print**(POST) - -```json -{ - "number": "LP1234567890", - "numberType": "waybill", - "labelTemplateId": 1 -} -``` - -> 参数说明:`number` 运单号/客户单号/跟踪号/其他单号;`numberType` 单号类型:waybill(运单号)/customerOrder(客户单号)/tracking(跟踪号)/other(其他单号);`labelTemplateId` 标签模板ID,可选,不传则按 templateType 取默认模板。也可传 `templateType`:lastMile(尾程)/customer(客户)/inbound(入库)/outbound(出库)/sku。 - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 1, - "waybillNo": "LP1234567890", - "customerOrderNo": "", - "trackingNo": "", - "otherNumber": "", - "labelTemplateId": 1, - "labelTemplateName": "尾程标签模板1", - "printSuccess": 1, - "printTime": "2024-01-01 12:00:00", - "operatorId": 1, - "createTime": "2024-01-01 12:00:00" - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 最近打印记录(GET /operation/print-label/recent-list) - -###### 1 接口请求示例 - -**请求 /operation/print-label/recent-list**(GET,Query 参数) - -``` -?limit=20&labelTemplateId= -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "waybillNo": "LP1234567890", - "customerOrderNo": "CUST001", - "trackingNo": "TRK001", - "otherNumber": "", - "labelTemplateId": 1, - "labelTemplateName": "尾程标签模板1", - "printSuccess": 1, - "printTime": "2024-01-01 12:00:00", - "operatorId": 1, - "operatorName": "张三", - "createTime": "2024-01-01 12:00:00" - } - ] - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 - -##### 标签模板列表(GET /operation/print-label/template-list) - -###### 1 接口请求示例 - -**请求 /operation/print-label/template-list**(GET,Query 参数) - -``` -?templateType=lastMile -``` - -> 参数说明:`templateType` 可选,筛选模板类型:lastMile/customer/inbound/outbound/sku。不传则返回全部可用模板。 - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "name": "尾程标签模板1", - "templateType": "lastMile", - "templateJson": {}, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ] - } -} -``` - ----------------------------------------------------------------------------------------- - ----------------------------------------------------------------------------------------- - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。 - -####### 校验与异常 - -- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。 -## 五、客户模块 - -### 4.1 核心需求说明 - -- **客户管理**:客户信息的增删改查,支持客户编号、手机、邮箱、余额、额度、状态等管理;支持重置密码;已有 customer API。 -- **子账号**:主客户可创建子账号,子账号共享主客户余额与额度,独立登录与权限。 - -### 4.2 表结构设计 - -#### 客户表 `t_customer` - -> 已有 customer API,表结构参考 CustomerDto。 - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| CustomerCode | VARCHAR | 32 | - | 是 | 客户编号,规则:前缀w+年份+序号(如W2501) | -| CustomerName | VARCHAR | 128 | - | 是 | 客户名称 | -| WechatAvatar | VARCHAR | 255 | - | 否 | 微信头像URL | -| WechatNickname | VARCHAR | 64 | - | 否 | 微信昵称 | -| Email | VARCHAR | 64 | - | 否 | 邮箱,唯一,用于登录/找回密码 | -| Phone | VARCHAR | 16 | - | 是 | 手机号,唯一,用于登录/验证 | -| Balance | DECIMAL | 18,2 | - | 是 | 账户余额,默认0 | -| TotalQuota | DECIMAL | 18,2 | - | 是 | 总额度,默认0 | -| UsedQuota | DECIMAL | 18,2 | - | 是 | 已用额度,默认0 | -| Status | TINYINT | - | - | 是 | 状态:0-禁用,1-正常,2-冻结 | -| Remark | VARCHAR | 512 | - | 否 | 备注信息 | -| CreateUserId | INT | - | - | 是 | 创建人(系统/管理员账号) | -| CreateTime | DATETIME | - | - | 是 | 创建时间,默认当前时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | -| Salesman | VARCHAR | 32 | - | 否 | 所属销售员账号 | -| CustomerService | VARCHAR | 32 | - | 否 | 所属客服账号 | -| CustomerGroup | TINYINT | - | - | 否 | 客户分组:0-普通客户,1-VIP1,2-VIP2,3-VIP3,4-VIP4,5-VIP5 | -| Role | VARCHAR | 64 | - | 否 | 角色,用于菜单权限 | -| LastLoginIp | VARCHAR | 64 | - | 否 | 最后在线IP | -| LastLoginTime | DATETIME | - | - | 否 | 最后在线时间 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| WechatOpenid | VARCHAR | 64 | - | 否 | 微信OpenID,唯一,关联微信账号 | -| Password | VARCHAR | 64 | - | 是 | 密码(MD5加密存储) | - -**CustomerGroup 枚举**:0-普通客户、1-VIP1、2-VIP2、3-VIP3、4-VIP4、5-VIP5 - -#### 子账号表 `t_customer_sub_account` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| MainCustomerId | BIGINT | - | 外键(t_customer.Id) | 是 | 主客户ID | -| SubAccountCode | VARCHAR | 32 | - | 是 | 子账号编号,规则:主客户编号+"_"+序号 | -| SubWechatAvatar | VARCHAR | 255 | - | 否 | 子账号微信头像 | -| SubWechatNickname | VARCHAR | 64 | - | 否 | 子账号微信昵称 | -| SubPhone | VARCHAR | 16 | - | 是 | 子账号手机号,唯一 | -| SubEmail | VARCHAR | 64 | - | 否 | 子账号邮箱 | -| SubPassword | VARCHAR | 64 | - | 是 | 子账号密码(MD5加密) | -| SubStatus | TINYINT | - | - | 是 | 子账号状态:0-禁用,1-正常 | -| CreateUserId | INT | - | - | 是 | 创建人(主客户/管理员) | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是 | - -### 4.3 核心接口设计 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 客户列表 | GET | /customer/list | 客户列表 | -| 客户详情 | GET | /customer/get | 客户详情 | -| 创建客户 | POST | /customer/create | 创建客户 | -| 更新客户 | POST | /customer/update | 更新客户 | -| 删除客户 | POST | /customer/delete | 删除客户 | -| 重置密码 | POST | /customer/reset-password | 重置密码 | - -> 个人中心可复用 `/customer/get`,传入当前登录客户 ID。 - -##### 客户列表(GET /customer/list) - -###### 1 接口请求示例 -**请求 /customer/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&keyword=&status=1&customerGroup=&salesman=&customerService= -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "customerCode": "W2501", - "wechatAvatar": "", - "wechatNickname": "", - "email": "customer@example.com", - "phone": "13800138000", - "balance": 1000.00, - "totalQuota": 5000.00, - "usedQuota": 500.00, - "status": 1, - "remark": "", - "salesman": "sales1", - "customerService": "cs1", - "customerGroup": 1, - "lastLoginIp": "192.168.1.1", - "lastLoginTime": "2024-01-01 12:00:00", - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 50 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据筛选参数分页查询客户列表,返回客户基础信息、状态与余额额度信息。 - -####### 校验与异常 - -- 校验分页参数合法性;当筛选条件无匹配数据时返回空列表与 total=0。 - -##### 客户详情(GET /customer/get) - -###### 1 接口请求示例 - -**请求 /customer/get**(GET,Query 参数) - -``` -?id=1 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 1, - "customerCode": "W2501", - "wechatAvatar": "", - "wechatNickname": "", - "email": "customer@example.com", - "phone": "13800138000", - "balance": 1000.00, - "totalQuota": 5000.00, - "usedQuota": 500.00, - "status": 1, - "remark": "", - "salesman": "sales1", - "customerService": "cs1", - "customerGroup": 1, - "lastLoginIp": "192.168.1.1", - "lastLoginTime": "2024-01-01 12:00:00", - "createTime": "2024-01-01 12:00:00", - "createBy": "admin", - "updateTime": "2024-01-01 12:00:00", - "updateBy": "admin" - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按客户 ID 查询客户详情,返回完整客户资料与审计字段。 - -####### 校验与异常 - -- 校验 `id` 必填且存在;不存在时返回业务错误码。 - -##### 创建客户(POST /customer/create) - -###### 1 接口请求示例 - -**请求 /customer/create**(POST) - -```json -{ - "customerCode": "W2501", - "customerName": "张三客户", - "wechatAvatar": "https://cdn.example.com/avatar/w2501.png", - "wechatNickname": "张三", - "phone": "13800138000", - "email": "customer@example.com", - "password": "123456", - "balance": 1000.00, - "totalQuota": 5000.00, - "usedQuota": 500.00, - "status": 1, - "role": "customer", - "remark": "", - "salesman": "sales1", - "customerService": "cs1", - "customerGroup": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 新增客户账号并写入客户基础资料(客户编号/名称、微信信息、联系方式)、余额额度、角色与业务归属信息。 - -####### 校验与异常 - -- 校验手机号/邮箱唯一性与必填字段,冲突时返回业务错误。 - -##### 更新客户(POST /customer/update) - -###### 1 接口请求示例 - -**请求 /customer/update**(POST) - -```json -{ - "id": 1, - "customerCode": "W2501", - "customerName": "张三客户", - "wechatAvatar": "https://cdn.example.com/avatar/w2501.png", - "wechatNickname": "张三", - "phone": "13800138000", - "email": "customer@example.com", - "balance": 1000.00, - "totalQuota": 5000.00, - "usedQuota": 500.00, - "status": 1, - "role": "customer", - "remark": "", - "salesman": "sales1", - "customerService": "cs1", - "customerGroup": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据客户 ID 更新客户资料(编号/名称、微信信息、联系方式)、余额额度、状态、角色与业务归属字段。 - -####### 校验与异常 - -- 校验 `id` 存在与可更新状态;非法状态流转时返回业务错误。 - -##### 删除客户(POST /customer/delete) - -###### 1 接口请求示例 - -**请求 /customer/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按客户 ID 执行逻辑删除,保留历史审计数据。 - -####### 校验与异常 - -- 校验 `id` 存在且可删除;存在关联数据限制时返回业务错误。 - -##### 重置密码(POST /customer/reset-password) - -###### 1 接口请求示例 - -**请求 /customer/reset-password**(POST) - -```json -{ - "id": 1, - "newPassword": "123456" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据客户 ID 重置登录密码并更新密码变更时间。 - -####### 校验与异常 - -- 校验 `id` 与 `newPassword` 合法性,不满足密码策略时返回业务错误。 - ----------------------------------------------------------------------------------------- - ----------------------------------------------------------------------------------------- - -## 六、充值管理 - -### 5.1 核心需求说明 - -- **充值流程**:客户提交充值金额 → 选择支付方式(微信/支付宝)→ 上传充值截图 → 提交审核 → 管理员审核(通过/驳回)→ 审核通过后更新客户余额。 -- **充值记录**:完整留存充值全流程信息,支持查询、导出;充值状态包含:待审核、审核通过、审核驳回。 - -### 5.2 表结构设计 - -#### 充值记录表 `t_recharge_record` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| CustomerId | BIGINT | - | 外键(t_customer.Id) | 是 | 客户ID(主账号/子账号关联主账号ID) | -| SerialNumber | VARCHAR | 64 | - | 是 | 流水号,规则:RECHARGE+日期+随机6位数字 | -| RechargeTime | DATETIME | - | - | 是 | 充值提交时间,默认当前时间 | -| PayType | TINYINT | - | - | 是 | 支付方式:1-微信,2-支付宝 | -| RechargeAmount | DECIMAL | 18,2 | - | 是 | 充值金额,≥0.01 | -| RechargeScreenshot | VARCHAR | 255 | - | 否 | 充值截图URL(存储文件服务器地址) | -| RechargeStatus | TINYINT | - | - | 是 | 充值状态:0-待审核,1-审核通过,2-审核驳回 | -| Remark | VARCHAR | 512 | - | 否 | 备注(客户/审核人填写) | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | -| AuditTime | DATETIME | - | - | 否 | 审核时间 | -| AuditBy | VARCHAR | 32 | - | 否 | 审核人(管理员账号) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | - -### 5.3 核心接口设计 - -| 接口名称 | 请求方式 | 请求路径 | 请求参数 | 响应参数 | 接口说明 | -| --- | --- | --- | --- | --- | --- | -| 提交充值申请 | POST | /recharge/recharge/submit | customerId, payType, rechargeAmount, screenshotUrl, remark | code, msg, data.serialNumber | 生成流水号,保存充值记录,状态为待审核 | -| 审核充值申请 | POST | /recharge/recharge-record/approve 或 reject | id, auditStatus, auditBy, remark | code, msg | 审核通过则更新客户余额,更新审核时间、状态、审核人 | -| 查询充值记录 | GET | /recharge/recharge-record/list | customerId, serialNumber, startTime, endTime, status | list, total | 多条件分页查询充值记录 | -| 导出充值记录 | GET | /recharge/export | customerId, startTime, endTime | 文件流 | 导出充值记录为Excel文件 | - -##### 提交充值申请(POST /recharge/recharge/submit) - -###### 1 接口请求示例 -**请求 /recharge/recharge/submit**(POST) - -```json -{ - "customerId": 1, - "rechargeAmount": 500, - "payType": 1, - "rechargeScreenshot": "https://example.com/upload/screenshot.jpg", - "remark": "充值500元" -} -``` - -> 参数说明:`customerId` 客户ID(客户端提交时可不传,取当前登录客户);`rechargeAmount` 充值金额,≥0.01;`payType` 支付方式:1-微信,2-支付宝;`rechargeScreenshot` 充值截图URL;`remark` 备注。 - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 1, - "serialNumber": "RECHARGE2024010100123456", - "customerId": 1, - "rechargeAmount": 500.00, - "payType": 1, - "rechargeScreenshot": "https://example.com/upload/screenshot.jpg", - "rechargeStatus": 0, - "remark": "充值500元", - "rechargeTime": "2024-01-01 12:00:00", - "createTime": "2024-01-01 12:00:00" - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 创建充值记录并生成流水号,初始状态为待审核。 - -####### 校验与异常 - -- 校验金额、支付方式与截图信息合法性,不合法时返回业务错误。 - -##### 审核充值申请-通过(POST /recharge/recharge-record/approve) - -###### 1 接口请求示例 - -**请求 /recharge/recharge-record/approve**(POST) - -```json -{ - "id": 1, - "remark": "审核通过" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 审核通过后更新充值状态与审核信息,并增加客户余额。 - -####### 校验与异常 - -- 校验记录状态必须为待审核;重复审核或状态冲突时返回业务错误。 - -##### 审核充值申请-驳回(POST /recharge/recharge-record/reject) - -###### 1 接口请求示例 - -**请求 /recharge/recharge-record/reject**(POST) - -```json -{ - "id": 1, - "remark": "截图不清晰,请重新上传" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 审核驳回后更新充值状态与审核备注,不变更客户余额。 - -####### 校验与异常 - -- 校验记录状态与审核权限,非法操作时返回业务错误。 - -##### 查询充值记录(GET /recharge/recharge-record/list) - -###### 1 接口请求示例 - -**请求 /recharge/recharge-record/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&customerId=1&serialNumber=&rechargeStatus=0&startTime=2024-01-01&endTime=2024-01-31 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "customerId": 1, - "customerCode": "W2501", - "customerPhone": "13800138000", - "serialNumber": "RECHARGE2024010100123456", - "rechargeTime": "2024-01-01 12:00:00", - "payType": 1, - "payTypeName": "微信", - "rechargeAmount": 500.00, - "rechargeScreenshot": "https://example.com/upload/screenshot.jpg", - "rechargeStatus": 0, - "rechargeStatusName": "待审核", - "remark": "充值500元", - "auditTime": null, - "auditBy": null, - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 20 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按客户、流水号、状态和时间区间进行分页查询,返回记录列表与总数。 - -####### 校验与异常 - -- 校验时间区间与分页参数合法性,查询异常时返回统一错误码。 - -##### 导出充值记录(GET /recharge/export) - -###### 1 接口请求示例 - -**请求 /recharge/export**(GET,Query 参数) - -``` -?customerId=1&startTime=2024-01-01&endTime=2024-01-31 -``` - -**响应** - -> 返回 Excel 文件流,Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据筛选条件导出充值记录为 Excel 文件流。 - -####### 校验与异常 - -- 校验导出条件与数据权限,导出失败时返回统一错误信息。 - ----------------------------------------------------------------------------------------- - ----------------------------------------------------------------------------------------- - -## 七、系统模块 - -### 6.1 核心需求说明 - -- **仓库地址**:管理仓库收货地址信息,包括主仓/中转仓/退货仓/其他类型;支持收件人、联系电话、收货地址、客服电话、客服微信及二维码等配置。 -- **国家管理**:维护国家基础资料(中文名称、英文名称、二字代码),供渠道分区、下单地址等模块复用。 -- **字典管理**:维护系统字典项,支持按字典类型分组管理字典编码、名称和排序,供下拉选项、状态映射等场景统一复用。 - -### 6.2 表结构设计 - -#### 国家表 `t_country` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| ChineseName | VARCHAR | 128 | - | 是 | 中文名称 | -| EnglishName | VARCHAR | 128 | - | 是 | 英文名称 | -| CountryCode | VARCHAR | 2 | - | 是 | 国家二字代码(ISO 3166-1 alpha-2) | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 字典表 `t_dict` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| DictType | VARCHAR | 64 | - | 是 | 字典类型(如 notice_type、warehouse_type) | -| DictSort | INT | - | - | 是 | 字典排序,值越小越靠前 | -| DictCode | VARCHAR | 64 | - | 是 | 字典编码(同类型下唯一) | -| DictName | VARCHAR | 128 | - | 是 | 字典名称 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 公告通知表 `t_notice` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| NoticeType | VARCHAR | 16 | - | 是 | 通知类型:notice-公告,message-站内信 | -| Title | VARCHAR | 200 | - | 是 | 标题 | -| Content | TEXT | - | - | 是 | 内容 | -| IsPopup | TINYINT | - | - | 是 | 是否弹窗:0-否,1-是 | -| ReceiverType | VARCHAR | 16 | - | 是 | 接收对象:user-用户,customer-客户 | -| ReceiverIds | JSON | - | - | 是 | 接收人 ID 列表 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -#### 通知已读明细表 `t_notice_read` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| NoticeId | BIGINT | - | 外键(关联 `t_notice.Id`) | 是 | 通知 ID | -| ReceiverType | VARCHAR | 16 | - | 是 | 接收对象:user-用户,customer-客户 | -| ReceiverId | BIGINT | - | - | 是 | 已读主体 ID(用户 ID/客户 ID) | -| ReadTime | DATETIME | - | - | 是 | 已读时间 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | - -#### 仓库地址表 `t_warehouse_address` - -| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | -| --- | --- | --- | --- | --- | --- | -| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 | -| WarehouseName | VARCHAR | 128 | - | 是 | 仓库名称 | -| WarehouseType | VARCHAR | 32 | - | 是 | main/transit/return/other | -| Recipient | VARCHAR | 64 | - | 否 | 收件人 | -| ContactPhone | VARCHAR | 32 | - | 否 | 联系电话 | -| ShippingAddress | TEXT | - | - | 否 | 收货地址 | -| ServicePhone | VARCHAR | 32 | - | 否 | 客服电话 | -| ServiceWechat | VARCHAR | 64 | - | 否 | 客服微信 | -| ServiceWechatFiles | JSON | - | - | 否 | 微信二维码图片 | -| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | -| CreateUserId | INT | - | - | 是 | 创建人 | -| CreateTime | DATETIME | - | - | 是 | 创建时间 | -| UpdateUserId | INT | - | - | 否 | 更新人 | -| UpdateTime | DATETIME | - | - | 否 | 更新时间 | - -### 6.3 核心接口设计 - -| 接口名称 | 请求方式 | 请求路径 | 说明 | -| --- | --- | --- | --- | -| 国家列表 | GET | /system/country/list | 查询国家列表 | -| 字典列表 | GET | /system/dict/list | 按类型分页查询字典 | -| 新增字典 | POST | /system/dict/create | 新增字典项 | -| 更新字典 | POST | /system/dict/update | 更新字典项 | -| 删除字典 | POST | /system/dict/delete | 删除字典项 | -| 仓库列表 | GET | /system/warehouse/list | 仓库列表 | -| 仓库详情 | GET | /system/warehouse/detail | 仓库详情 | -| 新增仓库 | POST | /system/warehouse/create | 新增 | -| 更新仓库 | POST | /system/warehouse/update | 更新 | -| 删除仓库 | POST | /system/warehouse/delete | 删除 | -| 通知列表 | GET | /system/notice/list | 分页查询通知 | -| 新增通知 | POST | /system/notice/create | 新增公告/站内信 | -| 编辑通知 | POST | /system/notice/update | 编辑公告/站内信 | -| 删除通知 | POST | /system/notice/delete | 删除公告/站内信 | -| 确认已读 | POST | /system/notice/read-confirm | 当前用户/客户确认已读 | - -##### 国家列表(GET /system/country/list) - -###### 1 接口请求示例 -**请求 /system/country/list**(GET) - -> 无请求参数,返回全部国家字段。 - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": [ - { - "id": 1, - "chineseName": "中国", - "englishName": "China", - "countryCode": "CN" - }, - { - "id": 2, - "chineseName": "美国", - "englishName": "United States", - "countryCode": "US" - } - ] -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 查询 `t_country` 全量有效数据,按 `countryCode` 或 `englishName` 排序返回。 - -####### 校验与异常 - -- 仅返回未删除记录;无数据时返回空数组。 - -##### 字典列表(GET /system/dict/list) - -###### 1 接口请求示例 -**请求 /system/dict/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&dictType=warehouse_type&keyword=主仓 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "dictType": "warehouse_type", - "dictSort": 1, - "dictCode": "main", - "dictName": "主仓", - "createTime": "2026-04-27 10:00:00", - "updateTime": "2026-04-27 10:00:00" - } - ], - "total": 1 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按 `dictType`、关键词分页查询 `t_dict`,按 `dictSort` 升序、`id` 降序返回。 - -####### 校验与异常 - -- 校验分页参数合法性;仅返回未删除记录,查询异常时返回统一错误码。 - -##### 新增字典(POST /system/dict/create) - -###### 1 接口请求示例 - -**请求 /system/dict/create**(POST) - -```json -{ - "dictType": "warehouse_type", - "dictSort": 1, - "dictCode": "main", - "dictName": "主仓" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 新增字典项并写入创建审计信息。 - -####### 校验与异常 - -- 校验 `dictType`、`dictSort`、`dictCode`、`dictName` 必填及格式合法性。 -- 校验同一 `dictType` 下 `dictCode` 唯一,重复时返回业务错误。 - -##### 更新字典(POST /system/dict/update) - -###### 1 接口请求示例 - -**请求 /system/dict/update**(POST) - -```json -{ - "id": 1, - "dictType": "warehouse_type", - "dictSort": 2, - "dictCode": "transit", - "dictName": "中转仓" -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据字典 ID 更新字典类型、排序、编码、名称及审计信息。 - -####### 校验与异常 - -- 校验 `id` 存在且未删除,更新字段合法。 -- 若更新后 `dictType + dictCode` 与其他记录冲突,返回业务错误。 - -##### 删除字典(POST /system/dict/delete) - -###### 1 接口请求示例 - -**请求 /system/dict/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据字典 ID 执行逻辑删除并保留历史审计信息。 - -####### 校验与异常 - -- 校验 `id` 存在与删除前置条件,不存在或已删除时返回业务错误。 - -##### 仓库列表(GET /system/warehouse/list) - -###### 1 接口请求示例 -**请求 /system/warehouse/list**(GET,Query 参数) - -``` -?page=1&pageSize=20&warehouseType=main&keyword= -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 1, - "warehouseName": "深圳主仓", - "warehouseType": "main", - "warehouseTypeName": "主仓", - "recipient": "张三", - "contactPhone": "0755-12345678", - "shippingAddress": "广东省深圳市南山区xxx路xxx号", - "servicePhone": "400-xxx-xxxx", - "serviceWechat": "itfx_service", - "serviceWechatFiles": ["https://example.com/qrcode1.jpg"], - "createTime": "2024-01-01 12:00:00", - "updateTime": "2024-01-01 12:00:00" - } - ], - "total": 5 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按仓库类型与关键词分页查询仓库地址信息列表。 - -####### 校验与异常 - -- 校验分页参数与筛选条件合法性,异常时返回统一错误码。 - -##### 仓库详情(GET /system/warehouse/detail) - -###### 1 接口请求示例 - -**请求 /system/warehouse/detail**(GET,Query 参数) - -``` -?id=1 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 1, - "warehouseName": "深圳主仓", - "warehouseType": "main", - "recipient": "张三", - "contactPhone": "0755-12345678", - "shippingAddress": "广东省深圳市南山区xxx路xxx号", - "servicePhone": "400-xxx-xxxx", - "serviceWechat": "itfx_service", - "serviceWechatFiles": ["https://example.com/qrcode1.jpg"], - "createTime": "2024-01-01 12:00:00", - "createBy": "admin", - "updateTime": "2024-01-01 12:00:00", - "updateBy": "admin" - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据仓库 ID 查询仓库详情,返回完整地址与客服信息。 - -####### 校验与异常 - -- 校验 `id` 必填且存在,不存在时返回业务错误。 - -##### 新增仓库(POST /system/warehouse/create) - -###### 1 接口请求示例 - -**请求 /system/warehouse/create**(POST) - -```json -{ - "warehouseName": "深圳主仓", - "warehouseType": "main", - "recipient": "张三", - "contactPhone": "0755-12345678", - "shippingAddress": "广东省深圳市南山区xxx路xxx号", - "servicePhone": "400-xxx-xxxx", - "serviceWechat": "itfx_service", - "serviceWechatFiles": ["https://example.com/qrcode1.jpg"] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 新增仓库地址配置并写入创建审计信息。 - -####### 校验与异常 - -- 校验仓库名称、类型及地址字段完整性,不合法时返回业务错误。 - -##### 更新仓库(POST /system/warehouse/update) - -###### 1 接口请求示例 - -**请求 /system/warehouse/update**(POST) - -```json -{ - "id": 1, - "warehouseName": "深圳主仓", - "warehouseType": "main", - "recipient": "张三", - "contactPhone": "0755-12345678", - "shippingAddress": "广东省深圳市南山区xxx路xxx号", - "servicePhone": "400-xxx-xxxx", - "serviceWechat": "itfx_service", - "serviceWechatFiles": ["https://example.com/qrcode1.jpg"] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据仓库 ID 更新仓库基础信息与联系方式。 - -####### 校验与异常 - -- 校验 `id` 存在与更新字段合法性,更新失败时返回业务错误。 - -##### 删除仓库(POST /system/warehouse/delete) - -###### 1 接口请求示例 - -**请求 /system/warehouse/delete**(POST) - -```json -{ - "id": 1 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据仓库 ID 执行逻辑删除并保留历史记录。 - -####### 校验与异常 - -- 校验 `id` 存在与删除前置条件,冲突时返回业务错误。 - -##### 通知列表(GET /system/notice/list) - -###### 1 接口请求示例 - -**请求 /system/notice/list**(GET,Query 参数) - -``` -?page=1&pageSize=20¬iceType=notice&receiverType=user&keyword=活动 -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "items": [ - { - "id": 101, - "noticeType": "notice", - "noticeTypeName": "公告", - "title": "五一放假通知", - "content": "仓库 5 月 1 日-5 月 3 日暂停收货。", - "isPopup": 1, - "receiverType": "user", - "receiverTypeName": "用户", - "receiverIds": [1001, 1002, 1003], - "readCount": 2, - "receiverCount": 3, - "createTime": "2026-04-25 12:00:00", - "updateTime": "2026-04-25 12:00:00" - } - ], - "total": 1 - } -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 按通知类型、接收对象、关键词分页查询 `t_notice`。 -- 已读数通过聚合 `t_notice_read`(按 `NoticeId` + `ReceiverId` 去重)计算,接收人总数按 `receiverIds` 统计。 - -####### 校验与异常 - -- 校验分页参数合法性,仅返回未删除记录;异常时返回统一错误码。 - -##### 新增通知(POST /system/notice/create) - -###### 1 接口请求示例 - -**请求 /system/notice/create**(POST) - -```json -{ - "noticeType": "notice", - "title": "五一放假通知", - "content": "仓库 5 月 1 日-5 月 3 日暂停收货。", - "isPopup": 1, - "receiverType": "user", - "receiverIds": [1001, 1002, 1003] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 新增公告/站内信并写入接收对象与接收人列表。 - -####### 校验与异常 - -- 校验 `noticeType`、`title`、`content`、`isPopup`、`receiverType`、`receiverIds` 必填及枚举合法性。 -- `receiverIds` 为空或包含无效用户/客户时返回业务错误。 - -##### 编辑通知(POST /system/notice/update) - -###### 1 接口请求示例 - -**请求 /system/notice/update**(POST) - -```json -{ - "id": 101, - "noticeType": "message", - "title": "账单补充说明", - "content": "请于本周内完成 4 月账单核对。", - "isPopup": 0, - "receiverType": "customer", - "receiverIds": [2001, 2002] -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据通知 ID 更新通知主体内容与接收范围。 -- 如接收人发生变化,清理 `t_notice_read` 中不在新接收范围内的已读明细。 - -####### 校验与异常 - -- 校验 `id` 存在且未删除,更新字段合法。 -- 已发布通知可编辑但必须保留至少 1 个接收人。 - -##### 删除通知(POST /system/notice/delete) - -###### 1 接口请求示例 - -**请求 /system/notice/delete**(POST) - -```json -{ - "id": 101 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 根据通知 ID 执行逻辑删除,保留历史审计信息。 - -####### 校验与异常 - -- 校验 `id` 必填且存在,不存在或已删除时返回业务错误。 - -##### 确认已读(POST /system/notice/read-confirm) - -###### 1 接口请求示例 - -**请求 /system/notice/read-confirm**(POST) - -```json -{ - "id": 101 -} -``` - -**响应** - -```json -{ - "code": 0, - "msg": "success", - "data": null -} -``` - -###### 2 接口业务逻辑 - -####### 核心流程 - -- 当前登录用户/客户调用后,在 `t_notice_read` 中按 `NoticeId + ReceiverType + ReceiverId` 幂等写入已读记录,并记录 `readTime`。 - -####### 校验与异常 - -- 校验通知存在且未删除。 -- 校验当前主体在通知接收范围内,不在范围内时返回无权限错误。 - ----------------------------------------------------------------------------------------- - ----------------------------------------------------------------------------------------- - -## 附录 - -### 附录一:接口路径规范(kebab-case 汇总) - -| 模块 | 路径前缀 | 示例 | -| --- | --- | --- | -| 订单 | /order/ | list, detail, pay, pre-declare, cancel-intercept, delete, price-check | -| 渠道-销售产品 | /channel/sales-product/ | list, detail, create, update, delete, price/list, allocation/get | -| 渠道-服务渠道 | /channel/service-channel/ | list, detail, create, update, delete, group/create, group/update, group/delete, price/list | -| 渠道-计重 | /channel/weight-rule/, weight-rounding/ | list, create, update, delete, info/list | -| 渠道-自动触发 | /channel/auto-trigger/ | list, create, update, delete, toggle-status | -| 渠道-分区(KpiArea) | /channel/kpi-area/ | create, update, delete, list, info/add, info/update, info/delete, info/list | -| 渠道-标签 | /channel/label-template/save | | -| 操作 | /operation/ | inbound/create, inbound/recent-list, outbound/create, print-label/print | -| 客户 | /customer/ | list, get, create, update, delete, reset-password | -| 充值 | /recharge/ | recharge/submit, recharge-record/list, approve, reject | -| 系统 | /system/ | country/list, warehouse/list, warehouse/detail, warehouse/create, warehouse/update, warehouse/delete, notice/list, notice/create, notice/update, notice/delete, notice/read-confirm | - -### 附录二:分区类型说明(黑名单/偏远/价格) - -| 页面 | areaType | 说明 | -| --- | --- | --- | -| 黑名单区 | blacklist | 黑名单分区 | -| 偏远分区 | remote | 偏远分区 | -| 价格分区 | price | 用于计价/价格表关联的分区 | - -调用 KpiArea 接口时,通过 `areaType` 区分不同业务分区,无需新建表或接口。 - -### 附录三:状态码与枚举 - -#### 订单状态 - -| status | 中文 | -| --- | --- | -| draft | 草稿 | -| predeclared | 已预报 | -| received | 已收货 | -| pendingPayment | 待支付 | -| pendingShip | 待发货 | -| pendingOnline | 待上网 | -| shipping | 运输中 | -| deliverySuccess | 派送成功 | -| deliveryFailed | 派送失败 | -| problematic | 问题件 | -| deleted | 已删除 | -| claimed | 已理赔 | - -#### 充值状态 - -| payStatus | 中文 | -| --- | --- | -| 0 | 待审核 | -| 1 | 成功 | -| 2 | 失败 | - -#### 仓库类型 - -| warehouseType | 中文 | -| --- | --- | -| main | 主仓 | -| transit | 中转仓 | -| return | 退货仓 | -| other | 其他 | - -#### 通知类型 - -| noticeType | 中文 | -| --- | --- | -| notice | 公告 | -| message | 站内信 | - -#### 接收对象类型 - -| receiverType | 中文 | -| --- | --- | -| user | 用户 | -| customer | 客户 | - -#### 客户分组(客户表) - -| customerGroup | 中文 | -| --- | --- | -| 0 | 普通客户 | -| 1 | VIP1 | -| 2 | VIP2 | -| 3 | VIP3 | -| 4 | VIP4 | -| 5 | VIP5 | - ----------------------------------------------------------------------------------------- - -*文档版本:v1.0 | 最后更新:2025-03-04* - - diff --git a/sql/parcel_billing.sql b/sql/parcel_billing.sql new file mode 100644 index 0000000..d4a3717 --- /dev/null +++ b/sql/parcel_billing.sql @@ -0,0 +1,11 @@ +-- 小包预报计费:订单版本字段 + 费用计费阶段 +-- 若列已存在可跳过对应语句 + +ALTER TABLE `t_order` + ADD COLUMN IF NOT EXISTS `ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式' AFTER `CargoAttribute`, + ADD COLUMN IF NOT EXISTS `PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因' AFTER `IsRemote`, + ADD COLUMN IF NOT EXISTS `SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本' AFTER `PredeclareFailReason`, + ADD COLUMN IF NOT EXISTS `SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本' AFTER `SalesProductRulesVersion`; + +ALTER TABLE `t_order_fee` + ADD COLUMN IF NOT EXISTS `ChargingStage` VARCHAR(16) NULL COMMENT '计费阶段:forecast/inbound/outbound' AFTER `PriceEffectiveTime`; diff --git a/sql/wechat_login.sql b/sql/wechat_login.sql new file mode 100644 index 0000000..6cc0067 --- /dev/null +++ b/sql/wechat_login.sql @@ -0,0 +1,5 @@ +-- 微信登录:客户 openId 唯一索引(允许多个 NULL) +-- 执行前请确认索引不存在 + +CREATE UNIQUE INDEX IF NOT EXISTS uk_customer_wechat_openid + ON t_customer (WechatOpenid); diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelGroupListInput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelGroupListInput.cs new file mode 100644 index 0000000..d394e76 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelGroupListInput.cs @@ -0,0 +1,22 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// 服务渠道分组列表查询入参。 +/// +public class ServiceChannelGroupListInput +{ + /// + /// 关键词(分组名称模糊匹配)。 + /// + public string? Keyword { get; set; } + + /// + /// 应用 ID。 + /// + public int? AppId { get; set; } + + /// + /// API 服务商 ID。 + /// + public int? ApiServiceProviderId { get; set; } +} diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelGroupListOutput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelGroupListOutput.cs new file mode 100644 index 0000000..7bd3cf1 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelGroupListOutput.cs @@ -0,0 +1,37 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// 服务渠道分组列表出参。 +/// +public class ServiceChannelGroupListOutput +{ + /// + /// 主键。 + /// + public int Id { get; set; } + + /// + /// 分组名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 应用 ID。 + /// + public int AppId { get; set; } + + /// + /// API 服务商 ID。 + /// + public int ApiServiceProviderId { get; set; } + + /// + /// 创建时间。 + /// + public DateTime CreateTime { get; set; } + + /// + /// 更新时间。 + /// + public DateTime? UpdateTime { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs index f7cedbe..0ea130a 100644 --- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs @@ -40,6 +40,11 @@ public class OrderParcelCreateInput /// public string? CargoAttribute { get; set; } + /// + /// 出口报关方式。 + /// + public string? ExportCustomsMode { get; set; } + /// /// 仓库主键。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs index 83d9324..a303919 100644 --- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs @@ -64,4 +64,24 @@ public class OrderPriceCheckOutput /// 总成本数值。 /// public decimal TotalCostNum { get; set; } + + /// + /// 产品编码。 + /// + public string? ProductCode { get; set; } + + /// + /// 绑定的规则快照版本。 + /// + public string? RulesVersion { get; set; } + + /// + /// 绑定的价格快照版本。 + /// + public string? PriceVersion { get; set; } + + /// + /// 是否偏远。 + /// + public bool IsRemote { get; set; } } diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs new file mode 100644 index 0000000..938dbf8 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs @@ -0,0 +1,41 @@ +using Oms.Application.Dtos.Channels; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 销售产品价格发布快照(与渠道发布 JSON 同构)。 +/// +public class SalesProductPricePublishSnapshotDto +{ + public int SalesProductId { get; set; } + + public List Prices { get; set; } = []; +} + +/// +/// 销售产品规则发布快照(与渠道发布 JSON 同构)。 +/// +public class SalesProductRulesPublishSnapshotDto +{ + public SalesProductSaveInput? Product { get; set; } + + public List Providers { get; set; } = []; +} + +/// +/// 绑定到订单的快照版本信息。 +/// +public class ProductSnapshotBinding +{ + public string RulesVersion { get; set; } = string.Empty; + + public string PriceVersion { get; set; } = string.Empty; + + public DateTime RulesEffectiveTime { get; set; } + + public DateTime PriceEffectiveTime { get; set; } + + public SalesProductRulesPublishSnapshotDto Rules { get; set; } = new(); + + public SalesProductPricePublishSnapshotDto Price { get; set; } = new(); +} diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj index 3a58643..c010438 100644 --- a/src/Oms.Application/Oms.Application.csproj +++ b/src/Oms.Application/Oms.Application.csproj @@ -22,4 +22,8 @@ + + + + diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index d5aa6e0..c97ff16 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -276,6 +276,19 @@ public partial class ServiceChannelService entity.IsActive = input.IsActive; } + private static ServiceChannelGroupListOutput MapGroupListOutput(ServiceChannelGroup entity) + { + return new ServiceChannelGroupListOutput + { + Id = entity.Id, + Name = entity.Name, + AppId = entity.AppId, + ApiServiceProviderId = entity.ApiServiceProviderId, + CreateTime = entity.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + }; + } + private static ServiceChannelListOutput MapListOutput(ServiceChannel entity, IReadOnlyDictionary groupMap) { return new ServiceChannelListOutput diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index b70ced5..410ed8d 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -125,6 +125,27 @@ public partial class ServiceChannelService : ApplicationService await ServiceChannelRepository.DeleteAsync(entity); } + /// + /// 查询服务渠道分组列表。 + /// + /// 入参:分组名称、应用 ID、API 服务商 ID 等筛选条件。 + /// 服务渠道分组列表,供下拉与左侧树筛选使用。 + /// + /// 核心业务逻辑: + /// 1. 按 keyword、appId、apiServiceProviderId 构造筛选条件。 + /// 2. 查询未删除分组并按 id 倒序返回。 + /// 3. 无数据时返回空数组。 + /// + [HttpGet("/channel/service-channel/group/list")] + public async Task> GetGroupListAsync([FromQuery] ServiceChannelGroupListInput input) + { + var predicate = BuildGroupListPredicate(input); + var entities = await ServiceChannelGroupRepository.GetListAsync( + predicate, + new Dictionary { [nameof(ServiceChannelGroup.Id)] = false }); + return entities.Select(MapGroupListOutput).ToList(); + } + /// /// 新增服务渠道分组。 /// @@ -465,4 +486,17 @@ public partial class ServiceChannelService : ApplicationService (string.IsNullOrWhiteSpace(keyword) || entity.ChannelCode.Contains(keyword) || entity.ChannelName.Contains(keyword)) && (!hasIsActiveFilter || entity.IsActive == isActiveValue); } + + private static Expression> BuildGroupListPredicate(ServiceChannelGroupListInput input) + { + var keyword = input.Keyword?.Trim(); + var hasAppId = input.AppId.HasValue; + var appId = input.AppId.GetValueOrDefault(); + var hasApiServiceProviderId = input.ApiServiceProviderId.HasValue; + var apiServiceProviderId = input.ApiServiceProviderId.GetValueOrDefault(); + return entity => + (string.IsNullOrWhiteSpace(keyword) || entity.Name.Contains(keyword)) && + (!hasAppId || entity.AppId == appId) && + (!hasApiServiceProviderId || entity.ApiServiceProviderId == apiServiceProviderId); + } } diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 4ca488e..325b7ad 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -111,41 +111,6 @@ public partial class OrderService }).ToList(); } - private async Task> BuildPriceCheckOutputsAsync(OrderPriceCheckInput input) - { - var products = await SalesProductRepository.GetListAsync( - x => x.IsActive == Oms.Domain.Shared.Enums.EnableStatus.Enabled && - (string.IsNullOrWhiteSpace(input.ShippingMethod) || x.ProductName.Contains(input.ShippingMethod) || x.ProductCode.Contains(input.ShippingMethod))); - var warehouses = await WarehouseAddressRepository.GetListAsync(); - var warehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault(); - var volumetricWeight = input.Length.HasValue && input.Width.HasValue && input.Height.HasValue - ? Math.Round((input.Length.Value * input.Width.Value * input.Height.Value) / 6000M, 3) - : 0; - var chargeableWeight = Math.Max(input.CargoWeight ?? 0, volumetricWeight); - - return products.Select(product => - { - var freight = Math.Round(chargeableWeight * 62M, 2); - var otherFees = 5M; - var totalCost = freight + otherFees; - return new OrderPriceCheckOutput - { - Id = product.Id, - ShippingMethod = product.ProductName, - Warehouse = warehouse?.WarehouseName, - DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}", - VolumetricWeight = volumetricWeight, - ChargeableWeight = chargeableWeight, - CostFormula = $"(62*{chargeableWeight})+{otherFees}", - Freight = freight, - OtherFees = otherFees, - TotalCost = totalCost, - MaxDeliveryDays = product.MaxTimeliness, - TotalCostNum = totalCost - }; - }).ToList(); - } - private async Task> GetTemplateOutputsAsync(OrderTemplateListInput input) { var page = OmsDocHelper.NormalizePage(input.Page); @@ -394,177 +359,6 @@ public partial class OrderService }; } - private async Task CreateParcelCoreAsync(OrderParcelCreateInput input) - { - if (!string.IsNullOrWhiteSpace(input.RequestId)) - { - var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId); - if (existed != null) - { - return new OrderParcelCreateOutput - { - ClientNo = existed.ClientNo, - ServiceNo = existed.ServiceNo, - TrackNo = existed.TrackNo, - LabelUrl = await CreateOrderPrintFileAsync((await MapOrderOutputsAsync([existed])).First()) - }; - } - } - - var serviceNo = input.ServiceNo?.Trim(); - if (string.IsNullOrWhiteSpace(serviceNo)) - { - throw new BusinessException("运单号不能为空"); - } - - if (await OrderRepository.HasServiceNoAsync(serviceNo)) - { - throw new BusinessException("运单号已存在"); - } - - var order = await OrderRepository.InsertAsync(BuildOrderEntity(input, serviceNo)); - await SaveOrderChildrenAsync(order, input); - return new OrderParcelCreateOutput - { - ClientNo = order.ClientNo, - ServiceNo = order.ServiceNo, - TrackNo = order.TrackNo, - LabelUrl = await CreateOrderPrintFileAsync((await MapOrderOutputsAsync([order])).First()) - }; - } - - private Order BuildOrderEntity(OrderParcelCreateInput input, string serviceNo) - { - return new Order - { - RequestId = input.RequestId, - ClientNo = input.ClientNo, - TrackNo = input.TrackNo, - ServiceNo = serviceNo, - ProductCode = input.ProductCode, - ChannelCode = input.ChannelCode, - CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General, - WarehouseId = input.WarehouseId, - CustomerId = input.CustomerId, - IsCod = input.IsCod ? (byte)1 : (byte)0, - CodCurrency = input.CodCurrency, - CodSum = input.CodSum, - DeclaredCurrency = input.DeclaredCurrency, - DeclaredSum = input.DeclaredSum, - Quantity = input.Quantity, - IsIntercept = 0, - IsRemote = 0, - OrderStatus = OrderStatusCodes.PendingPayment - }; - } - - private async Task SaveOrderChildrenAsync(Order order, OrderParcelCreateInput input) - { - var now = DateTime.Now; - if (input.Sender != null) - { - var sender = await OrderRepository.InsertSenderAsync(new OrderSender - { - OrderId = order.Id, - SenderName = input.Sender.SenderName, - Company = input.Sender.Company, - Country = input.Sender.CountryCode, - Province = input.Sender.Province, - City = input.Sender.City, - ZipCode = input.Sender.ZipCode, - StreetAddress1 = input.Sender.StreetAddress1, - StreetAddress2 = input.Sender.StreetAddress2, - StreetAddress3 = input.Sender.StreetAddress3, - HouseNumber = input.Sender.HouseNumber, - Phone = input.Sender.Phone, - TelePhone = input.Sender.TelePhone, - Email = input.Sender.Email, - IsDefault = 0 - }); - order.SenderId = sender.Id; - } - - if (input.Receiver != null) - { - var recipient = await OrderRepository.InsertRecipientAsync(new OrderRecipient - { - OrderId = order.Id, - RecipientsName = input.Receiver.RecipientsName, - CompanyName = input.Receiver.CompanyName, - Country = input.Receiver.CountryCode, - Phone = input.Receiver.Phone, - TelePhone = input.Receiver.TelePhone, - ZipCode = input.Receiver.ZipCode, - Province = input.Receiver.Province, - City = input.Receiver.City, - StreetNumber = input.Receiver.StreetNumber, - StreetAddress1 = input.Receiver.StreetAddress1, - StreetAddress2 = input.Receiver.StreetAddress2, - StreetAddress3 = input.Receiver.StreetAddress3, - Email = input.Receiver.Email, - TaxType = input.Receiver.TaxType, - TaxNo = input.Receiver.TaxNo, - CertificateType = input.Receiver.CertificateType, - CertificateNo = input.Receiver.CertificateNo, - Eori = input.Receiver.Eori, - Vat = input.Receiver.Vat - }); - order.RecipientId = recipient.Id; - } - - if (input.Items.Count > 0) - { - await OrderRepository.InsertDeclarationsAsync(input.Items.Select(x => new OrderDeclaration - { - OrderId = order.Id, - CnName = x.CnName, - EnName = x.EnName, - Sku = x.Sku, - Unit = x.Unit, - UnitPrice = x.UnitPrice, - UnitWeight = x.UnitWeight, - Quantity = x.Quantity, - Amount = x.Amount, - Weight = x.Weight, - HsCode = x.ExHsCode ?? x.ImHsCode, - ImHsCode = x.ImHsCode, - ExHsCode = x.ExHsCode, - SkuImage = x.SkuImage, - Category = x.Category, - Brand = x.Brand, - BrandType = x.BrandType, - IsMagnetic = x.IsMagnetic ? (byte)1 : (byte)0, - Distribution = x.Distribution, - Material = x.Material, - EnMaterial = x.EnMaterial, - Purpose = x.Purpose, - Attributes = x.Attributes, - OriginCountry = x.OriginCountry, - SalesLink = x.SalesLink, - Model = x.Model, - Specifications = x.Specifications, - Comment = x.Comment - })); - } - - await OrderRepository.InsertWeightsAsync([ - new OrderWeight - { - OrderId = order.Id, - Type = OrderWeightTypeCodes.Forecast, - Length = input.Length, - Width = input.Width, - Height = input.Height, - ActualWeight = input.ActualWeight, - VolumetricWeight = input.VolumetricWeight, - ChargeableWeight = input.ChargeableWeight, - Cubic = input.Cubic - } - ]); - - await OrderRepository.UpdateAsync(order); - } - private async Task CreateOrderPrintFileAsync(OrderOutput order) { var directory = Path.Combine(GetWebRootDirectory(), "uploads", "prints", "orders"); diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs new file mode 100644 index 0000000..e74605a --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -0,0 +1,693 @@ +using Fake.Application; +using Oms.Application.Dtos.Channels; +using Oms.Application.Dtos.Orders; +using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.CustomersAggregate.Enums; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.Shared.Enums; +using Oms.Domain.SystemAggregate.Repositories; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + private const string PriceOperatorType = "price"; + private const string RulesOperatorType = "rules"; + private const int ParcelBatchMaxCount = 2000; + + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); + private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService(); + private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService(); + private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService(); + + private async Task CreateParcelCoreAsync(OrderParcelCreateInput input) + { + if (!string.IsNullOrWhiteSpace(input.RequestId)) + { + var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId); + if (existed != null) + { + return await BuildParcelCreateOutputAsync(existed); + } + } + + var bindTime = DateTime.Now; + await ValidateParcelCreateInputAsync(input); + + var product = await SalesProductRepository.GetByProductCodeAsync(input.ProductCode!.Trim()); + if (product == null || product.IsActive != EnableStatus.Enabled) + { + throw new BusinessException("销售产品不存在或未启用"); + } + + var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime); + var country = await CountryRepository.FirstOrDefaultAsync(x => x.Id == input.Receiver!.CountryCode); + if (country == null) + { + throw new BusinessException("收件国家不存在"); + } + + if (await IsBlacklistHitAsync( + product, + country.CountryCode, + input.Receiver?.Province, + input.Receiver?.City, + input.Receiver?.ZipCode)) + { + throw new BusinessException("收件地址命中黑名单区域"); + } + + var serviceNo = await ResolveServiceNoAsync(input.ServiceNo); + var order = BuildDraftOrderEntity(input, product, country.CountryCode, snapshotBinding, serviceNo); + order = await OrderRepository.InsertAsync(order); + + var weights = BuildInitialWeightRows(order.Id, input); + await SaveParcelChildrenAsync(order, input, weights); + + try + { + order.IsRemote = await IsRemoteHitAsync( + product, + country.CountryCode, + input.Receiver?.Province, + input.Receiver?.City, + input.Receiver?.ZipCode) ? (byte)1 : (byte)0; + + var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast); + await ApplyChargeableWeightAsync(product, forecast); + SyncWeightRows(weights, forecast); + await OrderRepository.ReplaceWeightsAsync(order.Id, weights); + + var zoneName = await MatchPriceZoneAsync( + product, + country.CountryCode, + input.Receiver?.Province, + input.Receiver?.City, + input.Receiver?.ZipCode); + if (string.IsNullOrWhiteSpace(zoneName)) + { + throw new BusinessException("价格分区未匹配(规则快照)"); + } + + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId); + var fees = ParcelBillingCalculator.CalculateForecastReceivableFees( + snapshotBinding.Price, + snapshotBinding.PriceVersion, + snapshotBinding.PriceEffectiveTime, + forecast.ChargeableWeight ?? 0, + zoneName, + order.CargoAttribute ?? CargoAttributeCodes.General, + customer?.CustomerGroup); + + if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshotBinding.Price)) + { + throw new BusinessException("产品卖价快照未配置或未匹配到有效价格"); + } + + if (fees.Count > 0) + { + await OrderRepository.UpsertFeesAsync(order.Id, fees); + } + + order.OrderStatus = OrderStatusCodes.Predeclared; + order.PredeclareFailReason = string.Empty; + order.IsRemote = order.IsRemote; + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + + return await BuildParcelCreateOutputAsync(order); + } + catch (BusinessException ex) + { + await MarkParcelDraftFailedAsync(order, ex.Message); + throw; + } + catch (Exception ex) + { + await MarkParcelDraftFailedAsync(order, ex.Message); + throw new BusinessException(ex.Message); + } + } + + private async Task ValidateParcelCreateInputAsync(OrderParcelCreateInput input) + { + if (string.IsNullOrWhiteSpace(input.ProductCode)) + { + throw new BusinessException("产品代码不能为空"); + } + + if (!input.WarehouseId.HasValue || input.WarehouseId <= 0) + { + throw new BusinessException("仓库ID不能为空"); + } + + if (!input.CustomerId.HasValue || input.CustomerId <= 0) + { + throw new BusinessException("客户ID不能为空"); + } + + if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue || !input.ActualWeight.HasValue + || input.Length <= 0 || input.Width <= 0 || input.Height <= 0 || input.ActualWeight <= 0) + { + throw new BusinessException("件重尺必须大于0"); + } + + if (string.IsNullOrWhiteSpace(input.CargoAttribute) + || input.CargoAttribute is not (CargoAttributeCodes.General or CargoAttributeCodes.Electronics or CargoAttributeCodes.Sensitive)) + { + throw new BusinessException("货物属性不合法"); + } + + if (!string.IsNullOrWhiteSpace(input.ExportCustomsMode) + && !ExportCustomsModeCodes.All.Contains(input.ExportCustomsMode)) + { + throw new BusinessException("出口报关方式不合法"); + } + + if (string.IsNullOrWhiteSpace(input.DeclaredCurrency)) + { + throw new BusinessException("申报币种不能为空"); + } + + if (!input.Quantity.HasValue || input.Quantity <= 0) + { + throw new BusinessException("包裹数量不能为空"); + } + + if (input.Receiver == null + || string.IsNullOrWhiteSpace(input.Receiver.RecipientsName) + || !input.Receiver.CountryCode.HasValue + || string.IsNullOrWhiteSpace(input.Receiver.ZipCode) + || string.IsNullOrWhiteSpace(input.Receiver.StreetAddress1)) + { + throw new BusinessException("收件人信息不完整"); + } + + if (input.Sender == null || string.IsNullOrWhiteSpace(input.Sender.SenderName)) + { + throw new BusinessException("发件人姓名不能为空"); + } + + if (input.Items.Count == 0) + { + throw new BusinessException("申报明细不能为空"); + } + + foreach (var item in input.Items) + { + if (string.IsNullOrWhiteSpace(item.EnName) + || !item.UnitPrice.HasValue + || !item.UnitWeight.HasValue + || !item.Quantity.HasValue + || item.Quantity <= 0) + { + throw new BusinessException("申报明细必填字段不完整"); + } + } + + var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId.Value); + if (warehouse == null) + { + throw new BusinessException("仓库不存在"); + } + + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value); + if (customer == null || customer.Status != CustomerStatus.Normal) + { + throw new BusinessException("客户不存在或状态不可用"); + } + } + + private async Task ResolveSnapshotBindingAsync(SalesProduct product, DateTime bindTime) + { + var rulesRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, RulesOperatorType, bindTime); + var priceRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, PriceOperatorType, bindTime); + + SalesProductRulesPublishSnapshotDto rulesSnapshot; + if (rulesRecord == null) + { + rulesSnapshot = await BuildLiveRulesSnapshotAsync(product); + } + else + { + rulesSnapshot = OmsDocHelper.DeserializeJson(rulesRecord.Content) + ?? throw new BusinessException("未命中规则快照版本(内存/Redis/DB)"); + } + + SalesProductPricePublishSnapshotDto priceSnapshot; + if (priceRecord == null) + { + priceSnapshot = await BuildLivePriceSnapshotAsync(product); + if (priceSnapshot.Prices.Count == 0) + { + throw new BusinessException("未命中价格快照版本(内存/Redis/DB)"); + } + } + else + { + priceSnapshot = OmsDocHelper.DeserializeJson(priceRecord.Content) + ?? throw new BusinessException("未命中价格快照版本(内存/Redis/DB)"); + if (priceSnapshot.Prices.Count == 0) + { + throw new BusinessException("未命中价格快照版本(内存/Redis/DB)"); + } + } + + if (rulesRecord == null && !product.WeightRuleId.HasValue) + { + throw new BusinessException("未命中规则快照版本(内存/Redis/DB)"); + } + + return new ProductSnapshotBinding + { + RulesVersion = rulesRecord?.Version ?? $"LIVE-{product.Id}", + PriceVersion = priceRecord?.Version ?? $"LIVE-{product.Id}", + RulesEffectiveTime = rulesRecord?.PublishTime ?? bindTime, + PriceEffectiveTime = priceRecord?.PublishTime ?? bindTime, + Rules = rulesSnapshot, + Price = priceSnapshot + }; + } + + private async Task BuildLiveRulesSnapshotAsync(SalesProduct product) + { + return new SalesProductRulesPublishSnapshotDto + { + Product = new SalesProductSaveInput + { + Id = product.Id, + ProductCode = product.ProductCode, + ProductName = product.ProductName, + BlacklistAreaId = product.BlacklistAreaId, + RemoteAreaId = product.RemoteAreaId, + PriceAreaId = product.PriceAreaId, + WeightRuleId = product.WeightRuleId, + WeightRoundingId = product.WeightRoundingId, + IsActive = product.IsActive, + Scheme = product.Scheme + } + }; + } + + private async Task BuildLivePriceSnapshotAsync(SalesProduct product) + { + var prices = await SalesProductRepository.GetPricesAsync(product.Id); + var priceIds = prices.Select(x => x.Id).ToArray(); + var factorMap = (await SalesProductRepository.GetPriceFactorsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList()); + var detailMap = (await SalesProductRepository.GetPriceDetailsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()); + + return new SalesProductPricePublishSnapshotDto + { + SalesProductId = product.Id, + Prices = prices.Select(price => new SalesProductPriceListOutput + { + Id = price.Id, + SalesProductId = price.SalesProductId, + SerialNumber = price.SerialNumber, + Status = (byte)price.Status, + PriceName = price.PriceName, + CostItem = price.CostItem, + CostUnit = price.CostUnit, + Currency = price.Currency, + ChargingTime = price.ChargingTime, + ChargingCondition = price.ChargingCondition, + Factors = factorMap.TryGetValue(price.Id, out var factors) + ? factors.Select(MapPriceFactorOutput).ToList() + : [], + Details = detailMap.TryGetValue(price.Id, out var details) + ? details.Select(MapPriceDetailOutput).ToList() + : [] + }).ToList() + }; + } + + private static ChannelPriceFactorOutput MapPriceFactorOutput(SalesProductPriceFactor entity) + { + return new ChannelPriceFactorOutput + { + Id = entity.Id, + PriceId = entity.PriceId, + SerialNumber = entity.SerialNumber, + Factor = entity.Factor, + FactorValues = OmsDocHelper.DeserializeJson>(entity.FactorValues) ?? [] + }; + } + + private static ChannelPriceDetailOutput MapPriceDetailOutput(SalesProductPriceDetail entity) + { + return new ChannelPriceDetailOutput + { + Id = entity.Id, + PriceId = entity.PriceId, + FactorValuesJson = OmsDocHelper.DeserializeJsonObject(entity.FactorValuesJson), + CalculationFormula = entity.CalculationFormula, + CalculatedValue = entity.CalculatedValue, + CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(entity.CustomerGroupValues) + }; + } + + private async Task IsBlacklistHitAsync( + SalesProduct product, + string countryCode, + string? province, + string? city, + string? zipCode) + { + if (!product.BlacklistAreaId.HasValue || product.BlacklistAreaId <= 0) + { + return false; + } + + var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.BlacklistAreaId.Value); + return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( + rule, + countryCode, + province, + city, + zipCode)); + } + + private async Task IsRemoteHitAsync( + SalesProduct product, + string countryCode, + string? province, + string? city, + string? zipCode) + { + if (!product.RemoteAreaId.HasValue || product.RemoteAreaId <= 0) + { + return false; + } + + var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.RemoteAreaId.Value); + return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( + rule, + countryCode, + province, + city, + zipCode)); + } + + private async Task MatchPriceZoneAsync( + SalesProduct product, + string countryCode, + string? province, + string? city, + string? zipCode) + { + if (!product.PriceAreaId.HasValue || product.PriceAreaId <= 0) + { + return "默认分区"; + } + + var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == product.PriceAreaId.Value); + var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.PriceAreaId.Value); + var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched( + rule, + countryCode, + province, + city, + zipCode)); + return ParcelBillingCalculator.ResolveZoneName(matched, area); + } + + private async Task ApplyChargeableWeightAsync(SalesProduct product, OrderWeight forecast) + { + try + { + if (!product.WeightRuleId.HasValue || product.WeightRuleId <= 0) + { + throw new BusinessException("未配置计重规则"); + } + + var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRuleId.Value); + if (weightRule == null) + { + throw new BusinessException("未配置计重规则"); + } + + var ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value); + var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos); + forecast.VolumetricWeight = volumetricWeight; + + if (!product.WeightRoundingId.HasValue || product.WeightRoundingId <= 0) + { + throw new BusinessException("未配置计重进位"); + } + + var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRoundingId.Value); + if (rounding == null) + { + throw new BusinessException("未配置计重进位"); + } + + var ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value); + forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); + } + catch (BusinessException) + { + throw; + } + catch (InvalidOperationException ex) + { + throw new BusinessException(ex.Message); + } + } + + private static List BuildInitialWeightRows(int orderId, OrderParcelCreateInput input) + { + var cubic = ParcelBillingCalculator.CalculateCubic(input.Length!.Value, input.Width!.Value, input.Height!.Value); + var forecast = new OrderWeight + { + OrderId = orderId, + Type = OrderWeightTypeCodes.Forecast, + Length = input.Length, + Width = input.Width, + Height = input.Height, + ActualWeight = input.ActualWeight, + VolumetricWeight = input.VolumetricWeight ?? 0, + ChargeableWeight = input.ChargeableWeight ?? input.ActualWeight, + Cubic = input.Cubic ?? cubic + }; + + return + [ + forecast, + CloneWeightRow(orderId, OrderWeightTypeCodes.Receive, forecast), + CloneWeightRow(orderId, OrderWeightTypeCodes.Ship, forecast), + CloneWeightRow(orderId, OrderWeightTypeCodes.Downstream, forecast) + ]; + } + + private static OrderWeight CloneWeightRow(int orderId, string type, OrderWeight source) => + new() + { + OrderId = orderId, + Type = type, + Length = source.Length, + Width = source.Width, + Height = source.Height, + ActualWeight = source.ActualWeight, + VolumetricWeight = source.VolumetricWeight, + ChargeableWeight = source.ChargeableWeight, + Cubic = source.Cubic + }; + + private static void SyncWeightRows(List weights, OrderWeight forecast) + { + foreach (var weight in weights) + { + weight.Length = forecast.Length; + weight.Width = forecast.Width; + weight.Height = forecast.Height; + weight.ActualWeight = forecast.ActualWeight; + weight.VolumetricWeight = forecast.VolumetricWeight; + weight.ChargeableWeight = forecast.ChargeableWeight; + weight.Cubic = forecast.Cubic; + } + } + + private static Order BuildDraftOrderEntity( + OrderParcelCreateInput input, + SalesProduct product, + string consigneeCountry, + ProductSnapshotBinding snapshotBinding, + string serviceNo) + { + return new Order + { + RequestId = input.RequestId, + ClientNo = input.ClientNo, + TrackNo = input.TrackNo, + ServiceNo = serviceNo, + ProductCode = input.ProductCode, + ChannelCode = input.ChannelCode ?? product.ProductCode, + ConsigneeCountry = consigneeCountry, + CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General, + ExportCustomsMode = input.ExportCustomsMode, + WarehouseId = input.WarehouseId, + CustomerId = input.CustomerId, + IsCod = input.IsCod ? (byte)1 : (byte)0, + CodCurrency = input.CodCurrency, + CodSum = input.CodSum, + DeclaredCurrency = input.DeclaredCurrency, + DeclaredSum = input.DeclaredSum, + Quantity = input.Quantity, + IsIntercept = 0, + IsRemote = 0, + PredeclareFailReason = string.Empty, + SalesProductRulesVersion = snapshotBinding.RulesVersion, + SalesProductPriceVersion = snapshotBinding.PriceVersion, + OrderStatus = OrderStatusCodes.Draft + }; + } + + private async Task SaveParcelChildrenAsync(Order order, OrderParcelCreateInput input, List weights) + { + if (input.Sender != null) + { + var sender = await OrderRepository.InsertSenderAsync(new OrderSender + { + OrderId = order.Id, + SenderName = input.Sender.SenderName, + Company = input.Sender.Company, + Country = input.Sender.CountryCode, + Province = input.Sender.Province, + City = input.Sender.City, + ZipCode = input.Sender.ZipCode, + StreetAddress1 = input.Sender.StreetAddress1, + StreetAddress2 = input.Sender.StreetAddress2, + StreetAddress3 = input.Sender.StreetAddress3, + HouseNumber = input.Sender.HouseNumber, + Phone = input.Sender.Phone, + TelePhone = input.Sender.TelePhone, + Email = input.Sender.Email, + IsDefault = 0 + }); + order.SenderId = sender.Id; + } + + if (input.Receiver != null) + { + var recipient = await OrderRepository.InsertRecipientAsync(new OrderRecipient + { + OrderId = order.Id, + RecipientsName = input.Receiver.RecipientsName, + CompanyName = input.Receiver.CompanyName, + Country = input.Receiver.CountryCode, + Phone = input.Receiver.Phone, + TelePhone = input.Receiver.TelePhone, + ZipCode = input.Receiver.ZipCode, + Province = input.Receiver.Province, + City = input.Receiver.City, + StreetNumber = input.Receiver.StreetNumber, + StreetAddress1 = input.Receiver.StreetAddress1, + StreetAddress2 = input.Receiver.StreetAddress2, + StreetAddress3 = input.Receiver.StreetAddress3, + Email = input.Receiver.Email, + TaxType = input.Receiver.TaxType, + TaxNo = input.Receiver.TaxNo, + CertificateType = input.Receiver.CertificateType, + CertificateNo = input.Receiver.CertificateNo, + Eori = input.Receiver.Eori, + Vat = input.Receiver.Vat + }); + order.RecipientId = recipient.Id; + } + + if (input.Items.Count > 0) + { + await OrderRepository.InsertDeclarationsAsync(input.Items.Select(x => new OrderDeclaration + { + OrderId = order.Id, + CnName = x.CnName, + EnName = x.EnName, + Sku = x.Sku, + Unit = x.Unit, + UnitPrice = x.UnitPrice, + UnitWeight = x.UnitWeight, + Quantity = x.Quantity, + Amount = x.Amount, + Weight = x.Weight, + HsCode = x.ExHsCode ?? x.ImHsCode, + ImHsCode = x.ImHsCode, + ExHsCode = x.ExHsCode, + SkuImage = x.SkuImage, + Category = x.Category, + Brand = x.Brand, + BrandType = x.BrandType, + IsMagnetic = x.IsMagnetic ? (byte)1 : (byte)0, + Distribution = x.Distribution, + Material = x.Material, + EnMaterial = x.EnMaterial, + Purpose = x.Purpose, + Attributes = x.Attributes, + OriginCountry = x.OriginCountry, + SalesLink = x.SalesLink, + Model = x.Model, + Specifications = x.Specifications, + Comment = x.Comment + })); + } + + await OrderRepository.InsertWeightsAsync(weights); + await OrderRepository.UpdateAsync(order); + } + + private async Task ResolveServiceNoAsync(string? serviceNo) + { + var normalized = serviceNo?.Trim(); + if (!string.IsNullOrWhiteSpace(normalized)) + { + if (await OrderRepository.HasServiceNoAsync(normalized)) + { + throw new BusinessException("运单号已存在"); + } + + return normalized; + } + + for (var i = 0; i < 5; i++) + { + var generated = $"LP{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}"; + if (!await OrderRepository.HasServiceNoAsync(generated)) + { + return generated; + } + } + + throw new BusinessException("运单号生成失败"); + } + + private async Task MarkParcelDraftFailedAsync(Order order, string reason) + { + order.OrderStatus = OrderStatusCodes.Draft; + order.PredeclareFailReason = reason; + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + } + + private async Task BuildParcelCreateOutputAsync(Order order) + { + var mapped = (await MapOrderOutputsAsync([order])).First(); + return new OrderParcelCreateOutput + { + ClientNo = order.ClientNo, + ServiceNo = order.ServiceNo, + TrackNo = order.TrackNo, + LabelUrl = order.OrderStatus == OrderStatusCodes.Predeclared + ? await CreateOrderPrintFileAsync(mapped) + : string.Empty + }; + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs new file mode 100644 index 0000000..11defb2 --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -0,0 +1,206 @@ +using Fake.Application; +using Oms.Application.Dtos.Orders; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.CustomersAggregate.Enums; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.Shared.Enums; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + private async Task> BuildPriceCheckOutputsAsync(OrderPriceCheckInput input) + { + ValidatePriceCheckInput(input); + var bindTime = DateTime.Now; + var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry); + var cargoAttribute = ParcelBillingCalculator.MapCargoTypeToAttribute(input.CargoType); + var quoteContext = new ParcelQuoteContext + { + CountryCode = countryCode, + Province = input.DestinationProvince, + PostalCode = input.PostalCode, + CargoAttribute = cargoAttribute, + Length = input.Length!.Value, + Width = input.Width!.Value, + Height = input.Height!.Value, + ActualWeight = input.CargoWeight!.Value + }; + + var customerGroup = await ResolveCurrentCustomerGroupAsync(); + var shippingKeyword = input.ShippingMethod?.Trim(); + var products = await SalesProductRepository.GetListAsync(x => + x.IsActive == EnableStatus.Enabled && + (string.IsNullOrWhiteSpace(shippingKeyword) + || x.ProductName.Contains(shippingKeyword) + || x.ProductCode.Contains(shippingKeyword))); + + var warehouses = await WarehouseAddressRepository.GetListAsync(); + var defaultWarehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault(); + var outputs = new List(); + + foreach (var product in products.OrderBy(x => x.Id)) + { + var quote = await TryComputeProductQuoteAsync(product, quoteContext, customerGroup, bindTime); + if (quote == null) + { + continue; + } + + var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees); + outputs.Add(new OrderPriceCheckOutput + { + Id = quote.ProductId, + ProductCode = quote.ProductCode, + ShippingMethod = quote.ProductName, + Warehouse = defaultWarehouse?.WarehouseName, + DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}", + VolumetricWeight = quote.VolumetricWeight, + ChargeableWeight = quote.ChargeableWeight, + CostFormula = costFormula, + Freight = freight, + OtherFees = otherFees, + TotalCost = totalCost, + MaxDeliveryDays = product.MaxTimeliness, + TotalCostNum = totalCost, + RulesVersion = quote.RulesVersion, + PriceVersion = quote.PriceVersion, + IsRemote = quote.IsRemote + }); + } + + return outputs; + } + + private static void ValidatePriceCheckInput(OrderPriceCheckInput input) + { + if (string.IsNullOrWhiteSpace(input.DestinationCountry)) + { + throw new BusinessException("目的国不能为空"); + } + + if (!input.CargoWeight.HasValue || input.CargoWeight <= 0) + { + throw new BusinessException("货物重量必须大于0"); + } + + if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue + || input.Length <= 0 || input.Width <= 0 || input.Height <= 0) + { + throw new BusinessException("长宽高必须大于0"); + } + } + + private async Task ResolveDestinationCountryCodeAsync(string destinationCountry) + { + var trimmed = destinationCountry.Trim(); + var country = await CountryRepository.FirstOrDefaultAsync(x => + x.CountryCode == trimmed || x.ChineseName == trimmed || x.EnglishName == trimmed); + return country?.CountryCode ?? trimmed.ToUpperInvariant(); + } + + private async Task ResolveCurrentCustomerGroupAsync() + { + try + { + var userId = CurrentUser.GetId(); + if (userId <= 0) + { + return null; + } + + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == userId); + return customer?.CustomerGroup; + } + catch + { + return null; + } + } + + /// + /// 对单个销售产品执行预报试算(与 parcel/create 同源,不落库)。 + /// + private async Task TryComputeProductQuoteAsync( + SalesProduct product, + ParcelQuoteContext context, + CustomerGroup? customerGroup, + DateTime bindTime) + { + try + { + if (product.IsActive != EnableStatus.Enabled) + { + return null; + } + + var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime); + if (await IsBlacklistHitAsync( + product, + context.CountryCode, + context.Province, + context.City, + context.PostalCode)) + { + return null; + } + + var forecast = ParcelBillingCalculator.BuildForecastWeight(context); + await ApplyChargeableWeightAsync(product, forecast); + + var zoneName = await MatchPriceZoneAsync( + product, + context.CountryCode, + context.Province, + context.City, + context.PostalCode); + if (string.IsNullOrWhiteSpace(zoneName)) + { + return null; + } + + var fees = ParcelBillingCalculator.CalculateForecastReceivableFees( + snapshotBinding.Price, + snapshotBinding.PriceVersion, + snapshotBinding.PriceEffectiveTime, + forecast.ChargeableWeight ?? 0, + zoneName, + context.CargoAttribute, + customerGroup); + + if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshotBinding.Price)) + { + return null; + } + + var isRemote = await IsRemoteHitAsync( + product, + context.CountryCode, + context.Province, + context.City, + context.PostalCode); + + return new ParcelQuoteResult + { + ProductId = product.Id, + ProductCode = product.ProductCode, + ProductName = product.ProductName, + RulesVersion = snapshotBinding.RulesVersion, + PriceVersion = snapshotBinding.PriceVersion, + VolumetricWeight = forecast.VolumetricWeight ?? 0, + ChargeableWeight = forecast.ChargeableWeight ?? 0, + ZoneName = zoneName, + IsRemote = isRemote, + Fees = fees + }; + } + catch (BusinessException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 41dddcc..ecc9276 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -354,6 +354,11 @@ public partial class OrderService : ApplicationService throw new BusinessException("批量创建请求不能为空"); } + if (input.Count > ParcelBatchMaxCount) + { + throw new BusinessException($"单次批量创建不能超过{ParcelBatchMaxCount}条"); + } + var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}"; var items = new List(input.Count); var successCount = 0; diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs new file mode 100644 index 0000000..d9881d6 --- /dev/null +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -0,0 +1,597 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; +using Oms.Application.Dtos.Channels; +using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.CustomersAggregate.Enums; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +/// +/// 小包预报:计重、分区匹配、卖价算价。 +/// +internal static class ParcelBillingCalculator +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public static decimal CalculateCubic(decimal length, decimal width, decimal height) => + Math.Round(length * width * height / 1_000_000M, 6, MidpointRounding.AwayFromZero); + + public static (decimal VolumetricWeight, decimal RawBillableWeight) CalculateRawBillableWeight( + OrderWeight forecast, + IReadOnlyList rules) + { + var length = forecast.Length ?? 0; + var width = forecast.Width ?? 0; + var height = forecast.Height ?? 0; + var gw = forecast.ActualWeight ?? 0; + if (gw <= 0 || length <= 0 || width <= 0 || height <= 0) + { + throw new InvalidOperationException("件重尺数据不完整"); + } + + if (rules.Count == 0) + { + throw new InvalidOperationException("计重规则明细为空"); + } + + foreach (var rule in rules.OrderBy(x => x.Id)) + { + if (!decimal.TryParse(rule.VolumetricFactor, NumberStyles.Any, CultureInfo.InvariantCulture, out var factor) || factor <= 0) + { + continue; + } + + var vw = Math.Round(length * width * height / factor, 3, MidpointRounding.AwayFromZero); + if (!EvaluateCondition(rule.Condition, gw, vw)) + { + continue; + } + + var raw = string.Equals(rule.BillableWeight, "gw", StringComparison.OrdinalIgnoreCase) ? gw : vw; + return (vw, raw); + } + + throw new InvalidOperationException("计重规则条件均未命中"); + } + + public static decimal ApplyRounding(decimal rawWeight, IReadOnlyList ranges) + { + var hit = ranges + .OrderBy(x => x.Id) + .FirstOrDefault(x => rawWeight >= x.StartValue && rawWeight <= x.EndValue); + if (hit == null) + { + throw new InvalidOperationException("计重进位区间未匹配"); + } + + if (!decimal.TryParse(hit.Public, NumberStyles.Any, CultureInfo.InvariantCulture, out var unit) || unit <= 0) + { + throw new InvalidOperationException("计重进位值无效"); + } + + var units = Math.Ceiling(rawWeight / unit); + return Math.Round(units * unit, 3, MidpointRounding.AwayFromZero); + } + + public static bool IsAreaMatched( + KpiAreaInfo rule, + string countryCode, + string? province, + string? city, + string? zipCode) + { + if (!string.IsNullOrWhiteSpace(rule.CountryCode) + && !string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(rule.Province) + && !string.Equals(rule.Province, province, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(rule.City) + && !string.Equals(rule.City, city, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(rule.LeftPostcode) || !string.IsNullOrWhiteSpace(rule.RightPostcode)) + { + if (string.IsNullOrWhiteSpace(zipCode)) + { + return false; + } + + if (!IsPostcodeInRange(zipCode, rule.LeftPostcode, rule.RightPostcode)) + { + return false; + } + } + + return true; + } + + public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area) + { + if (matched == null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(matched.Province)) + { + return matched.Province; + } + + if (!string.IsNullOrWhiteSpace(matched.City)) + { + return matched.City; + } + + if (!string.IsNullOrWhiteSpace(matched.CountryName)) + { + return matched.CountryName; + } + + return area?.Name; + } + + public static List CalculateForecastReceivableFees( + SalesProductPricePublishSnapshotDto priceSnapshot, + string priceVersion, + DateTime priceEffectiveTime, + decimal chargeableWeight, + string zoneName, + string cargoAttribute, + CustomerGroup? customerGroup) + { + var items = priceSnapshot.Prices.Select(p => new BillingPriceItem( + p.Status, p.ChargingTime, p.CostItem, p.Currency, p.Details)); + return CalculateForecastFees( + items, + OrderFeeTypeCodes.Receivable, + priceVersion, + priceEffectiveTime, + chargeableWeight, + zoneName, + cargoAttribute, + customerGroup); + } + + /// + /// 预报阶段应付费用(买价快照,计费重取 downstream 行,调用方传入对应重量)。 + /// + public static List CalculateForecastPayableFees( + IReadOnlyList prices, + string priceVersion, + DateTime priceEffectiveTime, + decimal chargeableWeight, + string zoneName, + string cargoAttribute, + CustomerGroup? customerGroup) + { + var items = prices.Select(p => new BillingPriceItem( + p.Status, p.ChargingTime, p.CostItem, p.Currency, p.Details)); + return CalculateForecastFees( + items, + OrderFeeTypeCodes.Payable, + priceVersion, + priceEffectiveTime, + chargeableWeight, + zoneName, + cargoAttribute, + customerGroup); + } + + public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => + priceSnapshot.Prices.Any(x => x.Status == 1 && IsForecastChargingTime(x.ChargingTime)); + + public static bool HasEnabledForecastPayablePriceItems(IReadOnlyList prices) => + prices.Any(x => x.Status == 1 && IsForecastChargingTime(x.ChargingTime)); + + public static string NormalizeChargingTime(string? chargingTime) + { + var value = chargingTime?.Trim(); + if (string.IsNullOrWhiteSpace(value)) + { + return OrderFeeChargingStageCodes.Forecast; + } + + return value switch + { + "预报" => OrderFeeChargingStageCodes.Forecast, + "入库" => OrderFeeChargingStageCodes.Inbound, + "出库" => OrderFeeChargingStageCodes.Outbound, + _ => value.ToLowerInvariant() + }; + } + + public static string GetCargoAttributeLabel(string? cargoAttribute) => cargoAttribute switch + { + CargoAttributeCodes.Electronics => "3c&带电", + CargoAttributeCodes.Sensitive => "敏感货", + _ => "普货" + }; + + public static string MapCargoTypeToAttribute(string? cargoType) + { + if (string.IsNullOrWhiteSpace(cargoType)) + { + return CargoAttributeCodes.General; + } + + var value = cargoType.Trim(); + if (value.Contains("敏感", StringComparison.OrdinalIgnoreCase)) + { + return CargoAttributeCodes.Sensitive; + } + + if (value.Contains("带电", StringComparison.OrdinalIgnoreCase) + || value.Contains("3c", StringComparison.OrdinalIgnoreCase) + || value.Equals("electronics", StringComparison.OrdinalIgnoreCase)) + { + return CargoAttributeCodes.Electronics; + } + + if (value.Equals(CargoAttributeCodes.General, StringComparison.OrdinalIgnoreCase) + || value.Equals(CargoAttributeCodes.Electronics, StringComparison.OrdinalIgnoreCase) + || value.Equals(CargoAttributeCodes.Sensitive, StringComparison.OrdinalIgnoreCase)) + { + return value.ToLowerInvariant(); + } + + return CargoAttributeCodes.General; + } + + public static (decimal Freight, decimal OtherFees, decimal TotalCost, string CostFormula) SummarizeFees( + IReadOnlyList fees) + { + if (fees.Count == 0) + { + return (0, 0, 0, string.Empty); + } + + var freight = fees + .Where(x => x.CostItem.Equals("freight", StringComparison.OrdinalIgnoreCase) + || x.CostItem.Contains("运费", StringComparison.OrdinalIgnoreCase)) + .Sum(x => x.Amount); + if (freight <= 0) + { + freight = fees[0].Amount; + } + + var total = fees.Sum(x => x.Amount); + var otherFees = Math.Max(0, total - freight); + var formula = string.Join(" + ", fees.Select(x => $"{x.CostItem}:{x.Amount:0.####}")); + return (freight, otherFees, total, formula); + } + + public static OrderWeight BuildForecastWeight(ParcelQuoteContext context) => + new() + { + Type = OrderWeightTypeCodes.Forecast, + Length = context.Length, + Width = context.Width, + Height = context.Height, + ActualWeight = context.ActualWeight, + Cubic = CalculateCubic(context.Length, context.Width, context.Height) + }; + + private static List CalculateForecastFees( + IEnumerable priceItems, + string feeType, + string priceVersion, + DateTime priceEffectiveTime, + decimal chargeableWeight, + string zoneName, + string cargoAttribute, + CustomerGroup? customerGroup) + { + var fees = new List(); + foreach (var priceItem in priceItems.Where(x => x.Status == 1)) + { + if (!IsForecastChargingTime(priceItem.ChargingTime)) + { + continue; + } + + var detail = MatchPriceDetail(priceItem.Details, zoneName, cargoAttribute, chargeableWeight); + if (detail == null) + { + continue; + } + + var amount = CalculateDetailAmount(detail, chargeableWeight, customerGroup); + fees.Add(new OrderFee + { + Type = feeType, + CostItem = priceItem.CostItem, + Amount = amount, + Currency = priceItem.Currency, + PriceNo = priceVersion, + PriceEffectiveTime = priceEffectiveTime, + ChargingStage = OrderFeeChargingStageCodes.Forecast + }); + } + + return fees; + } + + private readonly record struct BillingPriceItem( + byte Status, + string? ChargingTime, + string CostItem, + string Currency, + List Details); + + private static bool IsForecastChargingTime(string? chargingTime) + { + var normalized = NormalizeChargingTime(chargingTime); + return normalized is OrderFeeChargingStageCodes.Forecast or ""; + } + + private static ChannelPriceDetailOutput? MatchPriceDetail( + IReadOnlyList details, + string zoneName, + string cargoAttribute, + decimal chargeableWeight) + { + var cargoLabel = GetCargoAttributeLabel(cargoAttribute); + foreach (var detail in details) + { + var factors = ParseFactorDictionary(detail.FactorValuesJson); + if (!MatchFactorValue(factors, "分区", zoneName) + && !MatchFactorValue(factors, "价格分区", zoneName)) + { + continue; + } + + if (!MatchFactorValue(factors, "货物类型", cargoLabel) + && factors.ContainsKey("货物类型")) + { + continue; + } + + if (!MatchWeightRange(factors, chargeableWeight)) + { + continue; + } + + return detail; + } + + return null; + } + + private static Dictionary ParseFactorDictionary(object? factorValuesJson) + { + if (factorValuesJson == null) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + if (factorValuesJson is JsonElement element) + { + return element.EnumerateObject() + .ToDictionary(x => x.Name, x => x.Value.ToString(), StringComparer.OrdinalIgnoreCase); + } + + var json = JsonSerializer.Serialize(factorValuesJson, JsonOptions); + return JsonSerializer.Deserialize>(json, JsonOptions) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private static bool MatchFactorValue(Dictionary factors, string key, string expected) + { + if (!factors.TryGetValue(key, out var actual) || string.IsNullOrWhiteSpace(actual)) + { + return true; + } + + return string.Equals(actual.Trim(), expected, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchWeightRange(Dictionary factors, decimal chargeableWeight) + { + if (!factors.TryGetValue("重量", out var rangeText) || string.IsNullOrWhiteSpace(rangeText)) + { + return true; + } + + var parts = rangeText.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2) + { + return true; + } + + if (!decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var start)) + { + return true; + } + + if (!decimal.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var end)) + { + return true; + } + + return chargeableWeight >= start && chargeableWeight <= end; + } + + private static decimal CalculateDetailAmount( + ChannelPriceDetailOutput detail, + decimal chargeableWeight, + CustomerGroup? customerGroup) + { + var formula = detail.CalculationFormula?.Trim() ?? string.Empty; + if (formula is "单价" or "固定价") + { + return ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0; + } + + if (formula is "续单价" or "续重价") + { + return CalculateContinuePrice(detail, chargeableWeight, customerGroup); + } + + return detail.CalculatedValue ?? 0; + } + + private static decimal CalculateContinuePrice( + ChannelPriceDetailOutput detail, + decimal chargeableWeight, + CustomerGroup? customerGroup) + { + var factors = ParseFactorDictionary(detail.FactorValuesJson); + var continuePricing = TryReadContinuePricing(factors, detail); + var firstWeight = continuePricing?.FirstWeight ?? 1M; + var firstPrice = continuePricing?.FirstPrice ?? detail.CalculatedValue ?? 0; + var continueUnit = continuePricing?.ContinueUnit ?? 0.5M; + var continuePrice = continuePricing?.ContinuePrice ?? firstPrice; + + if (customerGroup.HasValue) + { + var groupPrice = ResolveCustomerGroupPrice(detail, customerGroup); + if (groupPrice.HasValue) + { + firstPrice = groupPrice.Value; + } + } + + if (chargeableWeight <= firstWeight) + { + return Math.Round(firstPrice, 4, MidpointRounding.AwayFromZero); + } + + var overflow = chargeableWeight - firstWeight; + var continueUnits = Math.Ceiling(overflow / continueUnit); + return Math.Round(firstPrice + continueUnits * continuePrice, 4, MidpointRounding.AwayFromZero); + } + + private static ContinuePricingConfig? TryReadContinuePricing( + Dictionary factors, + ChannelPriceDetailOutput detail) + { + if (factors.TryGetValue("continuePricing", out var json) && !string.IsNullOrWhiteSpace(json)) + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + + if (detail.FactorValuesJson is JsonElement element + && element.TryGetProperty("continuePricing", out var continueNode)) + { + return continueNode.Deserialize(JsonOptions); + } + + if (factors.TryGetValue("首重", out var firstWeightText) + && decimal.TryParse(firstWeightText, NumberStyles.Any, CultureInfo.InvariantCulture, out var firstWeight)) + { + decimal.TryParse(factors.GetValueOrDefault("首重价"), NumberStyles.Any, CultureInfo.InvariantCulture, out var firstPrice); + decimal.TryParse(factors.GetValueOrDefault("续重单位"), NumberStyles.Any, CultureInfo.InvariantCulture, out var continueUnit); + decimal.TryParse(factors.GetValueOrDefault("续重价"), NumberStyles.Any, CultureInfo.InvariantCulture, out var continuePrice); + return new ContinuePricingConfig + { + FirstWeight = firstWeight, + FirstPrice = firstPrice > 0 ? firstPrice : detail.CalculatedValue ?? 0, + ContinueUnit = continueUnit > 0 ? continueUnit : 0.5M, + ContinuePrice = continuePrice > 0 ? continuePrice : detail.CalculatedValue ?? 0 + }; + } + + return null; + } + + private static decimal? ResolveCustomerGroupPrice(ChannelPriceDetailOutput detail, CustomerGroup? customerGroup) + { + if (!customerGroup.HasValue || detail.CustomerGroupValues == null) + { + return null; + } + + var json = JsonSerializer.Serialize(detail.CustomerGroupValues, JsonOptions); + var map = JsonSerializer.Deserialize>(json, JsonOptions); + if (map == null) + { + return null; + } + + var key = customerGroup.Value switch + { + CustomerGroup.Vip1 => "客户组1", + CustomerGroup.Vip2 => "客户组2", + CustomerGroup.Vip3 => "客户组3", + CustomerGroup.Vip4 => "客户组4", + CustomerGroup.Vip5 => "客户组5", + _ => null + }; + if (key != null && map.TryGetValue(key, out var value)) + { + return value; + } + + return null; + } + + private static bool EvaluateCondition(string condition, decimal gw, decimal vw) + { + if (string.IsNullOrWhiteSpace(condition)) + { + return true; + } + + var expr = Regex.Replace(condition.Trim(), @"\bgw\b", gw.ToString(CultureInfo.InvariantCulture), RegexOptions.IgnoreCase); + expr = Regex.Replace(expr, @"\bvw\b", vw.ToString(CultureInfo.InvariantCulture), RegexOptions.IgnoreCase); + expr = expr.Replace("==", "="); + + try + { + var result = new System.Data.DataTable().Compute(expr, string.Empty); + return result is bool boolResult && boolResult + || result is int intResult && intResult != 0 + || result is decimal decimalResult && decimalResult != 0 + || result is double doubleResult && Math.Abs(doubleResult) > double.Epsilon; + } + catch + { + return false; + } + } + + private static bool IsPostcodeInRange(string zipCode, string? left, string? right) + { + if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right)) + { + return true; + } + + if (long.TryParse(zipCode, out var zip) + && long.TryParse(left, out var leftValue) + && long.TryParse(right, out var rightValue)) + { + return zip >= leftValue && zip <= rightValue; + } + + return string.Compare(zipCode, left, StringComparison.OrdinalIgnoreCase) >= 0 + && string.Compare(zipCode, right, StringComparison.OrdinalIgnoreCase) <= 0; + } + + private sealed class ContinuePricingConfig + { + public decimal FirstWeight { get; set; } + + public decimal FirstPrice { get; set; } + + public decimal ContinueUnit { get; set; } + + public decimal ContinuePrice { get; set; } + } +} diff --git a/src/Oms.Application/Services/Orders/ParcelQuoteContext.cs b/src/Oms.Application/Services/Orders/ParcelQuoteContext.cs new file mode 100644 index 0000000..6f9d410 --- /dev/null +++ b/src/Oms.Application/Services/Orders/ParcelQuoteContext.cs @@ -0,0 +1,25 @@ +namespace Oms.Application.Services.Orders; + +/// +/// 查价/试算用的地址与件重尺上下文(不落库)。 +/// +public sealed class ParcelQuoteContext +{ + public string CountryCode { get; init; } = string.Empty; + + public string? Province { get; init; } + + public string? City { get; init; } + + public string? PostalCode { get; init; } + + public string CargoAttribute { get; init; } = string.Empty; + + public decimal Length { get; init; } + + public decimal Width { get; init; } + + public decimal Height { get; init; } + + public decimal ActualWeight { get; init; } +} diff --git a/src/Oms.Application/Services/Orders/ParcelQuoteResult.cs b/src/Oms.Application/Services/Orders/ParcelQuoteResult.cs new file mode 100644 index 0000000..1624c5c --- /dev/null +++ b/src/Oms.Application/Services/Orders/ParcelQuoteResult.cs @@ -0,0 +1,29 @@ +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +/// +/// 单产品销售产品试算结果(与 parcel/create 预报计费同源)。 +/// +public sealed class ParcelQuoteResult +{ + public int ProductId { get; init; } + + public string ProductCode { get; init; } = string.Empty; + + public string ProductName { get; init; } = string.Empty; + + public string RulesVersion { get; init; } = string.Empty; + + public string PriceVersion { get; init; } = string.Empty; + + public decimal VolumetricWeight { get; init; } + + public decimal ChargeableWeight { get; init; } + + public string? ZoneName { get; init; } + + public bool IsRemote { get; init; } + + public IReadOnlyList Fees { get; init; } = []; +} diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs index 9238fcc..5b3c230 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs @@ -20,4 +20,6 @@ public interface IKpiAreaRepository : IRepository Task UpdateInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default); Task DeleteInfoAsync(int id, CancellationToken cancellationToken = default); + + Task> GetAllInfosByAreaIdAsync(int areaId, CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs index e37d729..b8906e5 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs @@ -51,4 +51,10 @@ public interface ISalesProductRepository : IRepository Task InsertPublishRecordAsync(SalesProductPublishRecord record, CancellationToken cancellationToken = default); Task GetPublishRecordByIdAsync(int id, CancellationToken cancellationToken = default); + + Task GetEffectivePublishRecordAsync( + int salesProductId, + string operatorType, + DateTime bindTime, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IWeightRoundingRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IWeightRoundingRepository.cs index de631ad..eb8f562 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/IWeightRoundingRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IWeightRoundingRepository.cs @@ -20,4 +20,6 @@ public interface IWeightRoundingRepository : IRepository Task UpdateInfoAsync(WeightRoundingInfo info, CancellationToken cancellationToken = default); Task DeleteInfoAsync(int id, CancellationToken cancellationToken = default); + + Task> GetAllInfosByRoundingIdAsync(int roundingId, CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IWeightRuleRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IWeightRuleRepository.cs index 99a8d65..ea446d5 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/IWeightRuleRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IWeightRuleRepository.cs @@ -20,4 +20,6 @@ public interface IWeightRuleRepository : IRepository Task UpdateInfoAsync(WeightRuleInfo info, CancellationToken cancellationToken = default); Task DeleteInfoAsync(int id, CancellationToken cancellationToken = default); + + Task> GetAllInfosByRuleIdAsync(int ruleId, CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs new file mode 100644 index 0000000..0cb6040 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs @@ -0,0 +1,34 @@ +namespace Oms.Domain.OrdersAggregate.Consts; + +public static class OrderFeeTypeCodes +{ + public const string Receivable = "receivable"; + public const string Payable = "payable"; +} + +public static class OrderFeeChargingStageCodes +{ + public const string Forecast = "forecast"; + public const string Inbound = "inbound"; + public const string Outbound = "outbound"; +} + +public static class ExportCustomsModeCodes +{ + public const string Mode9610Simple = "9610Simple"; + public const string Mode9610Summary = "9610Summary"; + public const string Mode9610Separate = "9610Separate"; + public const string Mode9710 = "9710"; + public const string Mode9810 = "9810"; + public const string Mode0110 = "0110"; + + public static readonly HashSet All = + [ + Mode9610Simple, + Mode9610Summary, + Mode9610Separate, + Mode9710, + Mode9810, + Mode0110 + ]; +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs index cd3db45..fa0c6cf 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs @@ -22,6 +22,14 @@ public class Order : OmsAuditedAggregateRoot public string? CargoAttribute { get; set; } + public string? ExportCustomsMode { get; set; } + + public string? PredeclareFailReason { get; set; } + + public string? SalesProductRulesVersion { get; set; } + + public string? SalesProductPriceVersion { get; set; } + public string OrderStatus { get; set; } = string.Empty; public int? WarehouseId { get; set; } diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderFee.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderFee.cs index 00ed1e9..374c1e6 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/OrderFee.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderFee.cs @@ -21,4 +21,6 @@ public class OrderFee : OmsAuditedEntity public string? PriceNo { get; set; } public DateTime? PriceEffectiveTime { get; set; } + + public string? ChargingStage { get; set; } } diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index 5048071..7252a22 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -30,4 +30,10 @@ public interface IOrderRepository : IRepository Task InsertDeclarationsAsync(IEnumerable declarations, CancellationToken cancellationToken = default); Task InsertWeightsAsync(IEnumerable weights, CancellationToken cancellationToken = default); + + Task ReplaceWeightsAsync(int orderId, IEnumerable weights, CancellationToken cancellationToken = default); + + Task UpsertFeesAsync(int orderId, IEnumerable fees, CancellationToken cancellationToken = default); + + Task> GetFeesByOrderIdsAsync(IEnumerable orderIds, CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs index 023c3e3..53ba77e 100644 --- a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs @@ -55,4 +55,13 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit var db = await GetDbContextAsync(cancellationToken); await db.Deleteable().Where(x => x.Id == id).ExecuteCommandAsync(cancellationToken); } + + public async Task> GetAllInfosByAreaIdAsync(int areaId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.AreaId == areaId) + .OrderBy(x => x.Id) + .ToListAsync(cancellationToken); + } } diff --git a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs index 00aabee..7526450 100644 --- a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs @@ -195,6 +195,24 @@ public class SalesProductRepository : OmsSqlSugarRepository, ISale return await db.Queryable().FirstAsync(x => x.Id == id, cancellationToken); } + public async Task GetEffectivePublishRecordAsync( + int salesProductId, + string operatorType, + DateTime bindTime, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var records = await db.Queryable() + .Where(x => x.SalesProductId == salesProductId + && x.OperatorType == operatorType + && x.Status == "生效" + && x.PublishTime <= bindTime) + .OrderBy(x => x.PublishTime, SqlSugar.OrderByType.Desc) + .Take(1) + .ToListAsync(cancellationToken); + return records.FirstOrDefault(); + } + private static async Task InsertPriceChildrenAsync( SqlSugar.ISqlSugarClient db, int priceId, diff --git a/src/Oms.SqlSugarCore/Channels/WeightRoundingRepository.cs b/src/Oms.SqlSugarCore/Channels/WeightRoundingRepository.cs index 91a1cf2..de02d4a 100644 --- a/src/Oms.SqlSugarCore/Channels/WeightRoundingRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/WeightRoundingRepository.cs @@ -55,4 +55,13 @@ public class WeightRoundingRepository : OmsSqlSugarRepository, I var db = await GetDbContextAsync(cancellationToken); await db.Deleteable().Where(x => x.Id == id).ExecuteCommandAsync(cancellationToken); } + + public async Task> GetAllInfosByRoundingIdAsync(int roundingId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.RoundingId == roundingId) + .OrderBy(x => x.Id) + .ToListAsync(cancellationToken); + } } diff --git a/src/Oms.SqlSugarCore/Channels/WeightRuleRepository.cs b/src/Oms.SqlSugarCore/Channels/WeightRuleRepository.cs index 47e0d05..313ce39 100644 --- a/src/Oms.SqlSugarCore/Channels/WeightRuleRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/WeightRuleRepository.cs @@ -55,4 +55,13 @@ public class WeightRuleRepository : OmsSqlSugarRepository, IWeightRu var db = await GetDbContextAsync(cancellationToken); await db.Deleteable().Where(x => x.Id == id).ExecuteCommandAsync(cancellationToken); } + + public async Task> GetAllInfosByRuleIdAsync(int ruleId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.RuleId == ruleId) + .OrderBy(x => x.Id) + .ToListAsync(cancellationToken); + } } diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index 6a535a8..b607128 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -123,4 +123,65 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I var db = await GetDbContextAsync(cancellationToken); await db.Insertable(items).ExecuteCommandAsync(cancellationToken); } + + public async Task ReplaceWeightsAsync(int orderId, IEnumerable weights, CancellationToken cancellationToken = default) + { + var items = weights.ToArray(); + var db = await GetDbContextAsync(cancellationToken); + await db.Deleteable().Where(x => x.OrderId == orderId).ExecuteCommandAsync(cancellationToken); + if (items.Length > 0) + { + await db.Insertable(items).ExecuteCommandAsync(cancellationToken); + } + } + + public async Task UpsertFeesAsync(int orderId, IEnumerable fees, CancellationToken cancellationToken = default) + { + var items = fees.ToArray(); + if (items.Length == 0) + { + return; + } + + var db = await GetDbContextAsync(cancellationToken); + foreach (var fee in items) + { + fee.OrderId = orderId; + var existingList = await db.Queryable() + .Where(x => x.OrderId == orderId + && x.Type == fee.Type + && x.CostItem == fee.CostItem + && x.ChargingStage == fee.ChargingStage) + .Take(1) + .ToListAsync(cancellationToken); + var existing = existingList.FirstOrDefault(); + if (existing == null) + { + await db.Insertable(fee).ExecuteCommandAsync(cancellationToken); + continue; + } + + existing.Amount = fee.Amount; + existing.Currency = fee.Currency; + existing.PriceNo = fee.PriceNo; + existing.PriceEffectiveTime = fee.PriceEffectiveTime; + existing.DownstreamProvider = fee.DownstreamProvider; + existing.UpdateTime = DateTime.Now; + await db.Updateable(existing).ExecuteCommandAsync(cancellationToken); + } + } + + public async Task> GetFeesByOrderIdsAsync(IEnumerable orderIds, CancellationToken cancellationToken = default) + { + var ids = orderIds.Distinct().ToArray(); + if (ids.Length == 0) + { + return []; + } + + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => ids.Contains(x.OrderId)) + .ToListAsync(cancellationToken); + } } diff --git a/src/Oms.Tests/ParcelBillingCalculatorTests.cs b/src/Oms.Tests/ParcelBillingCalculatorTests.cs new file mode 100644 index 0000000..9a0b596 --- /dev/null +++ b/src/Oms.Tests/ParcelBillingCalculatorTests.cs @@ -0,0 +1,258 @@ +using FluentAssertions; +using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Orders; +using Oms.Domain.CustomersAggregate.Enums; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Tests.Support; + +namespace Oms.Tests; + +/// +/// 小包预报应收/应付计费单元测试(与设计文档 §4.4.3.1 续单价示例对齐)。 +/// +public class ParcelBillingCalculatorTests +{ + private const string Zone = "美西"; + private readonly (string PriceVersion, DateTime EffectiveTime) _meta = ParcelBillingTestData.SnapshotMeta(); + + #region 计重 + + [Fact] + public void CalculateRawBillableWeight_ShouldUseGrossWeightWhenConditionHits() + { + var forecast = new OrderWeight + { + Length = 30, + Width = 20, + Height = 10, + ActualWeight = 1.5M + }; + var rules = new[] { ParcelBillingTestData.GwRule() }; + + var (vw, raw) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, rules); + + vw.Should().Be(1M); + raw.Should().Be(1.5M); + } + + [Fact] + public void ApplyRounding_ShouldCeilToConfiguredUnit() + { + var ranges = new[] { ParcelBillingTestData.RoundingRange(0, 30, "0.5") }; + + var rounded = ParcelBillingCalculator.ApplyRounding(1.23M, ranges); + + rounded.Should().Be(1.5M); + } + + #endregion + + #region 应收(卖价) + + [Theory] + [InlineData(0.8, 18)] + [InlineData(1.2, 24)] + [InlineData(1.6, 30)] + public void CalculateForecastReceivableFees_ContinuePrice_ShouldMatchDesignExamples( + double chargeableWeight, + decimal expectedFreight) + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + "预报", + 1, + "freight", + ParcelBillingTestData.ContinueFreightDetail())); + + var fees = ParcelBillingCalculator.CalculateForecastReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + (decimal)chargeableWeight, + Zone, + "general", + null); + + fees.Should().ContainSingle(); + fees[0].Type.Should().Be(OrderFeeTypeCodes.Receivable); + fees[0].CostItem.Should().Be("freight"); + fees[0].Amount.Should().Be(expectedFreight); + fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Forecast); + fees[0].PriceNo.Should().Be(_meta.PriceVersion); + } + + [Theory] + [InlineData("入库")] + [InlineData("出库")] + [InlineData("inbound")] + [InlineData("outbound")] + public void CalculateForecastReceivableFees_ShouldSkipNonForecastChargingTime(string chargingTime) + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + chargingTime, + 1, + "freight", + ParcelBillingTestData.ContinueFreightDetail())); + + var fees = ParcelBillingCalculator.CalculateForecastReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 1.2M, + Zone, + "general", + null); + + fees.Should().BeEmpty(); + } + + [Fact] + public void CalculateForecastReceivableFees_ShouldIncludeForecastAndFixedPriceItems() + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + "预报", + 1, + "freight", + ParcelBillingTestData.ContinueFreightDetail()), + ParcelBillingTestData.ReceivablePriceItem( + "预报", + 1, + "操作费", + ParcelBillingTestData.FixedPriceDetail(5M))); + + var fees = ParcelBillingCalculator.CalculateForecastReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 0.8M, + Zone, + "general", + null); + + fees.Should().HaveCount(2); + fees.Should().Contain(x => x.CostItem == "freight" && x.Amount == 18M); + fees.Should().Contain(x => x.CostItem == "操作费" && x.Amount == 5M); + + var summary = ParcelBillingCalculator.SummarizeFees(fees); + summary.Freight.Should().Be(18M); + summary.OtherFees.Should().Be(5M); + summary.TotalCost.Should().Be(23M); + } + + [Fact] + public void CalculateForecastReceivableFees_ShouldApplyCustomerGroupPrice() + { + var detail = ParcelBillingTestData.UnitPriceDetail(20M); + detail.CustomerGroupValues = new Dictionary { ["客户组1"] = 15M }; + + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", detail)); + + var fees = ParcelBillingCalculator.CalculateForecastReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 2M, + Zone, + "general", + CustomerGroup.Vip1); + + fees.Should().ContainSingle(); + fees[0].Amount.Should().Be(15M); + } + + [Fact] + public void HasEnabledForecastPriceItems_ShouldDetectForecastEnabledItems() + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem("入库", 1, "freight", ParcelBillingTestData.ContinueFreightDetail()), + ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", ParcelBillingTestData.ContinueFreightDetail())); + + ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshot).Should().BeTrue(); + } + + #endregion + + #region 应付(买价) + + [Theory] + [InlineData(0.8, 12)] + [InlineData(1.2, 16)] + [InlineData(1.6, 20)] + public void CalculateForecastPayableFees_ContinuePrice_ShouldUseSameEngine( + double chargeableWeight, + decimal expectedFreight) + { + var prices = ParcelBillingTestData.BuildPayablePrices( + ParcelBillingTestData.PayablePriceItem( + "预报", + 1, + "freight", + ParcelBillingTestData.ContinueFreightDetail( + firstWeight: 1M, + firstPrice: 12M, + continueUnit: 0.5M, + continuePrice: 4M))); + + var fees = ParcelBillingCalculator.CalculateForecastPayableFees( + prices, + _meta.PriceVersion, + _meta.EffectiveTime, + (decimal)chargeableWeight, + Zone, + "general", + null); + + fees.Should().ContainSingle(); + fees[0].Type.Should().Be(OrderFeeTypeCodes.Payable); + fees[0].Amount.Should().Be(expectedFreight); + fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Forecast); + } + + [Fact] + public void CalculateForecastPayableFees_ShouldSkipDisabledPriceItems() + { + var prices = ParcelBillingTestData.BuildPayablePrices( + ParcelBillingTestData.PayablePriceItem( + "预报", + 0, + "freight", + ParcelBillingTestData.ContinueFreightDetail())); + + var fees = ParcelBillingCalculator.CalculateForecastPayableFees( + prices, + _meta.PriceVersion, + _meta.EffectiveTime, + 1.2M, + Zone, + "general", + null); + + fees.Should().BeEmpty(); + ParcelBillingCalculator.HasEnabledForecastPayablePriceItems(prices).Should().BeFalse(); + } + + [Fact] + public void ReceivableAndPayable_ShouldDifferByTypeButShareZoneMatching() + { + var detail = ParcelBillingTestData.ContinueFreightDetail(); + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", detail)); + var payablePrices = ParcelBillingTestData.BuildPayablePrices( + ParcelBillingTestData.PayablePriceItem("预报", 1, "freight", detail)); + + var receivable = ParcelBillingCalculator.CalculateForecastReceivableFees( + snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null); + var payable = ParcelBillingCalculator.CalculateForecastPayableFees( + payablePrices, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null); + + receivable[0].Type.Should().Be(OrderFeeTypeCodes.Receivable); + payable[0].Type.Should().Be(OrderFeeTypeCodes.Payable); + receivable[0].Amount.Should().Be(payable[0].Amount); + } + + #endregion +} diff --git a/src/Oms.Tests/Support/ParcelBillingTestData.cs b/src/Oms.Tests/Support/ParcelBillingTestData.cs new file mode 100644 index 0000000..4be347a --- /dev/null +++ b/src/Oms.Tests/Support/ParcelBillingTestData.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using Oms.Application.Dtos.Channels; +using Oms.Application.Dtos.Orders; +using Oms.Domain.ChannelsAggregate.Entities; + +namespace Oms.Tests.Support; + +internal static class ParcelBillingTestData +{ + private static readonly DateTime EffectiveTime = new(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc); + private const string PriceVersion = "P20260401001"; + private const string Zone = "美西"; + + public static SalesProductPricePublishSnapshotDto BuildReceivableSnapshot( + params SalesProductPriceListOutput[] prices) => + new() { SalesProductId = 1, Prices = prices.ToList() }; + + public static List BuildPayablePrices( + params ServiceChannelPriceListOutput[] prices) => prices.ToList(); + + public static SalesProductPriceListOutput ReceivablePriceItem( + string chargingTime, + byte status, + string costItem, + params ChannelPriceDetailOutput[] details) => + new() + { + Status = status, + CostItem = costItem, + Currency = "CNY", + ChargingTime = chargingTime, + Details = details.ToList() + }; + + public static ServiceChannelPriceListOutput PayablePriceItem( + string chargingTime, + byte status, + string costItem, + params ChannelPriceDetailOutput[] details) => + new() + { + Status = status, + CostItem = costItem, + Currency = "CNY", + ChargingTime = chargingTime, + Details = details.ToList() + }; + + public static ChannelPriceDetailOutput ContinueFreightDetail( + decimal firstWeight = 1M, + decimal firstPrice = 18M, + decimal continueUnit = 0.5M, + decimal continuePrice = 6M, + string zone = Zone, + string cargoType = "普货") => + new() + { + CalculationFormula = "续单价", + FactorValuesJson = new Dictionary + { + ["分区"] = zone, + ["货物类型"] = cargoType, + ["首重"] = firstWeight.ToString(CultureInfo.InvariantCulture), + ["首重价"] = firstPrice.ToString(CultureInfo.InvariantCulture), + ["续重单位"] = continueUnit.ToString(CultureInfo.InvariantCulture), + ["续重价"] = continuePrice.ToString(CultureInfo.InvariantCulture) + } + }; + + public static ChannelPriceDetailOutput FixedPriceDetail( + decimal amount, + string zone = Zone, + string cargoType = "普货") => + new() + { + CalculationFormula = "固定价", + CalculatedValue = amount, + FactorValuesJson = new Dictionary + { + ["分区"] = zone, + ["货物类型"] = cargoType + } + }; + + public static ChannelPriceDetailOutput UnitPriceDetail( + decimal unitPrice, + string zone = Zone) => + new() + { + CalculationFormula = "单价", + CalculatedValue = unitPrice, + FactorValuesJson = new Dictionary { ["分区"] = zone } + }; + + public static WeightRuleInfo GwRule(string condition = "gw>=vw", string factor = "6000") => + new() + { + Id = 1, + Condition = condition, + BillableWeight = "gw", + VolumetricFactor = factor + }; + + public static WeightRoundingInfo RoundingRange(decimal start, decimal end, string unit) => + new() + { + Id = 1, + StartValue = start, + EndValue = end, + Public = unit + }; + + public static (string PriceVersion, DateTime EffectiveTime) SnapshotMeta() => (PriceVersion, EffectiveTime); +} diff --git a/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs b/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs index c113ed9..fdc6ebd 100644 --- a/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs +++ b/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs @@ -25,6 +25,12 @@ public class OmsRefreshTokenInput public string RefreshToken { get; set; } = string.Empty; } +public class OmsWxLoginInput +{ + [Required] + public string LoginUuid { get; set; } = string.Empty; +} + public class OmsChangePasswordInput { [Required] diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index d86a777..48b08aa 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -18,6 +18,7 @@ using Oms.Application.Shared; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.CustomersAggregate.Repositories; +using SimpleAdmin.Api.WeChat.Services; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; @@ -37,7 +38,8 @@ public class AuthService( IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContextAccessor, FakeRbacDbContext rbacDbContext, - ICustomerRepository customerRepository) + ICustomerRepository customerRepository, + IWeChatLoginSessionStore weChatLoginSessionStore) : ApplicationService { private Guid CurrentUserGuid => CurrentUser.FindClaimValueOrNull(ClaimTypes.NameIdentifier)!.To(); @@ -60,6 +62,56 @@ public class AuthService( }; } + /// + /// 微信扫码登录(轮询)。 + /// + [AllowAnonymous] + [HttpPost("/rbac/auth/wx-login")] + public async Task WxLoginAsync( + [FromBody] OmsWxLoginInput input, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(input.LoginUuid)) + { + throw new BusinessException("缺少 loginUuid"); + } + + var loginUuid = input.LoginUuid.Trim(); + var openId = await weChatLoginSessionStore.GetOpenIdAsync(loginUuid, cancellationToken); + if (string.IsNullOrWhiteSpace(openId)) + { + throw new BusinessException("请扫码登录"); + } + + var customer = await customerRepository.GetByWechatOpenidAsync(openId, cancellationToken); + if (customer == null) + { + throw new BusinessException("未绑定微信,请先注册或绑定账号"); + } + + if (customer.ParentCustomerId.HasValue) + { + throw new BusinessException("子客户不支持微信扫码登录"); + } + + ValidateMainCustomerStatus(customer); + + customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); + customer.LastLoginTime = DateTime.Now; + await customerRepository.UpdateAsync(customer, cancellationToken); + + var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken); + if (!userGuid.HasValue) + { + throw new BusinessException("客户未关联用户"); + } + + await weChatLoginSessionStore.RemoveAsync(loginUuid, cancellationToken); + + var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); + return BuildLoginResult(userGuid.Value, claims); + } + /// /// 刷新登录令牌。 /// diff --git a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj index 61ff6fe..3ba3514 100644 --- a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj +++ b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj @@ -12,6 +12,7 @@ + diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index 064ac66..38716f4 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.HttpOverrides; using Oms.Application; using Oms.SqlSugarCore; using SimpleAdmin.Api.Auth; +using SimpleAdmin.Api.WeChat; namespace SimpleAdmin.Api; @@ -49,12 +50,18 @@ public class SimpleAdminApiModule : FakeModule settings.RootPath = "rbac"; settings.TypePredicate = type => type == typeof(AuthService); }); + options.ApplicationServices2Controller(settings => + { + settings.TypePredicate = type => type.Namespace == "SimpleAdmin.Api.WeChat"; + }); options.ApplicationServices2Controller(settings => { settings.RootPath = "rbac"; settings.TypePredicate = type => type != typeof(Fake.Rbac.Application.Services.AuthService); }); }); + + services.AddOmsWeChat(configuration); //Json格式化全局处理 context.Services.AddFakeNewtonsoft(); diff --git a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs new file mode 100644 index 0000000..9b8922d --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs @@ -0,0 +1,82 @@ +using System.ComponentModel.DataAnnotations; + +namespace SimpleAdmin.Api.WeChat.Dtos; + +public class WxLoginQrcodeOutput +{ + public string QrCodeUrl { get; set; } = string.Empty; + + public int ExpiresIn { get; set; } = 600; +} + +public class WeChatOAuthCodeInput +{ + [Required] + public string Code { get; set; } = string.Empty; +} + +public class WeChatOAuthUserInfoOutput +{ + public string Openid { get; set; } = string.Empty; + + public string? Nickname { get; set; } + + public int Sex { get; set; } + + public string? Province { get; set; } + + public string? City { get; set; } + + public string? Country { get; set; } + + public string? Headimgurl { get; set; } + + public string? Unionid { get; set; } + + public bool AlreadyBound { get; set; } +} + +public class WeChatProfileInput +{ + [Required] + public string Openid { get; set; } = string.Empty; + + public string? Nickname { get; set; } + + public string? Avatar { get; set; } + + public string? Unionid { get; set; } +} + +public class WeChatCustomerRegisterInput +{ + [Required] + public string CustomerCode { get; set; } = string.Empty; + + [Required] + public string CustomerName { get; set; } = string.Empty; + + [Required] + public string Phone { get; set; } = string.Empty; + + public string? Email { get; set; } + + [Required] + [MinLength(6)] + public string Password { get; set; } = string.Empty; + + [Required] + public WeChatProfileInput Wechat { get; set; } = new(); +} + +public class WeChatCustomerBindInput +{ + [Required] + public string Account { get; set; } = string.Empty; + + [Required] + public string Password { get; set; } = string.Empty; + + [Required] + public WeChatProfileInput Wechat { get; set; } = new(); +} diff --git a/src/SimpleAdmin.Api/WeChat/Options/WeChatMpOptions.cs b/src/SimpleAdmin.Api/WeChat/Options/WeChatMpOptions.cs new file mode 100644 index 0000000..bb7f8e5 --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/Options/WeChatMpOptions.cs @@ -0,0 +1,22 @@ +namespace SimpleAdmin.Api.WeChat.Options; + +/// +/// 微信公众号配置。 +/// +public class WeChatMpOptions +{ + public const string SectionName = "WeChat:Mp"; + + public string AppId { get; set; } = string.Empty; + + public string Secret { get; set; } = string.Empty; + + public string Token { get; set; } = string.Empty; + + public string? EncodingAesKey { get; set; } + + /// + /// OAuth 回调基础域名,如 https://oms.nanafa.com + /// + public string OAuthRedirectBase { get; set; } = string.Empty; +} diff --git a/src/SimpleAdmin.Api/WeChat/Services/IWeChatLoginSessionStore.cs b/src/SimpleAdmin.Api/WeChat/Services/IWeChatLoginSessionStore.cs new file mode 100644 index 0000000..9c43681 --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/Services/IWeChatLoginSessionStore.cs @@ -0,0 +1,13 @@ +namespace SimpleAdmin.Api.WeChat.Services; + +/// +/// 微信扫码登录会话:loginUuid → openId 桥接。 +/// +public interface IWeChatLoginSessionStore +{ + Task SetOpenIdAsync(string loginUuid, string openId, TimeSpan ttl, CancellationToken cancellationToken = default); + + Task GetOpenIdAsync(string loginUuid, CancellationToken cancellationToken = default); + + Task RemoveAsync(string loginUuid, CancellationToken cancellationToken = default); +} diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs new file mode 100644 index 0000000..2fc01b9 --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs @@ -0,0 +1,90 @@ +using Oms.Domain.CustomersAggregate.Repositories; + +namespace SimpleAdmin.Api.WeChat.Services; + +public interface IWeChatEventHandler +{ + Task HandleAsync(WeChatIncomingMessage message, CancellationToken cancellationToken = default); +} + +public class WeChatEventHandler( + ICustomerRepository customerRepository, + IWeChatLoginSessionStore sessionStore, + ILogger logger) : IWeChatEventHandler +{ + private const string QrScenePrefix = "qrscene_"; + + public async Task HandleAsync(WeChatIncomingMessage message, CancellationToken cancellationToken = default) + { + if (!string.Equals(message.MsgType, "event", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var eventName = message.Event ?? string.Empty; + if (string.Equals(eventName, "SCAN", StringComparison.OrdinalIgnoreCase) + || string.Equals(eventName, "subscribe", StringComparison.OrdinalIgnoreCase)) + { + return await HandleScanLoginAsync(message, cancellationToken); + } + + logger.LogDebug("未处理的微信事件: {Event}", eventName); + return null; + } + + private async Task HandleScanLoginAsync(WeChatIncomingMessage message, CancellationToken cancellationToken) + { + var openId = message.FromUserName; + if (string.IsNullOrWhiteSpace(openId)) + { + return null; + } + + var existing = await customerRepository.GetByWechatOpenidAsync(openId, cancellationToken); + if (existing == null) + { + return BuildTextReply(message, "未绑定账号,请在公众号菜单注册账号或绑定已有账号。"); + } + + var scene = NormalizeScene(message.EventKey); + if (string.IsNullOrWhiteSpace(scene)) + { + if (string.Equals(message.Event, "subscribe", StringComparison.OrdinalIgnoreCase)) + { + return BuildTextReply(message, "感谢关注"); + } + + return null; + } + + await sessionStore.SetOpenIdAsync(scene, openId, TimeSpan.FromMinutes(10), cancellationToken); + logger.LogInformation("微信扫码登录会话已写入: loginUuid={LoginUuid}, openId={OpenId}", scene, openId); + + if (string.Equals(message.Event, "subscribe", StringComparison.OrdinalIgnoreCase)) + { + return BuildTextReply(message, "感谢关注,请返回电脑端完成登录。"); + } + + return BuildTextReply(message, "扫码成功,请返回电脑端完成登录。"); + } + + private static string? NormalizeScene(string? eventKey) + { + if (string.IsNullOrWhiteSpace(eventKey)) + { + return null; + } + + return eventKey.StartsWith(QrScenePrefix, StringComparison.Ordinal) + ? eventKey[QrScenePrefix.Length..] + : eventKey; + } + + private static string BuildTextReply(WeChatIncomingMessage message, string content) + { + return WeChatXmlMessageBuilder.BuildTextReply( + message.FromUserName ?? string.Empty, + message.ToUserName ?? string.Empty, + content); + } +} diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionStore.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionStore.cs new file mode 100644 index 0000000..20b09a3 --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionStore.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Caching.Distributed; + +namespace SimpleAdmin.Api.WeChat.Services; + +public class WeChatLoginSessionStore(IDistributedCache cache) : IWeChatLoginSessionStore +{ + private const string KeyPrefix = "oms:wechat:login:"; + + public Task SetOpenIdAsync(string loginUuid, string openId, TimeSpan ttl, CancellationToken cancellationToken = default) + { + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ttl + }; + return cache.SetStringAsync(BuildKey(loginUuid), openId, options, cancellationToken); + } + + public Task GetOpenIdAsync(string loginUuid, CancellationToken cancellationToken = default) + { + return cache.GetStringAsync(BuildKey(loginUuid), cancellationToken); + } + + public Task RemoveAsync(string loginUuid, CancellationToken cancellationToken = default) + { + return cache.RemoveAsync(BuildKey(loginUuid), cancellationToken); + } + + private static string BuildKey(string loginUuid) => $"{KeyPrefix}{loginUuid}"; +} diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs new file mode 100644 index 0000000..85c2f38 --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs @@ -0,0 +1,293 @@ +using System.Text.Json.Serialization; +using System.Xml.Linq; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using SimpleAdmin.Api.WeChat.Options; + +namespace SimpleAdmin.Api.WeChat.Services; + +public interface IWeChatMpApiClient +{ + Task CreateLoginQrCodeUrlAsync(string loginUuid, int expireSeconds, CancellationToken cancellationToken = default); + + Task GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default); + + Task GetOAuthUserInfoAsync(string accessToken, string openId, CancellationToken cancellationToken = default); +} + +public sealed record WeChatOAuthTokenResult(string AccessToken, string OpenId); + +public sealed record WeChatOAuthUserResult( + string Openid, + string? Nickname, + int Sex, + string? Province, + string? City, + string? Country, + string? Headimgurl, + string? Unionid); + +internal sealed class WeChatMpApiClient( + IHttpClientFactory httpClientFactory, + IMemoryCache memoryCache, + IOptions options, + ILogger logger) : IWeChatMpApiClient +{ + private const string AccessTokenCacheKey = "oms:wechat:mp:access_token"; + + public async Task CreateLoginQrCodeUrlAsync( + string loginUuid, + int expireSeconds, + CancellationToken cancellationToken = default) + { + var accessToken = await GetAccessTokenAsync(cancellationToken); + var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient)); + + var payload = new + { + expire_seconds = expireSeconds, + action_name = "QR_STR_SCENE", + action_info = new + { + scene = new + { + scene_str = loginUuid + } + } + }; + + using var response = await client.PostAsJsonAsync( + $"https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token={Uri.EscapeDataString(accessToken)}", + payload, + cancellationToken); + + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (result == null || string.IsNullOrWhiteSpace(result.Ticket)) + { + throw new InvalidOperationException(result?.Errmsg ?? "创建微信二维码失败"); + } + + if (result.Errcode is > 0) + { + throw new InvalidOperationException(result.Errmsg ?? $"创建微信二维码失败({result.Errcode})"); + } + + return $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={Uri.EscapeDataString(result.Ticket)}"; + } + + public async Task GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default) + { + var mp = options.Value; + var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient)); + var url = + $"https://api.weixin.qq.com/sns/oauth2/access_token?appid={Uri.EscapeDataString(mp.AppId)}&secret={Uri.EscapeDataString(mp.Secret)}&code={Uri.EscapeDataString(code)}&grant_type=authorization_code"; + + using var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (body == null || string.IsNullOrWhiteSpace(body.AccessToken) || string.IsNullOrWhiteSpace(body.Openid)) + { + logger.LogWarning("微信 OAuth 换取 access_token 失败: {Message}", body?.Errmsg); + return null; + } + + return new WeChatOAuthTokenResult(body.AccessToken, body.Openid); + } + + public async Task GetOAuthUserInfoAsync( + string accessToken, + string openId, + CancellationToken cancellationToken = default) + { + var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient)); + var url = + $"https://api.weixin.qq.com/sns/userinfo?access_token={Uri.EscapeDataString(accessToken)}&openid={Uri.EscapeDataString(openId)}&lang=zh_CN"; + + using var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (body == null || string.IsNullOrWhiteSpace(body.Openid)) + { + logger.LogWarning("微信 OAuth 获取用户信息失败: {Message}", body?.Errmsg); + return null; + } + + return new WeChatOAuthUserResult( + body.Openid, + body.Nickname, + body.Sex, + body.Province, + body.City, + body.Country, + body.Headimgurl, + body.Unionid); + } + + private async Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + if (memoryCache.TryGetValue(AccessTokenCacheKey, out string? cached) && !string.IsNullOrWhiteSpace(cached)) + { + return cached; + } + + var mp = options.Value; + var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient)); + var url = + $"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={Uri.EscapeDataString(mp.AppId)}&secret={Uri.EscapeDataString(mp.Secret)}"; + + using var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (body == null || string.IsNullOrWhiteSpace(body.AccessToken)) + { + throw new InvalidOperationException(body?.Errmsg ?? "获取微信 access_token 失败"); + } + + memoryCache.Set(AccessTokenCacheKey, body.AccessToken, TimeSpan.FromSeconds(Math.Max(300, body.ExpiresIn - 300))); + return body.AccessToken; + } + + private sealed class WeChatAccessTokenResponse + { + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + [JsonPropertyName("errcode")] + public int Errcode { get; set; } + + [JsonPropertyName("errmsg")] + public string? Errmsg { get; set; } + } + + private sealed class WeChatQrTicketResponse + { + [JsonPropertyName("ticket")] + public string? Ticket { get; set; } + + [JsonPropertyName("expire_seconds")] + public int ExpireSeconds { get; set; } + + [JsonPropertyName("errcode")] + public int Errcode { get; set; } + + [JsonPropertyName("errmsg")] + public string? Errmsg { get; set; } + } + + private sealed class WeChatOAuthTokenResponse + { + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + + [JsonPropertyName("openid")] + public string? Openid { get; set; } + + [JsonPropertyName("errcode")] + public int Errcode { get; set; } + + [JsonPropertyName("errmsg")] + public string? Errmsg { get; set; } + } + + private sealed class WeChatOAuthUserResponse + { + [JsonPropertyName("openid")] + public string? Openid { get; set; } + + [JsonPropertyName("nickname")] + public string? Nickname { get; set; } + + [JsonPropertyName("sex")] + public int Sex { get; set; } + + [JsonPropertyName("province")] + public string? Province { get; set; } + + [JsonPropertyName("city")] + public string? City { get; set; } + + [JsonPropertyName("country")] + public string? Country { get; set; } + + [JsonPropertyName("headimgurl")] + public string? Headimgurl { get; set; } + + [JsonPropertyName("unionid")] + public string? Unionid { get; set; } + + [JsonPropertyName("errcode")] + public int Errcode { get; set; } + + [JsonPropertyName("errmsg")] + public string? Errmsg { get; set; } + } +} + +internal static class WeChatXmlMessageParser +{ + public static WeChatIncomingMessage? Parse(string xml) + { + if (string.IsNullOrWhiteSpace(xml)) + { + return null; + } + + var doc = XDocument.Parse(xml); + var root = doc.Root; + if (root == null) + { + return null; + } + + string? Value(string name) => root.Element(name)?.Value; + + return new WeChatIncomingMessage + { + ToUserName = Value("ToUserName"), + FromUserName = Value("FromUserName"), + CreateTime = Value("CreateTime"), + MsgType = Value("MsgType"), + Event = Value("Event"), + EventKey = Value("EventKey"), + Content = Value("Content") + }; + } +} + +internal sealed class WeChatIncomingMessage +{ + public string? ToUserName { get; init; } + + public string? FromUserName { get; init; } + + public string? CreateTime { get; init; } + + public string? MsgType { get; init; } + + public string? Event { get; init; } + + public string? EventKey { get; init; } + + public string? Content { get; init; } +} + +internal static class WeChatXmlMessageBuilder +{ + public static string BuildTextReply(string toUser, string fromUser, string content) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + return $""" + + + + {now} + + + + """; + } +} diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatSignatureHelper.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatSignatureHelper.cs new file mode 100644 index 0000000..e87e808 --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatSignatureHelper.cs @@ -0,0 +1,24 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SimpleAdmin.Api.WeChat.Services; + +internal static class WeChatSignatureHelper +{ + public static bool Validate(string token, string timestamp, string nonce, string signature) + { + if (string.IsNullOrWhiteSpace(token) + || string.IsNullOrWhiteSpace(timestamp) + || string.IsNullOrWhiteSpace(nonce) + || string.IsNullOrWhiteSpace(signature)) + { + return false; + } + + var parts = new[] { token, timestamp, nonce }.OrderBy(x => x, StringComparer.Ordinal).ToArray(); + var raw = string.Concat(parts); + var hash = SHA1.HashData(Encoding.UTF8.GetBytes(raw)); + var hex = Convert.ToHexString(hash).ToLowerInvariant(); + return string.Equals(hex, signature, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs new file mode 100644 index 0000000..b4d09ae --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs @@ -0,0 +1,297 @@ +using Fake.Application; +using Fake.Domain.Exceptions; +using Fake.Rbac.Application.Dtos.Role; +using Fake.Rbac.Application.Dtos.User; +using Fake.Rbac.Application.Services; +using Fake.Rbac.Infrastructure; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.CustomersAggregate.Enums; +using Oms.Domain.CustomersAggregate.Repositories; +using SimpleAdmin.Api.WeChat.Dtos; + +namespace SimpleAdmin.Api.WeChat; + +/// +/// 微信 OAuth 注册 / 绑定客户。 +/// +[AllowAnonymous] +[ApiExplorerSettings(GroupName = "WeChat")] +public class WeChatCustomerAppService( + IWeChatMpApiClient weChatMpApiClient, + ICustomerRepository customerRepository, + UserService userService, + RoleService roleService, + FakeRbacDbContext rbacDbContext) : ApplicationService +{ + private const string CustomerRoleCode = "Customer"; + + /// + /// OAuth code 换取微信用户信息。 + /// + [HttpPost("/wechat/oauth/user-info")] + public async Task GetOAuthUserInfoAsync( + [FromBody] WeChatOAuthCodeInput input, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(input.Code)) + { + throw new BusinessException("缺少 code"); + } + + var token = await weChatMpApiClient.GetOAuthAccessTokenAsync(input.Code.Trim(), cancellationToken); + if (token == null) + { + throw new BusinessException("获取授权信息出现错误"); + } + + var userInfo = await weChatMpApiClient.GetOAuthUserInfoAsync(token.AccessToken, token.OpenId, cancellationToken); + if (userInfo == null) + { + throw new BusinessException("获取授权信息出现错误"); + } + + var existing = await customerRepository.GetByWechatOpenidAsync(userInfo.Openid, cancellationToken); + return new WeChatOAuthUserInfoOutput + { + Openid = userInfo.Openid, + Nickname = userInfo.Nickname, + Sex = userInfo.Sex, + Province = userInfo.Province, + City = userInfo.City, + Country = userInfo.Country, + Headimgurl = userInfo.Headimgurl, + Unionid = userInfo.Unionid, + AlreadyBound = existing != null + }; + } + + /// + /// 微信授权注册客户。 + /// + [HttpPost("/wechat/customer/register")] + public async Task RegisterAsync( + [FromBody] WeChatCustomerRegisterInput input, + CancellationToken cancellationToken = default) + { + ValidateWechatProfile(input.Wechat); + + var customerCode = NormalizeRequired(input.CustomerCode, "客户编号"); + var customerName = NormalizeRequired(input.CustomerName, "客户名称"); + var phone = NormalizeRequired(input.Phone, "手机号"); + var email = NormalizeOptional(input.Email); + var password = NormalizeRequired(input.Password, "密码"); + + if (password.Length < 6) + { + throw new BusinessException("密码长度不能少于6位"); + } + + if (await customerRepository.GetByWechatOpenidAsync(input.Wechat.Openid, cancellationToken) != null) + { + throw new BusinessException("当前微信已绑定账号"); + } + + await EnsureCustomerIdentityUniqueAsync(customerCode, phone, email, cancellationToken); + + var customerRoleId = await GetCustomerRoleIdOrThrowAsync(cancellationToken); + var user = await userService.CreateAsync(new UserCreateDto + { + Name = customerName, + Account = phone, + Password = password, + Email = email, + Avatar = NormalizeOptional(input.Wechat.Avatar), + RoleIds = [] + }); + + try + { + await userService.AssignRolesAsync(user.Id, [customerRoleId]); + var userId = await GetRbacUserMapIdOrThrowAsync(user.Id, cancellationToken); + var entity = new Customer + { + UserId = userId, + CustomerCode = customerCode, + CustomerName = customerName, + Phone = phone, + Email = email, + Password = OmsPasswordHelper.ToMd5(password), + WechatOpenid = input.Wechat.Openid, + WechatNickname = NormalizeOptional(input.Wechat.Nickname), + WechatAvatar = NormalizeOptional(input.Wechat.Avatar), + Status = CustomerStatus.Normal + }; + await customerRepository.InsertAsync(entity); + } + catch + { + await TryRollbackRbacUserAsync(user.Id); + throw; + } + } + + /// + /// 微信授权绑定已有客户。 + /// + [HttpPost("/wechat/customer/bind")] + public async Task BindAsync( + [FromBody] WeChatCustomerBindInput input, + CancellationToken cancellationToken = default) + { + ValidateWechatProfile(input.Wechat); + + var account = NormalizeRequired(input.Account, "账号"); + var password = NormalizeRequired(input.Password, "密码"); + + if (await customerRepository.GetByWechatOpenidAsync(input.Wechat.Openid, cancellationToken) != null) + { + throw new BusinessException("当前微信已绑定账号"); + } + + var customer = await FindCustomerByAccountAsync(account, cancellationToken); + if (customer == null) + { + throw new BusinessException("未查询到账号信息"); + } + + if (customer.ParentCustomerId.HasValue) + { + throw new BusinessException("子客户不支持绑定微信"); + } + + if (!string.IsNullOrWhiteSpace(customer.WechatOpenid)) + { + throw new BusinessException("当前账号已绑定微信"); + } + + ValidateMainCustomerStatus(customer); + + if (!string.Equals(customer.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("账号或密码错误"); + } + + customer.WechatOpenid = input.Wechat.Openid; + customer.WechatNickname = NormalizeOptional(input.Wechat.Nickname) ?? customer.WechatNickname; + customer.WechatAvatar = NormalizeOptional(input.Wechat.Avatar) ?? customer.WechatAvatar; + await customerRepository.UpdateAsync(customer, cancellationToken); + } + + private static void ValidateWechatProfile(WeChatProfileInput wechat) + { + if (string.IsNullOrWhiteSpace(wechat.Openid)) + { + throw new BusinessException("缺少 Openid 授权信息"); + } + } + + private async Task EnsureCustomerIdentityUniqueAsync( + string customerCode, + string phone, + string? email, + CancellationToken cancellationToken) + { + if (await customerRepository.GetByCustomerCodeAsync(customerCode, cancellationToken) != null) + { + throw new BusinessException("客户编号已存在"); + } + + if (await customerRepository.GetByPhoneAsync(phone, cancellationToken) != null) + { + throw new BusinessException("手机号已存在"); + } + + if (email != null && await customerRepository.GetByEmailAsync(email, cancellationToken) != null) + { + throw new BusinessException("邮箱已存在"); + } + } + + private async Task FindCustomerByAccountAsync(string account, CancellationToken cancellationToken) + { + var customer = await customerRepository.GetByPhoneAsync(account, cancellationToken); + if (customer != null) + { + return customer; + } + + customer = await customerRepository.GetByEmailAsync(account, cancellationToken); + if (customer != null) + { + return customer; + } + + return await customerRepository.GetByCustomerCodeAsync(account, cancellationToken); + } + + private async Task GetCustomerRoleIdOrThrowAsync(CancellationToken cancellationToken) + { + var roles = await roleService.GetAllRolesAsync(); + var role = roles.FirstOrDefault(x => string.Equals(x.Code, CustomerRoleCode, StringComparison.OrdinalIgnoreCase)); + if (role == null) + { + throw new BusinessException("客户角色未初始化"); + } + + return role.Id; + } + + private async Task GetRbacUserMapIdOrThrowAsync(Guid id, CancellationToken cancellationToken) + { + var userId = await rbacDbContext.Database.SqlQuery( + $"select `userid` as `Value` from `user` where `Id` = {id}") + .FirstOrDefaultAsync(cancellationToken); + if (!userId.HasValue || userId.Value <= 0) + { + throw new BusinessException("用户编号创建失败"); + } + + return userId.Value; + } + + private async Task TryRollbackRbacUserAsync(Guid userId) + { + try + { + await userService.DeleteAsync(userId); + } + catch + { + // ignore rollback failure + } + } + + private static void ValidateMainCustomerStatus(Customer customer) + { + if (customer.Status == CustomerStatus.Disabled) + { + throw new BusinessException("客户已禁用"); + } + + if (customer.Status == CustomerStatus.Frozen) + { + throw new BusinessException("客户已冻结"); + } + } + + private static string NormalizeRequired(string? value, string fieldName) + { + var normalized = value?.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + throw new BusinessException($"{fieldName}不能为空"); + } + + return normalized; + } + + private static string? NormalizeOptional(string? value) + { + var normalized = value?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } +} diff --git a/src/SimpleAdmin.Api/WeChat/WeChatMpAppService.cs b/src/SimpleAdmin.Api/WeChat/WeChatMpAppService.cs new file mode 100644 index 0000000..6332a9c --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/WeChatMpAppService.cs @@ -0,0 +1,130 @@ +using Fake.Application; +using Fake.Domain.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using SimpleAdmin.Api.WeChat.Dtos; +using SimpleAdmin.Api.WeChat.Options; +using SimpleAdmin.Api.WeChat.Services; + +namespace SimpleAdmin.Api.WeChat; + +/// +/// 微信公众号服务器回调与登录二维码。 +/// +[AllowAnonymous] +[ApiExplorerSettings(GroupName = "WeChat")] +public class WeChatMpAppService( + IOptions options, + IWeChatMpApiClient weChatMpApiClient, + IWeChatEventHandler eventHandler, + ILogger logger) : ApplicationService +{ + /// + /// 微信服务器接入验签。 + /// + [HttpGet("/wechat/mp/{appId}")] + [Produces("text/plain")] + public ContentResult ValidateAsync( + [FromRoute] string appId, + [FromQuery] string? signature, + [FromQuery] string? timestamp, + [FromQuery] string? nonce, + [FromQuery] string? echostr) + { + EnsureAppId(appId); + if (string.IsNullOrWhiteSpace(signature) + || string.IsNullOrWhiteSpace(timestamp) + || string.IsNullOrWhiteSpace(nonce) + || string.IsNullOrWhiteSpace(echostr)) + { + return Content("error", "text/plain; charset=utf-8"); + } + + var valid = WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature); + return Content(valid ? echostr : "error", "text/plain; charset=utf-8"); + } + + /// + /// 微信消息/事件回调。 + /// + [HttpPost("/wechat/mp/{appId}")] + [Consumes("text/xml", "application/xml", "text/plain")] + [Produces("application/xml")] + public async Task MessageAsync( + [FromRoute] string appId, + [FromQuery] string signature, + [FromQuery] string timestamp, + [FromQuery] string nonce, + [FromQuery] string? openid, + [FromQuery(Name = "encrypt_type")] string? encryptType, + [FromQuery(Name = "msg_signature")] string? msgSignature, + CancellationToken cancellationToken = default) + { + EnsureAppId(appId); + if (!WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature)) + { + logger.LogWarning("微信消息验签失败"); + return Content("error", "text/plain; charset=utf-8"); + } + + using var reader = new StreamReader(HttpContext.Request.Body); + var requestBody = await reader.ReadToEndAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(requestBody)) + { + return Content(string.Empty, "application/xml; charset=utf-8"); + } + + if (!string.IsNullOrWhiteSpace(encryptType) + && !string.Equals(encryptType, "raw", StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning("收到加密微信消息({EncryptType}),当前仅支持明文模式,请在公众平台切换为明文或后续启用 AES。", encryptType); + return Content(string.Empty, "application/xml; charset=utf-8"); + } + + var message = WeChatXmlMessageParser.Parse(requestBody); + if (message == null) + { + return Content(string.Empty, "application/xml; charset=utf-8"); + } + + var reply = await eventHandler.HandleAsync(message, cancellationToken); + return Content(reply ?? string.Empty, "application/xml; charset=utf-8"); + } + + /// + /// 获取 PC 扫码登录二维码。 + /// + [HttpGet("/wechat/mp/login-qrcode")] + public async Task GetLoginQrcodeAsync( + [FromQuery] string loginUuid, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(loginUuid)) + { + throw new BusinessException("缺少 loginUuid"); + } + + const int expireSeconds = 600; + var qrCodeUrl = await weChatMpApiClient.CreateLoginQrCodeUrlAsync(loginUuid.Trim(), expireSeconds, cancellationToken); + return new WxLoginQrcodeOutput + { + QrCodeUrl = qrCodeUrl, + ExpiresIn = expireSeconds + }; + } + + private void EnsureAppId(string appId) + { + var configured = options.Value.AppId; + if (string.IsNullOrWhiteSpace(configured)) + { + throw new BusinessException("未配置微信公众号 AppId"); + } + + if (!string.Equals(configured, appId, StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("公众号 AppId 不匹配"); + } + } +} diff --git a/src/SimpleAdmin.Api/WeChat/WeChatServiceExtensions.cs b/src/SimpleAdmin.Api/WeChat/WeChatServiceExtensions.cs new file mode 100644 index 0000000..6213e1d --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/WeChatServiceExtensions.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleAdmin.Api.WeChat.Options; +using SimpleAdmin.Api.WeChat.Services; + +namespace SimpleAdmin.Api.WeChat; + +public static class WeChatServiceExtensions +{ + public static IServiceCollection AddOmsWeChat(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(WeChatMpOptions.SectionName)); + services.AddHttpClient(nameof(WeChatMpApiClient)); + services.AddMemoryCache(); + + var redisConnection = configuration.GetConnectionString("Redis") ?? configuration["Redis:ConnectionString"]; + if (!string.IsNullOrWhiteSpace(redisConnection)) + { + services.AddStackExchangeRedisCache(options => options.Configuration = redisConnection); + } + else + { + services.AddDistributedMemoryCache(); + } + + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + + return services; + } +} diff --git a/src/SimpleAdmin.Api/appsettings.Development.json b/src/SimpleAdmin.Api/appsettings.Development.json index 62539e8..f569101 100644 --- a/src/SimpleAdmin.Api/appsettings.Development.json +++ b/src/SimpleAdmin.Api/appsettings.Development.json @@ -1,9 +1,22 @@ { + "UseLocalConfigs": true, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Information" } + }, + "Redis": { + "ConnectionString": "" + }, + "WeChat": { + "Mp": { + "AppId": "", + "Secret": "", + "Token": "", + "EncodingAesKey": "", + "OAuthRedirectBase": "https://oms.nanafa.com" + } } } \ No newline at end of file