feat: add additional-service billing, conditions, and fee lifecycle hardening
Support optional services and executable billing conditions on sell/buy prices, percent-of-value and percent-of-freight formulas, place-order/inbound payable on chargeable weight, outbound receivable freeze, channel remote areas, billing simulate, and insurance-aware claims. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
592054f062
commit
c8107cb565
21
db.sql
21
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',
|
||||
|
||||
26
sql/20260801_ai_skill.sql
Normal file
26
sql/20260801_ai_skill.sql
Normal file
@ -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 转义问题)。
|
||||
11
sql/20260801_billing_additional_services.sql
Normal file
11
sql/20260801_billing_additional_services.sql
Normal file
@ -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`;
|
||||
5
sql/20260801_billing_phase2.sql
Normal file
5
sql/20260801_billing_phase2.sql
Normal file
@ -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`;
|
||||
70
src/Oms.Application/Dtos/Ai/AiSkillDtos.cs
Normal file
70
src/Oms.Application/Dtos/Ai/AiSkillDtos.cs
Normal file
@ -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
|
||||
{
|
||||
/// <summary>可选场景过滤</summary>
|
||||
public string? Scene { get; set; }
|
||||
|
||||
/// <summary>是否包含正文;调用方列表可 false,管理页 true</summary>
|
||||
public bool IncludeContent { get; set; } = true;
|
||||
|
||||
/// <summary>是否仅启用</summary>
|
||||
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; }
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// 销售产品计费模拟入参(基于当前库内卖价,非必须已发布)。
|
||||
/// </summary>
|
||||
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<string>? AdditionalServices { get; set; }
|
||||
|
||||
public decimal? DeclaredSum { get; set; }
|
||||
|
||||
[StringLength(16)]
|
||||
public string? DeclaredCurrency { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选覆盖是否偏远;未传时按产品偏远分区命中。
|
||||
/// </summary>
|
||||
public bool? IsRemote { get; set; }
|
||||
|
||||
[StringLength(64)]
|
||||
public string? PriceTierKey { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 销售产品计费模拟出参。
|
||||
/// </summary>
|
||||
public class SalesProductBillingSimulateOutput
|
||||
{
|
||||
public decimal ChargeableWeight { get; set; }
|
||||
|
||||
public List<string> ZoneKeys { get; set; } = [];
|
||||
|
||||
public bool IsRemote { get; set; }
|
||||
|
||||
public List<SalesProductBillingSimulateFeeOutput> ReceivableFees { get; set; } = [];
|
||||
|
||||
public List<SalesProductBillingSimulateFeeOutput> PayableFees { get; set; } = [];
|
||||
|
||||
public Dictionary<string, decimal> ReceivableTotal { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Dictionary<string, decimal> PayableTotal { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 同币种毛利(应收合计 - 应付合计);多币种时为 null。
|
||||
/// </summary>
|
||||
public decimal? Margin { get; set; }
|
||||
|
||||
public List<string> 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; }
|
||||
}
|
||||
@ -40,6 +40,11 @@ public class SalesProductDetailOutput
|
||||
/// </summary>
|
||||
public string? TradeTerms { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选附加服务字典 code JSON 数组。
|
||||
/// </summary>
|
||||
public string? AdditionalServices { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 系统面单类型。
|
||||
/// </summary>
|
||||
|
||||
@ -35,6 +35,11 @@ public class SalesProductListOutput
|
||||
/// </summary>
|
||||
public int? MaxTimeliness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选附加服务字典 code JSON 数组。
|
||||
/// </summary>
|
||||
public string? AdditionalServices { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 系统面单类型。
|
||||
/// </summary>
|
||||
|
||||
@ -42,6 +42,11 @@ public class SalesProductSaveInput
|
||||
/// </summary>
|
||||
public string? TradeTerms { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选附加服务字典 code JSON 数组。
|
||||
/// </summary>
|
||||
public string? AdditionalServices { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 系统面单类型(旧枚举字段,兼容展示)。
|
||||
/// </summary>
|
||||
|
||||
@ -55,6 +55,11 @@ public class ServiceChannelDetailOutput
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 偏远分区 KPI AreaId。
|
||||
/// </summary>
|
||||
public int? RemoteAreaId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预处理脚本。
|
||||
/// </summary>
|
||||
|
||||
@ -55,6 +55,11 @@ public class ServiceChannelListOutput
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 偏远分区 KPI AreaId。
|
||||
/// </summary>
|
||||
public int? RemoteAreaId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
|
||||
@ -52,6 +52,11 @@ public class ServiceChannelSaveInput
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 偏远分区 KPI AreaId。
|
||||
/// </summary>
|
||||
public int? RemoteAreaId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预处理脚本。
|
||||
/// </summary>
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 未付款订单更新附加服务入参。
|
||||
/// </summary>
|
||||
public class OrderAdditionalServicesUpdateInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单主键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 附加服务字典 code 列表。
|
||||
/// </summary>
|
||||
public List<string>? AdditionalServices { get; set; }
|
||||
}
|
||||
@ -258,6 +258,26 @@ public class OrderOutput
|
||||
/// </summary>
|
||||
public bool IsRemote { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户勾选附加服务字典 code 列表。
|
||||
/// </summary>
|
||||
public List<string> AdditionalServices { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 是否勾选保险附加服务。
|
||||
/// </summary>
|
||||
public bool HasInsurance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预报应收快照(入库不覆盖)。
|
||||
/// </summary>
|
||||
public decimal? ForecastReceivableAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预报应付快照(入库不覆盖)。
|
||||
/// </summary>
|
||||
public decimal? ForecastPayableAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
|
||||
@ -115,6 +115,16 @@ public class OrderParcelCreateInput
|
||||
/// </summary>
|
||||
public decimal? DeclaredSum { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户勾选附加服务字典 code 列表(落库序列化为 JSON 数组)。
|
||||
/// </summary>
|
||||
public List<string>? AdditionalServices { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否偏远(预计算后可写入,供下单前公式/计费条件求值)。
|
||||
/// </summary>
|
||||
public byte? IsRemote { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
|
||||
@ -69,4 +69,19 @@ public class OrderPriceCheckInput
|
||||
/// 选用的优惠券实例 Id。
|
||||
/// </summary>
|
||||
public int? CouponInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 勾选附加服务字典 code(用于计费条件求值)。
|
||||
/// </summary>
|
||||
public List<string>? AdditionalServices { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申报总金额(百分比申报类公式)。
|
||||
/// </summary>
|
||||
public decimal? DeclaredSum { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申报币种。
|
||||
/// </summary>
|
||||
public string? DeclaredCurrency { get; set; }
|
||||
}
|
||||
|
||||
@ -37,4 +37,8 @@
|
||||
<InternalsVisibleTo Include="Oms.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Services\Ai\AiSkillSeedContent.json" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
1
src/Oms.Application/Services/Ai/AiSkillSeedContent.json
Normal file
1
src/Oms.Application/Services/Ai/AiSkillSeedContent.json
Normal file
File diff suppressed because one or more lines are too long
246
src/Oms.Application/Services/Ai/AiSkillService.cs
Normal file
246
src/Oms.Application/Services/Ai/AiSkillService.cs
Normal file
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 平台级 AI Skill 管理(提示词/规范正文)。
|
||||
/// </summary>
|
||||
[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<IAiSkillRepository>();
|
||||
|
||||
/// <summary>
|
||||
/// Skill 列表(可按场景过滤)。
|
||||
/// </summary>
|
||||
[HttpPost("/ai/skill/list")]
|
||||
public async Task<OmsItemsResult<AiSkillOutput>> 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<AiSkillOutput>
|
||||
{
|
||||
Items = ordered.Select(x => Map(x, input.IncludeContent)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 Id 或 SkillKey 获取 Skill(含正文)。
|
||||
/// </summary>
|
||||
[HttpPost("/ai/skill/get")]
|
||||
public async Task<AiSkillOutput> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新 Skill。
|
||||
/// </summary>
|
||||
[HttpPost("/ai/skill/save")]
|
||||
public async Task<AiSkillOutput> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 软删除 Skill。
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 供内部调用:按 key 取正文,不存在返回 null。
|
||||
/// </summary>
|
||||
public async Task<string?> 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<SeedDef> 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<SeedPayload>(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;
|
||||
}
|
||||
}
|
||||
@ -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<IWarehouseAddressRepository>();
|
||||
|
||||
/// <summary>
|
||||
/// 基于当前库内卖价(非必须已发布)模拟计费,用于运营核对附加服务/偏远/申报比例等。
|
||||
/// </summary>
|
||||
[HttpPost("/channel/sales-product/billing/simulate")]
|
||||
public async Task<SalesProductBillingSimulateOutput> 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<string>();
|
||||
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<List<string>>(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<SalesProductBillingSimulateFeeOutput>();
|
||||
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<decimal> 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<WeightRuleInfo> 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<WeightRoundingInfo> 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<bool> 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<string?> 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<List<SalesProductBillingSimulateFeeOutput>> TrySimulatePayableFeesAsync(
|
||||
SalesProduct product,
|
||||
SalesProductRulesPublishSnapshotDto rulesSnapshot,
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
decimal chargeableWeight,
|
||||
string cargoAttribute,
|
||||
int? warehouseId,
|
||||
string? warehouseName,
|
||||
bool isRemote,
|
||||
decimal? declaredSum,
|
||||
string? declaredCurrency,
|
||||
string priceVersion,
|
||||
DateTime priceEffectiveTime,
|
||||
List<string> 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<List<ServiceChannelPriceListOutput>> 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<List<string>>(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<string, decimal> SumByCurrency(IEnumerable<SalesProductBillingSimulateFeeOutput> 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);
|
||||
}
|
||||
}
|
||||
@ -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<SalesProductPriceSaveInput> 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()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 保险/偏远费等附加费用项的计费条件校验。
|
||||
/// </summary>
|
||||
private static void ValidateBillingPriceItemRules(IReadOnlyList<SalesProductPriceSaveInput> 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<List<string>>(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,
|
||||
|
||||
@ -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<SalesProductPrice>();
|
||||
var factors = new List<SalesProductPriceFactor>();
|
||||
var details = new List<SalesProductPriceDetail>();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 买价偏远费等附加费用项的计费条件校验。
|
||||
/// </summary>
|
||||
private static void ValidateBillingPriceItemRules(IReadOnlyList<ServiceChannelPriceSaveInput> 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,
|
||||
|
||||
@ -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<ServiceChannelPrice>();
|
||||
var factors = new List<ServiceChannelPriceFactor>();
|
||||
var details = new List<ServiceChannelPriceDetail>();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<string>? 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)
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按订单附加服务/申报金额校验并写入理赔字段。
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
|
||||
@ -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<List<string>>(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),
|
||||
|
||||
@ -230,6 +230,7 @@ public partial class OrderService
|
||||
public async Task<OrderOutput> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 未付款订单更新附加服务并重算当前阶段应收(可选尝试重算应付)。
|
||||
/// </summary>
|
||||
[HttpPost("/order/additional-services/update")]
|
||||
public async Task<OrderOutput> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动添加订单费用(应收或应付)。
|
||||
/// </summary>
|
||||
@ -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))
|
||||
{
|
||||
|
||||
@ -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<AllocationResolver.ChannelCandidate>();
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 出库发货后:仅覆盖 ship 件重尺、重算计费重,写入 outbound 阶段应收,并更新订单状态。
|
||||
/// 出库发货后:仅覆盖 ship 件重尺、重算计费重并更新状态;不再重算应收。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预报计费完成后固化应收/应付快照;入库重算不得覆盖。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入库等场景重算应收后,按当前应收小计刷新营销应付金额(不重新锁定优惠券)。
|
||||
/// </summary>
|
||||
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<MarketingOrderHelper>();
|
||||
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<string>? 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<string>? codes)
|
||||
{
|
||||
var normalized = NormalizeAdditionalServiceCodes(codes);
|
||||
return normalized.Count == 0 ? null : OmsDocHelper.SerializeJson(normalized);
|
||||
}
|
||||
|
||||
private static List<string> NormalizeAdditionalServiceCodes(IEnumerable<string>? codes) =>
|
||||
(codes ?? [])
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
private static HashSet<string> ParseAdditionalServiceCodes(string? json)
|
||||
{
|
||||
var codes = OmsDocHelper.DeserializeJson<List<string>>(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<bool> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<IAiProviderRepository>();
|
||||
|
||||
@ -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<AiSkillService>();
|
||||
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;
|
||||
|
||||
@ -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<BillingFeeLine> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 计费条件:空/null/「无」视为命中;否则按中文公式求值,求值失败视为未命中。
|
||||
/// </summary>
|
||||
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<BillingFeeLine> CalculateReceivableFeeLines(
|
||||
IEnumerable<ReceivableBillingPriceItem> 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<BillingFeeLine>();
|
||||
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<OrderFee> 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<OrderFee>();
|
||||
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<OrderFee> 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<BillingFeeLine> 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<OrderFee> 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<ChannelPriceDetailOutput> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -977,13 +1209,17 @@ internal static class ParcelBillingCalculator
|
||||
private static decimal CalculatePayableDetailAmount(
|
||||
IReadOnlyList<ChannelPriceDetailOutput> 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<ChannelPriceDetailOutput> Details);
|
||||
List<ChannelPriceDetailOutput> Details,
|
||||
int SerialNumber = 0,
|
||||
string? ChargingCondition = null,
|
||||
string? CostUnit = null);
|
||||
|
||||
private readonly record struct PayableBillingPriceItem(
|
||||
byte Status,
|
||||
string CostItem,
|
||||
string Currency,
|
||||
List<ChannelPriceDetailOutput> Details);
|
||||
List<ChannelPriceDetailOutput> Details,
|
||||
int SerialNumber = 0,
|
||||
string? ChargingCondition = null);
|
||||
|
||||
private static ChannelPriceDetailOutput? MatchPriceDetail(
|
||||
IReadOnlyList<ChannelPriceDetailOutput> 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()
|
||||
};
|
||||
}
|
||||
|
||||
@ -29,4 +29,19 @@ internal sealed class ParcelQuoteContext
|
||||
public decimal Height { get; init; }
|
||||
|
||||
public decimal ActualWeight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 勾选附加服务字典 code。
|
||||
/// </summary>
|
||||
public List<string>? AdditionalServices { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 申报总金额。
|
||||
/// </summary>
|
||||
public decimal? DeclaredSum { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 申报币种。
|
||||
/// </summary>
|
||||
public string? DeclaredCurrency { get; init; }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验卖价明细中可解析的分区因子键均指向同一 AreaId;混用多个分区表时抛业务异常。
|
||||
/// 非数字/无法解析的分区名(如「美西」)跳过,与 ResolvePriceAreaIdAsync 一致。
|
||||
/// </summary>
|
||||
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<int?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
30
src/Oms.Domain/AiAggregate/Entities/AiSkill.cs
Normal file
30
src/Oms.Domain/AiAggregate/Entities/AiSkill.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using Oms.Domain.Shared;
|
||||
using SqlSugar;
|
||||
|
||||
namespace Oms.Domain.AiAggregate.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 平台级 AI Skill(提示词/规范正文,全局一份)。
|
||||
/// </summary>
|
||||
[SugarTable("t_ai_skill")]
|
||||
public class AiSkill : OmsAuditedAggregateRoot
|
||||
{
|
||||
/// <summary>稳定键,如 oms-sales-price-publish-risk</summary>
|
||||
public string SkillKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>场景:pricePublish / autoTriggerFormula / receiverAddressParse / itfxPrefixScript</summary>
|
||||
public string Scene { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[SugarColumn(IsNullable = true, Length = 512)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>发给模型的正文</summary>
|
||||
[SugarColumn(ColumnDataType = "longtext")]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
using Fake.Domain.Repositories;
|
||||
using Oms.Domain.AiAggregate.Entities;
|
||||
|
||||
namespace Oms.Domain.AiAggregate.Repositories;
|
||||
|
||||
public interface IAiSkillRepository : IRepository<AiSkill>
|
||||
{
|
||||
Task<AiSkill?> GetBySkillKeyAsync(string skillKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -22,6 +22,11 @@ public class SalesProduct : OmsAuditedAggregateRoot
|
||||
/// </summary>
|
||||
public string? TradeTerms { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选附加服务字典 code JSON 数组,如 ["insurance","remoteFee"]。
|
||||
/// </summary>
|
||||
public string? AdditionalServices { get; set; }
|
||||
|
||||
public string? CustomerWaybill { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -23,6 +23,11 @@ public class ServiceChannel : OmsAuditedAggregateRoot
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 偏远分区 KPI AreaId(渠道侧判定是否偏远)。
|
||||
/// </summary>
|
||||
public int? RemoteAreaId { get; set; }
|
||||
|
||||
public string? PreScript { get; set; }
|
||||
|
||||
public EnableStatus IsActive { get; set; }
|
||||
|
||||
@ -105,6 +105,16 @@ public class Order : OmsAuditedAggregateRoot
|
||||
|
||||
public decimal? ReceivableSubtotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预报应收快照(创建预报计费后固化;入库重算不覆盖,用于预报→入库差额)。
|
||||
/// </summary>
|
||||
public decimal? ForecastReceivableAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预报应付快照(创建预报计费后固化;入库重算不覆盖,用于预报→入库差额)。
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户勾选附加服务字典 code JSON 数组,如 ["insurance"]。
|
||||
/// </summary>
|
||||
public string? AdditionalServices { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 出单时实际使用的标签模板 Id(面单文件见 t_order_file.systemLabel)。
|
||||
/// </summary>
|
||||
|
||||
15
src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs
Normal file
15
src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs
Normal file
@ -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<AiSkill>, IAiSkillRepository, IScopedDependency
|
||||
{
|
||||
public Task<AiSkill?> GetBySkillKeyAsync(
|
||||
string skillKey,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
FirstOrDefaultAsync(x => x.SkillKey == skillKey, cancellationToken);
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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<string, string>
|
||||
{
|
||||
["分区"] = zone,
|
||||
["货物类型"] = cargoType
|
||||
}
|
||||
};
|
||||
|
||||
public static ChannelPriceDetailOutput PercentFreightDetail(
|
||||
decimal rate,
|
||||
string zone = Zone,
|
||||
string cargoType = "普货") =>
|
||||
new()
|
||||
{
|
||||
CalculationFormula = "percent_freight",
|
||||
CalculatedValue = rate,
|
||||
FactorValuesJson = new Dictionary<string, string>
|
||||
{
|
||||
["分区"] = zone,
|
||||
["货物类型"] = cargoType
|
||||
}
|
||||
};
|
||||
|
||||
public static ChannelPriceDetailOutput ContinueFreightDetail(
|
||||
decimal firstWeight = 1M,
|
||||
decimal firstPrice = 18M,
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user