diff --git a/SimpleAdmin.sln b/SimpleAdmin.sln index 9dbeb5a..a5b3729 100644 --- a/SimpleAdmin.sln +++ b/SimpleAdmin.sln @@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "items", "items", "{BE5132AD oms表结构与接口设计2026040802.md = oms表结构与接口设计2026040802.md .gitignore = .gitignore .dockerignore = .dockerignore + oms表结构与接口设计20260427.md = oms表结构与接口设计20260427.md + db.sql = db.sql EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleAdmin.Api", "src\SimpleAdmin.Api\SimpleAdmin.Api.csproj", "{229ED167-9ECC-4736-8DFB-522EEA8FF680}" @@ -23,6 +25,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oms.Application", "src\Oms. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oms.SqlSugarCore", "src\Oms.SqlSugarCore\Oms.SqlSugarCore.csproj", "{30C0D9A7-E710-4353-81DA-5F1F42F8AB63}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oms.Tests", "src\Oms.Tests\Oms.Tests.csproj", "{E8C403D2-7172-4D9F-BB0A-0618FEA12B03}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleAdmin.Api.IntegrationTests", "src\SimpleAdmin.Api.IntegrationTests\SimpleAdmin.Api.IntegrationTests.csproj", "{C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,6 +87,30 @@ Global {30C0D9A7-E710-4353-81DA-5F1F42F8AB63}.Release|x64.Build.0 = Release|Any CPU {30C0D9A7-E710-4353-81DA-5F1F42F8AB63}.Release|x86.ActiveCfg = Release|Any CPU {30C0D9A7-E710-4353-81DA-5F1F42F8AB63}.Release|x86.Build.0 = Release|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|x64.ActiveCfg = Debug|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|x64.Build.0 = Debug|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|x86.ActiveCfg = Debug|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|x86.Build.0 = Debug|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|Any CPU.Build.0 = Release|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|x64.ActiveCfg = Release|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|x64.Build.0 = Release|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|x86.ActiveCfg = Release|Any CPU + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|x86.Build.0 = Release|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|x64.ActiveCfg = Debug|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|x64.Build.0 = Debug|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|x86.ActiveCfg = Debug|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|x86.Build.0 = Debug|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|Any CPU.Build.0 = Release|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|x64.ActiveCfg = Release|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|x64.Build.0 = Release|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|x86.ActiveCfg = Release|Any CPU + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -90,5 +120,7 @@ Global {A4E19908-0101-4438-899C-CA1563ADF4A3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {0C57854D-BD5D-4E52-BD11-3F52C154AA07} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {30C0D9A7-E710-4353-81DA-5F1F42F8AB63} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E8C403D2-7172-4D9F-BB0A-0618FEA12B03} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/SimpleAdmin.sln.DotSettings.user b/SimpleAdmin.sln.DotSettings.user index a09d00e..771f328 100644 --- a/SimpleAdmin.sln.DotSettings.user +++ b/SimpleAdmin.sln.DotSettings.user @@ -11,9 +11,13 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded - ForceIncluded \ No newline at end of file + ForceIncluded + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &lt;src&gt;\&lt;SimpleAdmin.Api.IntegrationTests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="D:\my\itfx\admin\src\SimpleAdmin.Api.IntegrationTests" Presentation="&lt;src&gt;\&lt;SimpleAdmin.Api.IntegrationTests&gt;" /> +</SessionState> \ No newline at end of file diff --git a/db.sql b/db.sql index f8b5f5d..8cb218c 100644 --- a/db.sql +++ b/db.sql @@ -76,6 +76,7 @@ CREATE TABLE `t_order` ( `IsIntercept` TINYINT NOT NULL DEFAULT 0 COMMENT '是否拦截:0-否,1-是', `IsRemote` TINYINT NOT NULL DEFAULT 0 COMMENT '是否偏远:0-否,1-是', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -100,6 +101,7 @@ CREATE TABLE `t_order_template` ( `TemplateKey` VARCHAR(64) NULL COMMENT '模板标识', `CustomerId` BIGINT NULL COMMENT '客户ID(自定义模板)', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -119,6 +121,7 @@ CREATE TABLE `t_order_task` ( `TemplateId` BIGINT NULL COMMENT '模板ID', `CustomerId` BIGINT NULL COMMENT '客户ID', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -295,6 +298,7 @@ CREATE TABLE `t_sales_product` ( `CustomerWaybill` VARCHAR(32) NULL COMMENT '系统面单/客户面单', `BlacklistAreaId` BIGINT NULL COMMENT '黑名单区ID', `RemoteAreaId` BIGINT NULL COMMENT '偏远分区ID', + `PriceAreaId` BIGINT NULL COMMENT '价格分区ID', `WeightRuleId` BIGINT NULL COMMENT '计重规则ID', `WeightRoundingId` BIGINT NULL COMMENT '计重进位ID', `ProductDescription` TEXT COMMENT '产品描述', @@ -302,6 +306,7 @@ CREATE TABLE `t_sales_product` ( `IsActive` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用', `Scheme` VARCHAR(32) NOT NULL COMMENT '排货方案:costLowest/timeFastest/overallBest', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -407,13 +412,17 @@ CREATE TABLE `t_sales_product_publish_record` ( CREATE TABLE `t_service_channel_group` ( `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Name` VARCHAR(128) NOT NULL COMMENT '渠道组名称', + `AppId` BIGINT NOT NULL COMMENT '应用ID', + `ApiServiceProviderId` BIGINT NOT NULL COMMENT 'API服务商ID', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', `UpdateTime` DATETIME NULL COMMENT '更新时间', PRIMARY KEY (`Id`), - UNIQUE KEY `uk_scg_name` (`Name`) + UNIQUE KEY `uk_scg_name` (`Name`), + KEY `idx_scg_app_provider` (`AppId`, `ApiServiceProviderId`) ) COMMENT='服务渠道组表'; CREATE TABLE `t_service_channel` ( @@ -429,6 +438,7 @@ CREATE TABLE `t_service_channel` ( `PreScript` TEXT COMMENT '入库前脚本', `IsActive` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -518,6 +528,7 @@ CREATE TABLE `t_kpi_area` ( `Name` VARCHAR(128) NOT NULL COMMENT '分区名称', `SystemName` VARCHAR(128) NULL COMMENT '系统名称', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -551,6 +562,7 @@ CREATE TABLE `t_weight_rule` ( `AppKey` VARCHAR(64) NOT NULL COMMENT '应用标识', `Name` VARCHAR(128) NOT NULL COMMENT '规则名称', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -580,6 +592,7 @@ CREATE TABLE `t_weight_rounding` ( `AppKey` VARCHAR(64) NOT NULL COMMENT '应用标识', `Name` VARCHAR(128) NOT NULL COMMENT '进位名称', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -614,6 +627,7 @@ CREATE TABLE `t_auto_trigger` ( `PromptMessage` VARCHAR(512) NULL COMMENT '提示信息', `Notifier` VARCHAR(128) NULL COMMENT '通知人', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -629,6 +643,7 @@ CREATE TABLE `t_label_template` ( `TemplateJson` JSON NULL COMMENT 'vg-print 模板 JSON', `TemplateType` VARCHAR(32) NULL COMMENT '尾程/客户/入库/出库/sku', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -650,6 +665,7 @@ CREATE TABLE `t_inbound_record` ( `OperateTime` DATETIME NOT NULL COMMENT '操作时间', `OperatorId` BIGINT NULL COMMENT '操作人ID', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -667,6 +683,7 @@ CREATE TABLE `t_outbound_record` ( `OperateTime` DATETIME NOT NULL COMMENT '操作时间', `OperatorId` BIGINT NULL COMMENT '操作人ID', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -687,6 +704,7 @@ CREATE TABLE `t_label_print_record` ( `PrintTime` DATETIME NOT NULL COMMENT '打印时间', `OperatorId` BIGINT NULL COMMENT '操作人ID', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', @@ -700,6 +718,7 @@ CREATE TABLE `t_label_print_record` ( CREATE TABLE `t_customer` ( `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `CustomerCode` VARCHAR(32) NOT NULL COMMENT '客户编号,规则:前缀w+年份+序号', + `CustomerName` VARCHAR(128) NOT NULL COMMENT '客户名称', `WechatAvatar` VARCHAR(255) NULL COMMENT '微信头像URL', `WechatNickname` VARCHAR(64) NULL COMMENT '微信昵称', `Email` VARCHAR(64) NULL COMMENT '邮箱,用于登录/找回密码', @@ -720,6 +739,7 @@ CREATE TABLE `t_customer` ( `LastLoginIp` VARCHAR(64) NULL COMMENT '最后在线IP', `LastLoginTime` DATETIME NULL COMMENT '最后在线时间', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `WechatOpenid` VARCHAR(64) NULL COMMENT '微信OpenID', `Password` VARCHAR(64) NOT NULL COMMENT '密码(MD5加密存储)', PRIMARY KEY (`Id`), @@ -746,6 +766,7 @@ CREATE TABLE `t_customer_sub_account` ( `UpdateUserId` INT NULL COMMENT '更新人', `UpdateTime` DATETIME NULL COMMENT '更新时间', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', PRIMARY KEY (`Id`), UNIQUE KEY `uk_csa_code` (`SubAccountCode`), UNIQUE KEY `uk_csa_phone` (`SubPhone`), @@ -770,12 +791,142 @@ CREATE TABLE `t_recharge_record` ( `AuditTime` DATETIME NULL COMMENT '审核时间', `AuditBy` VARCHAR(32) NULL COMMENT '审核人(管理员账号)', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', PRIMARY KEY (`Id`), UNIQUE KEY `uk_rr_serial` (`SerialNumber`), KEY `idx_rr_customer_status_time` (`CustomerId`, `RechargeStatus`, `RechargeTime`), KEY `idx_rr_audit_time` (`AuditTime`) ) COMMENT='充值记录表'; +CREATE TABLE `t_country` ( + `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', + `ChineseName` VARCHAR(128) NOT NULL COMMENT '国家中文名', + `EnglishName` VARCHAR(128) NOT NULL COMMENT '国家英文名', + `CountryCode` VARCHAR(2) NOT NULL COMMENT 'ISO 3166-1 alpha-2 国家二字码', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_country_code` (`CountryCode`), + KEY `idx_country_name` (`EnglishName`, `ChineseName`) +) COMMENT='国家表'; + +CREATE TABLE `t_dict` ( + `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', + `DictType` VARCHAR(64) NOT NULL COMMENT '字典类型', + `DictSort` INT NOT NULL DEFAULT 0 COMMENT '排序值', + `DictCode` VARCHAR(64) NOT NULL COMMENT '字典编码', + `DictName` VARCHAR(128) NOT NULL COMMENT '字典名称', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_dict_type_code` (`DictType`, `DictCode`), + KEY `idx_dict_type_sort` (`DictType`, `DictSort`, `Id`) +) COMMENT='系统字典表'; + +CREATE TABLE `t_notice` ( + `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', + `NoticeType` VARCHAR(16) NOT NULL COMMENT '通知类型:notice/message', + `Title` VARCHAR(200) NOT NULL COMMENT '通知标题', + `Content` TEXT NOT NULL COMMENT '通知内容', + `IsPopup` TINYINT NOT NULL DEFAULT 0 COMMENT '是否弹窗:0-否,1-是', + `ReceiverType` VARCHAR(16) NOT NULL COMMENT '接收对象:user/customer', + `ReceiverIds` JSON NOT NULL COMMENT '接收对象ID列表(JSON数组)', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + KEY `idx_notice_type_receiver_time` (`NoticeType`, `ReceiverType`, `CreateTime`), + KEY `idx_notice_title` (`Title`) +) COMMENT='公告通知表'; + +CREATE TABLE `t_notice_read` ( + `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', + `NoticeId` BIGINT NOT NULL COMMENT '通知ID', + `ReceiverType` VARCHAR(16) NOT NULL COMMENT '接收对象:user/customer', + `ReceiverId` BIGINT NOT NULL COMMENT '接收主体ID', + `ReadTime` DATETIME NOT NULL COMMENT '已读时间', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_notice_read_identity` (`NoticeId`, `ReceiverType`, `ReceiverId`), + KEY `idx_notice_read_receiver` (`ReceiverType`, `ReceiverId`, `ReadTime`) +) COMMENT='通知已读明细表'; + +INSERT INTO `t_country` +(`ChineseName`, `EnglishName`, `CountryCode`, `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`) +VALUES +('中国', 'China', 'CN', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('中国香港', 'Hong Kong SAR China', 'HK', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('中国澳门', 'Macao SAR China', 'MO', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('中国台湾', 'Taiwan', 'TW', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('美国', 'United States', 'US', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('加拿大', 'Canada', 'CA', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('英国', 'United Kingdom', 'GB', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('爱尔兰', 'Ireland', 'IE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('德国', 'Germany', 'DE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('法国', 'France', 'FR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('意大利', 'Italy', 'IT', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('西班牙', 'Spain', 'ES', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('葡萄牙', 'Portugal', 'PT', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('荷兰', 'Netherlands', 'NL', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('比利时', 'Belgium', 'BE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('卢森堡', 'Luxembourg', 'LU', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('瑞士', 'Switzerland', 'CH', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('奥地利', 'Austria', 'AT', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('瑞典', 'Sweden', 'SE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('挪威', 'Norway', 'NO', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('芬兰', 'Finland', 'FI', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('丹麦', 'Denmark', 'DK', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('波兰', 'Poland', 'PL', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('捷克', 'Czechia', 'CZ', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('匈牙利', 'Hungary', 'HU', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('罗马尼亚', 'Romania', 'RO', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('俄罗斯', 'Russia', 'RU', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('乌克兰', 'Ukraine', 'UA', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('土耳其', 'Turkey', 'TR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('希腊', 'Greece', 'GR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('以色列', 'Israel', 'IL', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('阿联酋', 'United Arab Emirates', 'AE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('沙特阿拉伯', 'Saudi Arabia', 'SA', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('日本', 'Japan', 'JP', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('韩国', 'South Korea', 'KR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('新加坡', 'Singapore', 'SG', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('马来西亚', 'Malaysia', 'MY', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('泰国', 'Thailand', 'TH', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('越南', 'Vietnam', 'VN', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('印度尼西亚', 'Indonesia', 'ID', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('菲律宾', 'Philippines', 'PH', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('印度', 'India', 'IN', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('巴基斯坦', 'Pakistan', 'PK', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('孟加拉国', 'Bangladesh', 'BD', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('澳大利亚', 'Australia', 'AU', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('新西兰', 'New Zealand', 'NZ', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('南非', 'South Africa', 'ZA', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('尼日利亚', 'Nigeria', 'NG', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('肯尼亚', 'Kenya', 'KE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('埃及', 'Egypt', 'EG', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('巴西', 'Brazil', 'BR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('阿根廷', 'Argentina', 'AR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('智利', 'Chile', 'CL', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('哥伦比亚', 'Colombia', 'CO', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('秘鲁', 'Peru', 'PE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL), +('墨西哥', 'Mexico', 'MX', 0, 0, CURRENT_TIMESTAMP, NULL, NULL); + CREATE TABLE `t_warehouse_address` ( `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `WarehouseName` VARCHAR(128) NOT NULL COMMENT '仓库名称', @@ -787,6 +938,7 @@ CREATE TABLE `t_warehouse_address` ( `ServiceWechat` VARCHAR(64) NULL COMMENT '客服微信', `ServiceWechatFiles` JSON NULL COMMENT '微信二维码图片', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateUserId` INT NULL COMMENT '更新人', diff --git a/db.sync.sql b/db.sync.sql deleted file mode 100644 index cd8ed98..0000000 --- a/db.sync.sql +++ /dev/null @@ -1,41 +0,0 @@ --- OMS 增量数据库同步脚本 --- 适用场景: --- 1. 已经按旧版 db.sql 建库 --- 2. 需要把本轮实体新增字段和任务失败表补齐到当前代码 --- 3. 建议在执行前先备份数据库 - --- 订单主表:补充幂等字段与申报汇总字段 -ALTER TABLE `t_order` - ADD COLUMN `RequestId` VARCHAR(64) NULL COMMENT '请求幂等ID' AFTER `Id`, - ADD COLUMN `DeclaredCurrency` VARCHAR(16) NULL COMMENT '申报币种' AFTER `CodSum`, - ADD COLUMN `DeclaredSum` DECIMAL(18,4) NULL COMMENT '申报金额' AFTER `DeclaredCurrency`, - ADD COLUMN `Quantity` INT NULL COMMENT '包裹数量' AFTER `DeclaredSum`; - -ALTER TABLE `t_order` - ADD UNIQUE KEY `uk_ord_request_id` (`RequestId`); - --- 订单申报表:补充进口/出口 HsCode -ALTER TABLE `t_order_declaration` - ADD COLUMN `ImHsCode` VARCHAR(50) NULL COMMENT '进口HsCode' AFTER `HsCode`, - ADD COLUMN `ExHsCode` VARCHAR(50) NULL COMMENT '出口HsCode' AFTER `ImHsCode`; - --- 导入任务表:补充成功数与失败数 -ALTER TABLE `t_order_task` - ADD COLUMN `SuccessCount` INT NULL COMMENT '成功数' AFTER `Quantity`, - ADD COLUMN `FailCount` INT NULL COMMENT '失败数' AFTER `SuccessCount`; - --- 导入任务失败明细表:用于 /order/task/detail.failList -CREATE TABLE `t_order_task_fail` ( - `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', - `TaskId` BIGINT NOT NULL COMMENT '任务ID', - `RowNumber` INT NOT NULL COMMENT '失败行号', - `Reason` VARCHAR(500) NOT NULL COMMENT '失败原因', - `RawDataJson` JSON NULL COMMENT '原始行数据', - `IsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除', - `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', - `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `UpdateUserId` INT NULL DEFAULT NULL COMMENT '修改人', - `UpdateTime` DATETIME NULL DEFAULT NULL COMMENT '修改时间', - PRIMARY KEY (`Id`), - KEY `idx_otf_task` (`TaskId`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单导入任务失败明细表'; diff --git a/oms表结构与接口设计20260427.md b/oms表结构与接口设计20260427.md new file mode 100644 index 0000000..1d07366 --- /dev/null +++ b/oms表结构与接口设计20260427.md @@ -0,0 +1,5933 @@ +# 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/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs index 0fd4435..9b9e5b8 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs @@ -50,6 +50,11 @@ public class SalesProductDetailOutput /// public long? RemoteAreaId { get; set; } + /// + /// 价格分区主键。 + /// + public long? PriceAreaId { get; set; } + /// /// 计重规则主键。 /// diff --git a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs index a994dc4..b032cd8 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs @@ -50,6 +50,11 @@ public class SalesProductListOutput /// public long? RemoteAreaId { get; set; } + /// + /// 价格分区主键。 + /// + public long? PriceAreaId { get; set; } + /// /// 计重规则主键。 /// diff --git a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs index c64ec9f..c6f30fb 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs @@ -52,6 +52,11 @@ public class SalesProductSaveInput /// public long? RemoteAreaId { get; set; } + /// + /// 价格分区主键。 + /// + public long? PriceAreaId { get; set; } + /// /// 计重规则主键。 /// diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelGroupSaveInput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelGroupSaveInput.cs new file mode 100644 index 0000000..e5918af --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelGroupSaveInput.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Channels; + +/// +/// 服务渠道分组保存入参。 +/// +public class ServiceChannelGroupSaveInput +{ + /// + /// 分组主键,新增时可不传。 + /// + public long? Id { get; set; } + + [Required] + [StringLength(128)] + /// + /// 分组名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 业务应用ID。 + /// + public long AppId { get; set; } + + /// + /// API服务商ID。 + /// + public long ApiServiceProviderId { get; set; } +} diff --git a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs index 30ef744..342c579 100644 --- a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs +++ b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs @@ -8,58 +8,102 @@ namespace Oms.Application.Dtos.Customers; /// public class CreateCustomerInput { + /// + /// 客户编号。 + /// [Required] - [StringLength(16)] + [StringLength(32)] + public string CustomerCode { get; set; } = string.Empty; + + /// + /// 客户名称。 + /// + [Required] + [StringLength(128)] + public string CustomerName { get; set; } = string.Empty; + /// /// 手机号。 /// + [Required] + [StringLength(16)] public string Phone { get; set; } = string.Empty; - [EmailAddress] - [StringLength(64)] /// /// 邮箱。 /// + [EmailAddress] + [StringLength(64)] public string? Email { get; set; } - [Required] + /// + /// 微信头像地址。 + /// + [StringLength(255)] + public string? WechatAvatar { get; set; } + + /// + /// 微信昵称。 + /// [StringLength(64)] + public string? WechatNickname { get; set; } + /// /// 密码。 /// + [Required] + [StringLength(64)] public string Password { get; set; } = string.Empty; + /// + /// 账户余额。 + /// [Range(typeof(decimal), "0", "999999999999")] + public decimal Balance { get; set; } + /// /// 总额度。 /// + [Range(typeof(decimal), "0", "999999999999")] public decimal TotalQuota { get; set; } + /// + /// 已用额度。 + /// + [Range(typeof(decimal), "0", "999999999999")] + public decimal UsedQuota { get; set; } + /// /// 状态。 /// public CustomerStatus Status { get; set; } = CustomerStatus.Normal; - [StringLength(512)] /// /// 备注。 /// + [StringLength(512)] public string? Remark { get; set; } - [StringLength(32)] /// /// 销售人员。 /// + [StringLength(32)] public string? Salesman { get; set; } - [StringLength(32)] /// /// 客服人员。 /// + [StringLength(32)] public string? CustomerService { get; set; } /// /// 客户分组。 /// public CustomerGroup? CustomerGroup { get; set; } + + /// + /// 角色编码,控制客户登录后的菜单权限。 + /// + [StringLength(64)] + public string? Role { get; set; } } diff --git a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs index 0daed14..fe18d98 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs @@ -17,6 +17,11 @@ public class CustomerOutput /// public string CustomerCode { get; set; } = string.Empty; + /// + /// 客户名称。 + /// + public string CustomerName { get; set; } = string.Empty; + /// /// WechatAvatar。 /// diff --git a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs index 9b914b6..ca3bd09 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs @@ -17,6 +17,11 @@ public class CustomerPaginatedOutput /// public string CustomerCode { get; set; } = string.Empty; + /// + /// 客户名称。 + /// + public string CustomerName { get; set; } = string.Empty; + /// /// WechatAvatar。 /// @@ -77,6 +82,11 @@ public class CustomerPaginatedOutput /// public CustomerGroup? CustomerGroup { get; set; } + /// + /// 角色编码。 + /// + public string? Role { get; set; } + /// /// 最后登录 IP。 /// diff --git a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs index 5a0cab0..6aabf86 100644 --- a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs +++ b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs @@ -8,50 +8,101 @@ namespace Oms.Application.Dtos.Customers; /// public class UpdateCustomerInput { - [Required] /// /// 主键。 /// + [Required] public long Id { get; set; } - [EmailAddress] - [StringLength(64)] + /// + /// 客户编号。 + /// + [Required] + [StringLength(32)] + public string CustomerCode { get; set; } = string.Empty; + + /// + /// 客户名称。 + /// + [Required] + [StringLength(128)] + public string CustomerName { get; set; } = string.Empty; + + /// + /// 手机号。 + /// + [Required] + [StringLength(16)] + public string Phone { get; set; } = string.Empty; + /// /// 邮箱。 /// + [EmailAddress] + [StringLength(64)] public string? Email { get; set; } + /// + /// 微信头像地址。 + /// + [StringLength(255)] + public string? WechatAvatar { get; set; } + + /// + /// 微信昵称。 + /// + [StringLength(64)] + public string? WechatNickname { get; set; } + + /// + /// 账户余额。 + /// [Range(typeof(decimal), "0", "999999999999")] + public decimal Balance { get; set; } + /// /// 总额度。 /// + [Range(typeof(decimal), "0", "999999999999")] public decimal TotalQuota { get; set; } + /// + /// 已用额度。 + /// + [Range(typeof(decimal), "0", "999999999999")] + public decimal UsedQuota { get; set; } + /// /// 状态。 /// public CustomerStatus Status { get; set; } = CustomerStatus.Normal; - [StringLength(512)] /// /// 备注。 /// + [StringLength(512)] public string? Remark { get; set; } - [StringLength(32)] /// /// 销售人员。 /// + [StringLength(32)] public string? Salesman { get; set; } - [StringLength(32)] /// /// 客服人员。 /// + [StringLength(32)] public string? CustomerService { get; set; } /// /// 客户分组。 /// public CustomerGroup? CustomerGroup { get; set; } + + /// + /// 角色编码,控制客户登录后的菜单权限。 + /// + [StringLength(64)] + public string? Role { get; set; } } diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateInput.cs new file mode 100644 index 0000000..1eb2c65 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateInput.cs @@ -0,0 +1,8 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 批量创建小包入参,数组内每项与单票创建请求结构一致。 +/// +public class OrderParcelBatchCreateInput : List +{ +} diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateItemOutput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateItemOutput.cs new file mode 100644 index 0000000..cea3089 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateItemOutput.cs @@ -0,0 +1,47 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 批量创建小包单条处理结果。 +/// +public class OrderParcelBatchCreateItemOutput +{ + /// + /// 原始请求序号,从 1 开始。 + /// + public int Index { get; set; } + + /// + /// 幂等请求号。 + /// + public string? RequestId { get; set; } + + /// + /// 客户单号。 + /// + public string? ClientNo { get; set; } + + /// + /// 运单号。 + /// + public string? ServiceNo { get; set; } + + /// + /// 跟踪号。 + /// + public string? TrackNo { get; set; } + + /// + /// 面单地址。 + /// + public string? LabelUrl { get; set; } + + /// + /// 是否成功。 + /// + public bool Success { get; set; } + + /// + /// 处理说明。 + /// + public string? Message { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateOutput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateOutput.cs new file mode 100644 index 0000000..98a74c2 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateOutput.cs @@ -0,0 +1,27 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 批量创建小包出参。 +/// +public class OrderParcelBatchCreateOutput +{ + /// + /// 本次批量处理批次号。 + /// + public string BatchNo { get; set; } = string.Empty; + + /// + /// 成功数量。 + /// + public int SuccessCount { get; set; } + + /// + /// 失败数量。 + /// + public int FailCount { get; set; } + + /// + /// 逐条处理结果。 + /// + public List Items { get; set; } = []; +} diff --git a/src/Oms.Application/Dtos/System/CountryOutput.cs b/src/Oms.Application/Dtos/System/CountryOutput.cs new file mode 100644 index 0000000..a31fb97 --- /dev/null +++ b/src/Oms.Application/Dtos/System/CountryOutput.cs @@ -0,0 +1,27 @@ +namespace Oms.Application.Dtos.System; + +/// +/// 国家列表项出参。 +/// +public class CountryOutput +{ + /// + /// 主键。 + /// + public long Id { get; set; } + + /// + /// 国家中文名。 + /// + public string ChineseName { get; set; } = string.Empty; + + /// + /// 国家英文名。 + /// + public string EnglishName { get; set; } = string.Empty; + + /// + /// ISO 3166-1 alpha-2 国家二字码。 + /// + public string CountryCode { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/System/DictCreateInput.cs b/src/Oms.Application/Dtos/System/DictCreateInput.cs new file mode 100644 index 0000000..c0a1325 --- /dev/null +++ b/src/Oms.Application/Dtos/System/DictCreateInput.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.System; + +/// +/// 新增字典入参。 +/// +public class DictCreateInput +{ + [Required] + [StringLength(64)] + /// + /// 字典类型。 + /// + public string DictType { get; set; } = string.Empty; + + /// + /// 排序值。 + /// + public int DictSort { get; set; } + + [Required] + [StringLength(64)] + /// + /// 字典编码。 + /// + public string DictCode { get; set; } = string.Empty; + + [Required] + [StringLength(128)] + /// + /// 字典名称。 + /// + public string DictName { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/System/DictListInput.cs b/src/Oms.Application/Dtos/System/DictListInput.cs new file mode 100644 index 0000000..20b762f --- /dev/null +++ b/src/Oms.Application/Dtos/System/DictListInput.cs @@ -0,0 +1,27 @@ +namespace Oms.Application.Dtos.System; + +/// +/// 字典分页查询入参。 +/// +public class DictListInput +{ + /// + /// 页码,从 1 开始。 + /// + public int Page { get; set; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } = 20; + + /// + /// 字典类型。 + /// + public string? DictType { get; set; } + + /// + /// 关键词,匹配编码或名称。 + /// + public string? Keyword { get; set; } +} diff --git a/src/Oms.Application/Dtos/System/DictOutput.cs b/src/Oms.Application/Dtos/System/DictOutput.cs new file mode 100644 index 0000000..eb2e97a --- /dev/null +++ b/src/Oms.Application/Dtos/System/DictOutput.cs @@ -0,0 +1,42 @@ +namespace Oms.Application.Dtos.System; + +/// +/// 字典列表项出参。 +/// +public class DictOutput +{ + /// + /// 主键。 + /// + public long Id { get; set; } + + /// + /// 字典类型。 + /// + public string DictType { get; set; } = string.Empty; + + /// + /// 排序值。 + /// + public int DictSort { get; set; } + + /// + /// 字典编码。 + /// + public string DictCode { get; set; } = string.Empty; + + /// + /// 字典名称。 + /// + public string DictName { get; set; } = string.Empty; + + /// + /// 创建时间。 + /// + public DateTime CreateTime { get; set; } + + /// + /// 更新时间。 + /// + public DateTime? UpdateTime { get; set; } +} diff --git a/src/Oms.Application/Dtos/System/DictUpdateInput.cs b/src/Oms.Application/Dtos/System/DictUpdateInput.cs new file mode 100644 index 0000000..a4b282e --- /dev/null +++ b/src/Oms.Application/Dtos/System/DictUpdateInput.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.System; + +/// +/// 更新字典入参。 +/// +public class DictUpdateInput : DictCreateInput +{ + [Required] + /// + /// 字典主键。 + /// + public long Id { get; set; } +} diff --git a/src/Oms.Application/Dtos/System/NoticeCreateInput.cs b/src/Oms.Application/Dtos/System/NoticeCreateInput.cs new file mode 100644 index 0000000..b05c324 --- /dev/null +++ b/src/Oms.Application/Dtos/System/NoticeCreateInput.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.System; + +/// +/// 新增通知入参。 +/// +public class NoticeCreateInput +{ + [Required] + [StringLength(16)] + /// + /// 通知类型:notice/message。 + /// + public string NoticeType { get; set; } = string.Empty; + + [Required] + [StringLength(128)] + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + [Required] + /// + /// 正文内容。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 是否弹窗:0-否,1-是。 + /// + public byte IsPopup { get; set; } + + [Required] + [StringLength(16)] + /// + /// 接收对象类型:user/customer。 + /// + public string ReceiverType { get; set; } = string.Empty; + + /// + /// 接收主体 ID 列表。 + /// + public List ReceiverIds { get; set; } = []; +} diff --git a/src/Oms.Application/Dtos/System/NoticeListInput.cs b/src/Oms.Application/Dtos/System/NoticeListInput.cs new file mode 100644 index 0000000..1ebc69a --- /dev/null +++ b/src/Oms.Application/Dtos/System/NoticeListInput.cs @@ -0,0 +1,32 @@ +namespace Oms.Application.Dtos.System; + +/// +/// 通知分页查询入参。 +/// +public class NoticeListInput +{ + /// + /// 页码,从 1 开始。 + /// + public int Page { get; set; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } = 20; + + /// + /// 通知类型。 + /// + public string? NoticeType { get; set; } + + /// + /// 接收对象类型。 + /// + public string? ReceiverType { get; set; } + + /// + /// 关键词,匹配标题或内容。 + /// + public string? Keyword { get; set; } +} diff --git a/src/Oms.Application/Dtos/System/NoticeOutput.cs b/src/Oms.Application/Dtos/System/NoticeOutput.cs new file mode 100644 index 0000000..3ea8631 --- /dev/null +++ b/src/Oms.Application/Dtos/System/NoticeOutput.cs @@ -0,0 +1,72 @@ +namespace Oms.Application.Dtos.System; + +/// +/// 通知列表项出参。 +/// +public class NoticeOutput +{ + /// + /// 主键。 + /// + public long Id { get; set; } + + /// + /// 通知类型。 + /// + public string NoticeType { get; set; } = string.Empty; + + /// + /// 通知类型中文名。 + /// + public string NoticeTypeName { get; set; } = string.Empty; + + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 正文内容。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 是否弹窗:0-否,1-是。 + /// + public byte IsPopup { get; set; } + + /// + /// 接收对象类型。 + /// + public string ReceiverType { get; set; } = string.Empty; + + /// + /// 接收对象类型中文名。 + /// + public string ReceiverTypeName { get; set; } = string.Empty; + + /// + /// 接收主体 ID 列表。 + /// + public List ReceiverIds { get; set; } = []; + + /// + /// 已读人数。 + /// + public long ReadCount { get; set; } + + /// + /// 接收总人数。 + /// + public int ReceiverCount { get; set; } + + /// + /// 创建时间。 + /// + public DateTime CreateTime { get; set; } + + /// + /// 更新时间。 + /// + public DateTime? UpdateTime { get; set; } +} diff --git a/src/Oms.Application/Dtos/System/NoticeUpdateInput.cs b/src/Oms.Application/Dtos/System/NoticeUpdateInput.cs new file mode 100644 index 0000000..68e40f4 --- /dev/null +++ b/src/Oms.Application/Dtos/System/NoticeUpdateInput.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.System; + +/// +/// 更新通知入参。 +/// +public class NoticeUpdateInput : NoticeCreateInput +{ + [Required] + /// + /// 通知主键。 + /// + public long Id { get; set; } +} diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj index d2db6e8..6c0747a 100644 --- a/src/Oms.Application/Oms.Application.csproj +++ b/src/Oms.Application/Oms.Application.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index ed8829e..26fa1de 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -179,6 +179,7 @@ public partial class SalesProductService CustomerWaybill = product.CustomerWaybill, BlacklistAreaId = product.BlacklistAreaId, RemoteAreaId = product.RemoteAreaId, + PriceAreaId = product.PriceAreaId, WeightRuleId = product.WeightRuleId, WeightRoundingId = product.WeightRoundingId, ProductDescription = product.ProductDescription, @@ -269,6 +270,7 @@ public partial class SalesProductService entity.CustomerWaybill = input.CustomerWaybill; entity.BlacklistAreaId = input.BlacklistAreaId; entity.RemoteAreaId = input.RemoteAreaId; + entity.PriceAreaId = input.PriceAreaId; entity.WeightRuleId = input.WeightRuleId; entity.WeightRoundingId = input.WeightRoundingId; entity.ProductDescription = input.ProductDescription; @@ -290,6 +292,7 @@ public partial class SalesProductService CustomerWaybill = entity.CustomerWaybill, BlacklistAreaId = entity.BlacklistAreaId, RemoteAreaId = entity.RemoteAreaId, + PriceAreaId = entity.PriceAreaId, WeightRuleId = entity.WeightRuleId, WeightRoundingId = entity.WeightRoundingId, ProductDescription = entity.ProductDescription, @@ -313,6 +316,7 @@ public partial class SalesProductService CustomerWaybill = entity.CustomerWaybill, BlacklistAreaId = entity.BlacklistAreaId, RemoteAreaId = entity.RemoteAreaId, + PriceAreaId = entity.PriceAreaId, WeightRuleId = entity.WeightRuleId, WeightRoundingId = entity.WeightRoundingId, ProductDescription = entity.ProductDescription, diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index aa8f540..528bdc8 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -135,6 +135,7 @@ public partial class SalesProductService : ApplicationService /// 2. 按入参更新主体数据和关联配置。 /// 3. 写入更新审计字段并保存结果。 /// + [ApiExplorerSettings(IgnoreApi = true)] [HttpPost("/channel/sales-product/config/save")] public async Task SaveConfigAsync(SalesProductSaveInput input) { diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index 7d4d487..fcf6925 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -13,7 +13,53 @@ public partial class ServiceChannelService /// private async Task> GetGroupMapAsync() { - return (await ServiceChannelRepository.GetGroupsAsync()).ToDictionary(x => x.Id, x => x.Name); + return (await ServiceChannelGroupRepository.GetListAsync()).ToDictionary(x => x.Id, x => x.Name); + } + + /// + /// 获取服务渠道分组,不存在时直接抛异常。 + /// + private async Task GetGroupOrThrowAsync(long? id) + { + if (!id.HasValue || id.Value <= 0) + { + throw new Fake.Application.BusinessException("服务渠道分组不存在"); + } + + var group = await ServiceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == id.Value); + if (group == null) + { + throw new Fake.Application.BusinessException("服务渠道分组不存在"); + } + + return group; + } + + /// + /// 校验服务渠道分组基础信息和名称唯一性。 + /// + private async Task ValidateGroupInputAsync(ServiceChannelGroupSaveInput input, long? id) + { + if (string.IsNullOrWhiteSpace(input.Name)) + { + throw new Fake.Application.BusinessException("分组名称不能为空"); + } + + if (input.AppId <= 0) + { + throw new Fake.Application.BusinessException("应用ID不能为空"); + } + + if (input.ApiServiceProviderId <= 0) + { + throw new Fake.Application.BusinessException("API服务商ID不能为空"); + } + + var existed = await ServiceChannelGroupRepository.GetByNameAsync(input.Name.Trim()); + if (existed != null && existed.Id != id) + { + throw new Fake.Application.BusinessException("分组名称已存在"); + } } /// diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index dfa0f83..f141f2a 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -16,6 +16,7 @@ public partial class ServiceChannelService : ApplicationService private const string RulesOperatorType = "rules"; private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService(); + private IServiceChannelGroupRepository ServiceChannelGroupRepository => LazyServiceProvider.GetRequiredService(); /// /// 查询服务渠道列表。 @@ -124,6 +125,74 @@ public partial class ServiceChannelService : ApplicationService await ServiceChannelRepository.DeleteAsync(entity); } + /// + /// 新增服务渠道分组。 + /// + /// 入参:服务渠道分组保存参数。 + /// 无返回体,仅完成新增服务渠道分组。 + /// + /// 核心业务逻辑: + /// 1. 校验分组名称、应用ID、API服务商ID等基础字段。 + /// 2. 检查分组名称唯一性,避免重复配置。 + /// 3. 保存分组主数据供服务渠道绑定。 + /// + [HttpPost("/channel/service-channel/group/create")] + public async Task CreateGroupAsync(ServiceChannelGroupSaveInput input) + { + await ValidateGroupInputAsync(input, null); + await ServiceChannelGroupRepository.InsertAsync(new ServiceChannelGroup + { + Name = input.Name.Trim(), + AppId = input.AppId, + ApiServiceProviderId = input.ApiServiceProviderId + }); + } + + /// + /// 更新服务渠道分组。 + /// + /// 入参:服务渠道分组保存参数。 + /// 无返回体,仅完成更新服务渠道分组。 + /// + /// 核心业务逻辑: + /// 1. 查询并校验目标分组是否存在。 + /// 2. 检查名称唯一性并更新分组主数据。 + /// 3. 保留既有渠道关联,只更新分组属性。 + /// + [HttpPost("/channel/service-channel/group/update")] + public async Task UpdateGroupAsync(ServiceChannelGroupSaveInput input) + { + var group = await GetGroupOrThrowAsync(input.Id); + await ValidateGroupInputAsync(input, group.Id); + group.Name = input.Name.Trim(); + group.AppId = input.AppId; + group.ApiServiceProviderId = input.ApiServiceProviderId; + await ServiceChannelGroupRepository.UpdateAsync(group); + } + + /// + /// 删除服务渠道分组。 + /// + /// 入参:目标分组主键。 + /// 无返回体,仅完成删除服务渠道分组。 + /// + /// 核心业务逻辑: + /// 1. 查询并校验目标分组是否存在。 + /// 2. 检查分组下是否仍有关联渠道。 + /// 3. 无关联时执行删除,保留框架软删语义。 + /// + [HttpPost("/channel/service-channel/group/delete")] + public async Task DeleteGroupAsync(IdInput input) + { + var group = await GetGroupOrThrowAsync(input.Id); + if (await ServiceChannelRepository.AnyAsync(x => x.GroupId == group.Id)) + { + throw new BusinessException("当前分组下仍有关联渠道,无法删除"); + } + + await ServiceChannelGroupRepository.DeleteAsync(group); + } + /// /// 保存服务渠道配置。 /// @@ -135,6 +204,7 @@ public partial class ServiceChannelService : ApplicationService /// 2. 按入参更新主体数据和关联配置。 /// 3. 写入更新审计字段并保存结果。 /// + [ApiExplorerSettings(IgnoreApi = true)] [HttpPost("/channel/service-channel/config/save")] public async Task SaveConfigAsync(ServiceChannelSaveInput input) { diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index 74c7432..9bd28d5 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -84,14 +84,24 @@ public class CustomerService : ApplicationService [HttpPost("/customer/create")] public async Task CreateAsync(CreateCustomerInput input) { - if (await CustomerRepository.ExistsPhoneAsync(input.Phone)) + var customerCode = NormalizeRequiredText(input.CustomerCode, "客户编号"); + var customerName = NormalizeRequiredText(input.CustomerName, "客户名称"); + var phone = NormalizeRequiredText(input.Phone, "手机号"); + var email = NormalizeOptionalText(input.Email); + + if (await CustomerRepository.GetByCustomerCodeAsync(customerCode) != null) + { + throw new BusinessException("客户编号已存在"); + } + + if (await CustomerRepository.GetByPhoneAsync(phone) != null) { throw new BusinessException("手机号已存在"); } - if (!string.IsNullOrWhiteSpace(input.Email)) + if (email != null) { - var duplicatedEmail = await CustomerRepository.GetByEmailAsync(input.Email); + var duplicatedEmail = await CustomerRepository.GetByEmailAsync(email); if (duplicatedEmail != null) { throw new BusinessException("邮箱已存在"); @@ -100,18 +110,22 @@ public class CustomerService : ApplicationService var entity = new Customer { - CustomerCode = await GenerateCustomerCodeAsync(), - Phone = input.Phone, - Email = input.Email, + CustomerCode = customerCode, + CustomerName = customerName, + WechatAvatar = NormalizeOptionalText(input.WechatAvatar), + WechatNickname = NormalizeOptionalText(input.WechatNickname), + Phone = phone, + Email = email, Password = OmsPasswordHelper.ToMd5(input.Password), + Balance = input.Balance, TotalQuota = input.TotalQuota, - UsedQuota = 0, - Balance = 0, + UsedQuota = input.UsedQuota, Status = input.Status, - Remark = input.Remark, - Salesman = input.Salesman, - CustomerService = input.CustomerService, - CustomerGroup = input.CustomerGroup + Remark = NormalizeOptionalText(input.Remark), + Salesman = NormalizeOptionalText(input.Salesman), + CustomerService = NormalizeOptionalText(input.CustomerService), + CustomerGroup = input.CustomerGroup, + Role = NormalizeOptionalText(input.Role) }; await CustomerRepository.InsertAsync(entity); @@ -132,23 +146,47 @@ public class CustomerService : ApplicationService public async Task UpdateAsync(UpdateCustomerInput input) { var entity = await GetCustomerOrThrowAsync(input.Id); + var customerCode = NormalizeRequiredText(input.CustomerCode, "客户编号"); + var customerName = NormalizeRequiredText(input.CustomerName, "客户名称"); + var phone = NormalizeRequiredText(input.Phone, "手机号"); + var email = NormalizeOptionalText(input.Email); - if (!string.IsNullOrWhiteSpace(input.Email)) + var duplicatedCode = await CustomerRepository.GetByCustomerCodeAsync(customerCode); + if (duplicatedCode != null && duplicatedCode.Id != entity.Id) { - var duplicatedEmail = await CustomerRepository.GetByEmailAsync(input.Email); + throw new BusinessException("客户编号已存在"); + } + + var duplicatedPhone = await CustomerRepository.GetByPhoneAsync(phone); + if (duplicatedPhone != null && duplicatedPhone.Id != entity.Id) + { + throw new BusinessException("手机号已存在"); + } + + if (email != null) + { + var duplicatedEmail = await CustomerRepository.GetByEmailAsync(email); if (duplicatedEmail != null && duplicatedEmail.Id != entity.Id) { throw new BusinessException("邮箱已存在"); } } - entity.Email = input.Email; + entity.CustomerCode = customerCode; + entity.CustomerName = customerName; + entity.Phone = phone; + entity.Email = email; + entity.WechatAvatar = NormalizeOptionalText(input.WechatAvatar); + entity.WechatNickname = NormalizeOptionalText(input.WechatNickname); + entity.Balance = input.Balance; entity.TotalQuota = input.TotalQuota; + entity.UsedQuota = input.UsedQuota; entity.Status = input.Status; - entity.Remark = input.Remark; - entity.Salesman = input.Salesman; - entity.CustomerService = input.CustomerService; + entity.Remark = NormalizeOptionalText(input.Remark); + entity.Salesman = NormalizeOptionalText(input.Salesman); + entity.CustomerService = NormalizeOptionalText(input.CustomerService); entity.CustomerGroup = input.CustomerGroup; + entity.Role = NormalizeOptionalText(input.Role); await CustomerRepository.UpdateAsync(entity); } @@ -203,6 +241,7 @@ public class CustomerService : ApplicationService return customer => (string.IsNullOrWhiteSpace(keyword) || customer.CustomerCode.Contains(keyword) || + customer.CustomerName.Contains(keyword) || customer.Phone.Contains(keyword) || (customer.Email ?? string.Empty).Contains(keyword)) && (!hasStatusFilter || customer.Status == statusValue) && @@ -222,18 +261,20 @@ public class CustomerService : ApplicationService return entity; } - private async Task GenerateCustomerCodeAsync() + private static string NormalizeRequiredText(string? value, string fieldName) { - for (var i = 0; i < 5; i++) + var normalized = value?.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) { - var code = $"W{DateTime.Now:yyMMddHHmmss}{Random.Shared.Next(10, 99)}"; - var exists = await CustomerRepository.GetByCustomerCodeAsync(code); - if (exists == null) - { - return code; - } + throw new BusinessException($"{fieldName}不能为空"); } - throw new BusinessException("客户编号生成失败"); + return normalized; + } + + private static string? NormalizeOptionalText(string? value) + { + var normalized = value?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; } } diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 27fce63..db88bf4 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -335,6 +335,72 @@ public partial class OrderService : ApplicationService return await CreateParcelCoreAsync(input); } + /// + /// 批量创建订单包裹。 + /// + /// 入参:批量创建小包请求集合,每条记录沿用单票创建结构。 + /// 批量处理结果,包含批次号、成功失败数量与逐条执行明细。 + /// + /// 核心业务逻辑: + /// 1. 校验批量请求非空后逐条复用单票创建逻辑。 + /// 2. 单条失败不会中断整批处理,允许部分成功部分失败。 + /// 3. 返回逐条执行结果,方便调用方定位失败原因并重试。 + /// + [HttpPost("/order/parcel/batch-create")] + public async Task BatchCreateParcelAsync(OrderParcelBatchCreateInput input) + { + if (input == null || input.Count == 0) + { + throw new BusinessException("批量创建请求不能为空"); + } + + var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}"; + var items = new List(input.Count); + var successCount = 0; + + for (var index = 0; index < input.Count; index++) + { + var request = input[index]; + try + { + var output = await CreateParcelCoreAsync(request); + successCount++; + items.Add(new OrderParcelBatchCreateItemOutput + { + Index = index + 1, + RequestId = request.RequestId, + ClientNo = output.ClientNo ?? request.ClientNo, + ServiceNo = output.ServiceNo ?? request.ServiceNo, + TrackNo = output.TrackNo ?? request.TrackNo, + LabelUrl = output.LabelUrl, + Success = true, + Message = "success" + }); + } + catch (Exception ex) + { + items.Add(new OrderParcelBatchCreateItemOutput + { + Index = index + 1, + RequestId = request.RequestId, + ClientNo = request.ClientNo, + ServiceNo = request.ServiceNo, + TrackNo = request.TrackNo, + Success = false, + Message = ex.Message + }); + } + } + + return new OrderParcelBatchCreateOutput + { + BatchNo = batchNo, + SuccessCount = successCount, + FailCount = items.Count - successCount, + Items = items + }; + } + /// /// 将订单列表请求对象转换为仓储可执行的查询表达式。 /// diff --git a/src/Oms.Application/Services/System/CountryService.cs b/src/Oms.Application/Services/System/CountryService.cs new file mode 100644 index 0000000..3f465cd --- /dev/null +++ b/src/Oms.Application/Services/System/CountryService.cs @@ -0,0 +1,38 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.System; +using Oms.Domain.SystemAggregate.Repositories; + +namespace Oms.Application.Services.System; + +[ApiExplorerSettings(GroupName = "OMS-System")] +public class CountryService : ApplicationService +{ + private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); + + /// + /// 查询国家列表。 + /// + /// 国家列表,返回全部有效国家主数据。 + /// + /// 核心业务逻辑: + /// 1. 查询全部国家主数据。 + /// 2. 按国家代码和英文名稳定排序。 + /// 3. 返回页面选择器可直接消费的静态结果集。 + /// + [HttpGet("/system/country/list")] + public async Task> GetListAsync() + { + return (await CountryRepository.GetListAsync()) + .OrderBy(x => x.CountryCode) + .ThenBy(x => x.EnglishName) + .Select(x => new CountryOutput + { + Id = x.Id, + ChineseName = x.ChineseName, + EnglishName = x.EnglishName, + CountryCode = x.CountryCode + }) + .ToList(); + } +} diff --git a/src/Oms.Application/Services/System/DictService.cs b/src/Oms.Application/Services/System/DictService.cs new file mode 100644 index 0000000..b9a2c5b --- /dev/null +++ b/src/Oms.Application/Services/System/DictService.cs @@ -0,0 +1,172 @@ +using System.Linq.Expressions; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.System; +using Oms.Application.Shared; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; + +namespace Oms.Application.Services.System; + +[ApiExplorerSettings(GroupName = "OMS-System")] +public class DictService : ApplicationService +{ + private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService(); + + /// + /// 查询字典列表。 + /// + /// 入参:字典分页查询条件。 + /// 字典分页结果,包含字典列表与总记录数。 + /// + /// 核心业务逻辑: + /// 1. 根据字典类型和关键词拼装筛选条件。 + /// 2. 按排序值升序、主键降序返回分页结果。 + /// 3. 供系统配置页直接展示和编辑。 + /// + [HttpGet("/system/dict/list")] + public async Task> GetListAsync([FromQuery] DictListInput input) + { + var predicate = BuildListPredicate(input); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var items = await DictRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(Dict.DictSort)] = true, [nameof(Dict.Id)] = false }); + var total = await DictRepository.CountAsync(predicate); + + return new OmsPageResult + { + Items = items.Select(MapDictOutput).ToList(), + Total = total + }; + } + + /// + /// 新增字典。 + /// + /// 入参:字典新增参数。 + /// 无返回体,仅完成新增字典。 + /// + /// 核心业务逻辑: + /// 1. 校验类型、编码、名称等基础字段。 + /// 2. 检查同类型下编码唯一性。 + /// 3. 写入字典主数据供系统配置复用。 + /// + [HttpPost("/system/dict/create")] + public async Task CreateAsync(DictCreateInput input) + { + await ValidateDictInputAsync(input.DictType, input.DictCode, input.DictName, null); + await DictRepository.InsertAsync(new Dict + { + DictType = input.DictType.Trim(), + DictSort = input.DictSort, + DictCode = input.DictCode.Trim(), + DictName = input.DictName.Trim() + }); + } + + /// + /// 更新字典。 + /// + /// 入参:字典更新参数。 + /// 无返回体,仅完成更新字典。 + /// + /// 核心业务逻辑: + /// 1. 查询并校验目标字典是否存在。 + /// 2. 检查更新后的类型与编码组合唯一性。 + /// 3. 保存排序、编码和名称等最新配置。 + /// + [HttpPost("/system/dict/update")] + public async Task UpdateAsync(DictUpdateInput input) + { + var entity = await GetDictOrThrowAsync(input.Id); + await ValidateDictInputAsync(input.DictType, input.DictCode, input.DictName, entity.Id); + entity.DictType = input.DictType.Trim(); + entity.DictSort = input.DictSort; + entity.DictCode = input.DictCode.Trim(); + entity.DictName = input.DictName.Trim(); + await DictRepository.UpdateAsync(entity); + } + + /// + /// 删除字典。 + /// + /// 入参:目标主键。 + /// 无返回体,仅完成删除字典。 + /// + /// 核心业务逻辑: + /// 1. 查询并校验目标字典是否存在。 + /// 2. 走框架删除通道保留软删语义。 + /// 3. 让下游列表自动隐藏已删除记录。 + /// + [HttpPost("/system/dict/delete")] + public async Task DeleteAsync(IdInput input) + { + var entity = await GetDictOrThrowAsync(input.Id); + await DictRepository.DeleteAsync(entity); + } + + private static Expression> BuildListPredicate(DictListInput input) + { + var dictType = input.DictType?.Trim(); + var keyword = input.Keyword?.Trim(); + return x => + (string.IsNullOrWhiteSpace(dictType) || x.DictType == dictType) && + (string.IsNullOrWhiteSpace(keyword) || + x.DictCode.Contains(keyword) || + x.DictName.Contains(keyword)); + } + + private async Task GetDictOrThrowAsync(long id) + { + var entity = await DictRepository.FirstOrDefaultAsync(x => x.Id == id); + if (entity == null) + { + throw new BusinessException("字典不存在"); + } + + return entity; + } + + private async Task ValidateDictInputAsync(string dictType, string dictCode, string dictName, long? excludeId) + { + if (string.IsNullOrWhiteSpace(dictType)) + { + throw new BusinessException("字典类型不能为空"); + } + + if (string.IsNullOrWhiteSpace(dictCode)) + { + throw new BusinessException("字典编码不能为空"); + } + + if (string.IsNullOrWhiteSpace(dictName)) + { + throw new BusinessException("字典名称不能为空"); + } + + var existed = await DictRepository.GetByTypeAndCodeAsync(dictType.Trim(), dictCode.Trim()); + if (existed != null && existed.Id != excludeId) + { + throw new BusinessException("同类型下字典编码已存在"); + } + } + + private static DictOutput MapDictOutput(Dict entity) + { + return new DictOutput + { + Id = entity.Id, + DictType = entity.DictType, + DictSort = entity.DictSort, + DictCode = entity.DictCode, + DictName = entity.DictName, + CreateTime = entity.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + }; + } +} diff --git a/src/Oms.Application/Services/System/NoticeService.cs b/src/Oms.Application/Services/System/NoticeService.cs new file mode 100644 index 0000000..4a94dd2 --- /dev/null +++ b/src/Oms.Application/Services/System/NoticeService.cs @@ -0,0 +1,335 @@ +using System.Linq.Expressions; +using Fake.Application; +using Fake.Rbac.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.System; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; + +namespace Oms.Application.Services.System; + +[ApiExplorerSettings(GroupName = "OMS-System")] +public class NoticeService : ApplicationService +{ + private const string NoticeTypeNotice = "notice"; + private const string NoticeTypeMessage = "message"; + private const string ReceiverTypeUser = "user"; + private const string ReceiverTypeCustomer = "customer"; + private const string CustomerIdClaim = "customerId"; + + private INoticeRepository NoticeRepository => LazyServiceProvider.GetRequiredService(); + private INoticeReadRepository NoticeReadRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService(); + + /// + /// 查询通知列表。 + /// + /// 入参:通知分页查询条件。 + /// 通知分页结果,包含通知主体、接收范围与已读统计。 + /// + /// 核心业务逻辑: + /// 1. 根据通知类型、接收对象和关键词过滤通知。 + /// 2. 聚合已读明细,计算每条通知的已读数。 + /// 3. 返回接收范围、弹窗标记与统计字段供管理端展示。 + /// + [HttpGet("/system/notice/list")] + public async Task> GetListAsync([FromQuery] NoticeListInput input) + { + var predicate = BuildListPredicate(input); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var notices = await NoticeRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(Notice.CreateTime)] = false, [nameof(Notice.Id)] = false }); + var total = await NoticeRepository.CountAsync(predicate); + + var noticeIds = notices.Select(x => x.Id).Distinct().ToArray(); + var reads = noticeIds.Length == 0 + ? [] + : await NoticeReadRepository.GetListAsync(x => noticeIds.Contains(x.NoticeId)); + var readCountMap = reads + .GroupBy(x => x.NoticeId) + .ToDictionary( + x => x.Key, + x => (long)x.Select(item => $"{item.ReceiverType}:{item.ReceiverId}").Distinct(StringComparer.Ordinal).Count()); + + return new OmsPageResult + { + Items = notices.Select(x => MapNoticeOutput( + x, + readCountMap.TryGetValue(x.Id, out var readCount) ? readCount : 0)).ToList(), + Total = total + }; + } + + /// + /// 新增通知。 + /// + /// 入参:通知新增参数。 + /// 无返回体,仅完成新增通知。 + /// + /// 核心业务逻辑: + /// 1. 校验通知类型、接收对象和接收范围。 + /// 2. 验证用户或客户接收主体真实存在。 + /// 3. 保存公告或站内信主体,已读状态由确认接口单独落库。 + /// + [HttpPost("/system/notice/create")] + public async Task CreateAsync(NoticeCreateInput input) + { + ValidateNoticeText(input.Title, input.Content); + var receiverIds = await NormalizeAndValidateReceiverIdsAsync(input.ReceiverType, input.ReceiverIds); + await NoticeRepository.InsertAsync(new Notice + { + NoticeType = NormalizeNoticeType(input.NoticeType), + Title = input.Title.Trim(), + Content = input.Content.Trim(), + IsPopup = NormalizePopupValue(input.IsPopup), + ReceiverType = NormalizeReceiverType(input.ReceiverType), + ReceiverIds = OmsDocHelper.SerializeJson(receiverIds) ?? "[]" + }); + } + + /// + /// 更新通知。 + /// + /// 入参:通知更新参数。 + /// 无返回体,仅完成更新通知。 + /// + /// 核心业务逻辑: + /// 1. 查询并校验目标通知是否存在。 + /// 2. 更新主体内容与接收范围。 + /// 3. 清理不在新接收范围内的历史已读明细。 + /// + [HttpPost("/system/notice/update")] + public async Task UpdateAsync(NoticeUpdateInput input) + { + var entity = await GetNoticeOrThrowAsync(input.Id); + ValidateNoticeText(input.Title, input.Content); + var receiverIds = await NormalizeAndValidateReceiverIdsAsync(input.ReceiverType, input.ReceiverIds); + entity.NoticeType = NormalizeNoticeType(input.NoticeType); + entity.Title = input.Title.Trim(); + entity.Content = input.Content.Trim(); + entity.IsPopup = NormalizePopupValue(input.IsPopup); + entity.ReceiverType = NormalizeReceiverType(input.ReceiverType); + entity.ReceiverIds = OmsDocHelper.SerializeJson(receiverIds) ?? "[]"; + await NoticeRepository.UpdateAsync(entity); + + var existsReads = await NoticeReadRepository.GetListAsync(x => x.NoticeId == entity.Id); + var receiverIdSet = receiverIds.ToHashSet(); + foreach (var read in existsReads.Where(x => x.ReceiverType != entity.ReceiverType || !receiverIdSet.Contains(x.ReceiverId))) + { + await NoticeReadRepository.DeleteAsync(read); + } + } + + /// + /// 删除通知。 + /// + /// 入参:目标主键。 + /// 无返回体,仅完成删除通知。 + /// + /// 核心业务逻辑: + /// 1. 查询并校验目标通知是否存在。 + /// 2. 走框架删除通道保留软删语义。 + /// 3. 让通知与已读统计在常规查询中自动不可见。 + /// + [HttpPost("/system/notice/delete")] + public async Task DeleteAsync(IdInput input) + { + var entity = await GetNoticeOrThrowAsync(input.Id); + await NoticeRepository.DeleteAsync(entity); + } + + /// + /// 确认通知已读。 + /// + /// 入参:目标通知主键。 + /// 无返回体,仅完成当前主体的已读确认。 + /// + /// 核心业务逻辑: + /// 1. 根据当前登录主体解析 user/customer 接收身份。 + /// 2. 校验当前主体在通知接收范围内。 + /// 3. 按通知、接收类型、接收ID 幂等写入已读明细。 + /// + [HttpPost("/system/notice/read-confirm")] + public async Task ReadConfirmAsync(IdInput input) + { + var notice = await GetNoticeOrThrowAsync(input.Id); + var receiverIds = OmsDocHelper.DeserializeJson>(notice.ReceiverIds) ?? []; + var isCustomerPrincipal = CurrentUser.FindClaimOrNull(CustomerIdClaim) != null; + var currentReceiverType = isCustomerPrincipal ? ReceiverTypeCustomer : ReceiverTypeUser; + var currentReceiverId = CurrentUser.GetId(); + if (currentReceiverId <= 0) + { + throw new BusinessException("当前登录主体无效"); + } + + if (!string.Equals(notice.ReceiverType, currentReceiverType, StringComparison.OrdinalIgnoreCase) || + !receiverIds.Contains(currentReceiverId)) + { + throw new BusinessException("当前主体不在通知接收范围内"); + } + + var existed = await NoticeReadRepository.GetByNoticeReceiverAsync(notice.Id, currentReceiverType, currentReceiverId); + if (existed != null) + { + return; + } + + await NoticeReadRepository.InsertAsync(new NoticeRead + { + NoticeId = notice.Id, + ReceiverType = currentReceiverType, + ReceiverId = currentReceiverId, + ReadTime = DateTime.Now + }); + } + + private static Expression> BuildListPredicate(NoticeListInput input) + { + var noticeType = input.NoticeType?.Trim(); + var receiverType = input.ReceiverType?.Trim(); + var keyword = input.Keyword?.Trim(); + return x => + (string.IsNullOrWhiteSpace(noticeType) || x.NoticeType == noticeType) && + (string.IsNullOrWhiteSpace(receiverType) || x.ReceiverType == receiverType) && + (string.IsNullOrWhiteSpace(keyword) || + x.Title.Contains(keyword) || + x.Content.Contains(keyword)); + } + + private async Task GetNoticeOrThrowAsync(long id) + { + var entity = await NoticeRepository.FirstOrDefaultAsync(x => x.Id == id); + if (entity == null) + { + throw new BusinessException("通知不存在"); + } + + return entity; + } + + private async Task> NormalizeAndValidateReceiverIdsAsync(string receiverType, IEnumerable? receiverIds) + { + var normalizedReceiverType = NormalizeReceiverType(receiverType); + var normalizedIds = (receiverIds ?? []) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (normalizedIds.Count == 0) + { + throw new BusinessException("接收人不能为空"); + } + + if (normalizedReceiverType == ReceiverTypeCustomer) + { + var customers = await CustomerRepository.GetListAsync(x => normalizedIds.Contains(x.Id)); + if (customers.Count != normalizedIds.Count) + { + throw new BusinessException("接收客户包含无效数据"); + } + + return normalizedIds; + } + + var validUserIds = await QueryValidUserIdsAsync(normalizedIds); + if (validUserIds.Count != normalizedIds.Count) + { + throw new BusinessException("接收用户包含无效数据"); + } + + return normalizedIds; + } + + private async Task> QueryValidUserIdsAsync(IReadOnlyCollection userIds) + { + if (userIds.Count == 0) + { + return []; + } + + var sql = $"SELECT `userid` AS `UserId` FROM `user` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", userIds)})"; + return await RbacDbContext.Database.SqlQueryRaw(sql) + .Select(x => x.UserId) + .ToListAsync(); + } + + private static void ValidateNoticeText(string title, string content) + { + if (string.IsNullOrWhiteSpace(title)) + { + throw new BusinessException("通知标题不能为空"); + } + + if (string.IsNullOrWhiteSpace(content)) + { + throw new BusinessException("通知内容不能为空"); + } + } + + private static NoticeOutput MapNoticeOutput(Notice entity, long readCount) + { + var receiverIds = OmsDocHelper.DeserializeJson>(entity.ReceiverIds) ?? []; + return new NoticeOutput + { + Id = entity.Id, + NoticeType = entity.NoticeType, + NoticeTypeName = entity.NoticeType == NoticeTypeNotice ? "公告" : "站内信", + Title = entity.Title, + Content = entity.Content, + IsPopup = entity.IsPopup, + ReceiverType = entity.ReceiverType, + ReceiverTypeName = entity.ReceiverType == ReceiverTypeUser ? "用户" : "客户", + ReceiverIds = receiverIds, + ReadCount = readCount, + ReceiverCount = receiverIds.Distinct().Count(), + CreateTime = entity.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + }; + } + + private static string NormalizeNoticeType(string noticeType) + { + var normalized = noticeType.Trim().ToLowerInvariant(); + return normalized switch + { + NoticeTypeNotice => NoticeTypeNotice, + NoticeTypeMessage => NoticeTypeMessage, + _ => throw new BusinessException("通知类型无效") + }; + } + + private static string NormalizeReceiverType(string receiverType) + { + var normalized = receiverType.Trim().ToLowerInvariant(); + return normalized switch + { + ReceiverTypeUser => ReceiverTypeUser, + ReceiverTypeCustomer => ReceiverTypeCustomer, + _ => throw new BusinessException("接收对象类型无效") + }; + } + + private static byte NormalizePopupValue(byte value) + { + return value switch + { + 0 => 0, + 1 => 1, + _ => throw new BusinessException("弹窗标记无效") + }; + } + + private sealed class UserIdQueryResult + { + public long UserId { get; set; } + } +} diff --git a/src/Oms.Application/Shared/OmsPasswordHelper.cs b/src/Oms.Application/Shared/OmsPasswordHelper.cs index 37eda0b..2342e33 100644 --- a/src/Oms.Application/Shared/OmsPasswordHelper.cs +++ b/src/Oms.Application/Shared/OmsPasswordHelper.cs @@ -3,8 +3,14 @@ using System.Text; namespace Oms.Application.Shared; -internal static class OmsPasswordHelper +/// +/// 统一的密码摘要工具。 +/// +public static class OmsPasswordHelper { + /// + /// 将明文密码转换为小写 MD5 字符串。 + /// public static string ToMd5(string password) { var bytes = MD5.HashData(Encoding.UTF8.GetBytes(password)); diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs index d2eab3e..27f9fe3 100644 --- a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs +++ b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs @@ -23,6 +23,8 @@ public class SalesProduct : OmsAuditedAggregateRoot public long? RemoteAreaId { get; set; } + public long? PriceAreaId { get; set; } + public long? WeightRuleId { get; set; } public long? WeightRoundingId { get; set; } diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannelGroup.cs b/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannelGroup.cs index a76ac82..65683a1 100644 --- a/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannelGroup.cs +++ b/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannelGroup.cs @@ -4,7 +4,11 @@ using SqlSugar; namespace Oms.Domain.ChannelsAggregate.Entities; [SugarTable("t_service_channel_group")] -public class ServiceChannelGroup : OmsAuditedEntity +public class ServiceChannelGroup : OmsAuditedAggregateRoot { public string Name { get; set; } = string.Empty; + + public long AppId { get; set; } + + public long ApiServiceProviderId { get; set; } } diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelGroupRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelGroupRepository.cs new file mode 100644 index 0000000..749f4a0 --- /dev/null +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelGroupRepository.cs @@ -0,0 +1,9 @@ +using Fake.Domain.Repositories; +using Oms.Domain.ChannelsAggregate.Entities; + +namespace Oms.Domain.ChannelsAggregate.Repositories; + +public interface IServiceChannelGroupRepository : IRepository +{ + Task GetByNameAsync(string name, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs index 3a6f369..e20c460 100644 --- a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs +++ b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs @@ -9,6 +9,8 @@ public class Customer : OmsAuditedAggregateRoot { public string CustomerCode { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public string? WechatAvatar { get; set; } public string? WechatNickname { get; set; } diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerSubAccountRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerSubAccountRepository.cs index fea0fc0..7c2a498 100644 --- a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerSubAccountRepository.cs +++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerSubAccountRepository.cs @@ -9,5 +9,7 @@ public interface ICustomerSubAccountRepository : IRepository Task GetBySubPhoneAsync(string subPhone, CancellationToken cancellationToken = default); + Task GetBySubEmailAsync(string subEmail, CancellationToken cancellationToken = default); + Task ExistsSubPhoneAsync(string subPhone, CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/SystemAggregate/Entities/Country.cs b/src/Oms.Domain/SystemAggregate/Entities/Country.cs new file mode 100644 index 0000000..e76f7d5 --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Entities/Country.cs @@ -0,0 +1,14 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.SystemAggregate.Entities; + +[SugarTable("t_country")] +public class Country : OmsAuditedAggregateRoot +{ + public string ChineseName { get; set; } = string.Empty; + + public string EnglishName { get; set; } = string.Empty; + + public string CountryCode { get; set; } = string.Empty; +} diff --git a/src/Oms.Domain/SystemAggregate/Entities/Dict.cs b/src/Oms.Domain/SystemAggregate/Entities/Dict.cs new file mode 100644 index 0000000..8378007 --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Entities/Dict.cs @@ -0,0 +1,16 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.SystemAggregate.Entities; + +[SugarTable("t_dict")] +public class Dict : OmsAuditedAggregateRoot +{ + public string DictType { get; set; } = string.Empty; + + public int DictSort { get; set; } + + public string DictCode { get; set; } = string.Empty; + + public string DictName { get; set; } = string.Empty; +} diff --git a/src/Oms.Domain/SystemAggregate/Entities/Notice.cs b/src/Oms.Domain/SystemAggregate/Entities/Notice.cs new file mode 100644 index 0000000..e8fb134 --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Entities/Notice.cs @@ -0,0 +1,20 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.SystemAggregate.Entities; + +[SugarTable("t_notice")] +public class Notice : OmsAuditedAggregateRoot +{ + public string NoticeType { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string Content { get; set; } = string.Empty; + + public byte IsPopup { get; set; } + + public string ReceiverType { get; set; } = string.Empty; + + public string ReceiverIds { get; set; } = "[]"; +} diff --git a/src/Oms.Domain/SystemAggregate/Entities/NoticeRead.cs b/src/Oms.Domain/SystemAggregate/Entities/NoticeRead.cs new file mode 100644 index 0000000..7a35d41 --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Entities/NoticeRead.cs @@ -0,0 +1,16 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.SystemAggregate.Entities; + +[SugarTable("t_notice_read")] +public class NoticeRead : OmsAuditedAggregateRoot +{ + public long NoticeId { get; set; } + + public string ReceiverType { get; set; } = string.Empty; + + public long ReceiverId { get; set; } + + public DateTime ReadTime { get; set; } +} diff --git a/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs new file mode 100644 index 0000000..3ca1add --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs @@ -0,0 +1,9 @@ +using Fake.Domain.Repositories; +using Oms.Domain.SystemAggregate.Entities; + +namespace Oms.Domain.SystemAggregate.Repositories; + +public interface ICountryRepository : IRepository +{ + Task GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/SystemAggregate/Repositories/IDictRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/IDictRepository.cs new file mode 100644 index 0000000..9384d44 --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Repositories/IDictRepository.cs @@ -0,0 +1,9 @@ +using Fake.Domain.Repositories; +using Oms.Domain.SystemAggregate.Entities; + +namespace Oms.Domain.SystemAggregate.Repositories; + +public interface IDictRepository : IRepository +{ + Task GetByTypeAndCodeAsync(string dictType, string dictCode, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/SystemAggregate/Repositories/INoticeReadRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/INoticeReadRepository.cs new file mode 100644 index 0000000..d8b1a0f --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Repositories/INoticeReadRepository.cs @@ -0,0 +1,9 @@ +using Fake.Domain.Repositories; +using Oms.Domain.SystemAggregate.Entities; + +namespace Oms.Domain.SystemAggregate.Repositories; + +public interface INoticeReadRepository : IRepository +{ + Task GetByNoticeReceiverAsync(long noticeId, string receiverType, long receiverId, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/SystemAggregate/Repositories/INoticeRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/INoticeRepository.cs new file mode 100644 index 0000000..b8d02c9 --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Repositories/INoticeRepository.cs @@ -0,0 +1,8 @@ +using Fake.Domain.Repositories; +using Oms.Domain.SystemAggregate.Entities; + +namespace Oms.Domain.SystemAggregate.Repositories; + +public interface INoticeRepository : IRepository +{ +} diff --git a/src/Oms.SqlSugarCore/Channels/ServiceChannelGroupRepository.cs b/src/Oms.SqlSugarCore/Channels/ServiceChannelGroupRepository.cs new file mode 100644 index 0000000..22c5b78 --- /dev/null +++ b/src/Oms.SqlSugarCore/Channels/ServiceChannelGroupRepository.cs @@ -0,0 +1,14 @@ +using Fake.DependencyInjection; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Channels; + +public class ServiceChannelGroupRepository : OmsSqlSugarRepository, IServiceChannelGroupRepository, IScopedDependency +{ + public Task GetByNameAsync(string name, CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.Name == name, cancellationToken); + } +} diff --git a/src/Oms.SqlSugarCore/Customers/CustomerSubAccountRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerSubAccountRepository.cs index bc2efaa..4015307 100644 --- a/src/Oms.SqlSugarCore/Customers/CustomerSubAccountRepository.cs +++ b/src/Oms.SqlSugarCore/Customers/CustomerSubAccountRepository.cs @@ -17,6 +17,11 @@ public class CustomerSubAccountRepository : OmsSqlSugarRepository x.SubPhone == subPhone, cancellationToken); } + public Task GetBySubEmailAsync(string subEmail, CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.SubEmail == subEmail, cancellationToken); + } + public Task ExistsSubPhoneAsync(string subPhone, CancellationToken cancellationToken = default) { return AnyAsync(x => x.SubPhone == subPhone, cancellationToken); diff --git a/src/Oms.SqlSugarCore/System/CountryRepository.cs b/src/Oms.SqlSugarCore/System/CountryRepository.cs new file mode 100644 index 0000000..988c6ac --- /dev/null +++ b/src/Oms.SqlSugarCore/System/CountryRepository.cs @@ -0,0 +1,14 @@ +using Fake.DependencyInjection; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.System; + +public class CountryRepository : OmsSqlSugarRepository, ICountryRepository, IScopedDependency +{ + public Task GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.CountryCode == countryCode, cancellationToken); + } +} diff --git a/src/Oms.SqlSugarCore/System/DictRepository.cs b/src/Oms.SqlSugarCore/System/DictRepository.cs new file mode 100644 index 0000000..fd69ef8 --- /dev/null +++ b/src/Oms.SqlSugarCore/System/DictRepository.cs @@ -0,0 +1,14 @@ +using Fake.DependencyInjection; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.System; + +public class DictRepository : OmsSqlSugarRepository, IDictRepository, IScopedDependency +{ + public Task GetByTypeAndCodeAsync(string dictType, string dictCode, CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.DictType == dictType && x.DictCode == dictCode, cancellationToken); + } +} diff --git a/src/Oms.SqlSugarCore/System/NoticeReadRepository.cs b/src/Oms.SqlSugarCore/System/NoticeReadRepository.cs new file mode 100644 index 0000000..fd13b7f --- /dev/null +++ b/src/Oms.SqlSugarCore/System/NoticeReadRepository.cs @@ -0,0 +1,16 @@ +using Fake.DependencyInjection; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.System; + +public class NoticeReadRepository : OmsSqlSugarRepository, INoticeReadRepository, IScopedDependency +{ + public Task GetByNoticeReceiverAsync(long noticeId, string receiverType, long receiverId, CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync( + x => x.NoticeId == noticeId && x.ReceiverType == receiverType && x.ReceiverId == receiverId, + cancellationToken); + } +} diff --git a/src/Oms.SqlSugarCore/System/NoticeRepository.cs b/src/Oms.SqlSugarCore/System/NoticeRepository.cs new file mode 100644 index 0000000..4eac3c9 --- /dev/null +++ b/src/Oms.SqlSugarCore/System/NoticeRepository.cs @@ -0,0 +1,10 @@ +using Fake.DependencyInjection; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.System; + +public class NoticeRepository : OmsSqlSugarRepository, INoticeRepository, IScopedDependency +{ +} diff --git a/src/Oms.Tests/GlobalUsings.cs b/src/Oms.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/src/Oms.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/src/Oms.Tests/Oms.Tests.csproj b/src/Oms.Tests/Oms.Tests.csproj new file mode 100644 index 0000000..07d7250 --- /dev/null +++ b/src/Oms.Tests/Oms.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/src/Oms.Tests/OmsDocHelperTests.cs b/src/Oms.Tests/OmsDocHelperTests.cs new file mode 100644 index 0000000..c74b36a --- /dev/null +++ b/src/Oms.Tests/OmsDocHelperTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using FluentAssertions; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.SystemAggregate.Consts; + +namespace Oms.Tests; + +public class OmsDocHelperTests +{ + [Theory] + [InlineData(0, 1)] + [InlineData(-3, 1)] + [InlineData(5, 5)] + public void NormalizePage_ShouldClampInvalidValuesToOne(int page, int expectedPage) + { + var normalizedPage = OmsDocHelper.NormalizePage(page); + + normalizedPage.Should().Be(expectedPage); + } + + [Theory] + [InlineData(0, 20)] + [InlineData(-1, 20)] + [InlineData(50, 50)] + [InlineData(500, 200)] + public void NormalizePageSize_ShouldUseExpectedBounds(int pageSize, int expectedPageSize) + { + var normalizedPageSize = OmsDocHelper.NormalizePageSize(pageSize); + + normalizedPageSize.Should().Be(expectedPageSize); + } + + [Fact] + public void NullIfDefault_ShouldReturnNullForDefaultDateTime() + { + var value = OmsDocHelper.NullIfDefault(default); + + value.Should().BeNull(); + } + + [Fact] + public void SerializeJson_AndDeserializeJson_ShouldRoundTripObject() + { + var payload = new Dictionary + { + ["customerCode"] = "C20260428", + ["amount"] = 128.5m + }; + + var json = OmsDocHelper.SerializeJson(payload); + var result = OmsDocHelper.DeserializeJson>(json); + + result.Should().NotBeNull(); + result!["customerCode"].GetString().Should().Be("C20260428"); + result["amount"].GetDecimal().Should().Be(128.5m); + } + + [Fact] + public void DeserializeStringList_ShouldReturnEmptyListWhenJsonMissing() + { + var items = OmsDocHelper.DeserializeStringList(null); + + items.Should().BeEmpty(); + } + + [Fact] + public void GetOrderStatusName_ShouldReturnConfiguredName() + { + var statusName = OmsDocHelper.GetOrderStatusName(OrderStatusCodes.PendingShip); + + statusName.Should().Be("待发货"); + } + + [Fact] + public void GetOrderStatusName_ShouldReturnOriginalCodeWhenNotMapped() + { + var statusName = OmsDocHelper.GetOrderStatusName("customStatus"); + + statusName.Should().Be("customStatus"); + } + + [Fact] + public void GetWarehouseTypeName_ShouldReturnConfiguredName() + { + var typeName = OmsDocHelper.GetWarehouseTypeName(WarehouseTypeCodes.Transit); + + typeName.Should().Be("中转仓"); + } +} diff --git a/src/Oms.Tests/OmsPasswordHelperTests.cs b/src/Oms.Tests/OmsPasswordHelperTests.cs new file mode 100644 index 0000000..54b47e7 --- /dev/null +++ b/src/Oms.Tests/OmsPasswordHelperTests.cs @@ -0,0 +1,24 @@ +using FluentAssertions; +using Oms.Application.Shared; + +namespace Oms.Tests; + +public class OmsPasswordHelperTests +{ + [Fact] + public void ToMd5_ShouldReturnExpectedLowercaseHash() + { + var hash = OmsPasswordHelper.ToMd5("123456"); + + hash.Should().Be("e10adc3949ba59abbe56e057f20f883e"); + } + + [Fact] + public void ToMd5_ShouldBeDeterministicForSameInput() + { + var firstHash = OmsPasswordHelper.ToMd5("Nanafa@2026"); + var secondHash = OmsPasswordHelper.ToMd5("Nanafa@2026"); + + firstHash.Should().Be(secondHash); + } +} diff --git a/src/Oms.Tests/SimpleCurrentUserTests.cs b/src/Oms.Tests/SimpleCurrentUserTests.cs new file mode 100644 index 0000000..786a1ce --- /dev/null +++ b/src/Oms.Tests/SimpleCurrentUserTests.cs @@ -0,0 +1,73 @@ +using System.Security.Claims; +using Fake.Security.Claims; +using FluentAssertions; +using Oms.Tests.Support; +using SimpleAdmin.Api; + +namespace Oms.Tests; + +public class SimpleCurrentUserTests +{ + [Fact] + public void GetId_ShouldPreferNumericUserIdClaim() + { + var currentUser = CreateCurrentUser( + [ + new Claim(FakeClaimTypes.UserId, "42"), + new Claim("mapId", "99"), + new Claim("customerId", "100") + ]); + + currentUser.GetId().Should().Be(42); + currentUser.GetId().Should().Be(42); + } + + [Fact] + public void GetId_ShouldFallBackToMapIdWhenUserIdIsGuid() + { + var currentUser = CreateCurrentUser( + [ + new Claim(FakeClaimTypes.UserId, Guid.NewGuid().ToString()), + new Claim("mapId", "88") + ]); + + currentUser.GetId().Should().Be(88); + currentUser.GetId().Should().Be(88); + } + + [Fact] + public void GetId_ShouldFallBackToCustomerIdWhenMapIdMissing() + { + var currentUser = CreateCurrentUser( + [ + new Claim(FakeClaimTypes.UserId, Guid.NewGuid().ToString()), + new Claim("customerId", "77") + ]); + + currentUser.GetId().Should().Be(77); + } + + [Fact] + public void Id_ShouldKeepGuidIdentitySemantics() + { + var guid = Guid.NewGuid(); + var currentUser = CreateCurrentUser([new Claim(FakeClaimTypes.UserId, guid.ToString())]); + + currentUser.Id.Should().Be(guid); + } + + [Fact] + public void GetId_ShouldReturnZeroWhenNoNumericClaimsExist() + { + var currentUser = CreateCurrentUser([new Claim(FakeClaimTypes.UserId, Guid.NewGuid().ToString())]); + + currentUser.GetId().Should().Be(0); + } + + private static SimpleCurrentUser CreateCurrentUser(params Claim[] claims) + { + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "Test")); + var principalAccessor = new TestCurrentPrincipalAccessor(principal); + return new SimpleCurrentUser(principalAccessor); + } +} diff --git a/src/Oms.Tests/Support/TestCurrentPrincipalAccessor.cs b/src/Oms.Tests/Support/TestCurrentPrincipalAccessor.cs new file mode 100644 index 0000000..f8c1278 --- /dev/null +++ b/src/Oms.Tests/Support/TestCurrentPrincipalAccessor.cs @@ -0,0 +1,24 @@ +using System.Security.Claims; +using Fake.Security.Claims; + +namespace Oms.Tests.Support; + +internal sealed class TestCurrentPrincipalAccessor(ClaimsPrincipal principal) : ICurrentPrincipalAccessor +{ + public ClaimsPrincipal Principal { get; private set; } = principal; + + public IDisposable Change(ClaimsPrincipal principal) + { + var originalPrincipal = Principal; + Principal = principal; + return new RestorePrincipalScope(() => Principal = originalPrincipal); + } + + private sealed class RestorePrincipalScope(Action restore) : IDisposable + { + public void Dispose() + { + restore(); + } + } +} diff --git a/src/SimpleAdmin.Api.IntegrationTests/AuthApiIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/AuthApiIntegrationTests.cs new file mode 100644 index 0000000..21d0194 --- /dev/null +++ b/src/SimpleAdmin.Api.IntegrationTests/AuthApiIntegrationTests.cs @@ -0,0 +1,18 @@ +using System.Net; +using FluentAssertions; +using SimpleAdmin.Api.IntegrationTests.Support; + +namespace SimpleAdmin.Api.IntegrationTests; + +public class AuthApiIntegrationTests +{ + [Fact] + public async Task GetCurrentUser_ShouldReturnUnauthorizedWhenRequestHasNoToken() + { + await using var host = await SimpleAdminApiTestHost.CreateAsync(); + + var response = await host.Client.GetAsync("/rbac/auth/get-current-user"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} diff --git a/src/SimpleAdmin.Api.IntegrationTests/CountryApiIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/CountryApiIntegrationTests.cs new file mode 100644 index 0000000..61595a0 --- /dev/null +++ b/src/SimpleAdmin.Api.IntegrationTests/CountryApiIntegrationTests.cs @@ -0,0 +1,38 @@ +using System.Net; +using FluentAssertions; +using Moq; +using Oms.Application.Dtos.System; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; +using SimpleAdmin.Api.IntegrationTests.Support; + +namespace SimpleAdmin.Api.IntegrationTests; + +public class CountryApiIntegrationTests +{ + [Fact] + public async Task GetCountryList_ShouldReturnOrderedItemsFromRepository() + { + var repository = new Mock(); + repository + .Setup(x => x.GetListAsync( + It.IsAny>>(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync([ + new Country { Id = 2, CountryCode = "US", EnglishName = "United States", ChineseName = "美国" }, + new Country { Id = 1, CountryCode = "AU", EnglishName = "Australia", ChineseName = "澳大利亚" }, + new Country { Id = 3, CountryCode = "US", EnglishName = "America", ChineseName = "美利坚" } + ]); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(repository.Object); + + var response = await host.Client.GetAsync("/system/country/list"); + + response.StatusCode.Should().Be(HttpStatusCode.OK, string.Join(" | ", host.RoutePatterns)); + var items = await response.Content.ReadFromJsonAsync>(); + items.Should().NotBeNull(); + items!.Select(x => x.CountryCode).Should().ContainInOrder("AU", "US", "US"); + items.Select(x => x.EnglishName).Should().ContainInOrder("Australia", "America", "United States"); + } +} diff --git a/src/SimpleAdmin.Api.IntegrationTests/CustomerApiIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/CustomerApiIntegrationTests.cs new file mode 100644 index 0000000..a9d44c0 --- /dev/null +++ b/src/SimpleAdmin.Api.IntegrationTests/CustomerApiIntegrationTests.cs @@ -0,0 +1,286 @@ +using System.Net; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; +using Oms.Application.Dtos.Customers; +using Oms.Application.Dtos.Shared; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.CustomersAggregate.Enums; +using Oms.Domain.CustomersAggregate.Repositories; +using SimpleAdmin.Api.IntegrationTests.Support; + +namespace SimpleAdmin.Api.IntegrationTests; + +public class CustomerApiIntegrationTests +{ + [Fact] + public async Task GetCustomerList_ShouldReturnPagedItems() + { + var repository = new Mock(MockBehavior.Strict); + repository + .Setup(x => x.GetPagedListAsync( + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync([ + new Customer + { + Id = 1001, + CustomerCode = "CUST-001", + CustomerName = "Alpha Trade", + Phone = "13800138000", + Email = "alpha@nanafa.com", + Balance = 1200.50m, + TotalQuota = 5000m, + UsedQuota = 800m, + Status = CustomerStatus.Normal, + CustomerGroup = CustomerGroup.Vip1, + Role = "Customer", + Salesman = "sales-a", + CustomerService = "service-a", + CreateTime = new DateTime(2026, 4, 29, 9, 0, 0), + UpdateTime = new DateTime(2026, 4, 29, 12, 0, 0) + } + ]); + repository + .Setup(x => x.CountAsync( + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(1); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + services.RemoveAll(); + services.AddSingleton(repository.Object); + }); + + var response = await host.Client.GetAsync("/customer/list?Page=1&PageSize=20&Keyword=Alpha"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync>(); + payload.Should().NotBeNull(); + payload!.Total.Should().Be(1); + payload.Items.Should().ContainSingle(); + payload.Items[0].CustomerCode.Should().Be("CUST-001"); + payload.Items[0].CustomerName.Should().Be("Alpha Trade"); + payload.Items[0].Balance.Should().Be(1200.50m); + } + + [Fact] + public async Task GetCustomerDetail_ShouldReturnMappedAuditFields() + { + var repository = new Mock(MockBehavior.Strict); + repository + .Setup(x => x.FirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(new Customer + { + Id = 2002, + CustomerCode = "CUST-002", + CustomerName = "Beta Trade", + Phone = "13900139000", + Email = "beta@nanafa.com", + Balance = 100m, + TotalQuota = 2000m, + UsedQuota = 300m, + Status = CustomerStatus.Normal, + Role = "Customer", + CreateTime = new DateTime(2026, 4, 28, 9, 0, 0), + CreateUserId = 9, + UpdateTime = new DateTime(2026, 4, 28, 10, 30, 0), + UpdateUserId = 18 + }); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + services.RemoveAll(); + services.AddSingleton(repository.Object); + }); + + var response = await host.Client.GetAsync("/customer/get?id=2002"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.CustomerName.Should().Be("Beta Trade"); + payload.CreateBy.Should().Be("9"); + payload.UpdateBy.Should().Be("18"); + } + + [Fact] + public async Task CreateCustomer_ShouldHashPasswordAndPersistBusinessFields() + { + Customer? insertedEntity = null; + var repository = new Mock(MockBehavior.Strict); + repository + .Setup(x => x.GetByCustomerCodeAsync("CUST-NEW", It.IsAny())) + .ReturnsAsync((Customer?)null); + repository + .Setup(x => x.GetByPhoneAsync("13700137000", It.IsAny())) + .ReturnsAsync((Customer?)null); + repository + .Setup(x => x.GetByEmailAsync("new@nanafa.com", It.IsAny())) + .ReturnsAsync((Customer?)null); + repository + .Setup(x => x.InsertAsync(It.IsAny(), It.IsAny())) + .Callback((entity, _) => insertedEntity = entity) + .ReturnsAsync((Customer entity, CancellationToken _) => entity); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + services.RemoveAll(); + services.AddSingleton(repository.Object); + }); + + var response = await host.Client.PostAsJsonAsync("/customer/create", new CreateCustomerInput + { + CustomerCode = "CUST-NEW", + CustomerName = "New Customer", + Phone = "13700137000", + Email = "new@nanafa.com", + Password = "P@ssw0rd!", + Balance = 88.5m, + TotalQuota = 1000m, + UsedQuota = 100m, + Status = CustomerStatus.Normal, + Salesman = "sales-new", + CustomerService = "service-new", + CustomerGroup = CustomerGroup.Vip1, + Role = "Customer", + Remark = "integration-test", + WechatAvatar = "https://cdn.example.com/avatar.png", + WechatNickname = "newbie" + }); + + response.IsSuccessStatusCode.Should().BeTrue(); + insertedEntity.Should().NotBeNull(); + insertedEntity!.CustomerCode.Should().Be("CUST-NEW"); + insertedEntity.CustomerName.Should().Be("New Customer"); + insertedEntity.Password.Should().Be(OmsPasswordHelper.ToMd5("P@ssw0rd!")); + insertedEntity.Balance.Should().Be(88.5m); + insertedEntity.UsedQuota.Should().Be(100m); + insertedEntity.Role.Should().Be("Customer"); + } + + [Fact] + public async Task UpdateCustomer_ShouldPersistMutatedEntity() + { + Customer? updatedEntity = null; + var currentEntity = new Customer + { + Id = 3003, + CustomerCode = "CUST-OLD", + CustomerName = "Old Customer", + Phone = "13600136000", + Email = "old@nanafa.com", + Password = OmsPasswordHelper.ToMd5("old-password"), + Balance = 10m, + TotalQuota = 100m, + UsedQuota = 20m, + Status = CustomerStatus.Disabled, + Role = "Customer" + }; + + var repository = new Mock(MockBehavior.Strict); + repository + .Setup(x => x.FirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(currentEntity); + repository + .Setup(x => x.GetByCustomerCodeAsync("CUST-UPD", It.IsAny())) + .ReturnsAsync((Customer?)null); + repository + .Setup(x => x.GetByPhoneAsync("13500135000", It.IsAny())) + .ReturnsAsync((Customer?)null); + repository + .Setup(x => x.GetByEmailAsync("updated@nanafa.com", It.IsAny())) + .ReturnsAsync((Customer?)null); + repository + .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny())) + .Callback((entity, _) => updatedEntity = entity) + .Returns(Task.CompletedTask); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + services.RemoveAll(); + services.AddSingleton(repository.Object); + }); + + var response = await host.Client.PostAsJsonAsync("/customer/update", new UpdateCustomerInput + { + Id = 3003, + CustomerCode = "CUST-UPD", + CustomerName = "Updated Customer", + Phone = "13500135000", + Email = "updated@nanafa.com", + Balance = 520.75m, + TotalQuota = 8000m, + UsedQuota = 1500m, + Status = CustomerStatus.Normal, + Salesman = "sales-updated", + CustomerService = "service-updated", + CustomerGroup = CustomerGroup.Normal, + Role = "VipCustomer", + Remark = "updated by integration test", + WechatAvatar = "https://cdn.example.com/new-avatar.png", + WechatNickname = "updated-wechat" + }); + + response.IsSuccessStatusCode.Should().BeTrue(); + updatedEntity.Should().NotBeNull(); + updatedEntity!.CustomerCode.Should().Be("CUST-UPD"); + updatedEntity.CustomerName.Should().Be("Updated Customer"); + updatedEntity.Phone.Should().Be("13500135000"); + updatedEntity.Balance.Should().Be(520.75m); + updatedEntity.Role.Should().Be("VipCustomer"); + updatedEntity.Password.Should().Be(OmsPasswordHelper.ToMd5("old-password")); + } + + [Fact] + public async Task ResetPassword_ShouldHashNewPasswordBeforeSaving() + { + Customer? updatedEntity = null; + var currentEntity = new Customer + { + Id = 4004, + CustomerCode = "CUST-PWD", + CustomerName = "Password Customer", + Phone = "13400134000", + Password = OmsPasswordHelper.ToMd5("before-reset"), + Status = CustomerStatus.Normal + }; + + var repository = new Mock(MockBehavior.Strict); + repository + .Setup(x => x.FirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(currentEntity); + repository + .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny())) + .Callback((entity, _) => updatedEntity = entity) + .Returns(Task.CompletedTask); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + services.RemoveAll(); + services.AddSingleton(repository.Object); + }); + + var response = await host.Client.PostAsJsonAsync("/customer/reset-password", new ResetCustomerPasswordInput + { + Id = 4004, + NewPassword = "after-reset" + }); + + response.IsSuccessStatusCode.Should().BeTrue(); + updatedEntity.Should().NotBeNull(); + updatedEntity!.Password.Should().Be(OmsPasswordHelper.ToMd5("after-reset")); + } +} diff --git a/src/SimpleAdmin.Api.IntegrationTests/DictApiIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/DictApiIntegrationTests.cs new file mode 100644 index 0000000..e31141c --- /dev/null +++ b/src/SimpleAdmin.Api.IntegrationTests/DictApiIntegrationTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using FluentAssertions; +using Moq; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.System; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; +using SimpleAdmin.Api.IntegrationTests.Support; + +namespace SimpleAdmin.Api.IntegrationTests; + +public class DictApiIntegrationTests +{ + [Fact] + public async Task GetDictList_ShouldReturnPagedResultFromRepository() + { + var repository = new Mock(); + repository + .Setup(x => x.GetPagedListAsync( + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync([ + new Dict + { + Id = 9, + DictType = "orderStatus", + DictSort = 1, + DictCode = "draft", + DictName = "草稿", + CreateTime = new DateTime(2026, 4, 28, 10, 0, 0) + } + ]); + repository + .Setup(x => x.CountAsync( + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(1); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(dictRepository: repository.Object); + + var response = await host.Client.GetAsync("/system/dict/list?Page=1&PageSize=20"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync>(); + payload.Should().NotBeNull(); + payload!.Total.Should().Be(1); + payload.Items.Should().ContainSingle(); + payload.Items[0].DictCode.Should().Be("draft"); + payload.Items[0].DictName.Should().Be("草稿"); + } +} diff --git a/src/SimpleAdmin.Api.IntegrationTests/GlobalUsings.cs b/src/SimpleAdmin.Api.IntegrationTests/GlobalUsings.cs new file mode 100644 index 0000000..ccd2032 --- /dev/null +++ b/src/SimpleAdmin.Api.IntegrationTests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Xunit; +global using System.Net.Http.Json; diff --git a/src/SimpleAdmin.Api.IntegrationTests/SimpleAdmin.Api.IntegrationTests.csproj b/src/SimpleAdmin.Api.IntegrationTests/SimpleAdmin.Api.IntegrationTests.csproj new file mode 100644 index 0000000..3648313 --- /dev/null +++ b/src/SimpleAdmin.Api.IntegrationTests/SimpleAdmin.Api.IntegrationTests.csproj @@ -0,0 +1,38 @@ + + + + net8.0 + enable + enable + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/SimpleAdmin.Api.IntegrationTests/Support/SimpleAdminApiTestHost.cs b/src/SimpleAdmin.Api.IntegrationTests/Support/SimpleAdminApiTestHost.cs new file mode 100644 index 0000000..b09794b --- /dev/null +++ b/src/SimpleAdmin.Api.IntegrationTests/Support/SimpleAdminApiTestHost.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Fake.Data.Seeding; +using Fake.Rbac.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Oms.Domain.SystemAggregate.Repositories; +using SimpleAdmin.Api; + +namespace SimpleAdmin.Api.IntegrationTests.Support; + +internal sealed class SimpleAdminApiTestHost : IAsyncDisposable +{ + private readonly WebApplication _app; + + private SimpleAdminApiTestHost(WebApplication app) + { + _app = app; + Client = app.GetTestClient(); + } + + public HttpClient Client { get; } + + public IReadOnlyList RoutePatterns => + _app.Services.GetRequiredService() + .Endpoints + .OfType() + .Select(x => x.RoutePattern.RawText ?? string.Empty) + .OrderBy(x => x) + .ToList(); + + public static async Task CreateAsync( + ICountryRepository? countryRepository = null, + IDictRepository? dictRepository = null, + Action? configureServices = null) + { + var builder = SimpleAdminApiBootstrap.CreateBuilder([], webAppBuilder => + { + webAppBuilder.WebHost.UseTestServer(); + webAppBuilder.Configuration.AddInMemoryCollection(new Dictionary + { + ["UseLocalConfigs"] = "true", + ["ConnectionStrings:Default"] = "Server=127.0.0.1;Port=3306;Database=oms_integration_test;Uid=test;Pwd=test;", + ["App:SendStartupNotification"] = "false", + ["App:CorsOrigins:0"] = "http://localhost", + ["App:Urls:0"] = "http://127.0.0.1:5005", + ["Jwt:SecretKey"] = "simple-admin-integration-test-secret-key", + ["Jwt:Issuer"] = "SimpleAdmin.Api.IntegrationTests", + ["Jwt:Audience"] = "SimpleAdmin.Api.IntegrationTests", + ["Jwt:ExpiryInMinutes"] = "60", + ["Jwt:RefreshExpiryInDays"] = "7", + ["Jwt:ClockSkewMinutes"] = "5", + ["JwtSettings:SecretKey"] = "simple-admin-integration-test-secret-key", + ["JwtSettings:Issuer"] = "SimpleAdmin.Api.IntegrationTests", + ["JwtSettings:Audience"] = "SimpleAdmin.Api.IntegrationTests", + ["JwtSettings:ExpiryInMinutes"] = "60", + ["JwtSettings:RefreshExpiryInDays"] = "7", + ["JwtSettings:ClockSkewMinutes"] = "5", + ["FeiShu:Webhook"] = "https://example.com/webhook", + ["FeiShu:EnableFeiShuExceptionSubscribe"] = "false", + ["FeiShuNotice:Webhook"] = "https://example.com/webhook", + ["FeiShuNotice:EnableFeiShuExceptionSubscribe"] = "false" + }); + }, environmentName: "Testing"); + + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll>(); + builder.Services.AddDbContext(options => + { + options.UseInMemoryDatabase($"rbac-integration-{Guid.NewGuid():N}"); + }); + + if (countryRepository != null) + { + ReplaceSingleton(builder.Services, countryRepository); + } + + if (dictRepository != null) + { + ReplaceSingleton(builder.Services, dictRepository); + } + + configureServices?.Invoke(builder.Services); + + var app = SimpleAdminApiBootstrap.BuildApplication(builder); + await app.StartAsync(); + return new SimpleAdminApiTestHost(app); + } + + private static void ReplaceSingleton(IServiceCollection services, TService implementation) + where TService : class + { + services.RemoveAll(); + services.AddSingleton(implementation); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + } +} diff --git a/src/SimpleAdmin.Api.IntegrationTests/WarehouseApiIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/WarehouseApiIntegrationTests.cs new file mode 100644 index 0000000..9ed5e56 --- /dev/null +++ b/src/SimpleAdmin.Api.IntegrationTests/WarehouseApiIntegrationTests.cs @@ -0,0 +1,203 @@ +using System.Net; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.System; +using Oms.Domain.SystemAggregate.Consts; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; +using SimpleAdmin.Api.IntegrationTests.Support; + +namespace SimpleAdmin.Api.IntegrationTests; + +public class WarehouseApiIntegrationTests +{ + [Fact] + public async Task GetWarehouseList_ShouldReturnPagedItemsWithMappedFields() + { + var repository = new Mock(MockBehavior.Strict); + repository + .Setup(x => x.GetPagedListAsync( + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync([ + new WarehouseAddress + { + Id = 12, + WarehouseName = "UK Transit", + WarehouseType = WarehouseTypeCodes.Transit, + Recipient = "Alice", + ContactPhone = "13800138000", + ShippingAddress = "London", + ServicePhone = "020-123456", + ServiceWechat = "oms-warehouse", + ServiceWechatFiles = "[\"wx-1.png\",\"wx-2.png\"]", + CreateTime = new DateTime(2026, 4, 29, 9, 0, 0), + UpdateTime = new DateTime(2026, 4, 29, 10, 0, 0) + } + ]); + repository + .Setup(x => x.CountAsync( + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(1); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + services.RemoveAll(); + services.AddSingleton(repository.Object); + }); + + var response = await host.Client.GetAsync("/system/warehouse/list?Page=1&PageSize=20&WarehouseType=transit&Keyword=UK"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync>(); + payload.Should().NotBeNull(); + payload!.Total.Should().Be(1); + payload.Items.Should().ContainSingle(); + payload.Items[0].WarehouseTypeName.Should().Be("中转仓"); + payload.Items[0].ServiceWechatFiles.Should().ContainInOrder("wx-1.png", "wx-2.png"); + } + + [Fact] + public async Task GetWarehouseDetail_ShouldReturnMappedAuditAndFileFields() + { + var repository = new Mock(MockBehavior.Strict); + repository + .Setup(x => x.FirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(new WarehouseAddress + { + Id = 8, + WarehouseName = "Main Hub", + WarehouseType = WarehouseTypeCodes.Main, + Recipient = "Bob", + ContactPhone = "13900139000", + ShippingAddress = "Shenzhen", + ServicePhone = "0755-123456", + ServiceWechat = "main-hub", + ServiceWechatFiles = "[\"main.png\"]", + CreateTime = new DateTime(2026, 4, 20, 9, 30, 0), + CreateUserId = 66, + UpdateTime = new DateTime(2026, 4, 21, 14, 0, 0), + UpdateUserId = 88 + }); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + services.RemoveAll(); + services.AddSingleton(repository.Object); + }); + + var response = await host.Client.GetAsync("/system/warehouse/detail?id=8"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.CreateBy.Should().Be("66"); + payload.UpdateBy.Should().Be("88"); + payload.ServiceWechatFiles.Should().ContainSingle().Which.Should().Be("main.png"); + } + + [Fact] + public async Task CreateWarehouse_ShouldSerializeFilesBeforePersisting() + { + WarehouseAddress? insertedEntity = null; + var repository = new Mock(MockBehavior.Strict); + repository + .Setup(x => x.GetByWarehouseNameAsync("New Warehouse", It.IsAny())) + .ReturnsAsync((WarehouseAddress?)null); + repository + .Setup(x => x.InsertAsync(It.IsAny(), It.IsAny())) + .Callback((entity, _) => insertedEntity = entity) + .ReturnsAsync((WarehouseAddress entity, CancellationToken _) => entity); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + services.RemoveAll(); + services.AddSingleton(repository.Object); + }); + + var response = await host.Client.PostAsJsonAsync("/system/warehouse/create", new CreateWarehouseInput + { + WarehouseName = "New Warehouse", + WarehouseType = WarehouseTypeCodes.Return, + Recipient = "Cindy", + ContactPhone = "13700137000", + ShippingAddress = "Guangzhou", + ServicePhone = "020-888888", + ServiceWechat = "return-hub", + ServiceWechatFiles = ["qr-a.png", "qr-b.png"] + }); + + response.IsSuccessStatusCode.Should().BeTrue(); + insertedEntity.Should().NotBeNull(); + insertedEntity!.WarehouseName.Should().Be("New Warehouse"); + insertedEntity.WarehouseType.Should().Be(WarehouseTypeCodes.Return); + insertedEntity.ServiceWechatFiles.Should().Be("[\"qr-a.png\",\"qr-b.png\"]"); + } + + [Fact] + public async Task UpdateWarehouse_ShouldPersistMutatedEntity() + { + WarehouseAddress? updatedEntity = null; + var currentEntity = new WarehouseAddress + { + Id = 18, + WarehouseName = "Old Warehouse", + WarehouseType = WarehouseTypeCodes.Other, + Recipient = "Old", + ContactPhone = "13600136000", + ShippingAddress = "Old Address", + ServicePhone = "010-111111", + ServiceWechat = "old-wechat", + ServiceWechatFiles = "[\"old.png\"]" + }; + + var repository = new Mock(MockBehavior.Strict); + repository + .Setup(x => x.FirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(currentEntity); + repository + .Setup(x => x.GetByWarehouseNameAsync("Updated Warehouse", It.IsAny())) + .ReturnsAsync((WarehouseAddress?)null); + repository + .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny())) + .Callback((entity, _) => updatedEntity = entity) + .Returns(Task.CompletedTask); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + services.RemoveAll(); + services.AddSingleton(repository.Object); + }); + + var response = await host.Client.PostAsJsonAsync("/system/warehouse/update", new UpdateWarehouseInput + { + Id = 18, + WarehouseName = "Updated Warehouse", + WarehouseType = WarehouseTypeCodes.Main, + Recipient = "Daisy", + ContactPhone = "13500135000", + ShippingAddress = "New Address", + ServicePhone = "0755-999999", + ServiceWechat = "new-wechat", + ServiceWechatFiles = ["new-a.png", "new-b.png"] + }); + + response.IsSuccessStatusCode.Should().BeTrue(); + updatedEntity.Should().NotBeNull(); + updatedEntity!.WarehouseName.Should().Be("Updated Warehouse"); + updatedEntity.WarehouseType.Should().Be(WarehouseTypeCodes.Main); + updatedEntity.Recipient.Should().Be("Daisy"); + updatedEntity.ServiceWechatFiles.Should().Be("[\"new-a.png\",\"new-b.png\"]"); + } +} diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs new file mode 100644 index 0000000..d806a39 --- /dev/null +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -0,0 +1,723 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Fake.Application; +using Fake.Domain.Exceptions; +using Fake.Helpers; +using Fake.Rbac.Application.Dtos.Auth; +using Fake.Rbac.Application.Dtos.Menu; +using Fake.Rbac.Application.Dtos.Role; +using Fake.Rbac.Application.Dtos.User; +using Fake.Rbac.Application.Jwt; +using Fake.Rbac.Application.Services; +using Fake.Rbac.Domain.Managers; +using Fake.Rbac.Domain.MenuAggregate; +using Fake.Rbac.Domain.RoleAggregate; +using Fake.Rbac.Domain.UserAggregate; +using Fake.Rbac.Infrastructure; +using Fake.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +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 Oms.Domain.Shared.Enums; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; + +namespace SimpleAdmin.Api.Auth; + +/// +/// 接管 OMS 与 RBAC 共用的认证接口,补充客户登录和业务主体 claims 解析。 +/// +[Authorize] +[ApiExplorerSettings(GroupName = "RBAC")] +public class AuthService( + AccountManager accountManager, + IUserService userService, + IMenuService menuService, + IJwtService jwtService, + IUserRepository userRepository, + IWebHostEnvironment webHostEnvironment, + IHttpContextAccessor httpContextAccessor, + FakeRbacDbContext rbacDbContext, + ICustomerRepository customerRepository, + ICustomerSubAccountRepository customerSubAccountRepository) + : ApplicationService +{ + private const int AdminLoginType = 0; + private const int CustomerLoginType = 1; + private const string DefaultCustomerRoleCode = "Customer"; + + [AllowAnonymous] + [HttpPost("/rbac/auth/login")] + public async Task LoginAsync(string account, string password, int type = AdminLoginType, CancellationToken cancellationToken = default) + { + return type switch + { + AdminLoginType => await LoginAdminAsync(account, password, cancellationToken), + CustomerLoginType => await LoginCustomerAsync(account, password, cancellationToken), + _ => throw new BusinessException("不支持的登录类型") + }; + } + + [AllowAnonymous] + [HttpPost("/rbac/auth/refresh-token")] + public async Task RefreshTokenAsync(string refreshToken, CancellationToken cancellationToken = default) + { + var userIdText = jwtService.ValidateRefreshToken(refreshToken); + if (string.IsNullOrWhiteSpace(userIdText)) + { + throw new BusinessException("无效的刷新令牌"); + } + + var token = new JwtSecurityTokenHandler().ReadJwtToken(refreshToken); + var loginType = ParseLoginType(token.Claims); + + if (loginType == CustomerLoginType) + { + return await RefreshCustomerTokenAsync(token.Claims, cancellationToken); + } + + if (!Guid.TryParse(userIdText, out var userId)) + { + throw new BusinessException("无效的刷新令牌"); + } + + var user = await userRepository.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken: cancellationToken); + if (user == null) + { + throw new BusinessException("用户不存在"); + } + + var claims = await BuildAdminClaimsAsync(user.Id, cancellationToken); + return BuildLoginResult(user.Id, claims); + } + + [HttpGet("/rbac/auth/get-current-user")] + public async Task GetCurrentUserAsync(CancellationToken cancellationToken = default) + { + if (!CurrentUser.IsAuthenticated) + { + throw new UnauthorizedAccessException("用户未登录"); + } + + if (IsCustomerPrincipal()) + { + return await BuildCustomerUserInfoAsync(cancellationToken); + } + + var userId = CurrentUser.Id ?? throw new UnauthorizedAccessException("用户未登录"); + return await BuildAdminUserInfoAsync(userId, cancellationToken); + } + + [HttpPost("/rbac/auth/change-password")] + public async Task ChangePasswordAsync(string oldPassword, string newPassword, CancellationToken cancellationToken = default) + { + if (IsCustomerPrincipal()) + { + await ChangeCustomerPasswordAsync(oldPassword, newPassword, cancellationToken); + return; + } + + var userId = CurrentUser.Id ?? throw new UnauthorizedAccessException("用户未登录"); + await accountManager.UpdatePasswordAsync(userId, oldPassword, newPassword, cancellationToken); + } + + [HttpPut("/rbac/auth/update-profile")] + public async Task UpdateProfileAsync(string? name, string? email, CancellationToken cancellationToken = default) + { + EnsureAdminPrincipal(); + + var userId = CurrentUser.Id ?? throw new UnauthorizedAccessException("用户未登录"); + var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken); + user.Update(name, email); + await userRepository.UpdateAsync(user, cancellationToken: cancellationToken); + await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken); + return await BuildAdminUserInfoAsync(userId, cancellationToken); + } + + [HttpPost("/rbac/auth/upload-avatar")] + public async Task UploadAvatarAsync(IFormFile file, CancellationToken cancellationToken = default) + { + EnsureAdminPrincipal(); + + var userId = CurrentUser.Id ?? throw new UnauthorizedAccessException("用户未登录"); + if (file == null || file.Length == 0) + { + throw new DomainException("请选择要上传的文件"); + } + + var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; + var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!allowedExtensions.Contains(extension)) + { + throw new DomainException("只支持 jpg、jpeg、png、gif、webp 格式的图片"); + } + + if (file.Length > 10 * 1024 * 1024) + { + throw new DomainException("文件大小不能超过10MB"); + } + + var uploadPath = Path.Combine(webHostEnvironment.WebRootPath ?? webHostEnvironment.ContentRootPath, "uploads", "avatars"); + Directory.CreateDirectory(uploadPath); + + var fileName = $"{userId}_{DateTime.Now:yyyyMMddHHmmss}{extension}"; + var filePath = Path.Combine(uploadPath, fileName); + + using (var stream = file.OpenReadStream()) + using (var image = await Image.LoadAsync(stream, cancellationToken)) + { + var maxSize = 200; + var width = image.Width; + var height = image.Height; + + if (width > maxSize || height > maxSize) + { + if (width > height) + { + height = (int)(height * ((float)maxSize / width)); + width = maxSize; + } + else + { + width = (int)(width * ((float)maxSize / height)); + height = maxSize; + } + } + + image.Mutate(x => x.Resize(width, height)); + await image.SaveAsJpegAsync(filePath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder + { + Quality = 80 + }, cancellationToken); + } + + var avatarUrl = $"/uploads/avatars/{fileName}"; + var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken); + user.UpdateAvatar(avatarUrl); + await userRepository.UpdateAsync(user, cancellationToken: cancellationToken); + await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken); + return avatarUrl; + } + + private async Task LoginAdminAsync(string account, string password, CancellationToken cancellationToken) + { + var user = await accountManager.ValidateCredentialsAsync(account, password, cancellationToken); + var claims = await BuildAdminClaimsAsync(user.Id, cancellationToken); + return BuildLoginResult(user.Id, claims); + } + + private async Task LoginCustomerAsync(string account, string password, CancellationToken cancellationToken) + { + var normalizedAccount = account.Trim(); + var customer = await FindCustomerByAccountAsync(normalizedAccount, cancellationToken); + if (customer != null) + { + ValidateMainCustomerStatus(customer); + if (!string.Equals(customer.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("客户账号或密码错误"); + } + + var principalId = CreateStablePrincipalId($"customer:{customer.Id}"); + customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); + customer.LastLoginTime = DateTime.Now; + await customerRepository.UpdateAsync(customer, cancellationToken); + + var roleCode = GetCustomerRoleCode(customer.Role); + var claims = BuildCustomerClaims( + principalId, + customer.Id, + null, + roleCode, + customer.WechatNickname ?? customer.CustomerCode); + + return BuildLoginResult(principalId, claims); + } + + var subAccount = await FindCustomerSubAccountByAccountAsync(normalizedAccount, cancellationToken); + if (subAccount == null) + { + throw new BusinessException("客户账号或密码错误"); + } + + if (subAccount.SubStatus != EnableStatus.Enabled) + { + throw new BusinessException("子账号已禁用"); + } + + if (!string.Equals(subAccount.SubPassword, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("客户账号或密码错误"); + } + + customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == subAccount.MainCustomerId, cancellationToken); + if (customer == null) + { + throw new BusinessException("主客户不存在"); + } + + ValidateMainCustomerStatus(customer); + + var subPrincipalId = CreateStablePrincipalId($"customer-sub:{subAccount.Id}"); + var subClaims = BuildCustomerClaims( + subPrincipalId, + customer.Id, + subAccount.Id, + GetCustomerRoleCode(customer.Role), + subAccount.SubWechatNickname ?? subAccount.SubAccountCode); + + return BuildLoginResult(subPrincipalId, subClaims); + } + + private async Task RefreshCustomerTokenAsync(IEnumerable claims, CancellationToken cancellationToken) + { + var customerId = ParseLongClaim(claims, SimpleOmsClaimTypes.CustomerId); + if (customerId <= 0) + { + throw new BusinessException("无效的刷新令牌"); + } + + var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customerId, cancellationToken); + if (customer == null) + { + throw new BusinessException("客户不存在"); + } + + ValidateMainCustomerStatus(customer); + + var subAccountId = ParseNullableLongClaim(claims, SimpleOmsClaimTypes.SubAccountId); + if (subAccountId.HasValue) + { + var subAccount = await customerSubAccountRepository.FirstOrDefaultAsync(x => x.Id == subAccountId.Value, cancellationToken); + if (subAccount == null || subAccount.MainCustomerId != customer.Id) + { + throw new BusinessException("子账号不存在"); + } + + if (subAccount.SubStatus != EnableStatus.Enabled) + { + throw new BusinessException("子账号已禁用"); + } + + var principalId = CreateStablePrincipalId($"customer-sub:{subAccount.Id}"); + var customerClaims = BuildCustomerClaims( + principalId, + customer.Id, + subAccount.Id, + GetCustomerRoleCode(customer.Role), + subAccount.SubWechatNickname ?? subAccount.SubAccountCode); + + return BuildLoginResult(principalId, customerClaims); + } + + var mainPrincipalId = CreateStablePrincipalId($"customer:{customer.Id}"); + var mainClaims = BuildCustomerClaims( + mainPrincipalId, + customer.Id, + null, + GetCustomerRoleCode(customer.Role), + customer.WechatNickname ?? customer.CustomerCode); + + return BuildLoginResult(mainPrincipalId, mainClaims); + } + + private async Task BuildAdminUserInfoAsync(Guid userId, CancellationToken cancellationToken) + { + var userInfo = await userService.GetAsync(userId, cancellationToken); + return new UserInfoDto + { + Id = userInfo.Id, + Name = userInfo.Name, + Account = userInfo.Account, + Email = userInfo.Email, + Avatar = userInfo.Avatar, + OrganizationId = userInfo.OrganizationId, + OrganizationName = userInfo.OrganizationName, + Roles = userInfo.Roles, + Permissions = await userService.GetUserPermissionsAsync(userId, cancellationToken), + Menus = await menuService.GetUserMenusAsync(userId, cancellationToken), + CreatedAt = userInfo.CreatedAt, + CreatedBy = userInfo.CreatedBy, + UpdatedAt = userInfo.UpdatedAt, + UpdatedBy = userInfo.UpdatedBy + }; + } + + private async Task BuildCustomerUserInfoAsync(CancellationToken cancellationToken) + { + var context = await GetCurrentCustomerContextAsync(cancellationToken); + var roleBundle = await GetRoleBundleAsync(context.RoleCode, cancellationToken); + + return new UserInfoDto + { + Id = context.PrincipalId, + Name = context.DisplayName, + Account = context.AccountCode, + Email = context.Email, + Avatar = context.Avatar, + Roles = roleBundle.Role == null + ? [new RoleSimpleDto { Id = Guid.Empty, Code = context.RoleCode, Name = context.RoleCode }] + : [new RoleSimpleDto { Id = roleBundle.Role.Id, Code = roleBundle.Role.Code, Name = roleBundle.Role.Name }], + Permissions = roleBundle.PermissionCodes, + Menus = roleBundle.Menus, + CreatedAt = context.CreateTime, + UpdatedAt = context.UpdateTime == default ? null : context.UpdateTime + }; + } + + private async Task ChangeCustomerPasswordAsync(string oldPassword, string newPassword, CancellationToken cancellationToken) + { + accountManager.ValidatePasswordStrength(newPassword); + var context = await GetCurrentCustomerContextAsync(cancellationToken); + + if (context.SubAccount != null) + { + if (!string.Equals(context.SubAccount.SubPassword, OmsPasswordHelper.ToMd5(oldPassword), StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("旧密码不正确"); + } + + context.SubAccount.SubPassword = OmsPasswordHelper.ToMd5(newPassword); + await customerSubAccountRepository.UpdateAsync(context.SubAccount, cancellationToken); + return; + } + + if (!string.Equals(context.Customer.Password, OmsPasswordHelper.ToMd5(oldPassword), StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("旧密码不正确"); + } + + context.Customer.Password = OmsPasswordHelper.ToMd5(newPassword); + await customerRepository.UpdateAsync(context.Customer, cancellationToken); + } + + private async Task> BuildAdminClaimsAsync(Guid userId, CancellationToken cancellationToken) + { + var claims = await jwtService.GenerateClaimsByUserIdAsync(userId, cancellationToken); + ReplaceOrAppendClaim(claims, SimpleOmsClaimTypes.LoginType, AdminLoginType.ToString()); + return claims; + } + + private List BuildCustomerClaims(Guid principalId, long customerId, long? subAccountId, string roleCode, string userName) + { + var claims = new List + { + new(FakeClaimTypes.UserId, principalId.ToString()), + new(ClaimTypes.NameIdentifier, principalId.ToString()), + new(FakeClaimTypes.UserName, userName), + new(FakeClaimTypes.Role, roleCode), + new(SimpleOmsClaimTypes.MapId, customerId.ToString()), + new(SimpleOmsClaimTypes.LoginType, CustomerLoginType.ToString()), + new(SimpleOmsClaimTypes.CustomerId, customerId.ToString()) + }; + + if (subAccountId.HasValue) + { + claims.Add(new Claim(SimpleOmsClaimTypes.SubAccountId, subAccountId.Value.ToString())); + } + + return claims; + } + + private LoginResultDto BuildLoginResult(Guid userId, IReadOnlyCollection claims) + { + var accessClaims = CloneClaims(claims); + var refreshClaims = CloneClaims(claims); + + return new LoginResultDto + { + AccessToken = jwtService.GenerateAccessToken(accessClaims), + RefreshToken = jwtService.GenerateRefreshToken(refreshClaims), + TokenType = "Bearer", + ExpiresIn = jwtService.GetExpiresInSeconds(), + UserId = userId + }; + } + + private static List CloneClaims(IEnumerable claims) + { + return claims.Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer, x.OriginalIssuer)).ToList(); + } + + private bool IsCustomerPrincipal() + { + return CurrentUser.FindClaimOrNull(SimpleOmsClaimTypes.LoginType)?.Value == CustomerLoginType.ToString() || + CurrentUser.FindClaimOrNull(SimpleOmsClaimTypes.CustomerId) != null; + } + + private void EnsureAdminPrincipal() + { + if (IsCustomerPrincipal()) + { + throw new BusinessException("当前登录主体暂不支持此操作"); + } + } + + private async Task GetCurrentCustomerContextAsync(CancellationToken cancellationToken) + { + var customerId = CurrentUser.GetId(); + if (customerId <= 0) + { + throw new UnauthorizedAccessException("客户未登录"); + } + + var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customerId, cancellationToken); + if (customer == null) + { + throw new BusinessException("客户不存在"); + } + + ValidateMainCustomerStatus(customer); + + var subAccountId = ParseNullableLongClaim(CurrentUser.FindClaims(SimpleOmsClaimTypes.SubAccountId), SimpleOmsClaimTypes.SubAccountId); + CustomerSubAccount? subAccount = null; + if (subAccountId.HasValue) + { + subAccount = await customerSubAccountRepository.FirstOrDefaultAsync(x => x.Id == subAccountId.Value, cancellationToken); + if (subAccount == null || subAccount.MainCustomerId != customer.Id) + { + throw new BusinessException("子账号不存在"); + } + + if (subAccount.SubStatus != EnableStatus.Enabled) + { + throw new BusinessException("子账号已禁用"); + } + } + + return new CustomerPrincipalContext + { + PrincipalId = CurrentUser.Id ?? CreateStablePrincipalId(subAccount == null ? $"customer:{customer.Id}" : $"customer-sub:{subAccount.Id}"), + Customer = customer, + SubAccount = subAccount, + RoleCode = GetCustomerRoleCode(customer.Role), + DisplayName = subAccount?.SubWechatNickname ?? customer.WechatNickname ?? subAccount?.SubAccountCode ?? customer.CustomerCode, + AccountCode = subAccount?.SubAccountCode ?? customer.CustomerCode, + Email = subAccount?.SubEmail ?? customer.Email, + Avatar = subAccount?.SubWechatAvatar ?? customer.WechatAvatar, + CreateTime = subAccount?.CreateTime ?? customer.CreateTime, + UpdateTime = subAccount?.UpdateTime ?? customer.UpdateTime + }; + } + + 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 FindCustomerSubAccountByAccountAsync(string account, CancellationToken cancellationToken) + { + var subAccount = await customerSubAccountRepository.GetBySubPhoneAsync(account, cancellationToken); + if (subAccount != null) + { + return subAccount; + } + + subAccount = await customerSubAccountRepository.GetBySubEmailAsync(account, cancellationToken); + if (subAccount != null) + { + return subAccount; + } + + return await customerSubAccountRepository.GetBySubAccountCodeAsync(account, cancellationToken); + } + + private async Task GetRoleBundleAsync(string roleCode, CancellationToken cancellationToken) + { + var role = await rbacDbContext.Roles + .Include(x => x.Permissions) + .FirstOrDefaultAsync(x => x.Code == roleCode && !x.IsDeleted, cancellationToken); + + if (role == null) + { + return new CustomerRoleBundle + { + Menus = [], + PermissionCodes = [] + }; + } + + var permissionCodes = role.Permissions + .Select(x => x.PermissionCode) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var menus = await BuildMenusByPermissionsAsync(permissionCodes, cancellationToken); + return new CustomerRoleBundle + { + Role = role, + PermissionCodes = permissionCodes, + Menus = menus + }; + } + + private async Task> BuildMenusByPermissionsAsync(IReadOnlyCollection permissionCodes, CancellationToken cancellationToken) + { + if (permissionCodes.Count == 0) + { + return []; + } + + var allMenus = await rbacDbContext.Menus + .Where(x => !x.IsDeleted) + .OrderBy(x => x.Order) + .ToListAsync(cancellationToken); + + var menuMap = allMenus.ToDictionary(x => x.Id); + var selectedMenuIds = new HashSet(allMenus + .Where(x => !string.IsNullOrWhiteSpace(x.PermissionCode) && permissionCodes.Contains(x.PermissionCode, StringComparer.OrdinalIgnoreCase)) + .Select(x => x.Id)); + + var queue = new Queue(selectedMenuIds); + while (queue.Count > 0) + { + var menuId = queue.Dequeue(); + if (!menuMap.TryGetValue(menuId, out var currentMenu)) + { + continue; + } + + if (currentMenu.PId != Guid.Empty && selectedMenuIds.Add(currentMenu.PId)) + { + queue.Enqueue(currentMenu.PId); + } + } + + var menuDtos = allMenus + .Where(x => selectedMenuIds.Contains(x.Id)) + .OrderBy(x => x.Order) + .ThenBy(x => x.CreateTime) + .Select(x => new MenuTreeDto + { + Id = x.Id, + PId = x.PId, + Name = x.Name, + PermissionCode = x.PermissionCode, + Type = x.Type, + Icon = x.Icon, + Route = x.Route, + Component = x.Component, + Order = x.Order, + IsHidden = x.IsHidden, + IsCached = x.IsCached, + Description = x.Description, + CreatedAt = x.CreateTime, + UpdatedAt = x.UpdateTime == default ? null : x.UpdateTime + }) + .ToList(); + + var dtoMap = menuDtos.ToDictionary(x => x.Id); + var roots = new List(); + foreach (var menu in menuDtos) + { + if (menu.PId == Guid.Empty || !dtoMap.TryGetValue(menu.PId, out var parentMenu)) + { + roots.Add(menu); + continue; + } + + parentMenu.Children.Add(menu); + } + + return roots.OrderBy(x => x.Order).ToList(); + } + + 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 GetCustomerRoleCode(string? roleCode) + { + return string.IsNullOrWhiteSpace(roleCode) ? DefaultCustomerRoleCode : roleCode.Trim(); + } + + private static Guid CreateStablePrincipalId(string key) + { + var hash = MD5.HashData(Encoding.UTF8.GetBytes(key)); + return new Guid(hash); + } + + private static int ParseLoginType(IEnumerable claims) + { + return int.TryParse(claims.FirstOrDefault(x => x.Type == SimpleOmsClaimTypes.LoginType)?.Value, out var loginType) + ? loginType + : AdminLoginType; + } + + private static long ParseLongClaim(IEnumerable claims, string claimType) + { + return long.TryParse(claims.FirstOrDefault(x => x.Type == claimType)?.Value, out var value) ? value : 0; + } + + private static long? ParseNullableLongClaim(IEnumerable claims, string claimType) + { + return long.TryParse(claims.FirstOrDefault(x => x.Type == claimType)?.Value, out var value) ? value : null; + } + + private static void ReplaceOrAppendClaim(List claims, string claimType, string value) + { + claims.RemoveAll(x => x.Type == claimType); + claims.Add(new Claim(claimType, value)); + } + + private sealed class CustomerPrincipalContext + { + public Guid PrincipalId { get; set; } + + public required Customer Customer { get; init; } + + public CustomerSubAccount? SubAccount { get; init; } + + public required string RoleCode { get; init; } + + public required string DisplayName { get; init; } + + public required string AccountCode { get; init; } + + public string? Email { get; init; } + + public string? Avatar { get; init; } + + public DateTime CreateTime { get; init; } + + public DateTime UpdateTime { get; init; } + } + + private sealed class CustomerRoleBundle + { + public Role? Role { get; init; } + + public List PermissionCodes { get; init; } = []; + + public List Menus { get; init; } = []; + } +} diff --git a/src/SimpleAdmin.Api/Program.cs b/src/SimpleAdmin.Api/Program.cs index 386aaa7..c76523e 100644 --- a/src/SimpleAdmin.Api/Program.cs +++ b/src/SimpleAdmin.Api/Program.cs @@ -1,21 +1,10 @@ using Serilog; using SimpleAdmin.Api; -var builder = WebApplication.CreateSlimBuilder(args); -builder.Configuration.AddConsul("idwms/common", source => source.ReloadOnChange = true); -builder.Configuration.AddConsul("idwms/admin", source => source.ReloadOnChange = true); - -Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(builder.Configuration) - .CreateLogger(); - try { - builder.WebHost.UseUrls(builder.Configuration.GetSection("App:Urls").Get() ?? []); - builder.Host.UseAutofac().UseSerilog(); - builder.Services.AddApplication(); - var app = builder.Build(); - app.InitializeApplication(); + var builder = SimpleAdminApiBootstrap.CreateBuilder(args); + var app = SimpleAdminApiBootstrap.BuildApplication(builder); await app.RunAsync(); } catch (Exception ex) @@ -25,4 +14,6 @@ catch (Exception ex) finally { Log.CloseAndFlush(); -} \ No newline at end of file +} + +public partial class Program; diff --git a/src/SimpleAdmin.Api/SimpleAdminApiBootstrap.cs b/src/SimpleAdmin.Api/SimpleAdminApiBootstrap.cs new file mode 100644 index 0000000..1131c4b --- /dev/null +++ b/src/SimpleAdmin.Api/SimpleAdminApiBootstrap.cs @@ -0,0 +1,54 @@ +using Serilog; + +namespace SimpleAdmin.Api; + +/// +/// 为正式启动与集成测试提供统一的应用构建入口。 +/// +public static class SimpleAdminApiBootstrap +{ + /// + /// 创建并配置 API 应用构建器。 + /// + /// 启动参数。 + /// 附加构建配置,主要供测试环境注入本地配置和测试服务器使用。 + public static WebApplicationBuilder CreateBuilder( + string[] args, + Action? configureBuilder = null, + string? environmentName = null) + { + var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions + { + Args = args, + EnvironmentName = environmentName, + ApplicationName = typeof(SimpleAdminApiModule).Assembly.GetName().Name + }); + configureBuilder?.Invoke(builder); + + if (!builder.Configuration.GetValue("UseLocalConfigs")) + { + builder.Configuration.AddConsul("idwms/common", source => source.ReloadOnChange = true); + builder.Configuration.AddConsul("idwms/admin", source => source.ReloadOnChange = true); + } + + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .CreateLogger(); + + builder.WebHost.UseUrls(builder.Configuration.GetSection("App:Urls").Get() ?? []); + builder.Host.UseAutofac().UseSerilog(); + builder.Services.AddApplication(); + + return builder; + } + + /// + /// 根据构建器生成并初始化 WebApplication。 + /// + public static WebApplication BuildApplication(WebApplicationBuilder builder) + { + var app = builder.Build(); + app.InitializeApplication(); + return app; + } +} diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index c36f82e..0be6f83 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -8,8 +8,10 @@ using Fake.AspNetCore.Mvc; using Fake.FeiShu; using Fake.Serilog; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Logging; using Oms.Application; using Oms.SqlSugarCore; +using SimpleAdmin.Api.Auth; namespace SimpleAdmin.Api; @@ -34,9 +36,15 @@ public class SimpleAdminApiModule : FakeModule services.Configure(options => { options.ApplicationServices2Controller(); + options.ApplicationServices2Controller(settings => + { + settings.RootPath = "rbac"; + settings.TypePredicate = type => type == typeof(AuthService); + }); options.ApplicationServices2Controller(settings => { settings.RootPath = "rbac"; + settings.TypePredicate = type => type != typeof(Fake.Rbac.Application.Services.AuthService); }); }); @@ -97,6 +105,21 @@ public class SimpleAdminApiModule : FakeModule // 端点映射 app.MapControllers(); - app.Services.GetRequiredService().Send("Application started"); + var shouldSendStartupNotification = + !app.Environment.IsEnvironment("Testing") && + app.Configuration.GetValue("App:SendStartupNotification", true); + if (!shouldSendStartupNotification) + { + return; + } + + try + { + app.Services.GetRequiredService().Send("Application started"); + } + catch (Exception ex) + { + app.Logger.LogWarning(ex, "Failed to send startup notification."); + } } } diff --git a/src/SimpleAdmin.Api/SimpleCurrentUser.cs b/src/SimpleAdmin.Api/SimpleCurrentUser.cs new file mode 100644 index 0000000..47e7c27 --- /dev/null +++ b/src/SimpleAdmin.Api/SimpleCurrentUser.cs @@ -0,0 +1,54 @@ +using Fake.DependencyInjection; +using Fake.Security.Claims; +using Fake.Users; + +namespace SimpleAdmin.Api; + +/// +/// 扩展当前用户解析逻辑,支持从 OMS 业务 claims 中读取数值型主体标识。 +/// +[Dependency(Replace = true)] +public class SimpleCurrentUser(ICurrentPrincipalAccessor currentPrincipalAccessor) : CurrentUser(currentPrincipalAccessor) +{ + public override T GetId() + { + if (typeof(T) == typeof(long)) + { + var userId = base.GetId(); + if (userId != 0) + { + return (T)(object)userId; + } + + return (T)(object)GetFallbackNumericId(); + } + + if (typeof(T) == typeof(int)) + { + var userId = base.GetId(); + if (userId != 0) + { + return (T)(object)userId; + } + + return (T)(object)(int)GetFallbackNumericId(); + } + + return base.GetId()!; + } + + private long GetFallbackNumericId() + { + if (long.TryParse(FindClaimOrNull(SimpleOmsClaimTypes.MapId)?.Value, out var mapId) && mapId > 0) + { + return mapId; + } + + if (long.TryParse(FindClaimOrNull(SimpleOmsClaimTypes.CustomerId)?.Value, out var customerId) && customerId > 0) + { + return customerId; + } + + return 0; + } +} diff --git a/src/SimpleAdmin.Api/SimpleJwtService.cs b/src/SimpleAdmin.Api/SimpleJwtService.cs index 0f2149f..1f392e0 100644 --- a/src/SimpleAdmin.Api/SimpleJwtService.cs +++ b/src/SimpleAdmin.Api/SimpleJwtService.cs @@ -20,9 +20,17 @@ public class SimpleJwtService(IOptions jwtOptions, IUserService user CancellationToken cancellationToken) { var claims = await base.GenerateClaimsByUserIdAsync(userId, cancellationToken); - var mapId = await rbacDbContext.Database.SqlQuery($"select userid as value from user where id = {userId}") - .FirstAsync(cancellationToken); - claims.Add(new Claim("mapId", mapId.ToString())); + var mapId = await rbacDbContext.Database.SqlQuery( + $"select `userid` as `Value` from `user` where `Id` = {userId}") + .FirstOrDefaultAsync(cancellationToken); + if (mapId.HasValue) + { + claims.RemoveAll(x => x.Type == SimpleOmsClaimTypes.MapId); + claims.Add(new Claim(SimpleOmsClaimTypes.MapId, mapId.Value.ToString())); + } + + claims.RemoveAll(x => x.Type == SimpleOmsClaimTypes.LoginType); + claims.Add(new Claim(SimpleOmsClaimTypes.LoginType, "0")); return claims; } -} \ No newline at end of file +} diff --git a/src/SimpleAdmin.Api/SimpleOmsClaimTypes.cs b/src/SimpleAdmin.Api/SimpleOmsClaimTypes.cs new file mode 100644 index 0000000..7462564 --- /dev/null +++ b/src/SimpleAdmin.Api/SimpleOmsClaimTypes.cs @@ -0,0 +1,9 @@ +namespace SimpleAdmin.Api; + +internal static class SimpleOmsClaimTypes +{ + public const string MapId = "mapId"; + public const string LoginType = "loginType"; + public const string CustomerId = "customerId"; + public const string SubAccountId = "subAccountId"; +}