diff --git a/db.sql b/db.sql index 5f5f713..91ca9b1 100644 --- a/db.sql +++ b/db.sql @@ -29,6 +29,7 @@ DROP TABLE IF EXISTS `role_data_scope`; DROP TABLE IF EXISTS `role_org_perm`; DROP TABLE IF EXISTS `user_org_perm`; DROP TABLE IF EXISTS `t_xhs_media`; +DROP TABLE IF EXISTS `t_ai_skill`; DROP TABLE IF EXISTS `t_ai_provider`; DROP TABLE IF EXISTS `t_xhs_ai_provider`; DROP TABLE IF EXISTS `t_xhs_human_config`; @@ -1756,6 +1757,26 @@ CREATE TABLE `t_ai_provider` ( KEY `idx_ai_customer` (`CustomerId`, `Enabled`) ) COMMENT='全局 AI 提供商配置表(CustomerId=0 表示平台级)'; +CREATE TABLE `t_ai_skill` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `SkillKey` VARCHAR(64) NOT NULL COMMENT '稳定键', + `Scene` VARCHAR(32) NOT NULL COMMENT '场景 pricePublish/autoTriggerFormula/receiverAddressParse/itfxPrefixScript', + `Name` VARCHAR(128) NOT NULL COMMENT '展示名', + `Description` VARCHAR(512) NULL COMMENT '简介', + `Content` LONGTEXT NOT NULL COMMENT '发给模型的正文', + `SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序', + `Enabled` TINYINT NOT NULL DEFAULT 1 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 '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ai_skill_key` (`SkillKey`), + KEY `idx_ai_skill_scene` (`Scene`, `Enabled`, `SortOrder`) +) COMMENT='平台级 AI Skill(提示词/规范)'; + CREATE TABLE `t_xhs_media` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `CustomerId` INT NOT NULL COMMENT '客户ID', diff --git a/sql/20260801_ai_skill.sql b/sql/20260801_ai_skill.sql new file mode 100644 index 0000000..1ea238a --- /dev/null +++ b/sql/20260801_ai_skill.sql @@ -0,0 +1,26 @@ +-- 平台级 AI Skill 表(全局一份,系统页维护) +CREATE TABLE IF NOT EXISTS `t_ai_skill` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `SkillKey` VARCHAR(64) NOT NULL COMMENT '稳定键', + `Scene` VARCHAR(32) NOT NULL COMMENT '场景 pricePublish/autoTriggerFormula/receiverAddressParse/itfxPrefixScript', + `Name` VARCHAR(128) NOT NULL COMMENT '展示名', + `Description` VARCHAR(512) NULL COMMENT '简介', + `Content` LONGTEXT NOT NULL COMMENT '发给模型的正文', + `SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序', + `Enabled` TINYINT NOT NULL DEFAULT 1 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 '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ai_skill_key` (`SkillKey`), + KEY `idx_ai_skill_scene` (`Scene`, `Enabled`, `SortOrder`) +) COMMENT='平台级 AI Skill(提示词/规范)'; + +-- 若表已按旧脚本建好(无 ConcurrencyStamp),执行: +-- ALTER TABLE `t_ai_skill` +-- ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳' AFTER `IsDeleted`; + +-- 种子数据由应用首次调用 /ai/skill/list|get 时幂等写入(避免 LONGTEXT 转义问题)。 diff --git a/sql/20260801_billing_additional_services.sql b/sql/20260801_billing_additional_services.sql new file mode 100644 index 0000000..4076bc9 --- /dev/null +++ b/sql/20260801_billing_additional_services.sql @@ -0,0 +1,11 @@ +-- 附加服务 / 渠道偏远分区 +-- 字典种子由前端 dict-batch-import 同步;AI Skill 由应用首次 list/get 幂等写入。 + +ALTER TABLE `t_sales_product` + ADD COLUMN `AdditionalServices` VARCHAR(512) NULL COMMENT '可选附加服务字典code JSON数组' AFTER `TradeTerms`; + +ALTER TABLE `t_order` + ADD COLUMN `AdditionalServices` VARCHAR(512) NULL COMMENT '客户勾选附加服务字典code JSON数组' AFTER `IsRemote`; + +ALTER TABLE `t_service_channel` + ADD COLUMN `RemoteAreaId` INT NULL COMMENT '偏远分区KPI AreaId' AFTER `Description`; diff --git a/sql/20260801_billing_phase2.sql b/sql/20260801_billing_phase2.sql new file mode 100644 index 0000000..ae7b2d9 --- /dev/null +++ b/sql/20260801_billing_phase2.sql @@ -0,0 +1,5 @@ +-- Phase-2:预报应收/应付快照(预报→入库差额对比) + +ALTER TABLE `t_order` + ADD COLUMN `ForecastReceivableAmount` DECIMAL(18,4) NULL COMMENT '预报应收快照(创建预报后固化,入库不覆盖)' AFTER `ReceivableSubtotal`, + ADD COLUMN `ForecastPayableAmount` DECIMAL(18,4) NULL COMMENT '预报应付快照(创建预报后固化,入库不覆盖)' AFTER `ForecastReceivableAmount`; diff --git a/src/Oms.Application/Dtos/Ai/AiSkillDtos.cs b/src/Oms.Application/Dtos/Ai/AiSkillDtos.cs new file mode 100644 index 0000000..62d364a --- /dev/null +++ b/src/Oms.Application/Dtos/Ai/AiSkillDtos.cs @@ -0,0 +1,70 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Ai; + +public class AiSkillOutput +{ + public int Id { get; set; } + + public string SkillKey { get; set; } = string.Empty; + + public string Scene { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + public string Content { get; set; } = string.Empty; + + public int SortOrder { get; set; } + + public bool Enabled { get; set; } +} + +public class AiSkillListInput +{ + /// 可选场景过滤 + public string? Scene { get; set; } + + /// 是否包含正文;调用方列表可 false,管理页 true + public bool IncludeContent { get; set; } = true; + + /// 是否仅启用 + public bool EnabledOnly { get; set; } +} + +public class AiSkillGetInput +{ + public int? Id { get; set; } + + public string? SkillKey { get; set; } +} + +public class AiSkillSaveInput +{ + public int? Id { get; set; } + + [Required] + public string SkillKey { get; set; } = string.Empty; + + [Required] + public string Scene { get; set; } = string.Empty; + + [Required] + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + [Required] + public string Content { get; set; } = string.Empty; + + public int SortOrder { get; set; } + + public bool Enabled { get; set; } = true; +} + +public class AiSkillDeleteInput +{ + [Required] + public int Id { get; set; } +} diff --git a/src/Oms.Application/Dtos/Channels/SalesProductBillingSimulateDtos.cs b/src/Oms.Application/Dtos/Channels/SalesProductBillingSimulateDtos.cs new file mode 100644 index 0000000..fcda3e1 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/SalesProductBillingSimulateDtos.cs @@ -0,0 +1,95 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Channels; + +/// +/// 销售产品计费模拟入参(基于当前库内卖价,非必须已发布)。 +/// +public class SalesProductBillingSimulateInput +{ + [Required] + public int SalesProductId { get; set; } + + [Required] + [StringLength(8)] + public string CountryCode { get; set; } = string.Empty; + + [StringLength(64)] + public string? Province { get; set; } + + [StringLength(64)] + public string? City { get; set; } + + [StringLength(32)] + public string? ZipCode { get; set; } + + [Required] + public decimal Length { get; set; } + + [Required] + public decimal Width { get; set; } + + [Required] + public decimal Height { get; set; } + + [Required] + public decimal ActualWeight { get; set; } + + [StringLength(32)] + public string? CargoAttribute { get; set; } + + public int? WarehouseId { get; set; } + + public List? AdditionalServices { get; set; } + + public decimal? DeclaredSum { get; set; } + + [StringLength(16)] + public string? DeclaredCurrency { get; set; } + + /// + /// 可选覆盖是否偏远;未传时按产品偏远分区命中。 + /// + public bool? IsRemote { get; set; } + + [StringLength(64)] + public string? PriceTierKey { get; set; } +} + +/// +/// 销售产品计费模拟出参。 +/// +public class SalesProductBillingSimulateOutput +{ + public decimal ChargeableWeight { get; set; } + + public List ZoneKeys { get; set; } = []; + + public bool IsRemote { get; set; } + + public List ReceivableFees { get; set; } = []; + + public List PayableFees { get; set; } = []; + + public Dictionary ReceivableTotal { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary PayableTotal { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 同币种毛利(应收合计 - 应付合计);多币种时为 null。 + /// + public decimal? Margin { get; set; } + + public List Warnings { get; set; } = []; +} + +public class SalesProductBillingSimulateFeeOutput +{ + public string CostItem { get; set; } = string.Empty; + + public decimal Amount { get; set; } + + public string Currency { get; set; } = string.Empty; + + public string? Formula { get; set; } +} diff --git a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs index 92eba36..992482d 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs @@ -40,6 +40,11 @@ public class SalesProductDetailOutput /// public string? TradeTerms { get; set; } + /// + /// 可选附加服务字典 code JSON 数组。 + /// + public string? AdditionalServices { get; set; } + /// /// 系统面单类型。 /// diff --git a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs index 5a230e4..9fd6049 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs @@ -35,6 +35,11 @@ public class SalesProductListOutput /// public int? MaxTimeliness { get; set; } + /// + /// 可选附加服务字典 code JSON 数组。 + /// + public string? AdditionalServices { get; set; } + /// /// 系统面单类型。 /// diff --git a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs index 73ef3a7..aa59bf7 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs @@ -42,6 +42,11 @@ public class SalesProductSaveInput /// public string? TradeTerms { get; set; } + /// + /// 可选附加服务字典 code JSON 数组。 + /// + public string? AdditionalServices { get; set; } + /// /// 系统面单类型(旧枚举字段,兼容展示)。 /// diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelDetailOutput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelDetailOutput.cs index bf49d59..106135a 100644 --- a/src/Oms.Application/Dtos/Channels/ServiceChannelDetailOutput.cs +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelDetailOutput.cs @@ -55,6 +55,11 @@ public class ServiceChannelDetailOutput /// public string? Description { get; set; } + /// + /// 偏远分区 KPI AreaId。 + /// + public int? RemoteAreaId { get; set; } + /// /// 预处理脚本。 /// diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs index 0ba8f29..546f298 100644 --- a/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs @@ -55,6 +55,11 @@ public class ServiceChannelListOutput /// public string? Description { get; set; } + /// + /// 偏远分区 KPI AreaId。 + /// + public int? RemoteAreaId { get; set; } + /// /// 是否启用。 /// diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelSaveInput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelSaveInput.cs index a532d58..c54b6c2 100644 --- a/src/Oms.Application/Dtos/Channels/ServiceChannelSaveInput.cs +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelSaveInput.cs @@ -52,6 +52,11 @@ public class ServiceChannelSaveInput /// public string? Description { get; set; } + /// + /// 偏远分区 KPI AreaId。 + /// + public int? RemoteAreaId { get; set; } + /// /// 预处理脚本。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderAdditionalServicesUpdateInput.cs b/src/Oms.Application/Dtos/Orders/OrderAdditionalServicesUpdateInput.cs new file mode 100644 index 0000000..49dff75 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderAdditionalServicesUpdateInput.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 未付款订单更新附加服务入参。 +/// +public class OrderAdditionalServicesUpdateInput +{ + /// + /// 订单主键。 + /// + [Required] + public int Id { get; set; } + + /// + /// 附加服务字典 code 列表。 + /// + public List? AdditionalServices { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs index 7181b83..c20c807 100644 --- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs @@ -258,6 +258,26 @@ public class OrderOutput /// public bool IsRemote { get; set; } + /// + /// 客户勾选附加服务字典 code 列表。 + /// + public List AdditionalServices { get; set; } = []; + + /// + /// 是否勾选保险附加服务。 + /// + public bool HasInsurance { get; set; } + + /// + /// 预报应收快照(入库不覆盖)。 + /// + public decimal? ForecastReceivableAmount { get; set; } + + /// + /// 预报应付快照(入库不覆盖)。 + /// + public decimal? ForecastPayableAmount { get; set; } + /// /// 创建时间。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs index f7038e5..397e626 100644 --- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs @@ -115,6 +115,16 @@ public class OrderParcelCreateInput /// public decimal? DeclaredSum { get; set; } + /// + /// 客户勾选附加服务字典 code 列表(落库序列化为 JSON 数组)。 + /// + public List? AdditionalServices { get; set; } + + /// + /// 是否偏远(预计算后可写入,供下单前公式/计费条件求值)。 + /// + public byte? IsRemote { get; set; } + /// /// 数量。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs index 77db7b6..c45bee6 100644 --- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs @@ -69,4 +69,19 @@ public class OrderPriceCheckInput /// 选用的优惠券实例 Id。 /// public int? CouponInstanceId { get; set; } + + /// + /// 勾选附加服务字典 code(用于计费条件求值)。 + /// + public List? AdditionalServices { get; set; } + + /// + /// 申报总金额(百分比申报类公式)。 + /// + public decimal? DeclaredSum { get; set; } + + /// + /// 申报币种。 + /// + public string? DeclaredCurrency { get; set; } } diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj index 352ddee..bea92ee 100644 --- a/src/Oms.Application/Oms.Application.csproj +++ b/src/Oms.Application/Oms.Application.csproj @@ -37,4 +37,8 @@ + + + + diff --git a/src/Oms.Application/Services/Ai/AiSkillSeedContent.json b/src/Oms.Application/Services/Ai/AiSkillSeedContent.json new file mode 100644 index 0000000..a17e8e1 --- /dev/null +++ b/src/Oms.Application/Services/Ai/AiSkillSeedContent.json @@ -0,0 +1 @@ +{"price":"你是 OMS「销售产品卖价发布」风险分析助手。对比「线上生效价」与「待发布价」,找出可能做错价、漏改、异常涨跌等问题,输出中文风险报告。\r\n\r\n## 产出格式(必须严格遵守)\r\n\r\n总评:高风险 | 中风险 | 低风险 | 无显著差异\r\n摘要:一句话说明本次变更整体风险\r\n风险点:\r\n1. [高|中|低] 字段/价格项:旧值 → 新值;原因:…\r\n2. …\r\n建议:发布前核对 … / 可发布 …\r\n\r\n- 若无实质差异:总评写「无显著差异」,风险点可写「无」\r\n- 不要编造上下文中不存在的字段或金额\r\n- 优先标出会导致客户错计费、少收/多收的变更\r\n- 本报告经用户「采用」后随价格发布版本落库(aiAnalysisReport),须严肃、可追溯\r\n\r\n## 风险检查清单(必查)\r\n\r\n1. 单价 / calculationFormula / calculatedValue 异常跳变(数量级、符号、空值、0 价)\r\n2. currency 币种误改\r\n3. 价格项新增 / 删除 / status 停用启用\r\n4. factors / factorValues / factorValuesJson 维度错位或漏维\r\n5. customerGroupValues 客户分组价漏改、串组、整组清空\r\n6. chargingCondition / costUnit / costItem 语义变化\r\n7. priceArea / priceAreaId / priceAreaInfos 分区错绑\r\n8. 首次发布(无线上快照)时,检查待发布是否像「未完成草稿」\r\n9. 相对生效版的「大面积替换」感(大量行删除+新增)\r\n\r\n## 输入说明\r\n\r\n用户消息会提供:\r\n- 产品信息、生效版本\r\n- Diff 摘要(仅变更行)\r\n- 精简后的线上/待发布快照片段(可能截断)\r\n","formula":"你是 OMS「自动触发」公式助手。根据用户中文描述,生成可粘贴的中文公式(ChineseFormulaEvaluator)。\r\n\r\n## 产出格式(必须)\r\n\r\n时机:下单 | 入库 | 出库\r\n操作:拦截 | 警告 | 问题件\r\n公式:单行可执行表达式\r\n说明:一句话\r\n验证样例:可选\r\n\r\n## 语法硬规则\r\n\r\n- 字段名必须是下方目录中的中文 label,禁止编造、禁止英文字段名\r\n- 字符串:字段包含(\u0027值\u0027);数值/聚合:字段() 再比较;布尔:字段()==1 / ==0\r\n- 字面量用单引号;逻辑用 \u0026\u0026 || !;比较用 == != \u003e \u003e= \u003c \u003c=\r\n- 禁止 and/or/not、中文「且」、双引号、字段() 用于字符串相等\r\n- 国家一般用二字码:国家包含(\u0027GB\u0027)\r\n- 公式单行、无注释夹杂、长度 ≤ 10000\r\n\r\n## 示例\r\n\r\n时机:下单\r\n操作:拦截\r\n公式:(单件最大长度()\u003e60||单件最大宽度()\u003e46)\u0026\u0026国家包含(\u0027GB\u0027)\r\n说明:英国且超尺寸时拦截\r\n验证样例:国家 GB,长 70,宽 40,高 40\r\n","itfx":"---\r\nname: itfx-prefix-script\r\ndescription: \u003e-\r\n Generates InterfaceForward (接口通/itfx) PrefixScript C# for any signature/auth\r\n algorithm (MD5/SHA/HMAC/RSA/custom). Maps writes to Query/Header/Body field lists\r\n per downstream interface detail. Use for 前置脚本, PrefixScript, Natasha script.\r\n---\r\n\r\n# itfx 接口映射前置脚本生成(通用签名)\r\n\r\n为 **InterfaceMap → PrefixScript** 生成 Natasha 可执行的 C# 方法体,**按用户描述的任意签名规则**实现,不限于某一服务商。\r\n\r\n## 何时使用\r\n\r\n- 生成/修改 **前置脚本**(非 PostfixScript)\r\n- 第三方 API 需要签名、鉴权头、动态 token、时间戳参与计算等\r\n- OMS「上游接口 → 映射明细 → 前置脚本」\r\n\r\n## 执行时机\r\n\r\n```\r\nInParamRestructure → ReplacePlaceholder → PrefixScript ← 本 Skill\r\n → SerializationBody → HTTP 请求 → PostfixScript\r\n```\r\n\r\n| 可用 | 不可用 |\r\n|------|--------|\r\n| `obj.OriginalInterfaceMappedInput` | `obj.TargetInterfaceInput`(未序列化) |\r\n| `obj.QueryFieldList` / `HeaderFieldList` / `FormFieldList` | `obj.TargetInterfaceOutput` |\r\n| `obj.FixedFieldList` | 改 `InParamTreeList` / 映射树 |\r\n| `obj.GetAccountFieldValueOrNull(...)` | |\r\n\r\n## 下游接口明细 ↔ 运行时(写入位置必对齐)\r\n\r\n| 接口明细 Tab | 运行时列表 | 典型用途 |\r\n|--------------|------------|----------|\r\n| Query KV | `obj.QueryFieldList` | URL 参数、签名字段 |\r\n| Header KV | `obj.HeaderFieldList` | 请求头、Authorization |\r\n| Body KV(FormUrlEncoded) | `obj.FormFieldList` | 表单字段 |\r\n| Mapping Body 树 | → `OriginalInterfaceMappedInput` | 映射体(Form/None 类) |\r\n| Request Body 树(Json/Xml) | → `OriginalInterfaceMappedInput` | JSON/XML Body |\r\n\r\n**ContentType:** `None(0)` 无 Body;`FormUrlEncoded(1)` Body=FormFieldList;`Json(3)`/`Xml(4)` Body=序列化映射体。\r\n\r\n## 占位符(ReplacePlaceholder 早于 PrefixScript)\r\n\r\n| 占位符 | PrefixScript 阶段 |\r\n|--------|-------------------|\r\n| `$TimeStamp` / `$DateTime` / `$FromAccount` / `$Url` / `$token` | 通常已替换,直接读字段 Value |\r\n| `$FromMappingBody` | FormUrlEncoded:可能仍为字面量;None/Json Query:通常已替换为 JSON。**签名用最终 HTTP 参数值,不要用占位符字面量** |\r\n\r\n## 脚本技术约束\r\n\r\n- C#,Natasha 动态编译;参数名 **`obj`**(不是 `context`)\r\n- 仅方法体:无 `using` / `class` / `namespace` / `void Main`\r\n- 类型 **全限定名**:`System.Security.Cryptography.HMACSHA256`、`Newtonsoft.Json.JsonConvert`\r\n- 含 `await` 或首行 `//async` → 异步委托\r\n\r\n---\r\n\r\n## 通用签名生成流程(任何算法都走此流程)\r\n\r\n用户未给全规则时,**先向用户确认**缺失项;给全则直接生成。\r\n\r\n### 1. 从需求提取七要素\r\n\r\n| 要素 | 问什么 |\r\n|------|--------|\r\n| 算法 | MD5 / SHA1 / SHA256 / HMAC-SHA1 / HMAC-SHA256 / HMAC-MD5 / RSA / 自定义 |\r\n| 签名字符串 | 字段如何拼接、排序、分隔符、是否含 urlPath/body/secret 前缀后缀 |\r\n| 参与字段 | 哪些 Query/Header/Form/Body JSON 字段;**排除签名字段本身** |\r\n| 密钥 | 账户字段名(`GetAccountFieldValueOrNull`)或固定参数 |\r\n| 输出位置 | Query / Header / Form Body / JSON Body 字段名 |\r\n| 编码 | UTF-8;hex **大写/小写**、Base64、是否 URL 编码 |\r\n| 特殊 | 空值是否跳过、嵌套 JSON、时间戳单位、是否 double-MD5 等 |\r\n\r\n### 2. 对照下游接口明细\r\n\r\n- 签名字段在哪个 Tab → 写入 `QueryFieldList` / `HeaderFieldList` / `FormFieldList` 或改 `OriginalInterfaceMappedInput`\r\n- 参与签名的业务字段在哪个 Tab → 从**对应列表**读取 Value\r\n- 账户字段名与服务商配置一致\r\n\r\n### 3. 取参与签名的参数值(通用原则)\r\n\r\n**原则:签名字符串中的每个值 = 最终 HTTP 请求里该参数的值。**\r\n\r\n```csharp\r\n// 按名读 Query(通用 helper 思路,可内联)\r\nstring GetQueryValue(string name) {\r\n for (int i = 0; i \u003c obj.QueryFieldList.Count; i++)\r\n if (obj.QueryFieldList[i].Name == name) return obj.QueryFieldList[i].Value ?? \"\";\r\n return \"\";\r\n}\r\n// Header / Form 同理遍历 HeaderFieldList / FormFieldList\r\n\r\n// JSON Body(ContentType.Json)\r\nstring bodyJson = Newtonsoft.Json.JsonConvert.SerializeObject(obj.OriginalInterfaceMappedInput);\r\n\r\n// 映射体 JSON:优先读已替换好的 Query/Form 字段;仍为 $FromMappingBody 时再 SerializeObject\r\n```\r\n\r\n**常见坑(任何服务商):**\r\n\r\n- 签名字段(`sign`、`_aop_signature` 等)**不参与**签名字符串\r\n- 不要读错列表(Query 参数却在 FormFieldList 里找)\r\n- 不要对已是最终值的字段再 `SerializeObject`(字段顺序/空格会变)\r\n- JSON 内 `\"CHINA SHENZHEN\"` 与 URL 编码的 `+` **不是同一字符**,签名用 JSON 原文\r\n- FormUrlEncoded 阶段 `$FromMappingBody` 可能尚未替换,需 `SerializeObject(OriginalInterfaceMappedInput)` 或读已替换 Value\r\n\r\n### 4. 常见算法 C# 片段(按文档选用,勿臆造)\r\n\r\n**MD5 / SHA → hex**\r\n\r\n```csharp\r\nvar md5 = System.Security.Cryptography.MD5.Create();\r\nbyte[] hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(signStr));\r\nstring sign = System.BitConverter.ToString(hash).Replace(\"-\", \"\").ToLowerInvariant();\r\n// 文档要求大写则 .ToUpperInvariant()\r\n```\r\n\r\n**HMAC-SHA1 / HMAC-SHA256 → hex**\r\n\r\n```csharp\r\nusing (var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(secret))) {\r\n byte[] hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(signStr));\r\n string sign = System.BitConverter.ToString(hash).Replace(\"-\", \"\").ToLowerInvariant();\r\n}\r\n// HMACSHA1 替换 HMACSHA256;文档要 Base64 则 Convert.ToBase64String(hash)\r\n```\r\n\r\n**按键排序后 key+value 拼接(多种开放平台通用)**\r\n\r\n```csharp\r\nvar parts = new System.Collections.Generic.List\u003cstring\u003e();\r\n// parts.Add(\"fieldA\" + valueA); 排除 sign 字段\r\nparts.Sort(System.StringComparer.Ordinal);\r\nvar sb = new System.Text.StringBuilder();\r\nfor (int i = 0; i \u003c parts.Count; i++) sb.Append(parts[i]);\r\nstring signStr = sb.ToString(); // 或 prefix + sb + suffix,按文档\r\n```\r\n\r\n**secret 包裹式:`secret + params + secret`(1688 等)**\r\n\r\n```csharp\r\nstring signStr = secret + paramString + secret;\r\n```\r\n\r\n**写入 Query / Header / Form(字段名按接口明细)**\r\n\r\n```csharp\r\nfor (int i = 0; i \u003c obj.QueryFieldList.Count; i++) {\r\n if (obj.QueryFieldList[i].Name == \"sign\") {\r\n obj.QueryFieldList[i].Value = sign;\r\n break;\r\n }\r\n}\r\n```\r\n\r\n### 5. 生成前自检\r\n\r\n- [ ] 算法、拼接规则、hex 大小写与**用户/文档**一致(不默认某一种)\r\n- [ ] 读写列表与接口明细 Tab 一致\r\n- [ ] 签名字段未参与计算\r\n- [ ] 仅用 `obj`、全限定名、无 `using`\r\n- [ ] 空密钥有抛错或明确处理\r\n\r\n---\r\n\r\n## 示例(仅参考,生成时以用户需求为准)\r\n\r\n### 示例 A:HMAC-SHA1 + urlPath + 排序 param(阿里国际站 AOP 一类)\r\n\r\n- 拼接:`urlPath + sort(key+value)`,不含签名字段\r\n- 输出:Query `_aop_signature`,小写 hex\r\n- 参数在 **QueryFieldList**;JSON 优先读 `notifyBizEventDTO` 的 Value\r\n\r\n### 示例 B:MD5 + secret 包裹 + 排序 keyvalue(1688 一类)\r\n\r\n- 拼接:`appSecret + key1val1key2val2... + appSecret`\r\n- 输出:Query/Form 的 `sign`,常为大写 hex\r\n\r\n### 示例 C:HMAC-SHA256 + Header\r\n\r\n- Body 用 `SerializeObject(OriginalInterfaceMappedInput)` 参与拼接\r\n- 输出:`HeaderFieldList` 的 `X-Sign` 或 `Authorization`\r\n\r\n### 示例 D:JSON Body 内写字段\r\n\r\n- `obj.OriginalInterfaceMappedInput[\"sign\"] = sign;`\r\n\r\n更多代码模式见 [examples.md](examples.md)。\r\n\r\n---\r\n\r\n## 输出要求\r\n\r\n只输出一个 ```csharp 代码块(Natasha 方法体),不要额外解释。\r\n\r\n## 禁止事项\r\n\r\n- 不要用 `HttpClient` 再发请求\r\n- 不要改 `InParamTreeList` / `OutParamTreeList`\r\n- 不要用 `TargetInterfaceInput`\r\n- 不要输出 Python/JavaScript\r\n- 不要写 `using`;不要用 `context`\r\n- 不要把 Query/Header 签名写到 `FormFieldList`\r\n- **不要**在未确认文档时硬编码某一服务商的算法(如默认阿里 HMAC-SHA1)\r\n- 不要把签名字段放进待签名字符串\r\n","address":"你是国际物流 OMS 的收件地址解析助手(跨境打单场景)。\n目标:把客户粘贴的整段中/英收件信息拆成结构化字段,并做「粗校验」提示——只关心下单/预报大概能不能用,不做地图精校、不验证门牌是否真实、不臆造邮编与城市匹配。\n\n只输出 JSON,不要 Markdown,不要解释。字段:\n{\n \"recipientsName\": \"\",\n \"companyName\": \"\",\n \"phone\": \"\",\n \"telePhone\": \"\",\n \"email\": \"\",\n \"countryCode\": \"\",\n \"province\": \"\",\n \"city\": \"\",\n \"streetNumber\": \"\",\n \"streetAddress1\": \"\",\n \"streetAddress2\": \"\",\n \"streetAddress3\": \"\",\n \"zipCode\": \"\",\n \"warnings\": []\n}\n\n规则:\n1) countryCode 必须是 ISO 二字码大写(中国→CN,美国→US,英国→GB 不要用 UK,香港→HK,台湾→TW,澳门→MO)。无法判断则留空并在 warnings 说明。\n2) phone 优先填手机;固定电话放 telePhone;保留 + 国家码。\n3) 街道门牌放入 streetAddress1;过长拆到 streetAddress2/3;单独门牌号可放 streetNumber。\n4) 中国地址:省→province,市→city,区县街道合并进 streetAddress1(不要把省市区塞进姓名)。\n5) 英文地址:State→province,City→city,Street→streetAddress*,ZIP/Postcode→zipCode。\n6) 粗风险才写 warnings(短中文):缺国家/邮编/电话不像号码/街道与姓名粘连拆不清等。禁止写「地图不存在」「邮编与城市不匹配」这类无依据结论。\n7) 不确定的字段留空;不要编造原文没有的省市区邮编。\n8) 税号/EORI/VAT 不是本任务重点,原文没有就忽略。","billingCondition":"你是 OMS「计费条件」公式助手。根据用户中文描述,生成可粘贴到价格项 ChargingCondition 的中文公式(ChineseFormulaEvaluator)。\n\n## 产出格式(必须)\n\n公式:单行可执行表达式\n说明:一句话\n\n## 语法硬规则\n\n- 字段名必须是下方目录中的中文 label,禁止编造、禁止英文字段名\n- 字符串:字段包含(\u0027值\u0027);数值/聚合:字段() 再比较;布尔:字段()==1 / ==0\n- 字面量用单引号;逻辑用 \u0026\u0026 || !;比较用 == != \u003e \u003e= \u003c \u003c=\n- 禁止 and/or/not、中文「且」、双引号、字段() 用于字符串相等\n- 附加服务用:附加服务包含(\u0027insurance\u0027);偏远:是否偏远()==1\n- 空/「无」表示无条件(始终计费),不要输出空公式除非用户明确说无条件\n- 公式单行、无注释夹杂、长度 ≤ 10000\n- 不需要时机/操作(本场景仅计费条件)\n\n## 示例\n\n公式:附加服务包含(\u0027insurance\u0027)\n说明:客户勾选保险时计费\n\n公式:是否偏远()==1\n说明:偏远地址时计偏远费"} \ No newline at end of file diff --git a/src/Oms.Application/Services/Ai/AiSkillService.cs b/src/Oms.Application/Services/Ai/AiSkillService.cs new file mode 100644 index 0000000..226a7d3 --- /dev/null +++ b/src/Oms.Application/Services/Ai/AiSkillService.cs @@ -0,0 +1,246 @@ +using System.Text.Json; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Ai; +using Oms.Application.Dtos.Shared; +using Oms.Domain.AiAggregate.Entities; +using Oms.Domain.AiAggregate.Repositories; + +namespace Oms.Application.Services.Ai; + +/// +/// 平台级 AI Skill 管理(提示词/规范正文)。 +/// +[ApiExplorerSettings(GroupName = "OMS-Ai")] +public class AiSkillService : ApplicationService +{ + public const string ScenePricePublish = "pricePublish"; + public const string SceneAutoTriggerFormula = "autoTriggerFormula"; + public const string SceneBillingConditionFormula = "billingConditionFormula"; + public const string SceneReceiverAddressParse = "receiverAddressParse"; + public const string SceneItfxPrefixScript = "itfxPrefixScript"; + + public const string KeyPricePublish = "oms-sales-price-publish-risk"; + public const string KeyAutoTriggerFormula = "oms-auto-trigger-formula"; + public const string KeyBillingConditionFormula = "oms-billing-condition-formula"; + public const string KeyReceiverAddressParse = "oms-receiver-address-parse"; + public const string KeyItfxPrefixScript = "itfx-prefix-script"; + + private IAiSkillRepository AiSkillRepository => LazyServiceProvider.GetRequiredService(); + + /// + /// Skill 列表(可按场景过滤)。 + /// + [HttpPost("/ai/skill/list")] + public async Task> ListAsync(AiSkillListInput input) + { + await EnsureBuiltinSkillsAsync(); + var scene = NormalizeOptional(input.Scene); + var items = await AiSkillRepository.GetListAsync(x => + (scene == null || x.Scene == scene) && + (!input.EnabledOnly || x.Enabled)); + var ordered = items.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToList(); + return new OmsItemsResult + { + Items = ordered.Select(x => Map(x, input.IncludeContent)).ToList() + }; + } + + /// + /// 按 Id 或 SkillKey 获取 Skill(含正文)。 + /// + [HttpPost("/ai/skill/get")] + public async Task GetAsync(AiSkillGetInput input) + { + await EnsureBuiltinSkillsAsync(); + AiSkill? entity = null; + if (input.Id is > 0) + { + entity = await AiSkillRepository.FirstOrDefaultAsync(x => x.Id == input.Id.Value); + } + else if (!string.IsNullOrWhiteSpace(input.SkillKey)) + { + entity = await AiSkillRepository.GetBySkillKeyAsync(input.SkillKey.Trim()); + } + + if (entity == null) + { + throw new BusinessException("Skill 不存在"); + } + + return Map(entity, includeContent: true); + } + + /// + /// 新增或更新 Skill。 + /// + [HttpPost("/ai/skill/save")] + public async Task SaveAsync(AiSkillSaveInput input) + { + var skillKey = input.SkillKey.Trim(); + var scene = input.Scene.Trim(); + if (string.IsNullOrWhiteSpace(skillKey) || string.IsNullOrWhiteSpace(scene)) + { + throw new BusinessException("SkillKey / Scene 不能为空"); + } + + AiSkill? entity = null; + if (input.Id is > 0) + { + entity = await AiSkillRepository.FirstOrDefaultAsync(x => x.Id == input.Id.Value); + } + + entity ??= await AiSkillRepository.GetBySkillKeyAsync(skillKey); + + if (entity == null) + { + entity = new AiSkill { SkillKey = skillKey }; + } + else if (!string.Equals(entity.SkillKey, skillKey, StringComparison.OrdinalIgnoreCase)) + { + var conflict = await AiSkillRepository.GetBySkillKeyAsync(skillKey); + if (conflict != null && conflict.Id != entity.Id) + { + throw new BusinessException($"SkillKey 已存在:{skillKey}"); + } + + entity.SkillKey = skillKey; + } + + entity.Scene = scene; + entity.Name = input.Name.Trim(); + entity.Description = NormalizeOptional(input.Description); + entity.Content = input.Content ?? string.Empty; + entity.SortOrder = input.SortOrder; + entity.Enabled = input.Enabled; + + if (entity.Id > 0) + { + await AiSkillRepository.UpdateAsync(entity); + } + else + { + await AiSkillRepository.InsertAsync(entity); + } + + return Map(entity, includeContent: true); + } + + /// + /// 软删除 Skill。 + /// + [HttpPost("/ai/skill/delete")] + public async Task DeleteAsync(AiSkillDeleteInput input) + { + var entity = await AiSkillRepository.FirstOrDefaultAsync(x => x.Id == input.Id); + if (entity == null) + { + throw new BusinessException("Skill 不存在"); + } + + await AiSkillRepository.DeleteAsync(entity); + } + + /// + /// 供内部调用:按 key 取正文,不存在返回 null。 + /// + public async Task TryGetContentByKeyAsync(string skillKey) + { + if (string.IsNullOrWhiteSpace(skillKey)) return null; + await EnsureBuiltinSkillsAsync(); + var entity = await AiSkillRepository.GetBySkillKeyAsync(skillKey.Trim()); + if (entity == null || !entity.Enabled || string.IsNullOrWhiteSpace(entity.Content)) + { + return null; + } + + return entity.Content; + } + + private async Task EnsureBuiltinSkillsAsync() + { + var seeds = LoadSeedDefinitions(); + foreach (var seed in seeds) + { + var existing = await AiSkillRepository.GetBySkillKeyAsync(seed.SkillKey); + if (existing != null) continue; + + await AiSkillRepository.InsertAsync(new AiSkill + { + SkillKey = seed.SkillKey, + Scene = seed.Scene, + Name = seed.Name, + Description = seed.Description, + Content = seed.Content, + SortOrder = seed.SortOrder, + Enabled = true + }); + } + } + + private static List LoadSeedDefinitions() + { + var asm = typeof(AiSkillService).Assembly; + var resourceName = asm.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith("AiSkillSeedContent.json", StringComparison.OrdinalIgnoreCase)); + if (resourceName == null) + { + throw new InvalidOperationException("未找到嵌入资源 AiSkillSeedContent.json"); + } + + using var stream = asm.GetManifestResourceStream(resourceName)!; + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + var payload = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) + ?? throw new InvalidOperationException("AiSkillSeedContent.json 解析失败"); + + return + [ + new SeedDef(KeyPricePublish, ScenePricePublish, "销售产品价格发布风险", + "对比线上生效价与待发布价,输出中文风险报告", payload.Price, 10), + new SeedDef(KeyAutoTriggerFormula, SceneAutoTriggerFormula, "自动触发公式助手", + "根据中文描述生成可粘贴的自动触发公式(字段目录由前端追加)", payload.Formula, 20), + new SeedDef(KeyBillingConditionFormula, SceneBillingConditionFormula, "计费条件公式助手", + "根据中文描述生成价格项计费条件公式(字段目录由前端追加)", payload.BillingCondition, 25), + new SeedDef(KeyReceiverAddressParse, SceneReceiverAddressParse, "收件地址 AI 解析", + "解析粘贴的收件地址为结构化字段并做粗校验", payload.Address, 30), + new SeedDef(KeyItfxPrefixScript, SceneItfxPrefixScript, "接口通前置脚本", + "按用户需求生成 Natasha C# 前置签名脚本", payload.Itfx, 40), + ]; + } + + private static AiSkillOutput Map(AiSkill entity, bool includeContent) => new() + { + Id = entity.Id, + SkillKey = entity.SkillKey, + Scene = entity.Scene, + Name = entity.Name, + Description = entity.Description, + Content = includeContent ? entity.Content : string.Empty, + SortOrder = entity.SortOrder, + Enabled = entity.Enabled + }; + + private static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private sealed record SeedDef( + string SkillKey, + string Scene, + string Name, + string Description, + string Content, + int SortOrder); + + private sealed class SeedPayload + { + public string Price { get; set; } = string.Empty; + public string Formula { get; set; } = string.Empty; + public string BillingCondition { get; set; } = string.Empty; + public string Itfx { get; set; } = string.Empty; + public string Address { get; set; } = string.Empty; + } +} diff --git a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs new file mode 100644 index 0000000..8d7f591 --- /dev/null +++ b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs @@ -0,0 +1,428 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Channels; +using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Orders; +using Oms.Application.Services.Orders.Issues; +using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; + +namespace Oms.Application.Services.Channels; + +public partial class SalesProductService +{ + private IWarehouseAddressRepository WarehouseAddressRepository => + LazyServiceProvider.GetRequiredService(); + + /// + /// 基于当前库内卖价(非必须已发布)模拟计费,用于运营核对附加服务/偏远/申报比例等。 + /// + [HttpPost("/channel/sales-product/billing/simulate")] + public async Task SimulateBillingAsync( + [FromBody] SalesProductBillingSimulateInput input) + { + if (input.Length <= 0 || input.Width <= 0 || input.Height <= 0 || input.ActualWeight <= 0) + { + throw new BusinessException("长宽高与实重必须大于0"); + } + + var product = await GetProductOrThrowAsync(input.SalesProductId); + var countryCode = input.CountryCode?.Trim().ToUpperInvariant() + ?? throw new BusinessException("目的国家不能为空"); + if (string.IsNullOrWhiteSpace(countryCode)) + { + throw new BusinessException("目的国家不能为空"); + } + + var warnings = new List(); + var priceSnapshot = await BuildPricePublishSnapshotAsync(product.Id); + var rulesSnapshot = await BuildRulesPublishSnapshotAsync(product.Id); + + var weight = new OrderWeight + { + Type = OrderWeightTypeCodes.Forecast, + Length = input.Length, + Width = input.Width, + Height = input.Height, + ActualWeight = input.ActualWeight, + Cubic = ParcelBillingCalculator.CalculateCubic(input.Length, input.Width, input.Height) + }; + + decimal chargeableWeight; + try + { + chargeableWeight = await ResolveSimulateChargeableWeightAsync(product, rulesSnapshot, weight); + } + catch (BusinessException) + { + throw; + } + catch (InvalidOperationException ex) + { + throw new BusinessException($"计费重计算失败:{ex.Message}"); + } + + var zoneKeys = ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot( + priceSnapshot, + countryCode, + input.Province, + input.City, + input.ZipCode); + if (zoneKeys == null || zoneKeys.Count == 0) + { + throw new BusinessException("价格分区未匹配"); + } + + var isRemote = input.IsRemote + ?? await IsSimulateRemoteHitAsync(product, rulesSnapshot, countryCode, input.Province, input.City, input.ZipCode); + + var additionalServices = (input.AdditionalServices ?? []) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var allowed = OmsDocHelper.DeserializeJson>(product.AdditionalServices) ?? []; + var allowedSet = allowed + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var invalidServices = additionalServices.Where(x => !allowedSet.Contains(x)).ToList(); + if (invalidServices.Count > 0) + { + warnings.Add($"附加服务不在产品可选范围:{string.Join(", ", invalidServices)}"); + additionalServices = additionalServices.Where(allowedSet.Contains).ToList(); + } + + var cargoAttribute = string.IsNullOrWhiteSpace(input.CargoAttribute) + ? CargoAttributeCodes.General + : input.CargoAttribute.Trim(); + var warehouseName = await ResolveSimulateWarehouseNameAsync(input.WarehouseId); + var priceTierKey = string.IsNullOrWhiteSpace(input.PriceTierKey) ? null : input.PriceTierKey.Trim(); + var priceVersion = DateTime.Now.ToString("yyyyMMddHHmmss"); + var priceEffectiveTime = DateTime.Now; + + var conditionContext = OrderRuleEvaluationContext.FromOrderOnly(new Order + { + ProductCode = product.ProductCode, + ConsigneeCountry = countryCode, + CargoAttribute = cargoAttribute, + WarehouseId = input.WarehouseId, + DeclaredSum = input.DeclaredSum, + DeclaredCurrency = input.DeclaredCurrency, + IsRemote = isRemote ? (byte)1 : (byte)0, + AdditionalServices = additionalServices.Count == 0 + ? null + : OmsDocHelper.SerializeJson(additionalServices) + }); + + var receivableLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines( + priceSnapshot, + priceVersion, + priceEffectiveTime, + chargeableWeight, + zoneKeys, + cargoAttribute, + priceTierKey, + input.WarehouseId, + warehouseName, + conditionContext, + input.DeclaredSum, + input.DeclaredCurrency); + + if (receivableLines.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(priceSnapshot)) + { + var detail = ParcelBillingCalculator.ExplainQuotePriceMiss( + priceSnapshot, + chargeableWeight, + zoneKeys, + cargoAttribute, + priceTierKey, + input.WarehouseId, + warehouseName); + warnings.Add($"卖价未匹配到有效价格:{detail}"); + } + + var receivableFees = receivableLines.Select(x => new SalesProductBillingSimulateFeeOutput + { + CostItem = x.Fee.CostItem, + Amount = x.Fee.Amount, + Currency = x.Fee.Currency ?? string.Empty, + Formula = x.FormulaPart + }).ToList(); + + var payableFees = new List(); + try + { + payableFees = await TrySimulatePayableFeesAsync( + product, + rulesSnapshot, + zoneKeys, + chargeableWeight, + cargoAttribute, + input.WarehouseId, + warehouseName, + isRemote, + input.DeclaredSum, + input.DeclaredCurrency, + priceVersion, + priceEffectiveTime, + warnings); + } + catch (Exception ex) + { + warnings.Add($"应付估算失败:{ex.Message}"); + } + + var receivableTotal = SumByCurrency(receivableFees); + var payableTotal = SumByCurrency(payableFees); + decimal? margin = null; + if (receivableTotal.Count == 1 + && payableTotal.Count == 1 + && string.Equals(receivableTotal.Keys.First(), payableTotal.Keys.First(), StringComparison.OrdinalIgnoreCase)) + { + margin = receivableTotal.Values.First() - payableTotal.Values.First(); + } + else if (receivableTotal.Count > 1 || payableTotal.Count > 1) + { + warnings.Add("多币种费用无法直接计算毛利"); + } + else if (receivableTotal.Count == 1 && payableTotal.Count == 0) + { + warnings.Add("仅返回应收估算,无应付费用"); + } + + return new SalesProductBillingSimulateOutput + { + ChargeableWeight = chargeableWeight, + ZoneKeys = zoneKeys.ToList(), + IsRemote = isRemote, + ReceivableFees = receivableFees, + PayableFees = payableFees, + ReceivableTotal = receivableTotal, + PayableTotal = payableTotal, + Margin = margin, + Warnings = warnings + }; + } + + private async Task ResolveSimulateChargeableWeightAsync( + SalesProduct product, + SalesProductRulesPublishSnapshotDto rulesSnapshot, + OrderWeight weight) + { + var weightRuleId = product.WeightRuleId is > 0 + ? product.WeightRuleId + : rulesSnapshot.WeightRuleId; + if (weightRuleId is not > 0) + { + throw new BusinessException("未配置计重规则"); + } + + IReadOnlyList ruleInfos; + if (rulesSnapshot.WeightRuleInfos is { Count: > 0 }) + { + ruleInfos = RulesSnapshotHelper.ToWeightRuleEntities(rulesSnapshot.WeightRuleInfos); + } + else + { + ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value); + } + + var (_, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(weight, ruleInfos); + + var weightRoundingId = product.WeightRoundingId is > 0 + ? product.WeightRoundingId + : rulesSnapshot.WeightRoundingId; + if (weightRoundingId is not > 0) + { + throw new BusinessException("未配置计重进位"); + } + + IReadOnlyList ranges; + if (rulesSnapshot.WeightRoundingInfos is { Count: > 0 }) + { + ranges = RulesSnapshotHelper.ToWeightRoundingEntities(rulesSnapshot.WeightRoundingInfos); + } + else + { + ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value); + } + + return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); + } + + private async Task IsSimulateRemoteHitAsync( + SalesProduct product, + SalesProductRulesPublishSnapshotDto rulesSnapshot, + string countryCode, + string? province, + string? city, + string? zipCode) + { + var areaId = product.RemoteAreaId is > 0 ? product.RemoteAreaId : rulesSnapshot.RemoteAreaId; + if (areaId is not > 0) + { + return false; + } + + if (rulesSnapshot.RemoteAreaInfos is { Count: > 0 }) + { + return RulesSnapshotHelper.IsAreaHitFromSnapshot( + rulesSnapshot.RemoteAreaInfos, + countryCode, + province, + city, + zipCode); + } + + var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); + return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( + rule, + countryCode, + province, + city, + zipCode)); + } + + private async Task ResolveSimulateWarehouseNameAsync(int? warehouseId) + { + if (warehouseId is not > 0) + { + return null; + } + + var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == warehouseId.Value); + return warehouse?.WarehouseName; + } + + private async Task> TrySimulatePayableFeesAsync( + SalesProduct product, + SalesProductRulesPublishSnapshotDto rulesSnapshot, + IReadOnlyList zoneKeys, + decimal chargeableWeight, + string cargoAttribute, + int? warehouseId, + string? warehouseName, + bool isRemote, + decimal? declaredSum, + string? declaredCurrency, + string priceVersion, + DateTime priceEffectiveTime, + List warnings) + { + var providers = rulesSnapshot.Providers.Count > 0 + ? rulesSnapshot.Providers + : (await GetAllocationCoreAsync(product.Id)).Providers + .Select(PublishSnapshotSlimmer.SlimAllocationProvider) + .ToList(); + var filtered = AllocationResolver.FilterProviders(providers, customerId: null); + var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0); + if (provider?.ServiceChannelId is not > 0) + { + warnings.Add("未配置可用排货服务渠道,跳过应付估算"); + return []; + } + + var channelPrices = await BuildChannelPriceOutputsAsync(provider.ServiceChannelId.Value); + if (channelPrices.Count == 0) + { + warnings.Add($"服务渠道 {provider.ServiceChannelId} 无买价,跳过应付估算"); + return []; + } + + var payableContext = OrderRuleEvaluationContext.FromOrderOnly(new Order + { + ProductCode = product.ProductCode, + ConsigneeCountry = null, + CargoAttribute = cargoAttribute, + WarehouseId = warehouseId, + DeclaredSum = declaredSum, + DeclaredCurrency = declaredCurrency, + IsRemote = isRemote ? (byte)1 : (byte)0 + }); + + var payable = ParcelBillingCalculator.CalculatePayableFees( + channelPrices, + priceVersion, + priceEffectiveTime, + chargeableWeight, + zoneKeys, + cargoAttribute, + OrderFeeChargingStageCodes.Forecast, + warehouseId, + warehouseName, + payableContext, + declaredSum, + declaredCurrency); + + if (payable.Count == 0) + { + warnings.Add($"服务渠道 {provider.ServiceChannelId} 买价未匹配,跳过应付估算"); + return []; + } + + warnings.Add($"应付按排货服务渠道 {provider.ServiceChannelId} 估算(首个可用渠道)"); + return payable.Select(x => new SalesProductBillingSimulateFeeOutput + { + CostItem = x.CostItem, + Amount = x.Amount, + Currency = x.Currency ?? string.Empty + }).ToList(); + } + + private async Task> BuildChannelPriceOutputsAsync(int serviceChannelId) + { + var prices = await ServiceChannelRepository.GetPricesAsync(serviceChannelId); + var priceIds = prices.Select(x => x.Id).ToArray(); + var factorMap = (await ServiceChannelRepository.GetPriceFactorsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList()); + var detailMap = (await ServiceChannelRepository.GetPriceDetailsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()); + + return prices.Select(price => new ServiceChannelPriceListOutput + { + Id = price.Id, + ServiceChannelId = price.ServiceChannelId, + SerialNumber = price.SerialNumber, + Status = (byte)price.Status, + PriceName = price.PriceName, + CostItem = price.CostItem, + CostUnit = price.CostUnit, + Currency = price.Currency, + ChargingCondition = price.ChargingCondition, + Factors = factorMap.TryGetValue(price.Id, out var factors) + ? factors.Select(f => new ChannelPriceFactorOutput + { + Id = f.Id, + PriceId = f.PriceId, + SerialNumber = f.SerialNumber, + Factor = f.Factor, + FactorValues = OmsDocHelper.DeserializeJson>(f.FactorValues) ?? [] + }).ToList() + : [], + Details = detailMap.TryGetValue(price.Id, out var details) + ? details.Select(d => new ChannelPriceDetailOutput + { + Id = d.Id, + PriceId = d.PriceId, + FactorValuesJson = OmsDocHelper.DeserializeJsonObject(d.FactorValuesJson), + CalculationFormula = d.CalculationFormula, + CalculatedValue = d.CalculatedValue, + CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(d.CustomerGroupValues) + }).ToList() + : [] + }).ToList(); + } + + private static Dictionary SumByCurrency(IEnumerable fees) + { + return fees + .GroupBy(x => string.IsNullOrWhiteSpace(x.Currency) ? "UNK" : x.Currency.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Sum(x => x.Amount), StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index e363b3b..e5b097c 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -211,6 +211,7 @@ public partial class SalesProductService MinTimeliness = product.MinTimeliness, MaxTimeliness = product.MaxTimeliness, TradeTerms = product.TradeTerms, + AdditionalServices = product.AdditionalServices, CustomerWaybill = product.CustomerWaybill, LabelTemplateId = product.LabelTemplateId, BlacklistAreaId = product.BlacklistAreaId, @@ -234,9 +235,19 @@ public partial class SalesProductService string? description, string? aiAnalysisReport = null) { - object snapshot = operatorType == PriceOperatorType - ? await BuildPricePublishSnapshotAsync(salesProductId) - : await BuildRulesPublishSnapshotAsync(salesProductId); + object snapshot; + if (operatorType == PriceOperatorType) + { + var priceSnapshot = await BuildPricePublishSnapshotAsync(salesProductId); + ValidateBillingPriceItemRules( + priceSnapshot.Prices.Select(p => MapPriceSaveInput(p, salesProductId)).ToList()); + await PriceAreaSnapshotHelper.EnsureSingleResolvedPriceAreaAsync(priceSnapshot, KpiAreaRepository); + snapshot = priceSnapshot; + } + else + { + snapshot = await BuildRulesPublishSnapshotAsync(salesProductId); + } // Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。 await SalesProductRepository.InsertPublishRecordAsync(new SalesProductPublishRecord @@ -322,11 +333,13 @@ public partial class SalesProductService entity.MinTimeliness = input.MinTimeliness; entity.MaxTimeliness = input.MaxTimeliness; entity.TradeTerms = NormalizeTradeTerms(input.TradeTerms); + entity.AdditionalServices = NormalizeAdditionalServices(input.AdditionalServices); entity.CustomerWaybill = input.CustomerWaybill; entity.LabelTemplateId = input.LabelTemplateId; entity.BlacklistAreaId = input.BlacklistAreaId; entity.RemoteAreaId = input.RemoteAreaId; - entity.PriceAreaId = input.PriceAreaId; + // 价格分区改由卖价明细分区推断并固化进发布快照,产品表不再持久化 PriceAreaId。 + entity.PriceAreaId = null; entity.WeightRuleId = input.WeightRuleId; entity.WeightRoundingId = input.WeightRoundingId; entity.ProductDescription = input.ProductDescription; @@ -342,6 +355,85 @@ public partial class SalesProductService return value is "DDP" or "DDU" ? value : null; } + private static SalesProductPricePublishSnapshotDto BuildPriceSnapshotFromSaveInputs( + int salesProductId, + IReadOnlyList prices) => + new() + { + SalesProductId = salesProductId, + Prices = prices.Select(price => new SalesProductPriceSnapshotItemDto + { + Id = price.Id ?? 0, + SerialNumber = price.SerialNumber, + Status = (byte)price.Status, + PriceName = price.PriceName, + CostItem = price.CostItem, + CostUnit = price.CostUnit, + Currency = price.Currency, + ChargingCondition = price.ChargingCondition, + Details = price.Details.Select(detail => new ChannelPriceDetailOutput + { + Id = detail.Id ?? 0, + FactorValuesJson = detail.FactorValuesJson, + CalculationFormula = detail.CalculationFormula, + CalculatedValue = detail.CalculatedValue, + CustomerGroupValues = detail.CustomerGroupValues + }).ToList() + }).ToList() + }; + + /// + /// 保险/偏远费等附加费用项的计费条件校验。 + /// + private static void ValidateBillingPriceItemRules(IReadOnlyList prices) + { + foreach (var price in prices) + { + var costItem = price.CostItem?.Trim() ?? string.Empty; + var condition = price.ChargingCondition?.Trim() ?? string.Empty; + if (costItem.Equals("insurance", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(condition) + || string.Equals(condition, "无", StringComparison.OrdinalIgnoreCase)) + { + throw new Fake.Application.BusinessException("保险费用项必须配置非空计费条件(不能为「无」)"); + } + } + + if (costItem.Equals("remoteFee", StringComparison.OrdinalIgnoreCase) + || costItem.Equals("remote_fee", StringComparison.OrdinalIgnoreCase) + || costItem.Contains("偏远", StringComparison.OrdinalIgnoreCase)) + { + if (!condition.Contains("是否偏远", StringComparison.Ordinal)) + { + throw new Fake.Application.BusinessException("偏远费用项的计费条件必须包含「是否偏远」"); + } + } + } + } + + private static string? NormalizeAdditionalServices(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var trimmed = raw.Trim(); + var codes = OmsDocHelper.DeserializeJson>(trimmed); + if (codes == null) + { + return trimmed; + } + + var normalized = codes + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + return normalized.Count == 0 ? null : OmsDocHelper.SerializeJson(normalized); + } + private static SalesProductListOutput MapListOutput(SalesProduct entity) { return new SalesProductListOutput @@ -352,6 +444,7 @@ public partial class SalesProductService ProductGroup = entity.ProductGroup, MinTimeliness = entity.MinTimeliness, MaxTimeliness = entity.MaxTimeliness, + AdditionalServices = entity.AdditionalServices, CustomerWaybill = entity.CustomerWaybill, LabelTemplateId = entity.LabelTemplateId, BlacklistAreaId = entity.BlacklistAreaId, @@ -380,6 +473,7 @@ public partial class SalesProductService MinTimeliness = entity.MinTimeliness, MaxTimeliness = entity.MaxTimeliness, TradeTerms = entity.TradeTerms, + AdditionalServices = entity.AdditionalServices, CustomerWaybill = entity.CustomerWaybill, LabelTemplateId = entity.LabelTemplateId, BlacklistAreaId = entity.BlacklistAreaId, diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index afad134..ff28367 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -158,6 +158,7 @@ public partial class SalesProductService : ApplicationService public async Task CreatePriceAsync(SalesProductPriceSaveInput input) { await GetProductOrThrowAsync(input.SalesProductId); + ValidateBillingPriceItemRules([input]); await SalesProductRepository.InsertPriceAsync( new SalesProductPrice { @@ -188,6 +189,7 @@ public partial class SalesProductService : ApplicationService throw new BusinessException("价格项归属不匹配"); } + ValidateBillingPriceItemRules([input]); price.SerialNumber = input.SerialNumber; price.Status = input.Status; price.PriceName = input.PriceName; @@ -234,6 +236,10 @@ public partial class SalesProductService : ApplicationService public async Task SavePriceAsync(SalesProductPriceSaveAllInput input) { await GetProductOrThrowAsync(input.SalesProductId); + ValidateBillingPriceItemRules(input.Prices); + await PriceAreaSnapshotHelper.EnsureSingleResolvedPriceAreaAsync( + BuildPriceSnapshotFromSaveInputs(input.SalesProductId, input.Prices), + KpiAreaRepository); var prices = new List(); var factors = new List(); var details = new List(); diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index 69eabf1..af3ea5f 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -226,6 +226,7 @@ public partial class ServiceChannelService ForecastType = entity.ForecastType, ForecastCode = entity.ForecastCode, Description = entity.Description, + RemoteAreaId = entity.RemoteAreaId, PreScript = entity.PreScript, IsActive = entity.IsActive }; @@ -307,10 +308,32 @@ public partial class ServiceChannelService entity.ForecastType = input.ForecastType; entity.ForecastCode = input.ForecastCode; entity.Description = input.Description; + entity.RemoteAreaId = input.RemoteAreaId; entity.PreScript = input.PreScript; entity.IsActive = input.IsActive; } + /// + /// 买价偏远费等附加费用项的计费条件校验。 + /// + private static void ValidateBillingPriceItemRules(IReadOnlyList prices) + { + foreach (var price in prices) + { + var costItem = price.CostItem?.Trim() ?? string.Empty; + var condition = price.ChargingCondition?.Trim() ?? string.Empty; + if (costItem.Equals("remoteFee", StringComparison.OrdinalIgnoreCase) + || costItem.Equals("remote_fee", StringComparison.OrdinalIgnoreCase) + || costItem.Contains("偏远", StringComparison.OrdinalIgnoreCase)) + { + if (!condition.Contains("是否偏远", StringComparison.Ordinal)) + { + throw new Fake.Application.BusinessException("偏远费用项的计费条件必须包含「是否偏远」"); + } + } + } + } + private static ServiceChannelGroupListOutput MapGroupListOutput(ServiceChannelGroup entity) { return new ServiceChannelGroupListOutput @@ -338,6 +361,7 @@ public partial class ServiceChannelService ForecastType = entity.ForecastType, ForecastCode = entity.ForecastCode, Description = entity.Description, + RemoteAreaId = entity.RemoteAreaId, IsActive = (byte)entity.IsActive, CreateTime = entity.CreateTime, UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) @@ -361,6 +385,7 @@ public partial class ServiceChannelService ForecastType = entity.ForecastType, ForecastCode = entity.ForecastCode, Description = entity.Description, + RemoteAreaId = entity.RemoteAreaId, PreScript = entity.PreScript, IsActive = (byte)entity.IsActive, CreateTime = entity.CreateTime, diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index 389c859..dea8476 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -208,6 +208,7 @@ public partial class ServiceChannelService : ApplicationService public async Task CreatePriceAsync(ServiceChannelPriceSaveInput input) { await GetEntityOrThrowAsync(input.ServiceChannelId); + ValidateBillingPriceItemRules([input]); await ServiceChannelRepository.InsertPriceAsync( new ServiceChannelPrice { @@ -238,6 +239,7 @@ public partial class ServiceChannelService : ApplicationService throw new BusinessException("买价项归属不匹配"); } + ValidateBillingPriceItemRules([input]); price.SerialNumber = input.SerialNumber; price.Status = input.Status; price.PriceName = input.PriceName; @@ -284,6 +286,7 @@ public partial class ServiceChannelService : ApplicationService public async Task SavePriceAsync(ServiceChannelPriceSaveAllInput input) { await GetEntityOrThrowAsync(input.ServiceChannelId); + ValidateBillingPriceItemRules(input.Prices); var prices = new List(); var factors = new List(); var details = new List(); diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs index 50ae08e..0e9e8ce 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs @@ -146,6 +146,7 @@ public static class AutoTriggerFormulaMeta yield return ("订单件数", "t_order", "Quantity", FormulaValueType.Number, false); yield return ("是否拦截", "t_order", "IsIntercept", FormulaValueType.Boolean, false); yield return ("是否偏远", "t_order", "IsRemote", FormulaValueType.Boolean, false); + yield return ("附加服务", "t_order", "AdditionalServices", FormulaValueType.String, false); // t_order_recipient yield return ("收件人姓名", "t_order_recipient", "RecipientsName", FormulaValueType.String, false); diff --git a/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs b/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs index c62bc0b..077a4bf 100644 --- a/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs +++ b/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs @@ -123,10 +123,29 @@ public static class OrderFormulaFieldResolver "DeclaredCurrency" => input.DeclaredCurrency, "DeclaredSum" => input.DeclaredSum, "Quantity" => input.Quantity, + "IsRemote" => input.IsRemote, + "AdditionalServices" => SerializeAdditionalServices(input.AdditionalServices), _ => null }; } + private static string? SerializeAdditionalServices(List? codes) + { + if (codes == null || codes.Count == 0) + { + return null; + } + + var normalized = codes + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + return normalized.Count == 0 + ? null + : global::System.Text.Json.JsonSerializer.Serialize(normalized); + } + private static object? ResolveRecipientField(string field, OrderRuleEvaluationContext context) { if (context.Recipient != null) diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs index 97b5db7..cc2bbb0 100644 --- a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs +++ b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs @@ -86,27 +86,12 @@ public partial class OrderService ?? throw new BusinessException("理赔件参数无效"); var payload = NormalizeClaimedPayload(input); var order = await GetOrderOrThrowAsync(orderId); - - if (!OrderExceptionStatusRules.CanMarkException(order.OrderStatus)) - { - throw new BusinessException(OrderExceptionStatusRules.GetForbiddenReason(order.OrderStatus)); - } - var fromStatus = order.OrderStatus; - order.PreviousOrderStatus = fromStatus; - order.OrderStatus = OrderStatusCodes.Claimed; - order.HandleStatus = OrderHandleStatusCodes.Pending; - order.ClaimType = payload.ClaimType; - order.ClaimAmount = payload.ClaimAmount; - order.ClaimCurrency = payload.ClaimCurrency; - order.ClaimReason = payload.ClaimReason; - order.ClaimTime = DateTime.Now; - order.IssueType = null; - order.IssueTitle = null; - order.IssueContent = null; - order.IssueTemplateId = null; - order.IssueRemark = null; - order.IssueTime = null; + var reason = TryApplyClaimed(order, payload); + if (!string.IsNullOrWhiteSpace(reason)) + { + throw new BusinessException(reason); + } await OrderRepository.UpdateAsync(order); await OrderExceptionEventRepository.InsertAsync(new OrderExceptionEvent @@ -117,10 +102,10 @@ public partial class OrderService ToStatus = OrderStatusCodes.Claimed, FromHandleStatus = null, ToHandleStatus = OrderHandleStatusCodes.Pending, - ClaimType = payload.ClaimType, - ClaimAmount = payload.ClaimAmount, - ClaimCurrency = payload.ClaimCurrency, - ClaimReason = payload.ClaimReason, + ClaimType = order.ClaimType, + ClaimAmount = order.ClaimAmount, + ClaimCurrency = order.ClaimCurrency, + ClaimReason = order.ClaimReason, Remark = payload.Remark, }); } diff --git a/src/Oms.Application/Services/Orders/OrderService.Exception.cs b/src/Oms.Application/Services/Orders/OrderService.Exception.cs index 502ea6f..a0cf383 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Exception.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Exception.cs @@ -55,30 +55,7 @@ public partial class OrderService var payload = NormalizeClaimedPayload(input); return await ExecuteExceptionBatchAsync( input.OrderIds, - order => - { - if (!OrderExceptionStatusRules.CanMarkException(order.OrderStatus)) - { - return OrderExceptionStatusRules.GetForbiddenReason(order.OrderStatus); - } - - var fromStatus = order.OrderStatus; - order.PreviousOrderStatus = fromStatus; - order.OrderStatus = OrderStatusCodes.Claimed; - order.HandleStatus = OrderHandleStatusCodes.Pending; - order.ClaimType = payload.ClaimType; - order.ClaimAmount = payload.ClaimAmount; - order.ClaimCurrency = payload.ClaimCurrency; - order.ClaimReason = payload.ClaimReason; - order.ClaimTime = DateTime.Now; - order.IssueType = null; - order.IssueTitle = null; - order.IssueContent = null; - order.IssueTemplateId = null; - order.IssueRemark = null; - order.IssueTime = null; - return null; - }, + order => TryApplyClaimed(order, payload), (order, remark) => new OrderExceptionEvent { OrderId = order.Id, @@ -87,10 +64,10 @@ public partial class OrderService ToStatus = OrderStatusCodes.Claimed, FromHandleStatus = null, ToHandleStatus = OrderHandleStatusCodes.Pending, - ClaimType = payload.ClaimType, - ClaimAmount = payload.ClaimAmount, - ClaimCurrency = payload.ClaimCurrency, - ClaimReason = payload.ClaimReason, + ClaimType = order.ClaimType, + ClaimAmount = order.ClaimAmount, + ClaimCurrency = order.ClaimCurrency, + ClaimReason = order.ClaimReason, Remark = remark }, payload.Remark); @@ -319,12 +296,7 @@ public partial class OrderService } var currency = input.ClaimCurrency?.Trim().ToUpperInvariant(); - if (string.IsNullOrWhiteSpace(currency)) - { - throw new BusinessException("索赔币种不能为空"); - } - - if (currency.Length > 16) + if (!string.IsNullOrWhiteSpace(currency) && currency.Length > 16) { throw new BusinessException("索赔币种长度不能超过16"); } @@ -350,12 +322,71 @@ public partial class OrderService { ClaimType = claimType, ClaimAmount = input.ClaimAmount.Value, - ClaimCurrency = currency, + ClaimCurrency = string.IsNullOrWhiteSpace(currency) ? null : currency, ClaimReason = reason, Remark = remark }; } + /// + /// 按订单附加服务/申报金额校验并写入理赔字段。 + /// + private static string? TryApplyClaimed(Order order, ResolvedClaimedPayload payload) + { + if (!OrderExceptionStatusRules.CanMarkException(order.OrderStatus)) + { + return OrderExceptionStatusRules.GetForbiddenReason(order.OrderStatus); + } + + if (order.DeclaredSum is > 0 && payload.ClaimAmount > order.DeclaredSum.Value) + { + return "索赔金额不能超过申报金额"; + } + + var hasInsurance = ParseAdditionalServiceCodes(order.AdditionalServices) + .Contains("insurance"); + var currency = payload.ClaimCurrency; + if (string.IsNullOrWhiteSpace(currency)) + { + if (hasInsurance && !string.IsNullOrWhiteSpace(order.DeclaredCurrency)) + { + currency = order.DeclaredCurrency.Trim().ToUpperInvariant(); + } + else + { + return "索赔币种不能为空"; + } + } + + var fromStatus = order.OrderStatus; + order.PreviousOrderStatus = fromStatus; + order.OrderStatus = OrderStatusCodes.Claimed; + order.HandleStatus = OrderHandleStatusCodes.Pending; + order.ClaimType = payload.ClaimType; + order.ClaimAmount = payload.ClaimAmount; + order.ClaimCurrency = currency; + order.ClaimReason = payload.ClaimReason; + order.ClaimTime = DateTime.Now; + order.IssueType = null; + order.IssueTitle = null; + order.IssueContent = null; + order.IssueTemplateId = null; + order.IssueTime = null; + if (!hasInsurance) + { + const string uninsuredPrefix = "[未购保险]"; + order.IssueRemark = string.IsNullOrWhiteSpace(payload.Remark) + ? uninsuredPrefix + : $"{uninsuredPrefix}{payload.Remark}"; + } + else + { + order.IssueRemark = null; + } + + return null; + } + private sealed class ResolvedProblematicPayload { public string IssueType { get; set; } = string.Empty; @@ -377,7 +408,7 @@ public partial class OrderService public decimal ClaimAmount { get; set; } - public string ClaimCurrency { get; set; } = string.Empty; + public string? ClaimCurrency { get; set; } public string ClaimReason { get; set; } = string.Empty; diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index f40183d..2f3b88b 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -101,6 +101,10 @@ public partial class OrderService var receivableAmount = feeOutputs .Where(x => x.Type == OrderFeeTypeCodes.Receivable) .Sum(x => x.Amount); + var additionalServices = NormalizeAdditionalServiceCodes( + OmsDocHelper.DeserializeJson>(order.AdditionalServices)); + var hasInsurance = additionalServices.Any(x => + string.Equals(x, "insurance", StringComparison.OrdinalIgnoreCase)); string? customerName = null; string? customerCode = null; if (order.CustomerId is > 0 && customerMap.TryGetValue(order.CustomerId.Value, out var customer)) @@ -186,6 +190,10 @@ public partial class OrderService SignTime = order.SignTime, IsIntercept = order.IsIntercept > 0, IsRemote = order.IsRemote > 0, + AdditionalServices = additionalServices, + HasInsurance = hasInsurance, + ForecastReceivableAmount = order.ForecastReceivableAmount, + ForecastPayableAmount = order.ForecastPayableAmount, CreateTime = order.CreateTime, CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.CreateUserId, auditUserNameMap), UpdateTime = OmsDocHelper.NullIfDefault(order.UpdateTime), diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 63b583e..c9eea5e 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -230,6 +230,7 @@ public partial class OrderService public async Task RecalculateReceivableFeesAsync(OrderActionInput input) { var order = await GetOrderOrThrowAsync(input.Id); + EnsureReceivableMutable(order); await RecalculateReceivableCoreAsync(order); return (await MapOrderOutputsAsync([order])).First(); } @@ -245,6 +246,67 @@ public partial class OrderService return (await MapOrderOutputsAsync([order])).First(); } + /// + /// 未付款订单更新附加服务并重算当前阶段应收(可选尝试重算应付)。 + /// + [HttpPost("/order/additional-services/update")] + public async Task UpdateAdditionalServicesAsync(OrderAdditionalServicesUpdateInput input) + { + var order = await GetOrderOrThrowAsync(input.Id); + EnsureReceivableMutable(order); + + if (string.IsNullOrWhiteSpace(order.ProductCode)) + { + throw new BusinessException("订单未绑定销售产品,无法更新附加服务"); + } + + var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + if (product == null) + { + throw new BusinessException("销售产品不存在"); + } + + EnsureAdditionalServicesAllowed(product, input.AdditionalServices); + order.AdditionalServices = SerializeAdditionalServices(input.AdditionalServices); + + var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]); + var (weightType, chargingStage) = ResolveReceivableBillingStage(order); + var weightRow = weights.FirstOrDefault(x => x.Type == weightType) + ?? throw new BusinessException($"缺少件重尺类型 {weightType}"); + if (!weightRow.ChargeableWeight.HasValue || weightRow.ChargeableWeight <= 0) + { + throw new BusinessException("计费重无效,请先完善件重尺"); + } + + var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); + var billing = await ResolveOrderBillingContextAsync( + order, + product, + weightRow.ChargeableWeight ?? 0, + snapshotBinding); + + if (chargingStage == OrderFeeChargingStageCodes.Inbound) + { + await OrderRepository.DeleteReceivableFeesAsync(order.Id); + await UpsertReceivableFeesAsync(order, billing, chargingStage); + } + else + { + await UpsertReceivableFeesAsync( + order, + billing, + chargingStage, + replaceExistingReceivable: true); + } + + await RefreshMarketingPayableAmountAsync(order); + await TryConfirmServiceChannelAndPayableAsync(order, product, billing, chargingStage); + + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + return (await MapOrderOutputsAsync([order])).First(); + } + /// /// 手动添加订单费用(应收或应付)。 /// @@ -259,6 +321,11 @@ public partial class OrderService throw new BusinessException("费用类型无效"); } + if (type == OrderFeeTypeCodes.Receivable) + { + EnsureReceivableMutable(order); + } + var costItem = input.CostItem?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(costItem)) { diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 26e1309..3a9d009 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -85,6 +85,8 @@ public partial class OrderService throw new BusinessException("销售产品不存在或未启用"); } + EnsureAdditionalServicesAllowed(product, input.AdditionalServices); + var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime); var country = await GetCountryOrThrowAsync(input.Receiver!.CountryCode); @@ -162,13 +164,18 @@ public partial class OrderService billing, OrderFeeChargingStageCodes.Forecast); - await ApplyMarketingSnapshotOnCreateAsync(order, input.CouponInstanceId); + // 预报阶段尝试排货写应付;失败不阻断创建(仅记录 AllocationPendingReason)。 + await TryConfirmServiceChannelAndPayableAsync( + order, + product, + billing, + OrderFeeChargingStageCodes.Forecast); + + await ApplyMarketingSnapshotOnCreateAsync(order, input.CouponInstanceId); + await CaptureForecastBillingSnapshotAsync(order); - // 创建订单不对客户暴露排货/买价:渠道与应付在入库等环节由运营侧处理。 order.OrderStatus = OrderStatusCodes.Predeclared; order.PredeclareFailReason = string.Empty; - order.AllocationPendingReason = string.Empty; - order.IsRemote = order.IsRemote; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); @@ -681,11 +688,7 @@ public partial class OrderService int? warehouseId = null, string? warehouseName = null) { - if (chargeableWeight <= 0) - { - throw new BusinessException("计费重无效,无法计算应收费用"); - } - + var conditionContext = BuildBillingConditionContext(order); var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshotBinding.Price, snapshotBinding.PriceVersion, @@ -696,10 +699,18 @@ public partial class OrderService priceTierKey, chargingStage, warehouseId ?? order.WarehouseId, - warehouseName); + warehouseName, + conditionContext, + order.DeclaredSum, + order.DeclaredCurrency); if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price)) { + if (chargeableWeight <= 0) + { + throw new BusinessException("计费重无效,无法计算应收费用"); + } + var detail = ParcelBillingCalculator.ExplainQuotePriceMiss( snapshotBinding.Price, chargeableWeight, @@ -735,11 +746,6 @@ public partial class OrderService string chargingStage, string? _) { - if (chargeableWeight <= 0) - { - throw new BusinessException("计费重无效,无法计算应付费用"); - } - var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId); var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules); var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId); @@ -753,6 +759,7 @@ public partial class OrderService ?? product.Scheme ?? AllocationSchemeCodes.CostLowest; var candidates = new List(); + var recipient = (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault(); foreach (var provider in filtered) { @@ -762,6 +769,17 @@ public partial class OrderService continue; } + var channelIsRemote = await IsChannelRemoteHitAsync( + channel, + order.ConsigneeCountry ?? string.Empty, + recipient?.Province, + recipient?.City, + recipient?.ZipCode); + // 买价计费条件用渠道偏远命中,不覆盖订单上的产品偏远标记。 + var payableContext = BuildBillingConditionContext( + order, + channelIsRemote ? (byte)1 : (byte)0); + var channelPriceBinding = await ResolveChannelPriceBindingAsync(channel.Id, bindTime); var previewFees = ParcelBillingCalculator.CalculatePayableFees( channelPriceBinding.Prices, @@ -772,7 +790,10 @@ public partial class OrderService order.CargoAttribute ?? CargoAttributeCodes.General, chargingStage, order.WarehouseId, - warehouseName); + warehouseName, + payableContext, + order.DeclaredSum, + order.DeclaredCurrency); if (previewFees.Count == 0) { continue; @@ -789,6 +810,11 @@ public partial class OrderService if (candidates.Count == 0) { + if (chargeableWeight <= 0) + { + throw new BusinessException("计费重无效,无法计算应付费用"); + } + throw new BusinessException("排货规则未匹配到有效渠道买价"); } @@ -804,7 +830,8 @@ public partial class OrderService order, selectedCandidate.Provider, selectedCandidate.Channel); - await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage); + // 有效应付只保留本阶段一套,避免 forecast+inbound 叠行虚增成本。 + await OrderRepository.DeletePayableFeesAsync(order.Id); await OrderRepository.UpsertFeesAsync(order.Id, payableFees); order.AllocationPendingReason = string.Empty; } @@ -1358,26 +1385,6 @@ public partial class OrderService weightRow.Cubic = ParcelBillingCalculator.CalculateCubic(length, width, height); } - private async Task ApplyStageReceivableBillingAsync( - Order order, - SalesProduct product, - OrderWeight weightRow, - ProductSnapshotBinding snapshotBinding, - string chargingStage) - { - var billing = await ResolveOrderBillingContextAsync( - order, - product, - weightRow.ChargeableWeight ?? 0, - snapshotBinding); - - await UpsertReceivableFeesAsync( - order, - billing, - chargingStage, - replaceExistingReceivable: true); - } - private static int? ResolveEffectiveWeightRuleId( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot) @@ -1513,6 +1520,7 @@ public partial class OrderService Quantity = input.Quantity, IsIntercept = 0, IsRemote = 0, + AdditionalServices = SerializeAdditionalServices(input.AdditionalServices), PredeclareFailReason = string.Empty, AllocationPendingReason = string.Empty, SalesProductRulesVersion = snapshotBinding.RulesVersion, @@ -1805,11 +1813,12 @@ public partial class OrderService receive.ChargeableWeight ?? 0, snapshotBinding); + // 入库重算应收:先清空全部应收,再按 inbound 阶段写入。 + await OrderRepository.DeleteReceivableFeesAsync(order.Id); await UpsertReceivableFeesAsync( order, billing, - OrderFeeChargingStageCodes.Inbound, - replaceExistingReceivable: true); + OrderFeeChargingStageCodes.Inbound); var allocated = await TryConfirmServiceChannelAndPayableAsync( order, @@ -1817,6 +1826,8 @@ public partial class OrderService billing, OrderFeeChargingStageCodes.Inbound); + await RefreshMarketingPayableAmountAsync(order); + order.OrderStatus = allocated ? OrderStatusCodes.PendingPayment : OrderStatusCodes.PendingAllocation; @@ -1836,7 +1847,7 @@ public partial class OrderService } /// - /// 出库发货后:仅覆盖 ship 件重尺、重算计费重,写入 outbound 阶段应收,并更新订单状态。 + /// 出库发货后:仅覆盖 ship 件重尺、重算计费重并更新状态;不再重算应收。 /// internal async Task ApplyShipBillingByWaybillAsync( string waybillNo, @@ -1902,13 +1913,7 @@ public partial class OrderService await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, ship); await OrderRepository.ReplaceWeightsAsync(order.Id, weights); - await ApplyStageReceivableBillingAsync( - order, - product, - ship, - snapshotBinding, - OrderFeeChargingStageCodes.Outbound); - + // 出库仅更新件重尺与状态,不再重算/覆盖应收。 var now = DateTime.Now; order.OrderStatus = OrderStatusCodes.PendingOnline; order.ShipTime = now; @@ -2118,4 +2123,136 @@ public partial class OrderService await couponManager.LockForOrderAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id); } } + + /// + /// 预报计费完成后固化应收/应付快照;入库重算不得覆盖。 + /// + private async Task CaptureForecastBillingSnapshotAsync(Order order) + { + var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); + order.ForecastReceivableAmount = order.ReceivableSubtotal + ?? fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable).Sum(x => x.Amount); + order.ForecastPayableAmount = fees + .Where(x => x.Type == OrderFeeTypeCodes.Payable) + .Sum(x => x.Amount); + } + + /// + /// 入库等场景重算应收后,按当前应收小计刷新营销应付金额(不重新锁定优惠券)。 + /// + private async Task RefreshMarketingPayableAmountAsync(Order order) + { + var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); + var receivableSubtotal = fees + .Where(x => x.Type == OrderFeeTypeCodes.Receivable) + .Sum(x => x.Amount); + var marketingHelper = LazyServiceProvider.GetRequiredService(); + var breakdown = await marketingHelper.BuildBreakdownAsync( + order.CustomerId, + receivableSubtotal, + order.CouponInstanceId); + marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId); + } + + private static void EnsureReceivableMutable(Order order) + { + if (order.IsPaid != 0) + { + throw new BusinessException("订单已付款,不能修改应收费用"); + } + } + + private static void EnsureAdditionalServicesAllowed(SalesProduct product, List? selected) + { + var selectedCodes = NormalizeAdditionalServiceCodes(selected); + if (selectedCodes.Count == 0) + { + return; + } + + var allowed = ParseAdditionalServiceCodes(product.AdditionalServices); + if (allowed.Count == 0) + { + throw new BusinessException("销售产品未配置附加服务,不能勾选附加服务"); + } + + var invalid = selectedCodes + .Where(code => !allowed.Contains(code)) + .ToList(); + if (invalid.Count > 0) + { + throw new BusinessException($"附加服务不在产品可选范围:{string.Join(", ", invalid)}"); + } + } + + private static string? SerializeAdditionalServices(List? codes) + { + var normalized = NormalizeAdditionalServiceCodes(codes); + return normalized.Count == 0 ? null : OmsDocHelper.SerializeJson(normalized); + } + + private static List NormalizeAdditionalServiceCodes(IEnumerable? codes) => + (codes ?? []) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static HashSet ParseAdditionalServiceCodes(string? json) + { + var codes = OmsDocHelper.DeserializeJson>(json) ?? []; + return NormalizeAdditionalServiceCodes(codes) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static OrderRuleEvaluationContext BuildBillingConditionContext( + Order order, + byte? overrideIsRemote = null) + { + var evalOrder = overrideIsRemote.HasValue + ? new Order + { + Id = order.Id, + ProductCode = order.ProductCode, + ChannelCode = order.ChannelCode, + ConsigneeCountry = order.ConsigneeCountry, + ConsigneeCountryName = order.ConsigneeCountryName, + CargoAttribute = order.CargoAttribute, + WarehouseId = order.WarehouseId, + CustomerId = order.CustomerId, + DeclaredCurrency = order.DeclaredCurrency, + DeclaredSum = order.DeclaredSum, + Quantity = order.Quantity, + IsRemote = overrideIsRemote.Value, + AdditionalServices = order.AdditionalServices, + IsPaid = order.IsPaid, + IsCod = order.IsCod, + CodCurrency = order.CodCurrency, + CodSum = order.CodSum + } + : order; + + return OrderRuleEvaluationContext.FromOrderOnly(evalOrder); + } + + private async Task IsChannelRemoteHitAsync( + ServiceChannel channel, + string countryCode, + string? province, + string? city, + string? zipCode) + { + if (channel.RemoteAreaId is not > 0) + { + return false; + } + + var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(channel.RemoteAreaId.Value); + return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( + rule, + countryCode, + province, + city, + zipCode)); + } } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index fb2a1a8..fbfee95 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -1,6 +1,7 @@ using Fake.Application; using Oms.Application.Dtos.Marketing; using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Orders.Issues; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.OrdersAggregate.Consts; @@ -138,7 +139,10 @@ public partial class OrderService Length = dimensionLength, Width = dimensionWidth, Height = dimensionHeight, - ActualWeight = input.CargoWeight!.Value + ActualWeight = input.CargoWeight!.Value, + AdditionalServices = input.AdditionalServices, + DeclaredSum = input.DeclaredSum, + DeclaredCurrency = input.DeclaredCurrency }; var priceTierKey = await ResolveCurrentPriceTierKeyAsync(); @@ -233,7 +237,10 @@ public partial class OrderService Length = baseContext.Length, Width = baseContext.Width, Height = baseContext.Height, - ActualWeight = baseContext.ActualWeight + ActualWeight = baseContext.ActualWeight, + AdditionalServices = baseContext.AdditionalServices, + DeclaredSum = baseContext.DeclaredSum, + DeclaredCurrency = baseContext.DeclaredCurrency }; var attempt = await TryComputePriceCheckQuoteAsync( @@ -578,6 +585,16 @@ public partial class OrderService ParcelQuoteResult? bestQuote = null; decimal bestTotal = decimal.MaxValue; + var isRemote = profile.HasPostalCode + && await IsRemoteHitAsync( + product, + snapshotBinding.Rules, + context.CountryCode, + context.Province, + context.City, + context.PostalCode); + var conditionContext = BuildQuoteBillingConditionContext(context, isRemote); + foreach (var zoneAttempt in zoneAttempts) { var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines( @@ -589,7 +606,10 @@ public partial class OrderService context.CargoAttribute, priceTierKey, context.WarehouseId, - context.WarehouseName); + context.WarehouseName, + conditionContext, + context.DeclaredSum, + context.DeclaredCurrency); if (feeLines.Count == 0) { continue; @@ -601,15 +621,6 @@ public partial class OrderService continue; } - var isRemote = profile.HasPostalCode - && await IsRemoteHitAsync( - product, - snapshotBinding.Rules, - context.CountryCode, - context.Province, - context.City, - context.PostalCode); - bestTotal = total; bestQuote = new ParcelQuoteResult { @@ -762,6 +773,28 @@ public partial class OrderService return attempts; } + private static OrderRuleEvaluationContext BuildQuoteBillingConditionContext( + ParcelQuoteContext context, + bool isRemote) + { + var services = (context.AdditionalServices ?? []) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return OrderRuleEvaluationContext.FromOrderOnly(new Order + { + ConsigneeCountry = context.CountryCode, + CargoAttribute = context.CargoAttribute, + WarehouseId = context.WarehouseId, + DeclaredSum = context.DeclaredSum, + DeclaredCurrency = context.DeclaredCurrency, + IsRemote = isRemote ? (byte)1 : (byte)0, + AdditionalServices = services.Count == 0 ? null : OmsDocHelper.SerializeJson(services) + }); + } + private static PriceCheckAttemptResult PriceCheckFailure( SalesProduct product, string step, diff --git a/src/Oms.Application/Services/Orders/OrderService.ReceiverAddressParse.cs b/src/Oms.Application/Services/Orders/OrderService.ReceiverAddressParse.cs index 92d95ca..ce5f420 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ReceiverAddressParse.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ReceiverAddressParse.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using Fake.Application; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Ai; using Oms.Application.Shared; using Oms.Domain.AiAggregate.Entities; using Oms.Domain.AiAggregate.Repositories; @@ -20,6 +21,40 @@ public partial class OrderService "地址1", ]; + private const string DefaultReceiverAddressParseSkill = + """ + 你是国际物流 OMS 的收件地址解析助手(跨境打单场景)。 + 目标:把客户粘贴的整段中/英收件信息拆成结构化字段,并做「粗校验」提示——只关心下单/预报大概能不能用,不做地图精校、不验证门牌是否真实、不臆造邮编与城市匹配。 + + 只输出 JSON,不要 Markdown,不要解释。字段: + { + "recipientsName": "", + "companyName": "", + "phone": "", + "telePhone": "", + "email": "", + "countryCode": "", + "province": "", + "city": "", + "streetNumber": "", + "streetAddress1": "", + "streetAddress2": "", + "streetAddress3": "", + "zipCode": "", + "warnings": [] + } + + 规则: + 1) countryCode 必须是 ISO 二字码大写(中国→CN,美国→US,英国→GB 不要用 UK,香港→HK,台湾→TW,澳门→MO)。无法判断则留空并在 warnings 说明。 + 2) phone 优先填手机;固定电话放 telePhone;保留 + 国家码。 + 3) 街道门牌放入 streetAddress1;过长拆到 streetAddress2/3;单独门牌号可放 streetNumber。 + 4) 中国地址:省→province,市→city,区县街道合并进 streetAddress1(不要把省市区塞进姓名)。 + 5) 英文地址:State→province,City→city,Street→streetAddress*,ZIP/Postcode→zipCode。 + 6) 粗风险才写 warnings(短中文):缺国家/邮编/电话不像号码/街道与姓名粘连拆不清等。禁止写「地图不存在」「邮编与城市不匹配」这类无依据结论。 + 7) 不确定的字段留空;不要编造原文没有的省市区邮编。 + 8) 税号/EORI/VAT 不是本任务重点,原文没有就忽略。 + """; + private IAiProviderRepository AiProviderRepository => LazyServiceProvider.GetRequiredService(); @@ -47,39 +82,10 @@ public partial class OrderService var customerId = await OmsSessionHelper.ResolveXhsCustomerIdAsync(CustomerRepository, CurrentUser); var provider = await ResolveAiProviderAsync(customerId, input.ProviderKey); + var skillService = LazyServiceProvider.GetRequiredService(); var systemPrompt = - """ - 你是国际物流 OMS 的收件地址解析助手(跨境打单场景)。 - 目标:把客户粘贴的整段中/英收件信息拆成结构化字段,并做「粗校验」提示——只关心下单/预报大概能不能用,不做地图精校、不验证门牌是否真实、不臆造邮编与城市匹配。 - - 只输出 JSON,不要 Markdown,不要解释。字段: - { - "recipientsName": "", - "companyName": "", - "phone": "", - "telePhone": "", - "email": "", - "countryCode": "", - "province": "", - "city": "", - "streetNumber": "", - "streetAddress1": "", - "streetAddress2": "", - "streetAddress3": "", - "zipCode": "", - "warnings": [] - } - - 规则: - 1) countryCode 必须是 ISO 二字码大写(中国→CN,美国→US,英国→GB 不要用 UK,香港→HK,台湾→TW,澳门→MO)。无法判断则留空并在 warnings 说明。 - 2) phone 优先填手机;固定电话放 telePhone;保留 + 国家码。 - 3) 街道门牌放入 streetAddress1;过长拆到 streetAddress2/3;单独门牌号可放 streetNumber。 - 4) 中国地址:省→province,市→city,区县街道合并进 streetAddress1(不要把省市区塞进姓名)。 - 5) 英文地址:State→province,City→city,Street→streetAddress*,ZIP/Postcode→zipCode。 - 6) 粗风险才写 warnings(短中文):缺国家/邮编/电话不像号码/街道与姓名粘连拆不清等。禁止写「地图不存在」「邮编与城市不匹配」这类无依据结论。 - 7) 不确定的字段留空;不要编造原文没有的省市区邮编。 - 8) 税号/EORI/VAT 不是本任务重点,原文没有就忽略。 - """; + await skillService.TryGetContentByKeyAsync(AiSkillService.KeyReceiverAddressParse) + ?? DefaultReceiverAddressParseSkill; var userPrompt = "请解析以下收件信息:\n" + raw; string modelText; diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index c7a010d..20507b7 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Orders.Issues; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.OrdersAggregate.Consts; @@ -391,7 +392,10 @@ internal static class ParcelBillingCalculator string cargoAttribute, string? priceTierKey, int? warehouseId = null, - string? warehouseName = null) => + string? warehouseName = null, + OrderRuleEvaluationContext? conditionContext = null, + decimal? declaredSum = null, + string? declaredCurrency = null) => CalculateQuoteReceivableFeeLines( priceSnapshot, priceVersion, @@ -401,7 +405,10 @@ internal static class ParcelBillingCalculator cargoAttribute, priceTierKey, warehouseId, - warehouseName).Select(x => x.Fee).ToList(); + warehouseName, + conditionContext, + declaredSum, + declaredCurrency).Select(x => x.Fee).ToList(); public static List CalculateQuoteReceivableFeeLines( SalesProductPricePublishSnapshotDto priceSnapshot, @@ -412,10 +419,14 @@ internal static class ParcelBillingCalculator string cargoAttribute, string? priceTierKey, int? warehouseId = null, - string? warehouseName = null) + string? warehouseName = null, + OrderRuleEvaluationContext? conditionContext = null, + decimal? declaredSum = null, + string? declaredCurrency = null) { var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem( - p.Status, p.CostItem, p.PriceName, p.Currency, p.Details)); + p.Status, p.CostItem, p.PriceName, p.Currency, p.Details, + p.SerialNumber, p.ChargingCondition, p.CostUnit)); return CalculateReceivableFeeLines( items, priceVersion, @@ -426,7 +437,10 @@ internal static class ParcelBillingCalculator priceTierKey, OrderFeeChargingStageCodes.Forecast, warehouseId, - warehouseName); + warehouseName, + conditionContext, + declaredSum, + declaredCurrency); } public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => @@ -639,10 +653,14 @@ internal static class ParcelBillingCalculator string? priceTierKey, string chargingStage, int? warehouseId = null, - string? warehouseName = null) + string? warehouseName = null, + OrderRuleEvaluationContext? conditionContext = null, + decimal? declaredSum = null, + string? declaredCurrency = null) { var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem( - p.Status, p.CostItem, p.PriceName, p.Currency, p.Details)); + p.Status, p.CostItem, p.PriceName, p.Currency, p.Details, + p.SerialNumber, p.ChargingCondition, p.CostUnit)); return CalculateReceivableFeesFromItems( items, priceVersion, @@ -653,7 +671,10 @@ internal static class ParcelBillingCalculator priceTierKey, chargingStage, warehouseId, - warehouseName); + warehouseName, + conditionContext, + declaredSum, + declaredCurrency); } /// @@ -668,16 +689,23 @@ internal static class ParcelBillingCalculator string cargoAttribute, string chargingStage, int? warehouseId = null, - string? warehouseName = null) + string? warehouseName = null, + OrderRuleEvaluationContext? conditionContext = null, + decimal? declaredSum = null, + string? declaredCurrency = null) { var items = prices.Select(p => new PayableBillingPriceItem( - p.Status, p.CostItem, p.Currency, p.Details)); + p.Status, p.CostItem, p.Currency, p.Details, + p.SerialNumber, p.ChargingCondition)); return CalculatePayableFeesFromItems( items, priceVersion, priceEffectiveTime, new PriceDetailMatchContext(zoneKeys, cargoAttribute, chargeableWeight, warehouseId, warehouseName), - chargingStage); + chargingStage, + conditionContext, + declaredSum, + declaredCurrency); } /// @@ -691,7 +719,10 @@ internal static class ParcelBillingCalculator string zoneName, string cargoAttribute, int? warehouseId = null, - string? warehouseName = null) => + string? warehouseName = null, + OrderRuleEvaluationContext? conditionContext = null, + decimal? declaredSum = null, + string? declaredCurrency = null) => CalculatePayableFees( prices, priceVersion, @@ -701,7 +732,34 @@ internal static class ParcelBillingCalculator cargoAttribute, OrderFeeChargingStageCodes.Forecast, warehouseId, - warehouseName); + warehouseName, + conditionContext, + declaredSum, + declaredCurrency); + + /// + /// 计费条件:空/null/「无」视为命中;否则按中文公式求值,求值失败视为未命中。 + /// + public static bool IsBillingConditionMet(string? condition, OrderRuleEvaluationContext? context) + { + if (string.IsNullOrWhiteSpace(condition)) + { + return true; + } + + var trimmed = condition.Trim(); + if (string.Equals(trimmed, "无", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (context == null) + { + return false; + } + + return ChineseFormulaEvaluator.TryEvaluate(trimmed, context, out var hit) && hit; + } public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => HasEnabledReceivablePriceItems(priceSnapshot); @@ -838,14 +896,11 @@ internal static class ParcelBillingCalculator string? priceTierKey, string chargingStage, int? warehouseId = null, - string? warehouseName = null) - { - if (chargeableWeight <= 0) - { - return []; - } - - return CalculateReceivableFeeLines( + string? warehouseName = null, + OrderRuleEvaluationContext? conditionContext = null, + decimal? declaredSum = null, + string? declaredCurrency = null) => + CalculateReceivableFeeLines( priceItems, priceVersion, priceEffectiveTime, @@ -855,8 +910,10 @@ internal static class ParcelBillingCalculator priceTierKey, chargingStage, warehouseId, - warehouseName).Select(x => x.Fee).ToList(); - } + warehouseName, + conditionContext, + declaredSum, + declaredCurrency).Select(x => x.Fee).ToList(); private static List CalculateReceivableFeeLines( IEnumerable priceItems, @@ -868,11 +925,26 @@ internal static class ParcelBillingCalculator string? priceTierKey, string chargingStage, int? warehouseId = null, - string? warehouseName = null) + string? warehouseName = null, + OrderRuleEvaluationContext? conditionContext = null, + decimal? declaredSum = null, + string? declaredCurrency = null) { - var fees = new List(); + var pass1 = new List<(int SerialNumber, BillingFeeLine Line)>(); + var percentFreightPending = new List<(int SerialNumber, ReceivableBillingPriceItem PriceItem, ChannelPriceDetailOutput Detail)>(); + foreach (var priceItem in priceItems.Where(x => EnableStatusRules.IsEnabled(x.Status))) { + if (!IsBillingConditionMet(priceItem.ChargingCondition, conditionContext)) + { + continue; + } + + if (chargeableWeight <= 0 && !CanBillWithoutChargeableWeight(priceItem.Details)) + { + continue; + } + var detail = MatchPriceDetail( priceItem.Details, zoneKeys, @@ -885,6 +957,21 @@ internal static class ParcelBillingCalculator continue; } + var formula = NormalizeCalculationFormula(detail.CalculationFormula); + if (formula == "percent_freight") + { + percentFreightPending.Add((priceItem.SerialNumber, priceItem, detail)); + continue; + } + + if (formula == "percent_declared" + && !string.IsNullOrWhiteSpace(declaredCurrency) + && !string.IsNullOrWhiteSpace(priceItem.Currency) + && !string.Equals(declaredCurrency.Trim(), priceItem.Currency.Trim(), StringComparison.OrdinalIgnoreCase)) + { + continue; + } + var amount = CalculateDetailAmount( priceItem.Details, detail, @@ -893,7 +980,13 @@ internal static class ParcelBillingCalculator chargeableWeight, priceTierKey, warehouseId, - warehouseName); + warehouseName, + declaredSum); + if (amount <= 0) + { + continue; + } + var formulaPart = BuildDetailFormulaExpression( priceItem.Details, detail, @@ -902,8 +995,9 @@ internal static class ParcelBillingCalculator chargeableWeight, priceTierKey, warehouseId, - warehouseName); - fees.Add(new BillingFeeLine + warehouseName, + declaredSum); + pass1.Add((priceItem.SerialNumber, new BillingFeeLine { Fee = new OrderFee { @@ -917,10 +1011,46 @@ internal static class ParcelBillingCalculator }, PriceName = priceItem.PriceName, FormulaPart = formulaPart - }); + })); } - return fees; + var pass1Deduped = DedupeFeeLinesByCostItem(pass1); + var candidates = new List<(int SerialNumber, BillingFeeLine Line)>(pass1); + foreach (var pending in percentFreightPending) + { + var freightBase = SumFreightBaseAmount( + pass1Deduped.Select(x => x.Fee), + pending.PriceItem.Currency); + if (freightBase <= 0) + { + continue; + } + + var rate = ResolvePriceTierKeyPrice(pending.Detail, priceTierKey) ?? pending.Detail.CalculatedValue ?? 0; + var amount = CalculatePercentFreightAmount(rate, freightBase); + if (amount <= 0) + { + continue; + } + + candidates.Add((pending.SerialNumber, new BillingFeeLine + { + Fee = new OrderFee + { + Type = OrderFeeTypeCodes.Receivable, + CostItem = pending.PriceItem.CostItem, + Amount = amount, + Currency = pending.PriceItem.Currency, + PriceNo = priceVersion, + PriceEffectiveTime = priceEffectiveTime, + ChargingStage = chargingStage + }, + PriceName = pending.PriceItem.PriceName, + FormulaPart = BuildPercentFreightFormulaExpression(rate, freightBase) + })); + } + + return DedupeFeeLinesByCostItem(candidates); } private static List CalculatePayableFeesFromItems( @@ -928,16 +1058,26 @@ internal static class ParcelBillingCalculator string priceVersion, DateTime priceEffectiveTime, PriceDetailMatchContext match, - string chargingStage) + string chargingStage, + OrderRuleEvaluationContext? conditionContext = null, + decimal? declaredSum = null, + string? declaredCurrency = null) { - if (match.ChargeableWeight <= 0) - { - return []; - } + var pass1 = new List<(int SerialNumber, OrderFee Fee)>(); + var percentFreightPending = new List<(int SerialNumber, PayableBillingPriceItem PriceItem, ChannelPriceDetailOutput Detail)>(); - var fees = new List(); foreach (var priceItem in priceItems.Where(x => EnableStatusRules.IsEnabled(x.Status))) { + if (!IsBillingConditionMet(priceItem.ChargingCondition, conditionContext)) + { + continue; + } + + if (match.ChargeableWeight <= 0 && !CanBillWithoutChargeableWeight(priceItem.Details)) + { + continue; + } + var detail = MatchPriceDetail( priceItem.Details, match.ZoneKeys, @@ -950,13 +1090,28 @@ internal static class ParcelBillingCalculator continue; } - var amount = CalculatePayableDetailAmount(priceItem.Details, detail, match); + var formula = NormalizeCalculationFormula(detail.CalculationFormula); + if (formula == "percent_freight") + { + percentFreightPending.Add((priceItem.SerialNumber, priceItem, detail)); + continue; + } + + if (formula == "percent_declared" + && !string.IsNullOrWhiteSpace(declaredCurrency) + && !string.IsNullOrWhiteSpace(priceItem.Currency) + && !string.Equals(declaredCurrency.Trim(), priceItem.Currency.Trim(), StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var amount = CalculatePayableDetailAmount(priceItem.Details, detail, match, declaredSum); if (amount <= 0) { continue; } - fees.Add(new OrderFee + pass1.Add((priceItem.SerialNumber, new OrderFee { Type = OrderFeeTypeCodes.Payable, CostItem = priceItem.CostItem, @@ -965,10 +1120,87 @@ internal static class ParcelBillingCalculator PriceNo = priceVersion, PriceEffectiveTime = priceEffectiveTime, ChargingStage = chargingStage - }); + })); } - return fees; + var pass1Deduped = DedupeFeesByCostItem(pass1); + var candidates = new List<(int SerialNumber, OrderFee Fee)>(pass1); + foreach (var pending in percentFreightPending) + { + var freightBase = SumFreightBaseAmount(pass1Deduped, pending.PriceItem.Currency); + if (freightBase <= 0) + { + continue; + } + + var rate = ResolveBuyPrice(pending.Detail); + var amount = CalculatePercentFreightAmount(rate, freightBase); + if (amount <= 0) + { + continue; + } + + candidates.Add((pending.SerialNumber, new OrderFee + { + Type = OrderFeeTypeCodes.Payable, + CostItem = pending.PriceItem.CostItem, + Amount = amount, + Currency = pending.PriceItem.Currency, + PriceNo = priceVersion, + PriceEffectiveTime = priceEffectiveTime, + ChargingStage = chargingStage + })); + } + + return DedupeFeesByCostItem(candidates); + } + + private static decimal SumFreightBaseAmount(IEnumerable fees, string? currency) + { + var query = fees.Where(x => IsFreightCostItem(x.CostItem)); + if (!string.IsNullOrWhiteSpace(currency)) + { + query = query.Where(x => + string.IsNullOrWhiteSpace(x.Currency) + || string.Equals(x.Currency.Trim(), currency.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + return query.Sum(x => x.Amount); + } + + private static List DedupeFeeLinesByCostItem( + IReadOnlyList<(int SerialNumber, BillingFeeLine Line)> candidates) => + candidates + .GroupBy(x => x.Line.Fee.CostItem, StringComparer.OrdinalIgnoreCase) + .Select(g => g.OrderBy(x => x.SerialNumber).First().Line) + .ToList(); + + private static List DedupeFeesByCostItem( + IReadOnlyList<(int SerialNumber, OrderFee Fee)> candidates) => + candidates + .GroupBy(x => x.Fee.CostItem, StringComparer.OrdinalIgnoreCase) + .Select(g => g.OrderBy(x => x.SerialNumber).First().Fee) + .ToList(); + + private static bool CanBillWithoutChargeableWeight(IReadOnlyList details) + { + foreach (var detail in details) + { + var formula = NormalizeCalculationFormula(detail.CalculationFormula); + // percent_freight 依赖同批次运费;无计费重时先放行,pass-2 若无运费则跳过。 + if (formula is not ("percent_declared" or "percent_freight" or "total")) + { + continue; + } + + var factors = ParseFactorDictionary(detail.FactorValuesJson); + if (!TryGetWeightFactorText(factors, out _)) + { + return true; + } + } + + return false; } /// @@ -977,13 +1209,17 @@ internal static class ParcelBillingCalculator private static decimal CalculatePayableDetailAmount( IReadOnlyList allDetails, ChannelPriceDetailOutput detail, - PriceDetailMatchContext match) + PriceDetailMatchContext match, + decimal? declaredSum = null) { return NormalizeCalculationFormula(detail.CalculationFormula) switch { "unit" => CalculateBuyUnitPrice(detail, match.ChargeableWeight), "total" => ResolveBuyPrice(detail), "continue" => CalculateBuyContinueAmount(allDetails, detail, match), + "percent_declared" => CalculatePercentDeclaredAmount(ResolveBuyPrice(detail), declaredSum), + // percent_freight 在两趟计算的第二趟处理 + "percent_freight" => 0, _ => ResolveBuyPrice(detail) }; } @@ -1088,13 +1324,18 @@ internal static class ParcelBillingCalculator string CostItem, string PriceName, string Currency, - List Details); + List Details, + int SerialNumber = 0, + string? ChargingCondition = null, + string? CostUnit = null); private readonly record struct PayableBillingPriceItem( byte Status, string CostItem, string Currency, - List Details); + List Details, + int SerialNumber = 0, + string? ChargingCondition = null); private static ChannelPriceDetailOutput? MatchPriceDetail( IReadOnlyList details, @@ -1334,7 +1575,8 @@ internal static class ParcelBillingCalculator decimal chargeableWeight, string? priceTierKey, int? warehouseId = null, - string? warehouseName = null) + string? warehouseName = null, + decimal? declaredSum = null) { return NormalizeCalculationFormula(detail.CalculationFormula) switch { @@ -1349,6 +1591,11 @@ internal static class ParcelBillingCalculator priceTierKey, warehouseId, warehouseName), + "percent_declared" => CalculatePercentDeclaredAmount( + ResolvePriceTierKeyPrice(detail, priceTierKey) ?? detail.CalculatedValue ?? 0, + declaredSum), + // percent_freight 在两趟计算的第二趟处理 + "percent_freight" => 0, _ => detail.CalculatedValue ?? 0 }; } @@ -1361,7 +1608,9 @@ internal static class ParcelBillingCalculator decimal chargeableWeight, string? priceTierKey, int? warehouseId = null, - string? warehouseName = null) + string? warehouseName = null, + decimal? declaredSum = null, + decimal? freightBase = null) { return NormalizeCalculationFormula(detail.CalculationFormula) switch { @@ -1377,10 +1626,45 @@ internal static class ParcelBillingCalculator priceTierKey, warehouseId, warehouseName), + "percent_declared" => BuildPercentDeclaredFormulaExpression( + ResolvePriceTierKeyPrice(detail, priceTierKey) ?? detail.CalculatedValue ?? 0, + declaredSum), + "percent_freight" => BuildPercentFreightFormulaExpression( + ResolvePriceTierKeyPrice(detail, priceTierKey) ?? detail.CalculatedValue ?? 0, + freightBase ?? 0M), _ => FormatDecimal(detail.CalculatedValue ?? 0) }; } + private static decimal CalculatePercentDeclaredAmount(decimal rate, decimal? declaredSum) + { + if (!declaredSum.HasValue || declaredSum.Value <= 0 || rate <= 0) + { + return 0; + } + + return Math.Round(declaredSum.Value * rate / 100M, 2, MidpointRounding.AwayFromZero); + } + + private static string BuildPercentDeclaredFormulaExpression(decimal rate, decimal? declaredSum) + { + var sum = declaredSum is > 0 ? declaredSum.Value : 0M; + return $"{FormatDecimal(sum)}*{FormatDecimal(rate)}/100"; + } + + private static decimal CalculatePercentFreightAmount(decimal rate, decimal freightBase) + { + if (freightBase <= 0 || rate <= 0) + { + return 0; + } + + return Math.Round(freightBase * rate / 100M, 2, MidpointRounding.AwayFromZero); + } + + private static string BuildPercentFreightFormulaExpression(decimal rate, decimal freightBase) => + $"{FormatDecimal(freightBase)}*{FormatDecimal(rate)}/100"; + private static string BuildUnitFormulaExpression( ChannelPriceDetailOutput detail, decimal chargeableWeight, @@ -1565,6 +1849,8 @@ internal static class ParcelBillingCalculator "unit" or "单价" => "unit", "total" or "总价" or "固定价" => "total", "continueunit" or "continue_unit" or "continueprice" or "continue_price" or "续单价" or "续重价" => "continue", + "percent_declared" or "percentdeclared" or "申报比例" or "申报金额比例" or "货值比例" => "percent_declared", + "percent_freight" or "percentfreight" or "运费比例" or "运费百分比" => "percent_freight", _ => value.ToLowerInvariant() }; } diff --git a/src/Oms.Application/Services/Orders/ParcelQuoteContext.cs b/src/Oms.Application/Services/Orders/ParcelQuoteContext.cs index 357469a..8b00eb0 100644 --- a/src/Oms.Application/Services/Orders/ParcelQuoteContext.cs +++ b/src/Oms.Application/Services/Orders/ParcelQuoteContext.cs @@ -29,4 +29,19 @@ internal sealed class ParcelQuoteContext public decimal Height { get; init; } public decimal ActualWeight { get; init; } + + /// + /// 勾选附加服务字典 code。 + /// + public List? AdditionalServices { get; init; } + + /// + /// 申报总金额。 + /// + public decimal? DeclaredSum { get; init; } + + /// + /// 申报币种。 + /// + public string? DeclaredCurrency { get; init; } } diff --git a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs index 6a4b7e2..3cd3f06 100644 --- a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs +++ b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs @@ -1,3 +1,4 @@ +using Fake.Application; using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Orders; @@ -20,6 +21,8 @@ public static class PriceAreaSnapshotHelper IKpiAreaRepository kpiAreaRepository, CancellationToken cancellationToken = default) { + await EnsureSingleResolvedPriceAreaAsync(snapshot, kpiAreaRepository, cancellationToken); + var priceAreaId = await ResolvePriceAreaIdAsync( product, snapshot, @@ -74,4 +77,62 @@ public static class PriceAreaSnapshotHelper return null; } + + /// + /// 校验卖价明细中可解析的分区因子键均指向同一 AreaId;混用多个分区表时抛业务异常。 + /// 非数字/无法解析的分区名(如「美西」)跳过,与 ResolvePriceAreaIdAsync 一致。 + /// + public static async Task EnsureSingleResolvedPriceAreaAsync( + SalesProductPricePublishSnapshotDto snapshot, + IKpiAreaRepository kpiAreaRepository, + CancellationToken cancellationToken = default) + { + var zoneKeys = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshot); + if (zoneKeys.Count == 0) + { + return; + } + + int? resolvedAreaId = null; + foreach (var zoneKey in zoneKeys) + { + var areaId = await TryResolveZoneKeyToAreaIdAsync(zoneKey, kpiAreaRepository, cancellationToken); + if (!areaId.HasValue) + { + continue; + } + + if (resolvedAreaId == null) + { + resolvedAreaId = areaId; + continue; + } + + if (resolvedAreaId.Value != areaId.Value) + { + throw new BusinessException( + $"卖价明细分区混用了多个价格分区(AreaId={resolvedAreaId} 与 {areaId}),请统一分区后再发布"); + } + } + } + + private static async Task TryResolveZoneKeyToAreaIdAsync( + string zoneKey, + IKpiAreaRepository kpiAreaRepository, + CancellationToken cancellationToken) + { + if (!int.TryParse(zoneKey.Trim(), out var zoneId) || zoneId <= 0) + { + return null; + } + + var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == zoneId, cancellationToken); + if (area != null) + { + return zoneId; + } + + var info = await kpiAreaRepository.GetInfoByIdAsync(zoneId, cancellationToken); + return info?.AreaId; + } } diff --git a/src/Oms.Domain/AiAggregate/Entities/AiSkill.cs b/src/Oms.Domain/AiAggregate/Entities/AiSkill.cs new file mode 100644 index 0000000..d4253f7 --- /dev/null +++ b/src/Oms.Domain/AiAggregate/Entities/AiSkill.cs @@ -0,0 +1,30 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.AiAggregate.Entities; + +/// +/// 平台级 AI Skill(提示词/规范正文,全局一份)。 +/// +[SugarTable("t_ai_skill")] +public class AiSkill : OmsAuditedAggregateRoot +{ + /// 稳定键,如 oms-sales-price-publish-risk + public string SkillKey { get; set; } = string.Empty; + + /// 场景:pricePublish / autoTriggerFormula / receiverAddressParse / itfxPrefixScript + public string Scene { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + [SugarColumn(IsNullable = true, Length = 512)] + public string? Description { get; set; } + + /// 发给模型的正文 + [SugarColumn(ColumnDataType = "longtext")] + public string Content { get; set; } = string.Empty; + + public int SortOrder { get; set; } + + public bool Enabled { get; set; } = true; +} diff --git a/src/Oms.Domain/AiAggregate/Repositories/IAiSkillRepository.cs b/src/Oms.Domain/AiAggregate/Repositories/IAiSkillRepository.cs new file mode 100644 index 0000000..8dfae78 --- /dev/null +++ b/src/Oms.Domain/AiAggregate/Repositories/IAiSkillRepository.cs @@ -0,0 +1,9 @@ +using Fake.Domain.Repositories; +using Oms.Domain.AiAggregate.Entities; + +namespace Oms.Domain.AiAggregate.Repositories; + +public interface IAiSkillRepository : IRepository +{ + Task GetBySkillKeyAsync(string skillKey, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs index c1f95e8..ba63d85 100644 --- a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs +++ b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs @@ -22,6 +22,11 @@ public class SalesProduct : OmsAuditedAggregateRoot /// public string? TradeTerms { get; set; } + /// + /// 可选附加服务字典 code JSON 数组,如 ["insurance","remoteFee"]。 + /// + public string? AdditionalServices { get; set; } + public string? CustomerWaybill { get; set; } /// diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannel.cs b/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannel.cs index 3b61b85..109a891 100644 --- a/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannel.cs +++ b/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannel.cs @@ -23,6 +23,11 @@ public class ServiceChannel : OmsAuditedAggregateRoot public string? Description { get; set; } + /// + /// 偏远分区 KPI AreaId(渠道侧判定是否偏远)。 + /// + public int? RemoteAreaId { get; set; } + public string? PreScript { get; set; } public EnableStatus IsActive { get; set; } diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs index f096c77..2625a7a 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs @@ -105,6 +105,16 @@ public class Order : OmsAuditedAggregateRoot public decimal? ReceivableSubtotal { get; set; } + /// + /// 预报应收快照(创建预报计费后固化;入库重算不覆盖,用于预报→入库差额)。 + /// + public decimal? ForecastReceivableAmount { get; set; } + + /// + /// 预报应付快照(创建预报计费后固化;入库重算不覆盖,用于预报→入库差额)。 + /// + public decimal? ForecastPayableAmount { get; set; } + public decimal LevelDiscountAmount { get; set; } public int? CouponInstanceId { get; set; } @@ -167,6 +177,11 @@ public class Order : OmsAuditedAggregateRoot public byte IsRemote { get; set; } + /// + /// 客户勾选附加服务字典 code JSON 数组,如 ["insurance"]。 + /// + public string? AdditionalServices { get; set; } + /// /// 出单时实际使用的标签模板 Id(面单文件见 t_order_file.systemLabel)。 /// diff --git a/src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs b/src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs new file mode 100644 index 0000000..cac80c7 --- /dev/null +++ b/src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs @@ -0,0 +1,15 @@ +using Fake.DependencyInjection; +using Oms.Domain.AiAggregate.Entities; +using Oms.Domain.AiAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Ai; + +public class AiSkillRepository + : OmsSqlSugarRepository, IAiSkillRepository, IScopedDependency +{ + public Task GetBySkillKeyAsync( + string skillKey, + CancellationToken cancellationToken = default) => + FirstOrDefaultAsync(x => x.SkillKey == skillKey, cancellationToken); +} diff --git a/src/Oms.Tests/ParcelBillingCalculatorTests.cs b/src/Oms.Tests/ParcelBillingCalculatorTests.cs index 66add3a..2279e47 100644 --- a/src/Oms.Tests/ParcelBillingCalculatorTests.cs +++ b/src/Oms.Tests/ParcelBillingCalculatorTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Orders; +using Oms.Application.Services.Orders.Issues; using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; @@ -351,4 +352,210 @@ public class ParcelBillingCalculatorTests } #endregion + + #region 计费条件 / 申报比例 / 去重 + + [Fact] + public void CalculateReceivableFees_BillingCondition_ShouldSkipWhenAdditionalServiceMissing() + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + 1, + "insurance", + serialNumber: 1, + chargingCondition: "附加服务包含('insurance')", + ParcelBillingTestData.FixedPriceDetail(20M))); + + var context = OrderRuleEvaluationContext.FromOrderOnly(new Order + { + AdditionalServices = """["remoteFee"]""" + }); + + var fees = ParcelBillingCalculator.CalculateReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 1.2M, + new[] { Zone }, + "general", + null, + OrderFeeChargingStageCodes.Forecast, + conditionContext: context); + + fees.Should().BeEmpty(); + } + + [Fact] + public void CalculateReceivableFees_BillingCondition_ShouldHitWhenAdditionalServiceSelected() + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + 1, + "insurance", + serialNumber: 1, + chargingCondition: "附加服务包含('insurance')", + ParcelBillingTestData.FixedPriceDetail(20M))); + + var context = OrderRuleEvaluationContext.FromOrderOnly(new Order + { + AdditionalServices = """["insurance"]""" + }); + + var fees = ParcelBillingCalculator.CalculateReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 1.2M, + new[] { Zone }, + "general", + null, + OrderFeeChargingStageCodes.Forecast, + conditionContext: context); + + fees.Should().ContainSingle(); + fees[0].CostItem.Should().Be("insurance"); + fees[0].Amount.Should().Be(20M); + } + + [Fact] + public void CalculateReceivableFees_PercentDeclared_ShouldRoundToTwoDecimals() + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + 1, + "insurance", + ParcelBillingTestData.PercentDeclaredDetail(1.5M))); + + var fees = ParcelBillingCalculator.CalculateReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 0M, + new[] { Zone }, + "general", + null, + OrderFeeChargingStageCodes.Forecast, + declaredSum: 1000M, + declaredCurrency: "CNY"); + + fees.Should().ContainSingle(); + fees[0].Amount.Should().Be(15M); + } + + [Fact] + public void CalculateReceivableFees_PercentFreight_ShouldUseSameBatchFreightBase() + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + 1, + "freight", + ParcelBillingTestData.FixedPriceDetail(100M)), + ParcelBillingTestData.ReceivablePriceItem( + 1, + "fuel", + ParcelBillingTestData.PercentFreightDetail(10M))); + + var fees = ParcelBillingCalculator.CalculateReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 1.2M, + new[] { Zone }, + "general", + null, + OrderFeeChargingStageCodes.Forecast); + + fees.Should().HaveCount(2); + fees.Should().Contain(x => x.CostItem == "freight" && x.Amount == 100M); + fees.Should().Contain(x => x.CostItem == "fuel" && x.Amount == 10M); + } + + [Fact] + public void CalculateReceivableFees_PercentFreight_ShouldSkipWhenNoFreightInBatch() + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + 1, + "fuel", + ParcelBillingTestData.PercentFreightDetail(10M))); + + var fees = ParcelBillingCalculator.CalculateReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 1.2M, + new[] { Zone }, + "general", + null, + OrderFeeChargingStageCodes.Forecast); + + fees.Should().BeEmpty(); + } + + [Fact] + public void CalculatePayableFees_PercentFreight_ShouldUseSameBatchFreightBase() + { + var payablePrices = ParcelBillingTestData.BuildPayablePrices( + ParcelBillingTestData.PayablePriceItem( + 1, + "freight", + ParcelBillingTestData.FixedPriceDetail(80M)), + ParcelBillingTestData.PayablePriceItem( + 1, + "fuel", + ParcelBillingTestData.PercentFreightDetail(5M))); + + var fees = ParcelBillingCalculator.CalculatePayableFees( + payablePrices, + _meta.PriceVersion, + _meta.EffectiveTime, + 1.2M, + new[] { Zone }, + "general", + OrderFeeChargingStageCodes.Forecast); + + fees.Should().HaveCount(2); + fees.Should().Contain(x => x.CostItem == "freight" && x.Amount == 80M); + fees.Should().Contain(x => x.CostItem == "fuel" && x.Amount == 4M); + } + + [Fact] + public void NormalizeCalculationFormula_ShouldAliasPercentFreight() + { + ParcelBillingCalculator.NormalizeCalculationFormula("运费比例").Should().Be("percent_freight"); + ParcelBillingCalculator.NormalizeCalculationFormula("percentFreight").Should().Be("percent_freight"); + } + + [Fact] + public void CalculateReceivableFees_ShouldDedupeCostItemBySmallestSerialNumber() + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + 1, + "freight", + serialNumber: 20, + chargingCondition: null, + ParcelBillingTestData.FixedPriceDetail(99M)), + ParcelBillingTestData.ReceivablePriceItem( + 1, + "freight", + serialNumber: 5, + chargingCondition: null, + ParcelBillingTestData.FixedPriceDetail(12M))); + + var fees = ParcelBillingCalculator.CalculateReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 1.2M, + new[] { Zone }, + "general", + null, + OrderFeeChargingStageCodes.Forecast); + + fees.Should().ContainSingle(); + fees[0].Amount.Should().Be(12M); + } + + #endregion } diff --git a/src/Oms.Tests/Support/ParcelBillingTestData.cs b/src/Oms.Tests/Support/ParcelBillingTestData.cs index 78ea605..56c4ee2 100644 --- a/src/Oms.Tests/Support/ParcelBillingTestData.cs +++ b/src/Oms.Tests/Support/ParcelBillingTestData.cs @@ -27,11 +27,21 @@ internal static class ParcelBillingTestData byte status, string costItem, params ChannelPriceDetailOutput[] details) => + ReceivablePriceItem(status, costItem, serialNumber: 1, chargingCondition: null, details); + + public static SalesProductPriceListOutput ReceivablePriceItem( + byte status, + string costItem, + int serialNumber, + string? chargingCondition, + params ChannelPriceDetailOutput[] details) => new() { Status = status == 0 ? (byte)0 : (byte)2, + SerialNumber = serialNumber, CostItem = costItem, Currency = "CNY", + ChargingCondition = chargingCondition, Details = details.ToList() }; @@ -47,6 +57,36 @@ internal static class ParcelBillingTestData Details = details.ToList() }; + public static ChannelPriceDetailOutput PercentDeclaredDetail( + decimal rate, + string zone = Zone, + string cargoType = "普货") => + new() + { + CalculationFormula = "percent_declared", + CalculatedValue = rate, + FactorValuesJson = new Dictionary + { + ["分区"] = zone, + ["货物类型"] = cargoType + } + }; + + public static ChannelPriceDetailOutput PercentFreightDetail( + decimal rate, + string zone = Zone, + string cargoType = "普货") => + new() + { + CalculationFormula = "percent_freight", + CalculatedValue = rate, + FactorValuesJson = new Dictionary + { + ["分区"] = zone, + ["货物类型"] = cargoType + } + }; + public static ChannelPriceDetailOutput ContinueFreightDetail( decimal firstWeight = 1M, decimal firstPrice = 18M, diff --git a/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs b/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs index 5935dc2..8050ec1 100644 --- a/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs +++ b/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs @@ -23,7 +23,9 @@ public class XhsRolePermissionSeedContributor(RoleService roleService) : IDataSe "Xhs.Skills.Run", "Xhs.HumanSettings.Save", "System.Ai.Use", - "System.Ai.Save" + "System.Ai.Save", + "System.AiSkill.Use", + "System.AiSkill.Save" ]; public async Task SeedAsync()