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:
zzh 2026-08-01 18:04:28 +08:00
parent 592054f062
commit c8107cb565
45 changed files with 2308 additions and 201 deletions

21
db.sql
View File

@ -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
View 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 转义问题)。

View 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`;

View 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`;

View 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; }
}

View File

@ -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; }
}

View File

@ -40,6 +40,11 @@ public class SalesProductDetailOutput
/// </summary>
public string? TradeTerms { get; set; }
/// <summary>
/// 可选附加服务字典 code JSON 数组。
/// </summary>
public string? AdditionalServices { get; set; }
/// <summary>
/// 系统面单类型。
/// </summary>

View File

@ -35,6 +35,11 @@ public class SalesProductListOutput
/// </summary>
public int? MaxTimeliness { get; set; }
/// <summary>
/// 可选附加服务字典 code JSON 数组。
/// </summary>
public string? AdditionalServices { get; set; }
/// <summary>
/// 系统面单类型。
/// </summary>

View File

@ -42,6 +42,11 @@ public class SalesProductSaveInput
/// </summary>
public string? TradeTerms { get; set; }
/// <summary>
/// 可选附加服务字典 code JSON 数组。
/// </summary>
public string? AdditionalServices { get; set; }
/// <summary>
/// 系统面单类型(旧枚举字段,兼容展示)。
/// </summary>

View File

@ -55,6 +55,11 @@ public class ServiceChannelDetailOutput
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 偏远分区 KPI AreaId。
/// </summary>
public int? RemoteAreaId { get; set; }
/// <summary>
/// 预处理脚本。
/// </summary>

View File

@ -55,6 +55,11 @@ public class ServiceChannelListOutput
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 偏远分区 KPI AreaId。
/// </summary>
public int? RemoteAreaId { get; set; }
/// <summary>
/// 是否启用。
/// </summary>

View File

@ -52,6 +52,11 @@ public class ServiceChannelSaveInput
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 偏远分区 KPI AreaId。
/// </summary>
public int? RemoteAreaId { get; set; }
/// <summary>
/// 预处理脚本。
/// </summary>

View File

@ -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; }
}

View File

@ -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>

View File

@ -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>

View File

@ -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; }
}

View File

@ -37,4 +37,8 @@
<InternalsVisibleTo Include="Oms.Tests" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Services\Ai\AiSkillSeedContent.json" />
</ItemGroup>
</Project>

File diff suppressed because one or more lines are too long

View 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;
}
}

View File

@ -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);
}
}

View File

@ -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,

View File

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

View File

@ -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,

View File

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

View File

@ -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);

View File

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

View File

@ -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,
});
}

View File

@ -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;

View File

@ -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),

View File

@ -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))
{

View File

@ -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));
}
}

View File

@ -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,

View File

@ -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;

View File

@ -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()
};
}

View File

@ -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; }
}

View File

@ -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;
}
}

View 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;
}

View File

@ -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);
}

View File

@ -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>

View File

@ -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; }

View File

@ -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>

View 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);
}

View File

@ -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
}

View File

@ -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,

View File

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