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()