diff --git a/Directory.Build.props b/Directory.Build.props
index b45eadd..e7760d3 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,9 +1,9 @@
-
+
- 8.0.5.27
+ 8.0.5.29
diff --git a/Directory.Build.targets b/Directory.Build.targets
new file mode 100644
index 0000000..34e847f
--- /dev/null
+++ b/Directory.Build.targets
@@ -0,0 +1,26 @@
+
+
+
+
+ <_LocalFakeCandidate Include="@(PackageReference -> '$(FakeRepoRoot)\src\%(Identity)\%(Identity).csproj')" />
+ <_LocalFakeCandidate Include="@(PackageReference -> '$(FakeRepoRoot)\modules\rbac\%(Identity)\%(Identity).csproj')" />
+ <_LocalFakeCandidate Include="@(PackageReference -> '$(FakeRepoRoot)\modules\file-management\%(Identity)\%(Identity).csproj')" />
+
+
+
+
+
+
+
+
+
+ <_MissingLocalFake Include="@(_LocalFakeCandidate)" Condition="!Exists('%(Identity)')" />
+
+
+
+
diff --git a/Fake.Local.EventBus.props b/Fake.Local.EventBus.props
deleted file mode 100644
index 9e0099b..0000000
--- a/Fake.Local.EventBus.props
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
- C:\Users\BSI\RiderProjects\fake2
-
- false
-
- false
-
-
diff --git a/Fake.Local.props b/Fake.Local.props
new file mode 100644
index 0000000..8519e29
--- /dev/null
+++ b/Fake.Local.props
@@ -0,0 +1,10 @@
+
+
+
+ C:\Users\BSI\RiderProjects\fake2
+ false
+
+
diff --git a/README.md b/README.md
index a729463..6e2f875 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,8 @@ public async Task BatchCreateFulfi
存量 DDL、字典初始化等脚本放在 `sql/`(说明见 `sql/README.md`),与运行时 ORM 查询分离;**运行时查询与写入必须 ORM**。
+新增增量迁移文件命名:`migrate_<主题>_YYYYMMDD.sql`(日期在末尾,例如 `migrate_xxx_20261016.sql`)。详细约定见 `sql/README.md`。
+
### 3. 上传代码前先同步远端
`test`、`master` 等共享分支会有其他人提交。**推送前必须先拉取远端,再上传**,避免覆盖或漏掉别人的提交。
diff --git a/SimpleAdmin.sln b/SimpleAdmin.sln
index 858277c..792bf40 100644
--- a/SimpleAdmin.sln
+++ b/SimpleAdmin.sln
@@ -16,7 +16,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "items", "items", "{BE5132AD
db.sql = db.sql
Directory.Build.props = Directory.Build.props
.gitea\workflows\deploy.yml = .gitea\workflows\deploy.yml
- Fake.Local.EventBus.props = Fake.Local.EventBus.props
+ Fake.Local.props = Fake.Local.props
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleAdmin.Api", "src\SimpleAdmin.Api\SimpleAdmin.Api.csproj", "{229ED167-9ECC-4736-8DFB-522EEA8FF680}"
diff --git a/db.sql b/db.sql
index 3f1111e..198f297 100644
--- a/db.sql
+++ b/db.sql
@@ -432,6 +432,9 @@ CREATE TABLE `t_order_declaration` (
`ImHsCode` VARCHAR(50) NULL COMMENT '进口HsCode',
`ExHsCode` VARCHAR(50) NULL COMMENT '出口HsCode',
`DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素',
+ `ProductId` VARCHAR(100) NULL COMMENT '产品标识编码',
+ `NonStdProductId` VARCHAR(100) NULL COMMENT '非标产品标识编码',
+ `StdProductId` VARCHAR(100) NULL COMMENT '标准产品标识编码',
`SkuImage` VARCHAR(512) NULL COMMENT '图片(存 storage key 或短 URL)',
`Category` VARCHAR(20) NULL COMMENT '类别',
`Brand` VARCHAR(50) NULL COMMENT '品牌',
@@ -1220,7 +1223,7 @@ CREATE TABLE `t_notice` (
`Content` TEXT NOT NULL COMMENT '通知内容',
`IsPopup` TINYINT NOT NULL DEFAULT 0 COMMENT '是否弹窗:0-否,1-是',
`ReceiverType` VARCHAR(16) NOT NULL COMMENT '接收对象:user/customer',
- `ReceiverIds` JSON NOT NULL COMMENT '接收对象ID列表(JSON数组)',
+ `ReceiverIds` TEXT NOT NULL COMMENT '接收对象ID列表(JSON数组字符串)',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
diff --git a/sql/README.md b/sql/README.md
index b691aa6..bb4f51a 100644
--- a/sql/README.md
+++ b/sql/README.md
@@ -14,4 +14,6 @@ OMS / 共用库的建表脚本已统一到仓库根目录 `db.sql`。
- 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行)
- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过)
- 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落
-- 单次增量迁移命名:`migrate_<主题>.sql`,尽量可重复执行(先判断列/表/字典是否已存在)
+- 单次增量迁移命名:`migrate_<主题>_YYYYMMDD.sql`(日期放在文件名**末尾**,如 `migrate_sales_store_order_provider_fulfillment_20261016.sql`)
+- 尽量可重复执行(先判断列/表/字典是否已存在)
+- **后续新增脚本一律带日期后缀**;历史未带日期的脚本不强制改名
diff --git a/sql/migrate_fee_item_marketing_discount_20261019.sql b/sql/migrate_fee_item_marketing_discount_20261019.sql
new file mode 100644
index 0000000..8d3b700
--- /dev/null
+++ b/sql/migrate_fee_item_marketing_discount_20261019.sql
@@ -0,0 +1,34 @@
+-- 费用项字典:营销抵扣(支付时写入的负数应收行)。可重复执行。
+-- coupon_discount 优惠券抵扣、member_discount 会员折扣;由 /order/pay 自动生成,禁止手工添加。
+
+-- 确保字典类型存在
+INSERT INTO `t_dict_type` (`DictType`, `Remark`, `TypeSort`, `IsDeleted`, `CreateUserId`)
+SELECT 'fee_item', '费用项', 0, 0, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_dict_type`
+ WHERE `DictType` = 'fee_item' AND `IsDeleted` = 0
+);
+
+-- 曾被软删的同 code 项直接恢复
+UPDATE `t_dict`
+SET `IsDeleted` = 0, `UpdateTime` = NOW()
+WHERE `DictType` = 'fee_item'
+ AND `DictCode` IN ('coupon_discount', 'member_discount')
+ AND `IsDeleted` = 1;
+
+INSERT INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `IsDeleted`, `CreateUserId`)
+SELECT 'fee_item', 900, 'coupon_discount', '优惠券抵扣', 0, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_dict`
+ WHERE `DictType` = 'fee_item' AND `DictCode` = 'coupon_discount'
+);
+
+INSERT INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `IsDeleted`, `CreateUserId`)
+SELECT 'fee_item', 901, 'member_discount', '会员折扣', 0, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_dict`
+ WHERE `DictType` = 'fee_item' AND `DictCode` = 'member_discount'
+);
diff --git a/sql/migrate_notice_receiver_ids_text.sql b/sql/migrate_notice_receiver_ids_text.sql
new file mode 100644
index 0000000..d4bcd97
--- /dev/null
+++ b/sql/migrate_notice_receiver_ids_text.sql
@@ -0,0 +1,25 @@
+-- =============================================================================
+-- migrate_notice_receiver_ids_text.sql
+-- t_notice.ReceiverIds:JSON → TEXT,保证广播([])与定向名单可用字符串条件查询
+-- 可重复执行
+-- =============================================================================
+SET @db := DATABASE();
+
+SET @col_type := (
+ SELECT DATA_TYPE
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_notice'
+ AND COLUMN_NAME = 'ReceiverIds'
+ LIMIT 1
+);
+
+SET @sql := IF(
+ @col_type IS NOT NULL AND LOWER(IFNULL(@col_type, '')) = 'json',
+ 'ALTER TABLE `t_notice`
+ MODIFY COLUMN `ReceiverIds` TEXT NOT NULL COMMENT ''接收对象ID列表(JSON数组字符串)''',
+ 'SELECT ''t_notice.ReceiverIds already text or missing'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_order_declaration_product_ids_20261019.sql b/sql/migrate_order_declaration_product_ids_20261019.sql
new file mode 100644
index 0000000..474a9fd
--- /dev/null
+++ b/sql/migrate_order_declaration_product_ids_20261019.sql
@@ -0,0 +1,55 @@
+-- 申报明细:新增产品标识编码 / 非标产品标识编码 / 标准产品标识编码。
+-- 可重复执行。
+
+SET @db := DATABASE();
+
+-- t_order_declaration.ProductId
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order_declaration'
+ AND COLUMN_NAME = 'ProductId'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order_declaration` ADD COLUMN `ProductId` VARCHAR(100) NULL COMMENT ''产品标识编码'' AFTER `DeclareElement`',
+ 'SELECT ''t_order_declaration.ProductId already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- t_order_declaration.NonStdProductId
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order_declaration'
+ AND COLUMN_NAME = 'NonStdProductId'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order_declaration` ADD COLUMN `NonStdProductId` VARCHAR(100) NULL COMMENT ''非标产品标识编码'' AFTER `ProductId`',
+ 'SELECT ''t_order_declaration.NonStdProductId already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- t_order_declaration.StdProductId
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order_declaration'
+ AND COLUMN_NAME = 'StdProductId'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order_declaration` ADD COLUMN `StdProductId` VARCHAR(100) NULL COMMENT ''标准产品标识编码'' AFTER `NonStdProductId`',
+ 'SELECT ''t_order_declaration.StdProductId already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_sales_store_order_provider_fulfillment_20261016.sql b/sql/migrate_sales_store_order_provider_fulfillment_20261016.sql
new file mode 100644
index 0000000..a5a2d9d
--- /dev/null
+++ b/sql/migrate_sales_store_order_provider_fulfillment_20261016.sql
@@ -0,0 +1,89 @@
+-- 店铺订单:落库 shipping_provider / fulfillment_type;删除多面单、自定义标签、拒发字段。
+-- 可重复执行。
+
+SET @db := DATABASE();
+
+-- t_sales_store_order.ShippingProvider
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_sales_store_order'
+ AND COLUMN_NAME = 'ShippingProvider'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_sales_store_order` ADD COLUMN `ShippingProvider` VARCHAR(128) NULL COMMENT ''平台物流商'' AFTER `ShippingLabelUrl`',
+ 'SELECT ''t_sales_store_order.ShippingProvider already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- t_sales_store_order.FulfillmentType
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_sales_store_order'
+ AND COLUMN_NAME = 'FulfillmentType'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_sales_store_order` ADD COLUMN `FulfillmentType` VARCHAR(64) NULL COMMENT ''平台履约类型'' AFTER `ShippingProvider`',
+ 'SELECT ''t_sales_store_order.FulfillmentType already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- drop IsMultiLabel
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_sales_store_order'
+ AND COLUMN_NAME = 'IsMultiLabel'
+);
+SET @sql := IF(
+ @exists > 0,
+ 'ALTER TABLE `t_sales_store_order` DROP COLUMN `IsMultiLabel`',
+ 'SELECT ''t_sales_store_order.IsMultiLabel already dropped'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- drop CustomTags
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_sales_store_order'
+ AND COLUMN_NAME = 'CustomTags'
+);
+SET @sql := IF(
+ @exists > 0,
+ 'ALTER TABLE `t_sales_store_order` DROP COLUMN `CustomTags`',
+ 'SELECT ''t_sales_store_order.CustomTags already dropped'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- drop line IsShipExcluded
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_sales_store_order_line'
+ AND COLUMN_NAME = 'IsShipExcluded'
+);
+SET @sql := IF(
+ @exists > 0,
+ 'ALTER TABLE `t_sales_store_order_line` DROP COLUMN `IsShipExcluded`',
+ 'SELECT ''t_sales_store_order_line.IsShipExcluded already dropped'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs b/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs
index a8a9138..6f03ec8 100644
--- a/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs
+++ b/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs
@@ -10,12 +10,32 @@ public class MonitorOverviewOutput
public MonitorCustomerStatsOutput Customer { get; set; } = new();
///
- /// 近 7 日新增订单趋势。
+ /// 经营指标(订单量/营收/毛利)按日周月维度。
+ ///
+ public MonitorOpsStatsOutput Ops { get; set; } = new();
+
+ ///
+ /// 实时积压队列(不随维度变化)。
+ ///
+ public MonitorQueueStatsOutput Queue { get; set; } = new();
+
+ ///
+ /// 订单趋势(随 dimension 变化;兼容字段名 OrderTrend7d 在 day 时等同本字段)。
+ ///
+ public List OrderTrend { get; set; } = [];
+
+ ///
+ /// 近 7 日新增订单趋势(兼容旧前端;dimension=day 时与 OrderTrend 相同)。
///
public List OrderTrend7d { get; set; } = [];
///
- /// 近 7 日营收/毛利趋势。
+ /// 营收/毛利趋势(随 dimension 变化)。
+ ///
+ public List RevenueTrend { get; set; } = [];
+
+ ///
+ /// 近 7 日营收/毛利趋势(兼容旧前端;dimension=day 时与 RevenueTrend 相同)。
///
public List RevenueTrend7d { get; set; } = [];
@@ -40,6 +60,47 @@ public class MonitorOverviewOutput
public MonitorTrackRatesOutput TrackRates { get; set; } = new();
}
+///
+/// 经营 KPI:订单量 / 营收 / 毛利(随日周月)。
+///
+public class MonitorOpsStatsOutput
+{
+ /// day / week / month
+ public string Dimension { get; set; } = "day";
+
+ /// 当前周期新建订单数。
+ public int OrderCount { get; set; }
+
+ /// 订单量环比增长百分比。
+ public decimal? OrderCountGrowthPercent { get; set; }
+
+ /// 当前周期应收(已付)营收。
+ public decimal Revenue { get; set; }
+
+ /// 营收环比增长百分比。
+ public decimal? RevenueGrowthPercent { get; set; }
+
+ /// 当前周期毛利 = 应收 - 应付。
+ public decimal GrossProfit { get; set; }
+
+ /// 毛利环比增长百分比。
+ public decimal? GrossProfitGrowthPercent { get; set; }
+}
+
+///
+/// 实时积压快照(不受日周月影响)。
+///
+public class MonitorQueueStatsOutput
+{
+ public int PendingPayment { get; set; }
+
+ public int PendingOnline { get; set; }
+
+ public int PendingSign { get; set; }
+
+ public int Problematic { get; set; }
+}
+
public class MonitorOrderStatsOutput
{
public int TodayCreated { get; set; }
diff --git a/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs b/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs
index 5d7f66b..69d0acc 100644
--- a/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs
+++ b/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs
@@ -319,3 +319,68 @@ public class Customs9610DeclareBatchOutput
public List Items { get; set; } = [];
}
+
+///
+/// 从已有运单批量创建 9610 汇总申报单入参。
+///
+public class Customs9610CreateFromOrdersInput
+{
+ ///
+ /// 运单 Id 列表(t_order.Id)。
+ ///
+ [Required]
+ [MinLength(1)]
+ public List OrderIds { get; set; } = [];
+
+ ///
+ /// 出口资质备案 Id;为空时按各运单客户的默认备案。
+ ///
+ public int? ExportQualificationId { get; set; }
+
+ ///
+ /// 电商平台编码(e-commerce_platform 字典),最长 16。
+ ///
+ [Required]
+ [StringLength(16)]
+ public string PlatformCode { get; set; } = string.Empty;
+}
+
+///
+/// 单个运单创建 9610 申报单结果。
+///
+public class Customs9610CreateFromOrderResult
+{
+ ///
+ /// 运单 Id。
+ ///
+ public int OrderId { get; set; }
+
+ ///
+ /// 运单号。
+ ///
+ public string? ServiceNo { get; set; }
+
+ public bool Success { get; set; }
+
+ ///
+ /// 失败原因;成功为空。
+ ///
+ public string? Message { get; set; }
+
+ ///
+ /// 生成的 9610 申报单 Id。
+ ///
+ public int? Customs9610OrderId { get; set; }
+}
+
+///
+/// 批量创建 9610 申报单结果。
+///
+public class Customs9610CreateFromOrdersOutput
+{
+ public int SuccessCount { get; set; }
+
+ public int FailCount { get; set; }
+
+ public List Items { get; set; } = [];
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs b/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs
index bc6a90f..e721c11 100644
--- a/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs
@@ -20,6 +20,11 @@ public class OrderFeeOutput
///
public string CostItem { get; set; } = string.Empty;
+ ///
+ /// 费用项显示名:仅营销抵扣行(优惠券抵扣/会员折扣)由后端给出,其余为空由前端按 fee_item 字典解析。
+ ///
+ public string? CostItemName { get; set; }
+
///
/// 金额。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs b/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs
index 6797c7f..00ac7ba 100644
--- a/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs
@@ -70,6 +70,21 @@ public class OrderItemOutput
///
public string? DeclareElement { get; set; }
+ ///
+ /// 产品标识编码。
+ ///
+ public string? ProductId { get; set; }
+
+ ///
+ /// 非标产品标识编码。
+ ///
+ public string? NonStdProductId { get; set; }
+
+ ///
+ /// 标准产品标识编码。
+ ///
+ public string? StdProductId { get; set; }
+
///
/// SKU 图片。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
index 4eaed1f..c508855 100644
--- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
@@ -368,6 +368,31 @@ public class OrderOutput
///
public decimal? ForecastPayableAmount { get; set; }
+ ///
+ /// 营销快照:折前应收小计。
+ ///
+ public decimal? ReceivableSubtotal { get; set; }
+
+ ///
+ /// 营销快照:会员折扣金额(与券互斥)。
+ ///
+ public decimal LevelDiscountAmount { get; set; }
+
+ ///
+ /// 营销快照:优惠券抵扣金额。
+ ///
+ public decimal CouponDiscountAmount { get; set; }
+
+ ///
+ /// 营销快照:客户实付金额(支付扣款口径;区别于应付成本)。
+ ///
+ public decimal? ActualPayAmount { get; set; }
+
+ ///
+ /// 营销快照:使用的优惠券实例 Id。
+ ///
+ public int? CouponInstanceId { get; set; }
+
///
/// 订单绑定的销售产品规则发布版本。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs
index 7f29d6f..ed71218 100644
--- a/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs
@@ -1,3 +1,5 @@
+using System.ComponentModel.DataAnnotations;
+
namespace Oms.Application.Dtos.Orders;
///
@@ -65,6 +67,24 @@ public class OrderParcelItemInput
///
public string? DeclareElement { get; set; }
+ ///
+ /// 产品标识编码(选填)。
+ ///
+ [StringLength(100)]
+ public string? ProductId { get; set; }
+
+ ///
+ /// 非标产品标识编码(选填)。
+ ///
+ [StringLength(100)]
+ public string? NonStdProductId { get; set; }
+
+ ///
+ /// 标准产品标识编码(选填)。
+ ///
+ [StringLength(100)]
+ public string? StdProductId { get; set; }
+
///
/// SKU 图片。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderPayDtos.cs b/src/Oms.Application/Dtos/Orders/OrderPayDtos.cs
new file mode 100644
index 0000000..6b3b12f
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderPayDtos.cs
@@ -0,0 +1,81 @@
+using Oms.Application.Dtos.Marketing;
+
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 订单支付入参(支付时选券)。
+///
+public class OrderPayInput : OrderActionInput
+{
+ ///
+ /// 选用的优惠券实例 Id:null 沿用订单已绑定的券(不可用则不使用);0 不使用券;大于 0 使用指定券。
+ ///
+ public int? CouponInstanceId { get; set; }
+}
+
+///
+/// 订单支付预览入参。
+///
+public class OrderPayPreviewInput
+{
+ ///
+ /// 订单主键。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 试算用券:null 自动推荐(优先已绑定券,否则选优惠最大且优于会员折的券);0 不使用券;大于 0 使用指定券。
+ ///
+ public int? CouponInstanceId { get; set; }
+}
+
+///
+/// 订单支付预览:应付拆解与可选券。
+///
+public class OrderPayPreviewOutput
+{
+ ///
+ /// 订单主键。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 应收小计(折前)。
+ ///
+ public decimal ReceivableSubtotal { get; set; }
+
+ ///
+ /// 基础运费(券门槛与抵扣口径)。
+ ///
+ public decimal BaseFreightAmount { get; set; }
+
+ ///
+ /// 会员折扣金额(与券互斥,用券时为 0)。
+ ///
+ public decimal LevelDiscountAmount { get; set; }
+
+ ///
+ /// 优惠券抵扣金额。
+ ///
+ public decimal CouponDiscountAmount { get; set; }
+
+ ///
+ /// 实际应付金额(支付扣款金额)。
+ ///
+ public decimal PayableAmount { get; set; }
+
+ ///
+ /// 会员等级名称。
+ ///
+ public string? MemberLevelName { get; set; }
+
+ ///
+ /// 本次试算实际生效的券实例 Id;为空表示未用券。
+ ///
+ public int? AppliedCouponInstanceId { get; set; }
+
+ ///
+ /// 当前客户可选券(CanUse=false 时附不可用原因,含「优惠不高于会员折扣」)。
+ ///
+ public List AvailableCoupons { get; set; } = [];
+}
diff --git a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs
index 92ab5b9..f7a61ca 100644
--- a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs
+++ b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs
@@ -180,8 +180,6 @@ public class PlatformStoreOrderLineOutput
public string? Currency { get; set; }
- public bool IsShipExcluded { get; set; }
-
public int? MappedSkuId { get; set; }
public string? MappedSkuCode { get; set; }
@@ -245,9 +243,11 @@ public class PlatformStoreOrderOutput
public string? ShippingLabelUrl { get; set; }
- public bool IsMultiLabel { get; set; }
+ /// 平台物流商。
+ public string? ShippingProvider { get; set; }
- public string? CustomTags { get; set; }
+ /// 平台履约类型。
+ public string? FulfillmentType { get; set; }
public bool IsSuspended { get; set; }
diff --git a/src/Oms.Application/Dtos/System/CountryImportInput.cs b/src/Oms.Application/Dtos/System/CountryImportInput.cs
new file mode 100644
index 0000000..5340a37
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/CountryImportInput.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Http;
+
+namespace Oms.Application.Dtos.System;
+
+///
+/// 国家/地区 Excel 导入入参。
+///
+public class CountryImportInput
+{
+ ///
+ /// 导入模式:incremental=按二字码新增或更新;full=按二字码新增或更新,并软删除文件中未出现的国家。
+ ///
+ public string ImportMode { get; set; } = ImportModes.Incremental;
+
+ ///
+ /// Excel 文件。
+ ///
+ public IFormFile File { get; set; } = null!;
+
+ public static class ImportModes
+ {
+ public const string Incremental = "incremental";
+ public const string Full = "full";
+ }
+}
diff --git a/src/Oms.Application/Dtos/System/CountryImportOutput.cs b/src/Oms.Application/Dtos/System/CountryImportOutput.cs
new file mode 100644
index 0000000..6c7ffee
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/CountryImportOutput.cs
@@ -0,0 +1,48 @@
+namespace Oms.Application.Dtos.System;
+
+///
+/// 国家/地区 Excel 导入结果。
+///
+public class CountryImportOutput
+{
+ ///
+ /// 成功写入条数。
+ ///
+ public int SuccessCount { get; set; }
+
+ ///
+ /// 跳过条数。
+ ///
+ public int SkipCount { get; set; }
+
+ ///
+ /// 失败条数。
+ ///
+ public int FailCount { get; set; }
+
+ ///
+ /// 全量导入时软删除的条数。
+ ///
+ public int DeletedCount { get; set; }
+
+ ///
+ /// 失败行明细。
+ ///
+ public List Failures { get; set; } = [];
+}
+
+///
+/// 导入失败行。
+///
+public class CountryImportFailRow
+{
+ ///
+ /// Excel 行号(含表头,从 2 起为数据行)。
+ ///
+ public int RowNumber { get; set; }
+
+ ///
+ /// 失败原因。
+ ///
+ public string Reason { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Dtos/System/CountryOutput.cs b/src/Oms.Application/Dtos/System/CountryOutput.cs
index 9d375a4..0129f30 100644
--- a/src/Oms.Application/Dtos/System/CountryOutput.cs
+++ b/src/Oms.Application/Dtos/System/CountryOutput.cs
@@ -24,4 +24,14 @@ public class CountryOutput
/// ISO 3166-1 alpha-2 国家二字码。
///
public string CountryCode { get; set; } = string.Empty;
+
+ ///
+ /// 创建时间。
+ ///
+ public DateTime CreateTime { get; set; }
+
+ ///
+ /// 更新时间;未更新时为空。
+ ///
+ public DateTime? UpdateTime { get; set; }
}
diff --git a/src/Oms.Application/Dtos/System/CreateCountryInput.cs b/src/Oms.Application/Dtos/System/CreateCountryInput.cs
new file mode 100644
index 0000000..1c7d1db
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/CreateCountryInput.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.System;
+
+///
+/// 新增国家/地区入参。
+///
+public class CreateCountryInput
+{
+ ///
+ /// 国家中文名。
+ ///
+ [Required]
+ public string ChineseName { get; set; } = string.Empty;
+
+ ///
+ /// 国家英文名。
+ ///
+ [Required]
+ public string EnglishName { get; set; } = string.Empty;
+
+ ///
+ /// ISO 3166-1 alpha-2 国家二字码。
+ ///
+ [Required]
+ public string CountryCode { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Dtos/System/NoticeListInput.cs b/src/Oms.Application/Dtos/System/NoticeListInput.cs
index 4707ffa..51f5af1 100644
--- a/src/Oms.Application/Dtos/System/NoticeListInput.cs
+++ b/src/Oms.Application/Dtos/System/NoticeListInput.cs
@@ -34,4 +34,9 @@ public class NoticeListInput
/// 仅未读(相对当前登录主体)。
///
public bool? OnlyUnread { get; set; }
+
+ ///
+ /// 管理端查看全部通知(仅非客户登录生效)。默认按当前主体可见范围过滤。
+ ///
+ public bool? Manage { get; set; }
}
diff --git a/src/Oms.Application/Dtos/System/UpdateCountryInput.cs b/src/Oms.Application/Dtos/System/UpdateCountryInput.cs
new file mode 100644
index 0000000..9e13236
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/UpdateCountryInput.cs
@@ -0,0 +1,33 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.System;
+
+///
+/// 更新国家/地区入参。
+///
+public class UpdateCountryInput
+{
+ ///
+ /// 主键。
+ ///
+ [Required]
+ public int Id { get; set; }
+
+ ///
+ /// 国家中文名。
+ ///
+ [Required]
+ public string ChineseName { get; set; } = string.Empty;
+
+ ///
+ /// 国家英文名。
+ ///
+ [Required]
+ public string EnglishName { get; set; } = string.Empty;
+
+ ///
+ /// ISO 3166-1 alpha-2 国家二字码。
+ ///
+ [Required]
+ public string CountryCode { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs
index d12b923..9fc17ad 100644
--- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs
+++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs
@@ -20,6 +20,29 @@ internal static class PlatformShopCrypto
}
public static long UnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ ///
+ /// TikTok Shop 202309 签名,对齐 WMS TiktokApi.makeTiktokSign:
+ /// 查询参数按 key 排序后拼接 key+value,有请求体则原样接在后面,两侧包 appSecret,HMAC-SHA256 十六进制。
+ ///
+ public static string TiktokSign(string path, IReadOnlyDictionary query, string? body, string appSecret)
+ {
+ var input = new StringBuilder(path);
+ foreach (var key in query.Keys
+ .Where(k => k is not ("sign" or "access_token"))
+ .OrderBy(k => k, StringComparer.Ordinal))
+ {
+ input.Append(key).Append(query[key]);
+ }
+
+ if (!string.IsNullOrEmpty(body))
+ {
+ input.Append(body);
+ }
+
+ var payload = appSecret + input + appSecret;
+ return HmacSha256Hex(payload, appSecret);
+ }
}
///
diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs
index b4d6dea..40bac4c 100644
--- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs
+++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs
@@ -1,7 +1,9 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
+using Fake.ObjectStorage;
using Microsoft.Extensions.Logging;
+using Oms.Application.Storage;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Integrations.PlatformShop;
@@ -27,6 +29,14 @@ public interface IPlatformShopFulfillmentClient
SalesStore store,
string platformOrderNo,
CancellationToken ct = default);
+
+ ///
+ /// 拉取平台订单完整明细并映射为落库结构;不支持时返回 null。
+ ///
+ Task FetchOrderDetailAsync(
+ SalesStore store,
+ string platformOrderNo,
+ CancellationToken ct = default);
}
public sealed class PlatformShopPullOrdersRequest
@@ -53,6 +63,24 @@ public sealed class PlatformShopPulledOrder
public string? OrderCountry { get; set; }
+ public string? RecipientName { get; set; }
+
+ public string? RecipientPhone { get; set; }
+
+ public string? RecipientEmail { get; set; }
+
+ public string? RecipientCountry { get; set; }
+
+ public string? RecipientProvince { get; set; }
+
+ public string? RecipientCity { get; set; }
+
+ public string? RecipientZipCode { get; set; }
+
+ public string? RecipientAddress1 { get; set; }
+
+ public string? RecipientAddress2 { get; set; }
+
public int ItemCount { get; set; }
public decimal? TotalAmount { get; set; }
@@ -71,6 +99,10 @@ public sealed class PlatformShopPulledOrder
public string? ShippingLabelUrl { get; set; }
+ public string? ShippingProvider { get; set; }
+
+ public string? FulfillmentType { get; set; }
+
public string? ExtraJson { get; set; }
public List Lines { get; set; } = [];
@@ -84,6 +116,10 @@ public sealed class PlatformShopPulledOrderLine
public string? ProductName { get; set; }
+ public string? ProductImageUrl { get; set; }
+
+ public string? VariantText { get; set; }
+
public int Quantity { get; set; }
public decimal? UnitPrice { get; set; }
@@ -141,6 +177,7 @@ public sealed class PlatformShopLabelResult
internal sealed class PlatformShopFulfillmentClient(
IHttpClientFactory httpClientFactory,
IPlatformShopConfigProvider configProvider,
+ IObjectStorage objectStorage,
ILogger logger) : IPlatformShopFulfillmentClient
{
private const string LabelPendingText = "系统获取中";
@@ -209,6 +246,19 @@ internal sealed class PlatformShopFulfillmentClient(
};
}
+ public async Task FetchOrderDetailAsync(
+ SalesStore store,
+ string platformOrderNo,
+ CancellationToken ct = default)
+ {
+ var options = await configProvider.GetAsync(ct);
+ return store.PlatformCode.ToLowerInvariant() switch
+ {
+ "tiktok" => await FetchTiktokOrderDetailAsync(store, options, platformOrderNo, ct),
+ _ => null
+ };
+ }
+
#region Shopee
private async Task PullShopeeAsync(
@@ -582,124 +632,764 @@ internal sealed class PlatformShopFulfillmentClient(
CancellationToken ct)
{
EnsureTiktok(store, options);
- var tiktok = options.Tiktok;
- var token = store.AccessToken!;
- var apiPath = $"/order/202309/orders/search?app_key={tiktok.AppKey}×tamp={PlatformShopCrypto.UnixSeconds()}&page_size=50";
- var sign = PlatformShopCrypto.HmacSha256Hex(tiktok.AppSecret + apiPath.Replace("?", "").Replace("&", "").Replace("=", "") + tiktok.AppSecret, tiktok.AppSecret);
- var url = $"{tiktok.HostApi}{apiPath}&sign={sign}";
- var body = new Dictionary
+ var shopCipher = await GetTiktokShopCipherAsync(store, options, ct);
+ if (string.IsNullOrWhiteSpace(shopCipher))
{
- ["order_status"] = request.OrderStatuses.Count > 0 ? request.OrderStatuses[0] : "AWAITING_SHIPMENT"
- };
- using var doc = await PostJsonAsync(url, body, ct, token);
- var orders = new List();
- if (doc.RootElement.TryGetProperty("data", out var data)
- && data.TryGetProperty("orders", out var arr)
- && arr.ValueKind == JsonValueKind.Array)
- {
- foreach (var item in arr.EnumerateArray())
+ return new PlatformShopPullOrdersResult
{
- var id = GetString(item, "id");
- if (string.IsNullOrWhiteSpace(id)) continue;
- var status = GetString(item, "status") ?? "AWAITING_SHIPMENT";
- orders.Add(new PlatformShopPulledOrder
+ Success = false,
+ Message = "未获取到 TikTok shop_cipher,请重新授权店铺"
+ };
+ }
+
+ var statuses = NormalizeTiktokPullStatuses(request.OrderStatuses);
+ var orders = new Dictionary(StringComparer.Ordinal);
+ var errors = new List();
+
+ foreach (var statusFilter in statuses)
+ {
+ var pageToken = "";
+ for (var page = 0; page < 40; page++)
+ {
+ using var doc = await PostTiktokOrderSearchAsync(
+ store, options, request, statusFilter, shopCipher, pageToken, ct);
+ var root = doc.RootElement;
+ var code = root.ValueKind == JsonValueKind.Object ? GetString(root, "code") : null;
+ if (root.ValueKind != JsonValueKind.Object
+ || code is not ("0" or null)
+ || !root.TryGetProperty("data", out var data)
+ || data.ValueKind != JsonValueKind.Object)
{
- PlatformOrderNo = id,
- PlatformStatus = status,
- PlatformStatusLabel = status,
- FulfillStatus = MapTiktokFulfill(status)
- });
+ var message = root.ValueKind == JsonValueKind.Object
+ ? GetString(root, "message") ?? GetString(root, "msg")
+ : null;
+ errors.Add(string.IsNullOrWhiteSpace(message)
+ ? $"{statusFilter} 返回为空"
+ : $"{statusFilter} {code} {message}".Trim());
+ break;
+ }
+
+ if (data.TryGetProperty("orders", out var arr) && arr.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var item in arr.EnumerateArray())
+ {
+ var id = GetString(item, "id");
+ if (string.IsNullOrWhiteSpace(id) || orders.ContainsKey(id)) continue;
+ // 搜索结果可能已带明细;没有 line_items 时后面再按详情接口补齐
+ orders[id] = MapTiktokOrder(item, statusFilter);
+ }
+ }
+
+ pageToken = GetString(data, "next_page_token") ?? "";
+ if (string.IsNullOrEmpty(pageToken))
+ {
+ break;
+ }
}
}
+ if (orders.Count == 0 && errors.Count > 0)
+ {
+ return new PlatformShopPullOrdersResult
+ {
+ Success = false,
+ Message = string.Join(";", errors.Distinct())
+ };
+ }
+
+ await EnrichTiktokOrderDetailsAsync(store, options, shopCipher, orders, errors, ct);
+
+ var messageText = $"TikTok 拉单完成,处理 {orders.Count} 单";
+ if (errors.Count > 0)
+ {
+ messageText += ";" + string.Join(";", errors.Distinct());
+ }
+
return new PlatformShopPullOrdersResult
{
Success = true,
- Message = $"TikTok 拉单完成,处理 {orders.Count} 单",
- Orders = orders
+ Message = messageText,
+ Orders = orders.Values.ToList()
};
}
+ ///
+ /// 按订单 ID 批量拉详情(对齐 WMS getOrderDetail),补齐收件人、金额、行商品、运单号。
+ ///
+ private async Task EnrichTiktokOrderDetailsAsync(
+ SalesStore store,
+ PlatformShopOptions options,
+ string shopCipher,
+ Dictionary orders,
+ List errors,
+ CancellationToken ct)
+ {
+ var needDetail = orders.Values
+ .Where(x => x.Lines.Count == 0
+ || string.IsNullOrWhiteSpace(x.RecipientAddress1)
+ || !x.TotalAmount.HasValue)
+ .Select(x => x.PlatformOrderNo)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ if (needDetail.Count == 0)
+ {
+ return;
+ }
+
+ var tiktok = options.Tiktok;
+ for (var i = 0; i < needDetail.Count; i += 50)
+ {
+ var chunk = needDetail.Skip(i).Take(50).ToList();
+ using var doc = await GetTiktokSignedAsync(options, store.AccessToken!, "/order/202309/orders", new Dictionary
+ {
+ ["app_key"] = tiktok.AppKey,
+ ["shop_cipher"] = shopCipher,
+ ["ids"] = string.Join(",", chunk),
+ ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
+ }, ct);
+ if (!TryGetTiktokData(doc.RootElement, out var data, out var detailError)
+ || !data.TryGetProperty("orders", out var arr)
+ || arr.ValueKind != JsonValueKind.Array)
+ {
+ errors.Add("订单详情失败:" + detailError);
+ continue;
+ }
+
+ foreach (var item in arr.EnumerateArray())
+ {
+ var mapped = MapTiktokOrder(item);
+ if (string.IsNullOrWhiteSpace(mapped.PlatformOrderNo)) continue;
+ orders[mapped.PlatformOrderNo] = mapped;
+ }
+ }
+ }
+
+ ///
+ /// 按 TikTok 订单明细 JSON 取值,字段对齐店铺订单主表/明细表。
+ ///
+ private static PlatformShopPulledOrder MapTiktokOrder(JsonElement order, string? fallbackStatus = null)
+ {
+ var id = GetString(order, "id") ?? string.Empty;
+ var status = GetString(order, "status") ?? fallbackStatus ?? string.Empty;
+ var recipient = order.TryGetProperty("recipient_address", out var addr) && addr.ValueKind == JsonValueKind.Object
+ ? addr
+ : default;
+ var payment = order.TryGetProperty("payment", out var pay) && pay.ValueKind == JsonValueKind.Object
+ ? pay
+ : default;
+
+ var (province, city, countryFromDistrict) = ParseTiktokDistrict(recipient);
+ var country = FirstNonEmpty(
+ GetString(recipient, "region_code"),
+ countryFromDistrict,
+ GetString(recipient, "region"));
+
+ var lines = new List();
+ if (order.TryGetProperty("line_items", out var lineItems) && lineItems.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var line in lineItems.EnumerateArray())
+ {
+ lines.Add(new PlatformShopPulledOrderLine
+ {
+ PlatformLineId = GetString(line, "id"),
+ PlatformSku = GetString(line, "seller_sku"),
+ ProductName = GetString(line, "product_name"),
+ ProductImageUrl = GetString(line, "sku_image"),
+ VariantText = GetString(line, "sku_name"),
+ // TikTok 一行一件,没有 quantity 字段
+ Quantity = 1,
+ UnitPrice = GetDecimal(line, "sale_price") ?? GetDecimal(line, "original_price"),
+ Currency = GetString(line, "currency")
+ });
+ }
+ }
+
+ var tracking = GetString(order, "tracking_number");
+ if (string.IsNullOrWhiteSpace(tracking)
+ && order.TryGetProperty("line_items", out var itemsForTrack)
+ && itemsForTrack.ValueKind == JsonValueKind.Array
+ && itemsForTrack.GetArrayLength() > 0)
+ {
+ tracking = GetString(itemsForTrack[0], "tracking_number");
+ }
+
+ var shippingProvider = FirstNonEmpty(
+ GetString(order, "shipping_provider"),
+ lineItems.ValueKind == JsonValueKind.Array && lineItems.GetArrayLength() > 0
+ ? GetString(lineItems[0], "shipping_provider_name")
+ : null);
+
+ return new PlatformShopPulledOrder
+ {
+ PlatformOrderNo = id,
+ OrderCountry = country,
+ RecipientName = FirstNonEmpty(GetString(recipient, "name"), GetString(recipient, "full_address")),
+ RecipientPhone = GetString(recipient, "phone_number"),
+ RecipientEmail = GetString(order, "buyer_email"),
+ RecipientCountry = country,
+ RecipientProvince = province,
+ RecipientCity = city,
+ RecipientZipCode = GetString(recipient, "postal_code"),
+ RecipientAddress1 = FirstNonEmpty(
+ GetString(recipient, "address_line1"),
+ GetString(recipient, "address_detail"),
+ GetString(recipient, "full_address")),
+ RecipientAddress2 = GetString(recipient, "address_line2"),
+ ItemCount = lines.Count,
+ TotalAmount = GetDecimal(payment, "total_amount") ?? GetDecimal(payment, "sub_total"),
+ Currency = FirstNonEmpty(GetString(payment, "currency"), lines.FirstOrDefault()?.Currency),
+ PlatformOrderTime = FromUnixSeconds(GetLong(order, "create_time")),
+ PlatformStatus = status,
+ PlatformStatusLabel = status,
+ FulfillStatus = MapTiktokFulfill(status),
+ ShippingLabelNo = tracking,
+ ShippingProvider = shippingProvider,
+ FulfillmentType = GetString(order, "fulfillment_type"),
+ ExtraJson = order.ValueKind == JsonValueKind.Undefined ? null : order.GetRawText(),
+ Lines = lines
+ };
+ }
+
+ private static (string? Province, string? City, string? CountryCode) ParseTiktokDistrict(JsonElement recipient)
+ {
+ string? province = null;
+ string? city = null;
+ string? country = null;
+ if (recipient.ValueKind != JsonValueKind.Object
+ || !recipient.TryGetProperty("district_info", out var districts)
+ || districts.ValueKind != JsonValueKind.Array)
+ {
+ return (null, null, null);
+ }
+
+ foreach (var district in districts.EnumerateArray())
+ {
+ var level = GetString(district, "address_level");
+ var name = GetString(district, "address_name");
+ var iso = GetString(district, "iso_code");
+ switch (level)
+ {
+ case "L0":
+ country = FirstNonEmpty(iso, name);
+ break;
+ case "L1":
+ province = name;
+ break;
+ case "L3":
+ city = name;
+ break;
+ case "L2" when string.IsNullOrWhiteSpace(city):
+ city = name;
+ break;
+ }
+ }
+
+ return (province, city, country);
+ }
+
+ ///
+ /// 从 Get Authorized Shops 取当前店的 shop_cipher。WMS 授权时落库,拉单查询必须带上。
+ ///
+ private async Task GetTiktokShopCipherAsync(SalesStore store, PlatformShopOptions options, CancellationToken ct)
+ {
+ var tiktok = options.Tiktok;
+ const string path = "/authorization/202309/shops";
+ var query = new Dictionary
+ {
+ ["app_key"] = tiktok.AppKey,
+ ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
+ };
+ var sign = PlatformShopCrypto.TiktokSign(path, query, null, tiktok.AppSecret);
+ var url = $"{tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}";
+ using var doc = await GetJsonAsync(url, ct, store.AccessToken);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object
+ || !doc.RootElement.TryGetProperty("data", out var data)
+ || data.ValueKind != JsonValueKind.Object
+ || !data.TryGetProperty("shops", out var shops)
+ || shops.ValueKind != JsonValueKind.Array)
+ {
+ var message = doc.RootElement.ValueKind == JsonValueKind.Object
+ ? GetString(doc.RootElement, "message")
+ : null;
+ logger.LogWarning("TikTok shops 未返回 cipher:{Message}", message);
+ return null;
+ }
+
+ string? fallback = null;
+ foreach (var shop in shops.EnumerateArray())
+ {
+ var cipher = GetString(shop, "cipher");
+ if (string.IsNullOrWhiteSpace(cipher)) continue;
+ fallback ??= cipher;
+ var id = GetString(shop, "id") ?? GetString(shop, "shop_id");
+ if (!string.IsNullOrWhiteSpace(store.PlatformStoreId)
+ && string.Equals(id, store.PlatformStoreId, StringComparison.Ordinal))
+ {
+ return cipher;
+ }
+ }
+
+ return fallback;
+ }
+
+ ///
+ /// 按单个 TikTok order_status 搜索一页订单。签名和字段对齐 WMS TiktokApi.getOrdersSearch。
+ ///
+ private async Task PostTiktokOrderSearchAsync(
+ SalesStore store,
+ PlatformShopOptions options,
+ PlatformShopPullOrdersRequest request,
+ string orderStatus,
+ string shopCipher,
+ string pageToken,
+ CancellationToken ct)
+ {
+ var tiktok = options.Tiktok;
+ const string path = "/order/202309/orders/search";
+ var query = new Dictionary
+ {
+ ["app_key"] = tiktok.AppKey,
+ ["shop_cipher"] = shopCipher,
+ ["page_size"] = "50",
+ ["page_token"] = pageToken,
+ ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
+ };
+ var body = new Dictionary
+ {
+ ["order_status"] = orderStatus
+ };
+ if (request.TimeStart.HasValue)
+ {
+ body["create_time_ge"] = ToUnix(request.TimeStart.Value);
+ }
+
+ if (request.TimeEnd.HasValue)
+ {
+ body["create_time_lt"] = ToUnix(request.TimeEnd.Value.AddSeconds(1));
+ }
+
+ var bodyJson = JsonSerializer.Serialize(body);
+ var sign = PlatformShopCrypto.TiktokSign(path, query, bodyJson, tiktok.AppSecret);
+ var url = $"{tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}";
+ return await PostRawJsonAsync(url, bodyJson, ct, store.AccessToken);
+ }
+
+ private static string BuildTiktokQuery(IReadOnlyDictionary query, string sign)
+ {
+ var parts = query
+ .OrderBy(kv => kv.Key, StringComparer.Ordinal)
+ .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")
+ .ToList();
+ parts.Add($"sign={Uri.EscapeDataString(sign)}");
+ return string.Join("&", parts);
+ }
+
+ private static readonly string[] TiktokPullStatuses =
+ [
+ "UNPAID",
+ "ON_HOLD",
+ "PARTIALLY_SHIPPING",
+ "AWAITING_SHIPMENT",
+ "AWAITING_COLLECTION",
+ "IN_TRANSIT",
+ "DELIVERED",
+ "COMPLETED",
+ "CANCELLED"
+ ];
+
+ private static List NormalizeTiktokPullStatuses(IEnumerable? selected)
+ {
+ var allowed = new HashSet(TiktokPullStatuses, StringComparer.OrdinalIgnoreCase);
+ var normalized = (selected ?? [])
+ .Select(x => (x ?? string.Empty).Trim().ToUpperInvariant())
+ .Where(allowed.Contains)
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ return normalized.Count > 0 ? normalized : [.. TiktokPullStatuses];
+ }
+
+ private async Task FetchTiktokOrderDetailAsync(
+ SalesStore store,
+ PlatformShopOptions options,
+ string platformOrderNo,
+ CancellationToken ct)
+ {
+ EnsureTiktok(store, options);
+ var shopCipher = await GetTiktokShopCipherAsync(store, options, ct);
+ if (string.IsNullOrWhiteSpace(shopCipher))
+ {
+ return null;
+ }
+
+ using var doc = await GetTiktokSignedAsync(options, store.AccessToken!, "/order/202309/orders", new Dictionary
+ {
+ ["app_key"] = options.Tiktok.AppKey,
+ ["shop_cipher"] = shopCipher,
+ ["ids"] = platformOrderNo,
+ ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
+ }, ct);
+ if (!TryGetTiktokData(doc.RootElement, out var data, out _)
+ || !data.TryGetProperty("orders", out var orders)
+ || orders.ValueKind != JsonValueKind.Array
+ || orders.GetArrayLength() == 0)
+ {
+ return null;
+ }
+
+ return MapTiktokOrder(orders[0]);
+ }
+
private async Task SyncTiktokAsync(
SalesStore store,
PlatformShopOptions options,
string platformOrderNo,
CancellationToken ct)
{
- EnsureTiktok(store, options);
- var tiktok = options.Tiktok;
- var token = store.AccessToken!;
- var apiPath = $"/order/202309/orders?app_key={tiktok.AppKey}&ids={platformOrderNo}×tamp={PlatformShopCrypto.UnixSeconds()}";
- var sign = PlatformShopCrypto.HmacSha256Hex(tiktok.AppSecret + apiPath.Replace("?", "").Replace("&", "").Replace("=", "") + tiktok.AppSecret, tiktok.AppSecret);
- var url = $"{tiktok.HostApi}{apiPath}&sign={sign}";
- using var doc = await GetJsonAsync(url, ct, token);
- var orders = doc.RootElement.GetProperty("data").GetProperty("orders");
- if (orders.GetArrayLength() == 0)
+ var mapped = await FetchTiktokOrderDetailAsync(store, options, platformOrderNo, ct);
+ if (mapped == null)
{
- return new PlatformShopOrderStatusResult { Success = false, Message = "TikTok 未找到订单" };
+ return new PlatformShopOrderStatusResult { Success = false, Message = "TikTok 未找到订单或缺少 shop_cipher" };
}
- var order = orders[0];
- var status = GetString(order, "status") ?? string.Empty;
return new PlatformShopOrderStatusResult
{
Success = true,
Message = "同步成功",
- PlatformStatus = status,
- PlatformStatusLabel = status,
- FulfillStatus = MapTiktokFulfill(status)
+ PlatformStatus = mapped.PlatformStatus,
+ PlatformStatusLabel = mapped.PlatformStatusLabel,
+ FulfillStatus = mapped.FulfillStatus,
+ ShippingLabelNo = mapped.ShippingLabelNo
};
}
+ ///
+ /// 对齐 WMS ShopordTiktokImpl.send:订单详情取 package_id → 校验 DropOff → shipPackage,再尝试拉面单。
+ ///
private async Task RtsTiktokAsync(
SalesStore store,
PlatformShopOptions options,
string platformOrderNo,
CancellationToken ct)
{
- var sync = await SyncTiktokAsync(store, options, platformOrderNo, ct);
- if (!sync.Success)
+ EnsureTiktok(store, options);
+ var shopCipher = await GetTiktokShopCipherAsync(store, options, ct);
+ if (string.IsNullOrWhiteSpace(shopCipher))
{
- return new PlatformShopReadyToShipResult { Success = false, Message = sync.Message };
+ return new PlatformShopReadyToShipResult { Success = false, Message = "未获取到 TikTok shop_cipher,请重新授权店铺" };
}
- if (!string.Equals(sync.PlatformStatus, "AWAITING_SHIPMENT", StringComparison.OrdinalIgnoreCase)
- && sync.PlatformStatus != "111")
+ var tiktok = options.Tiktok;
+ using var detail = await GetTiktokSignedAsync(options, store.AccessToken!, "/order/202309/orders", new Dictionary
+ {
+ ["app_key"] = tiktok.AppKey,
+ ["shop_cipher"] = shopCipher,
+ ["ids"] = platformOrderNo,
+ ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
+ }, ct);
+ if (!TryGetTiktokData(detail.RootElement, out var data, out var detailError))
+ {
+ return new PlatformShopReadyToShipResult { Success = false, Message = "订单详情信息异常:" + detailError };
+ }
+
+ if (!data.TryGetProperty("orders", out var orders)
+ || orders.ValueKind != JsonValueKind.Array
+ || orders.GetArrayLength() == 0)
+ {
+ return new PlatformShopReadyToShipResult { Success = false, Message = "TikTok 未找到订单" };
+ }
+
+ var order = orders[0];
+ var status = GetString(order, "status") ?? string.Empty;
+ if (string.Equals(status, "CANCELLED", StringComparison.OrdinalIgnoreCase))
+ {
+ return new PlatformShopReadyToShipResult { Success = false, Message = "订单已取消,不可准备发货" };
+ }
+
+ if (status is "IN_TRANSIT" or "DELIVERED" or "COMPLETED"
+ || string.Equals(status, "AWAITING_COLLECTION", StringComparison.OrdinalIgnoreCase))
{
return new PlatformShopReadyToShipResult
{
Success = false,
- Message = $"TikTok 订单状态为 {sync.PlatformStatus},不可 RTS"
+ Message = $"TikTok 订单状态为 {status},不可准备发货"
};
}
+ if (!order.TryGetProperty("line_items", out var lineItems)
+ || lineItems.ValueKind != JsonValueKind.Array
+ || lineItems.GetArrayLength() == 0)
+ {
+ return new PlatformShopReadyToShipResult { Success = false, Message = "商品信息为空" };
+ }
+
+ var packageIds = new List();
+ foreach (var line in lineItems.EnumerateArray())
+ {
+ var packageId = GetString(line, "package_id");
+ if (string.IsNullOrWhiteSpace(packageId)) continue;
+ if (!packageIds.Contains(packageId, StringComparer.Ordinal))
+ {
+ packageIds.Add(packageId);
+ }
+ }
+
+ if (packageIds.Count == 0)
+ {
+ return new PlatformShopReadyToShipResult { Success = false, Message = "订单没有 package_id,无法准备发货" };
+ }
+
+ foreach (var packageId in packageIds)
+ {
+ using var handoverDoc = await GetTiktokSignedAsync(
+ options,
+ store.AccessToken!,
+ $"/fulfillment/202309/packages/{packageId}/handover_time_slots",
+ new Dictionary
+ {
+ ["app_key"] = tiktok.AppKey,
+ ["shop_cipher"] = shopCipher,
+ ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
+ },
+ ct);
+ if (!TryGetTiktokData(handoverDoc.RootElement, out var handoverData, out var handoverError))
+ {
+ return new PlatformShopReadyToShipResult
+ {
+ Success = false,
+ Message = "包裹支持发货类型获异常:" + handoverError
+ };
+ }
+
+ var canDropOff = handoverData.TryGetProperty("can_drop_off", out var drop)
+ && drop.ValueKind is JsonValueKind.True or JsonValueKind.False
+ && drop.GetBoolean();
+ if (!canDropOff)
+ {
+ return new PlatformShopReadyToShipResult { Success = false, Message = "包裹不支持 DropOff 发货" };
+ }
+
+ var shipPath = $"/fulfillment/202309/packages/{packageId}/ship";
+ var shipQuery = new Dictionary
+ {
+ ["app_key"] = tiktok.AppKey,
+ ["shop_cipher"] = shopCipher,
+ ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
+ };
+ var shipBody = new Dictionary { ["handover_method"] = "DROP_OFF" };
+ using var shipDoc = await PostTiktokSignedAsync(options, store.AccessToken!, shipPath, shipQuery, shipBody, ct);
+ // ship 成功时 data 可能为 null,只认 code=0
+ var shipCode = GetString(shipDoc.RootElement, "code");
+ if (shipCode is not ("0" or null))
+ {
+ var msg = GetString(shipDoc.RootElement, "message") ?? "发货失败";
+ return new PlatformShopReadyToShipResult { Success = false, Message = "发货异常:" + msg };
+ }
+ }
+
+ string? labelNo = null;
+ string? labelUrl = LabelPendingText;
+ var labelPending = true;
+ try
+ {
+ var label = await LabelTiktokAsync(store, options, platformOrderNo, ct);
+ if (label.Success)
+ {
+ labelNo = label.ShippingLabelNo;
+ if (!string.IsNullOrWhiteSpace(label.ShippingLabelUrl))
+ {
+ labelUrl = label.ShippingLabelUrl;
+ labelPending = false;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "TikTok 准备发货后拉面单失败:{OrderNo}", platformOrderNo);
+ }
+
return new PlatformShopReadyToShipResult
{
Success = true,
- Message = "TikTok RTS 已提交,面单获取中",
+ Message = labelPending ? "准备发货成功,面单获取中" : "准备发货成功",
PlatformStatus = "AWAITING_COLLECTION",
PlatformStatusLabel = "AWAITING_COLLECTION",
FulfillStatus = "toShip",
- ShippingLabelUrl = LabelPendingText,
- LabelPending = true
+ ShippingLabelNo = labelNo,
+ ShippingLabelUrl = labelUrl,
+ LabelPending = labelPending
};
}
- private Task LabelTiktokAsync(
+ private async Task PostTiktokSignedAsync(
+ PlatformShopOptions options,
+ string accessToken,
+ string path,
+ Dictionary query,
+ object body,
+ CancellationToken ct)
+ {
+ var bodyJson = JsonSerializer.Serialize(body);
+ var sign = PlatformShopCrypto.TiktokSign(path, query, bodyJson, options.Tiktok.AppSecret);
+ var url = $"{options.Tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}";
+ return await PostRawJsonAsync(url, bodyJson, ct, accessToken);
+ }
+
+ ///
+ /// 对齐 WMS ShopordTiktokImpl.retryOrderSheet:订单详情取 package_id,再取 SHIPPING_LABEL 并落库。
+ ///
+ private async Task LabelTiktokAsync(
SalesStore store,
PlatformShopOptions options,
string platformOrderNo,
CancellationToken ct)
{
- var labelNo = $"TT-{platformOrderNo}";
- var labelUrl = $"https://seller.tiktokglobalshop.com/order/label/{platformOrderNo}";
- return Task.FromResult(new PlatformShopLabelResult
+ EnsureTiktok(store, options);
+ var shopCipher = await GetTiktokShopCipherAsync(store, options, ct);
+ if (string.IsNullOrWhiteSpace(shopCipher))
+ {
+ return new PlatformShopLabelResult { Success = false, Message = "未获取到 TikTok shop_cipher,请重新授权店铺" };
+ }
+
+ var tiktok = options.Tiktok;
+ using var detail = await GetTiktokSignedAsync(options, store.AccessToken!, "/order/202309/orders", new Dictionary
+ {
+ ["app_key"] = tiktok.AppKey,
+ ["shop_cipher"] = shopCipher,
+ ["ids"] = platformOrderNo,
+ ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
+ }, ct);
+ if (!TryGetTiktokData(detail.RootElement, out var data, out var detailError))
+ {
+ return new PlatformShopLabelResult { Success = false, Message = "订单详情信息异常:" + detailError };
+ }
+
+ if (!data.TryGetProperty("orders", out var orders)
+ || orders.ValueKind != JsonValueKind.Array
+ || orders.GetArrayLength() == 0)
+ {
+ return new PlatformShopLabelResult { Success = false, Message = "TikTok 未找到订单" };
+ }
+
+ var order = orders[0];
+ if (!order.TryGetProperty("line_items", out var lines)
+ || lines.ValueKind != JsonValueKind.Array
+ || lines.GetArrayLength() == 0)
+ {
+ return new PlatformShopLabelResult { Success = false, Message = "订单没有包裹,无法获取面单" };
+ }
+
+ var line = lines[0];
+ var packageId = GetString(line, "package_id");
+ var trackingNumber = GetString(line, "tracking_number");
+ if (string.IsNullOrWhiteSpace(packageId))
+ {
+ return new PlatformShopLabelResult { Success = false, Message = "订单没有 package_id,请先发货后再获取面单" };
+ }
+
+ using var document = await GetTiktokSignedAsync(
+ options,
+ store.AccessToken!,
+ $"/fulfillment/202309/packages/{packageId}/shipping_documents",
+ new Dictionary
+ {
+ ["app_key"] = tiktok.AppKey,
+ ["shop_cipher"] = shopCipher,
+ ["document_type"] = "SHIPPING_LABEL",
+ ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
+ },
+ ct);
+ if (!TryGetTiktokData(document.RootElement, out var docData, out var docError))
+ {
+ return new PlatformShopLabelResult { Success = false, Message = "获取面单失败:" + docError };
+ }
+
+ var docUrl = GetString(docData, "doc_url");
+ if (string.IsNullOrWhiteSpace(docUrl) || !docUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ return new PlatformShopLabelResult
+ {
+ Success = false,
+ Message = string.IsNullOrWhiteSpace(docUrl) ? "获取面单失败" : "获取面单失败:" + docUrl
+ };
+ }
+
+ var storedUrl = await SaveTiktokLabelAsync(docUrl, trackingNumber, platformOrderNo, ct);
+ return new PlatformShopLabelResult
{
Success = true,
- Message = "TikTok 面单信息已更新",
- ShippingLabelNo = labelNo,
- ShippingLabelUrl = labelUrl
- });
+ Message = "面单获取成功",
+ ShippingLabelNo = string.IsNullOrWhiteSpace(trackingNumber) ? null : trackingNumber,
+ ShippingLabelUrl = storedUrl
+ };
+ }
+
+ ///
+ /// 下载 TikTok 面单 PDF 并写入对象存储。下载失败时退回平台临时地址。
+ ///
+ private async Task SaveTiktokLabelAsync(
+ string docUrl,
+ string? trackingNumber,
+ string platformOrderNo,
+ CancellationToken ct)
+ {
+ try
+ {
+ var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName);
+ var bytes = await client.GetByteArrayAsync(docUrl, ct);
+ if (bytes.Length == 0)
+ {
+ return docUrl;
+ }
+
+ var name = string.IsNullOrWhiteSpace(trackingNumber) ? platformOrderNo : trackingNumber;
+ var key = $"uploads/platform-label/{DateTime.Now:yyyyMM}/md_{name}_{Guid.NewGuid():N}.pdf";
+ await using var stream = new MemoryStream(bytes);
+ var objectKey = await objectStorage.SaveObjectAsync(stream, key, "application/pdf", ct);
+ var publicUrl = await objectStorage.GetPublicUrlAsync(objectKey, ct);
+ return publicUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? publicUrl : docUrl;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "TikTok 面单下载落库失败,改用平台地址");
+ return docUrl;
+ }
+ }
+
+ private async Task GetTiktokSignedAsync(
+ PlatformShopOptions options,
+ string accessToken,
+ string path,
+ Dictionary query,
+ CancellationToken ct)
+ {
+ var sign = PlatformShopCrypto.TiktokSign(path, query, null, options.Tiktok.AppSecret);
+ var url = $"{options.Tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}";
+ return await GetJsonAsync(url, ct, accessToken);
+ }
+
+ private static bool TryGetTiktokData(JsonElement root, out JsonElement data, out string error)
+ {
+ data = default;
+ error = "返回为空";
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return false;
+ }
+
+ var code = GetString(root, "code");
+ error = GetString(root, "message") ?? GetString(root, "msg") ?? error;
+ if (code is not ("0" or null))
+ {
+ return false;
+ }
+
+ if (!root.TryGetProperty("data", out data) || data.ValueKind != JsonValueKind.Object)
+ {
+ return false;
+ }
+
+ return true;
}
private static string MapTiktokFulfill(string? status) => status?.ToUpperInvariant() switch
@@ -833,6 +1523,31 @@ internal sealed class PlatformShopFulfillmentClient(
return JsonDocument.Parse(raw);
}
+ ///
+ /// 发送已经参与签名的原始 JSON,避免二次序列化改掉请求体。
+ ///
+ private async Task PostRawJsonAsync(
+ string url,
+ string bodyJson,
+ CancellationToken ct,
+ string? accessToken = null)
+ {
+ var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName);
+ using var request = new HttpRequestMessage(HttpMethod.Post, url)
+ {
+ Content = new StringContent(bodyJson, Encoding.UTF8, "application/json")
+ };
+ if (!string.IsNullOrWhiteSpace(accessToken))
+ {
+ request.Headers.TryAddWithoutValidation("x-tts-access-token", accessToken);
+ }
+
+ using var response = await client.SendAsync(request, ct);
+ var raw = await response.Content.ReadAsStringAsync(ct);
+ logger.LogInformation("Platform POST {Url} => {Body}", url, raw);
+ return JsonDocument.Parse(string.IsNullOrWhiteSpace(raw) ? "{}" : raw);
+ }
+
private async Task PostFormAsync(
string url,
IDictionary form,
@@ -930,11 +1645,73 @@ internal sealed class PlatformShopFulfillmentClient(
}
}
- private static string? GetString(JsonElement el, string name) =>
- el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() :
- el.TryGetProperty(name, out p) && p.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False
- ? p.ToString()
- : null;
+ private static string? GetString(JsonElement el, string name)
+ {
+ if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var p))
+ {
+ return null;
+ }
+
+ return p.ValueKind switch
+ {
+ JsonValueKind.String => p.GetString(),
+ JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => p.ToString(),
+ _ => null
+ };
+ }
+
+ private static decimal? GetDecimal(JsonElement el, string name)
+ {
+ if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var p))
+ {
+ return null;
+ }
+
+ if (p.ValueKind == JsonValueKind.Number && p.TryGetDecimal(out var number))
+ {
+ return number;
+ }
+
+ if (p.ValueKind == JsonValueKind.String
+ && decimal.TryParse(p.GetString(), System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var parsed))
+ {
+ return parsed;
+ }
+
+ return null;
+ }
+
+ private static long? GetLong(JsonElement el, string name)
+ {
+ if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var p))
+ {
+ return null;
+ }
+
+ if (p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var number))
+ {
+ return number;
+ }
+
+ if (p.ValueKind == JsonValueKind.String
+ && long.TryParse(p.GetString(), System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var parsed))
+ {
+ return parsed;
+ }
+
+ return null;
+ }
+
+ private static DateTime? FromUnixSeconds(long? unix)
+ {
+ if (unix is null or <= 0) return null;
+ return DateTimeOffset.FromUnixTimeSeconds(unix.Value).LocalDateTime;
+ }
+
+ private static string? FirstNonEmpty(params string?[] values) =>
+ values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
#endregion
}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs
index 84a3c24..d73df52 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs
@@ -1,5 +1,7 @@
+using Fake.EventBus.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
using Oms.Application.Messaging.OrderSideEffects;
using Oms.Application.Messaging.ParcelCreate;
using Oms.Application.Services.Orders.Batch;
@@ -25,8 +27,15 @@ internal sealed class SmallBagTrackOrderNotifier(
return;
}
- var publisher = scope.ServiceProvider.GetRequiredService();
- await publisher.PublishTrackUpsertAsync(order, request, customerId);
+ var eventBus = scope.ServiceProvider.GetRequiredService();
+ await eventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent
+ {
+ OrderId = order.Id,
+ CustomerId = customerId,
+ PayloadJson = JsonConvert.SerializeObject(request),
+ AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess,
+ Authorization = request.Authorization
+ });
}
catch (Exception ex)
{
@@ -75,8 +84,19 @@ internal sealed class SmallBagTrackOrderNotifier(
try
{
using var scope = scopeFactory.CreateScope();
- var publisher = scope.ServiceProvider.GetRequiredService();
- await publisher.PublishLifecycleTrackAppendAsync(orderId, orderNo, trackCode, authorization);
+ if (orderId <= 0 || string.IsNullOrWhiteSpace(orderNo) || string.IsNullOrWhiteSpace(trackCode))
+ {
+ return;
+ }
+
+ var eventBus = scope.ServiceProvider.GetRequiredService();
+ await eventBus.PublishAsync(new OrderLifecycleTrackAppendIntegrationEvent
+ {
+ OrderId = orderId,
+ OrderNo = orderNo.Trim(),
+ TrackCode = trackCode.Trim(),
+ Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim()
+ });
}
catch (Exception ex)
{
diff --git a/src/Oms.Application/Messaging/IOrderBatchJobPublisher.cs b/src/Oms.Application/Messaging/IOrderBatchJobPublisher.cs
deleted file mode 100644
index 40054e3..0000000
--- a/src/Oms.Application/Messaging/IOrderBatchJobPublisher.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace Oms.Application.Messaging;
-
-public interface IOrderBatchJobPublisher
-{
- Task PublishAsync(OrderBatchJobMessage message, CancellationToken cancellationToken = default);
-
- bool IsEnabled { get; }
-}
diff --git a/src/Oms.Application/Messaging/OmsAfterCommit.cs b/src/Oms.Application/Messaging/OmsAfterCommit.cs
deleted file mode 100644
index 9b31ee7..0000000
--- a/src/Oms.Application/Messaging/OmsAfterCommit.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Fake.UnitOfWork;
-
-namespace Oms.Application.Messaging;
-
-///
-/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。
-///
-internal static class OmsAfterCommit
-{
- public static Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func action)
- {
- var current = unitOfWorkManager.Current;
- if (current != null && !current.IsCompleted)
- {
- // OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。
- // 等工作单元释放后再开独立事务。
- EventHandler handler = null!;
- handler = (_, _) =>
- {
- current.Disposed -= handler;
- _ = Task.Run(action);
- };
- current.Disposed += handler;
- return Task.CompletedTask;
- }
-
- return action();
- }
-
- ///
- /// 新开独立事务执行一步。失败只记日志,不抛回调用方。
- ///
- public static async Task RunStepAsync(
- IServiceScopeFactory scopeFactory,
- ILogger logger,
- string step,
- Func work)
- {
- try
- {
- using var scope = scopeFactory.CreateScope();
- var unitOfWorkManager = scope.ServiceProvider.GetRequiredService();
- using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent());
- await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager);
- await work(scope.ServiceProvider);
- await unitOfWork.CompleteAsync();
- return true;
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "订单后置步骤失败 Step={Step}", step);
- return false;
- }
- }
-}
diff --git a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs
deleted file mode 100644
index b572be4..0000000
--- a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs
+++ /dev/null
@@ -1,119 +0,0 @@
-using System.Data;
-using Fake.EntityFrameworkCore;
-using Fake.EventBus.Distributed;
-using Fake.EventBus.IntegrationEventLog;
-using Fake.UnitOfWork;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace Oms.Application.Messaging;
-
-///
-/// 可靠发布 IntegrationEvent:事务 UoW 内写入 Outbox(由 Fake.EventBus.IntegrationEventLog 投递)。
-/// SqlSugar 与 EF 默认各持连接;若环境事务已由 SqlSugar 占用,则改用 RequiresNew 短事务只写 Outbox。
-///
-internal static class OmsIntegrationEventPublisher
-{
- public static async Task PublishAsync(
- IDistributedEventBus eventBus,
- IUnitOfWorkManager unitOfWorkManager,
- ILogger logger,
- IntegrationEvent @event,
- CancellationToken cancellationToken = default)
- {
- if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager))
- {
- try
- {
- await EnsureOutboxDbContextAsync(unitOfWorkManager);
- await eventBus.PublishAsync(@event, cancellationToken);
- logger.LogDebug(
- "Outbox enqueued {EventName} Id={EventId} (ambient TX)",
- @event.GetType().Name,
- @event.Id);
- return;
- }
- catch (Exception ex) when (ShouldUseIndependentOutbox(ex))
- {
- // 环境事务已提交、连接对不上,或还没有可征用的数据库事务时,改走独立短事务。
- logger.LogWarning(
- ex,
- "Ambient UoW TX incompatible with EF Outbox; using short outbox TX. Event={EventName} Id={EventId}",
- @event.GetType().Name,
- @event.Id);
- }
- }
- else
- {
- logger.LogWarning(
- "Publish {EventName} Id={EventId} without ambient transactional UoW; opening short outbox TX",
- @event.GetType().Name,
- @event.Id);
- }
-
- using var uow = unitOfWorkManager.Begin(new UnitOfWorkAttribute
- {
- IsTransactional = true,
- RequiresNew = true,
- IsolationLevel = IsolationLevel.ReadCommitted
- });
- await EnsureOutboxDbContextAsync(unitOfWorkManager);
- await eventBus.PublishAsync(@event, cancellationToken);
- await uow.CompleteAsync(cancellationToken);
- }
-
- public static async Task TryPublishAsync(
- IDistributedEventBus eventBus,
- IUnitOfWorkManager unitOfWorkManager,
- ILogger logger,
- IntegrationEvent @event,
- CancellationToken cancellationToken = default)
- {
- try
- {
- await PublishAsync(eventBus, unitOfWorkManager, logger, @event, cancellationToken);
- return true;
- }
- catch (Exception ex)
- {
- logger.LogError(
- ex,
- "Outbox publish failed {EventName} Id={EventId}",
- @event.GetType().Name,
- @event.Id);
- return false;
- }
- }
-
- ///
- /// 业务写库前先打开 Outbox DbContext,尽量让 EF 先占住环境事务连接,供 SqlSugar 征用。
- ///
- public static Task EnsureOutboxDbContextAsync(IUnitOfWorkManager unitOfWorkManager)
- {
- var current = unitOfWorkManager.Current
- ?? throw new InvalidOperationException("No ambient unit of work");
- var dbProvider = current.ServiceProvider
- .GetRequiredService>();
- return dbProvider.GetDbContextAsync();
- }
-
- private static bool ShouldUseIndependentOutbox(Exception ex)
- {
- for (var e = ex; e != null; e = e.InnerException!)
- {
- var message = e.Message;
- if (message.Contains("not associated with the current connection", StringComparison.OrdinalIgnoreCase)
- || message.Contains("associated with the current connection", StringComparison.OrdinalIgnoreCase)
- || message.Contains("可征用的数据库事务", StringComparison.Ordinal)
- || message.Contains("returned null", StringComparison.OrdinalIgnoreCase)
- || message.Contains("No ambient transaction", StringComparison.OrdinalIgnoreCase)
- || message.Contains("已经回滚", StringComparison.Ordinal)
- || message.Contains("已被调用", StringComparison.Ordinal))
- {
- return true;
- }
- }
-
- return false;
- }
-}
diff --git a/src/Oms.Application/Messaging/OrderBatchJobPublisher.cs b/src/Oms.Application/Messaging/OrderBatchJobPublisher.cs
deleted file mode 100644
index a007ac4..0000000
--- a/src/Oms.Application/Messaging/OrderBatchJobPublisher.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using Fake.EventBus.Distributed;
-using Fake.UnitOfWork;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-
-namespace Oms.Application.Messaging;
-
-///
-/// 通过 Fake 分布式事件总线(Outbox)发布订单批量任务。
-///
-public sealed class OrderBatchJobPublisher(
- IDistributedEventBus eventBus,
- IUnitOfWorkManager unitOfWorkManager,
- IOptions options,
- ILogger logger) : IOrderBatchJobPublisher
-{
- public bool IsEnabled => options.Value.Enabled;
-
- public async Task PublishAsync(OrderBatchJobMessage message, CancellationToken cancellationToken = default)
- {
- if (!options.Value.Enabled)
- {
- throw new InvalidOperationException("Order batch RabbitMQ is disabled.");
- }
-
- var @event = OrderBatchJobIntegrationEvent.FromMessage(message);
- await OmsIntegrationEventPublisher.PublishAsync(
- eventBus, unitOfWorkManager, logger, @event, cancellationToken);
-
- logger.LogDebug(
- "Outbox enqueued order batch event {EventId} messageId={MessageId} action={Action}",
- @event.Id,
- message.MessageId,
- message.Action);
- }
-}
diff --git a/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs b/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs
deleted file mode 100644
index 9cba9f9..0000000
--- a/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Oms.Application.Services.Orders.Issues;
-
-namespace Oms.Application.Messaging.OrderSideEffects;
-
-///
-/// 订单侧后置副作用发布(生命周期轨迹、自动触发等)。
-///
-public interface IOrderSideEffectEventPublisher
-{
- Task PublishLifecycleTrackAppendAsync(
- int orderId,
- string orderNo,
- string trackCode,
- string? authorization = null,
- CancellationToken cancellationToken = default);
-
- Task PublishAutoTriggerFireAsync(
- int orderId,
- AutoTriggerTiming timing,
- int operatorUserId,
- CancellationToken cancellationToken = default);
-}
diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs
deleted file mode 100644
index 9322fc4..0000000
--- a/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using Fake.EventBus.Distributed;
-using Fake.UnitOfWork;
-using Microsoft.Extensions.Logging;
-using Oms.Application.Services.Orders.Issues;
-
-namespace Oms.Application.Messaging.OrderSideEffects;
-
-///
-/// 订单旁路副作用事件:事务内写 Outbox,Commit 后投递 RabbitMQ。
-///
-public sealed class OrderSideEffectEventPublisher(
- IDistributedEventBus eventBus,
- IUnitOfWorkManager unitOfWorkManager,
- ILogger logger) : IOrderSideEffectEventPublisher
-{
- public async Task PublishLifecycleTrackAppendAsync(
- int orderId,
- string orderNo,
- string trackCode,
- string? authorization = null,
- CancellationToken cancellationToken = default)
- {
- if (orderId <= 0 || string.IsNullOrWhiteSpace(orderNo) || string.IsNullOrWhiteSpace(trackCode))
- {
- return;
- }
-
- var @event = new OrderLifecycleTrackAppendIntegrationEvent
- {
- OrderId = orderId,
- OrderNo = orderNo.Trim(),
- TrackCode = trackCode.Trim(),
- Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim()
- };
-
- await OmsIntegrationEventPublisher.PublishAsync(
- eventBus, unitOfWorkManager, logger, @event, cancellationToken);
- }
-
- public async Task PublishAutoTriggerFireAsync(
- int orderId,
- AutoTriggerTiming timing,
- int operatorUserId,
- CancellationToken cancellationToken = default)
- {
- if (orderId <= 0)
- {
- return;
- }
-
- var @event = new OrderAutoTriggerFireIntegrationEvent
- {
- OrderId = orderId,
- Timing = timing.ToString(),
- OperatorUserId = operatorUserId
- };
-
- await OmsIntegrationEventPublisher.PublishAsync(
- eventBus, unitOfWorkManager, logger, @event, cancellationToken);
- }
-}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs b/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs
deleted file mode 100644
index 2545a2e..0000000
--- a/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using Oms.Application.Dtos.Orders;
-using Oms.Application.Integrations.SmallBagTrack;
-using Oms.Domain.OrdersAggregate.Entities;
-
-namespace Oms.Application.Messaging.ParcelCreate;
-
-///
-/// 创单后置副作用:发布到 Fake.EventBus.RabbitMQ;发布失败时回落同步执行。
-///
-public interface IOrderParcelCreateEventPublisher
-{
- ///
- /// 轨迹订单 + 创建成功轨迹(A0101)合并为一个事件。
- ///
- Task PublishTrackUpsertAsync(
- Order order,
- SmallBagTrackUpsertRequest request,
- int? customerId,
- CancellationToken cancellationToken = default);
-
- Task PublishLabelGenerateAsync(
- Order order,
- int operatorUserId = 0,
- CancellationToken cancellationToken = default);
-
- Task PublishCustoms9610UpsertAsync(
- Order order,
- string? platformCode = null,
- CancellationToken cancellationToken = default);
-
- ///
- /// 发布揽收挂接事件。返回 true 表示已入队;false 表示已同步回落执行(或参数不足未执行)。
- ///
- Task PublishPickupAttachAsync(
- OrderParcelCreateInput input,
- int orderId,
- CancellationToken cancellationToken = default);
-
- ///
- /// 同步执行揽收挂接(用于创单 HTTP 需要回写 PickupNo 的场景)。
- ///
- Task ExecutePickupAttachAsync(
- OrderParcelCreateInput input,
- int orderId,
- CancellationToken cancellationToken = default);
-}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs
deleted file mode 100644
index cf35e80..0000000
--- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs
+++ /dev/null
@@ -1,143 +0,0 @@
-using Fake.EventBus.Distributed;
-using Fake.UnitOfWork;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Newtonsoft.Json;
-using Oms.Application.Dtos.Orders;
-using Oms.Application.Integrations.SmallBagTrack;
-using Oms.Application.Services.Orders;
-using Oms.Domain.OrdersAggregate.Consts;
-using Oms.Domain.OrdersAggregate.Entities;
-
-namespace Oms.Application.Messaging.ParcelCreate;
-
-///
-/// 创单后置事件发布器:事务内写 Outbox(Fake.EventBus.IntegrationEventLog),Commit 后投递 RabbitMQ。
-/// 揽收 Publish 失败时由调用方同步 ExecutePickupAttachAsync 回落。
-///
-public sealed class OrderParcelCreateEventPublisher(
- IDistributedEventBus eventBus,
- IUnitOfWorkManager unitOfWorkManager,
- IServiceScopeFactory scopeFactory,
- ILogger logger) : IOrderParcelCreateEventPublisher
-{
- public async Task PublishTrackUpsertAsync(
- Order order,
- SmallBagTrackUpsertRequest request,
- int? customerId,
- CancellationToken cancellationToken = default)
- {
- var @event = new OrderParcelTrackUpsertIntegrationEvent
- {
- OrderId = order.Id,
- CustomerId = customerId,
- PayloadJson = JsonConvert.SerializeObject(request),
- AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess,
- Authorization = request.Authorization
- };
-
- await OmsIntegrationEventPublisher.PublishAsync(
- eventBus, unitOfWorkManager, logger, @event, cancellationToken);
- }
-
- public async Task PublishLabelGenerateAsync(
- Order order,
- int operatorUserId = 0,
- CancellationToken cancellationToken = default)
- {
- var @event = new OrderParcelLabelGenerateIntegrationEvent
- {
- OrderId = order.Id,
- OperatorUserId = operatorUserId
- };
-
- await OmsIntegrationEventPublisher.PublishAsync(
- eventBus, unitOfWorkManager, logger, @event, cancellationToken);
- }
-
- public async Task PublishCustoms9610UpsertAsync(
- Order order,
- string? platformCode = null,
- CancellationToken cancellationToken = default)
- {
- var @event = new OrderParcelCustoms9610UpsertIntegrationEvent
- {
- OrderId = order.Id,
- PlatformCode = platformCode
- };
-
- await OmsIntegrationEventPublisher.PublishAsync(
- eventBus, unitOfWorkManager, logger, @event, cancellationToken);
- }
-
- public async Task PublishPickupAttachAsync(
- OrderParcelCreateInput input,
- int orderId,
- CancellationToken cancellationToken = default)
- {
- var @event = BuildPickupEvent(input, orderId);
- if (@event == null)
- {
- return false;
- }
-
- return await OmsIntegrationEventPublisher.TryPublishAsync(
- eventBus, unitOfWorkManager, logger, @event, cancellationToken);
- }
-
- public async Task ExecutePickupAttachAsync(
- OrderParcelCreateInput input,
- int orderId,
- CancellationToken cancellationToken = default)
- {
- if (orderId <= 0
- || input.SenderAddressId is not > 0
- || input.ExpectPickupTime is null
- || input.ExpectPickupTime == default
- || input.WarehouseId is not > 0)
- {
- return null;
- }
-
- using var scope = scopeFactory.CreateScope();
- var pickupService = scope.ServiceProvider.GetRequiredService();
- return await pickupService.CreateForParcelInternalAsync(new PickupOrderCreateInput
- {
- CustomerId = input.CustomerId,
- SenderAddressId = input.SenderAddressId.Value,
- Direction = PickupDirectionCodes.NormalizeOrDefault(input.PickupDirection),
- WarehouseId = input.WarehouseId.Value,
- CarrierCode = input.CarrierCode,
- CarrierService = input.CarrierService,
- ExpectPickupTime = input.ExpectPickupTime.Value,
- OrderIds = [orderId],
- Remark = null
- });
- }
-
- private static OrderParcelPickupAttachIntegrationEvent? BuildPickupEvent(
- OrderParcelCreateInput input,
- int orderId)
- {
- if (orderId <= 0
- || input.SenderAddressId is not > 0
- || input.ExpectPickupTime is null
- || input.ExpectPickupTime == default
- || input.WarehouseId is not > 0)
- {
- return null;
- }
-
- return new OrderParcelPickupAttachIntegrationEvent
- {
- OrderId = orderId,
- CustomerId = input.CustomerId,
- SenderAddressId = input.SenderAddressId.Value,
- WarehouseId = input.WarehouseId.Value,
- PickupDirection = input.PickupDirection,
- CarrierCode = input.CarrierCode,
- CarrierService = input.CarrierService,
- ExpectPickupTime = input.ExpectPickupTime.Value
- };
- }
-}
diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj
index 515006d..63f8eff 100644
--- a/src/Oms.Application/Oms.Application.csproj
+++ b/src/Oms.Application/Oms.Application.csproj
@@ -20,6 +20,11 @@
+
+
+
+
+
@@ -30,29 +35,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs b/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs
index 8131be4..6772161 100644
--- a/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs
+++ b/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs
@@ -1,8 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Oms.Application.Messaging;
-using Oms.Application.Messaging.OrderSideEffects;
-using Oms.Application.Messaging.ParcelCreate;
namespace Oms.Application;
@@ -11,10 +9,7 @@ public static class OrderBatchMessagingServiceExtensions
public static IServiceCollection AddOmsOrderBatchMessaging(this IServiceCollection services, IConfiguration configuration)
{
services.Configure(configuration.GetSection(OrderBatchRabbitMqOptions.SectionName));
- services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
// 消费端由 Fake.EventBus.RabbitMQ(RabbitMqEventBus HostedService)托管
services.AddHostedService();
return services;
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
index 1c0ef56..4008592 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
@@ -1,5 +1,6 @@
using Fake.Application;
using Fake.DependencyInjection;
+using Fake.EventBus.Distributed;
using Fake.ObjectStorage;
using Newtonsoft.Json;
using Oms.Application.Dtos.Orders;
@@ -24,7 +25,7 @@ public sealed class OrderBatchActionExecutor(
IObjectStorage fileStorage,
ISmallBagTrackOrderClient smallBagTrackOrderClient,
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
- IOrderParcelCreateEventPublisher parcelCreateEventPublisher,
+ IDistributedEventBus eventBus,
OrderDownstreamCredentialResolver downstreamCredentialResolver,
IItfxForwardClient itfxForwardClient,
OrderFileManager orderFileManager,
@@ -252,11 +253,14 @@ public sealed class OrderBatchActionExecutor(
trackRequest.Authorization = authorization.Trim();
}
- await parcelCreateEventPublisher.PublishTrackUpsertAsync(
- order,
- trackRequest,
- customerId: order.CustomerId,
- cancellationToken);
+ await eventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent
+ {
+ OrderId = order.Id,
+ CustomerId = order.CustomerId,
+ PayloadJson = JsonConvert.SerializeObject(trackRequest),
+ AppendInitialTrackOnSuccess = trackRequest.AppendInitialTrackOnSuccess,
+ Authorization = trackRequest.Authorization
+ }, cancellationToken);
}
}
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
index 3cac21b..493334e 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
@@ -1,6 +1,6 @@
using Fake.Application;
using Fake.DependencyInjection;
-using Fake.UnitOfWork;
+using Fake.EventBus.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
@@ -29,9 +29,8 @@ public sealed class OrderBatchJobCoordinator(
ICountryRepository countryRepository,
IServiceChannelRepository serviceChannelRepository,
IObjectStorage fileStorage,
- IOrderBatchJobPublisher publisher,
+ IDistributedEventBus eventBus,
OrderBatchJobProcessor batchJobProcessor,
- IUnitOfWorkManager unitOfWorkManager,
IOptions batchOptions,
ILogger logger) : IScopedDependency
{
@@ -44,7 +43,7 @@ public sealed class OrderBatchJobCoordinator(
string? authorization = null,
CancellationToken cancellationToken = default)
{
- if (!publisher.IsEnabled)
+ if (!batchOptions.Value.Enabled)
{
throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置");
}
@@ -78,12 +77,6 @@ public sealed class OrderBatchJobCoordinator(
}
}
- // 先打开 EF Outbox 上下文,尽量让环境事务由 EF 占连接,避免 SqlSugar 先开事务后 Outbox 征用失败。
- if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager))
- {
- await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager);
- }
-
var now = DateTime.Now;
var job = new OrderBatchJob
{
@@ -161,7 +154,7 @@ public sealed class OrderBatchJobCoordinator(
MessageId = item.MessageId ?? $"{job.Id}:{item.OrderId}:{action}",
};
- await publisher.PublishAsync(message, cancellationToken);
+ await eventBus.PublishAsync(OrderBatchJobIntegrationEvent.FromMessage(message), cancellationToken);
}
logger.LogInformation(
@@ -181,7 +174,7 @@ public sealed class OrderBatchJobCoordinator(
int operatorUserId,
CancellationToken cancellationToken = default)
{
- if (!publisher.IsEnabled)
+ if (!batchOptions.Value.Enabled)
{
throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置");
}
@@ -194,8 +187,9 @@ public sealed class OrderBatchJobCoordinator(
for (var index = 0; index < items.Count; index++)
{
- await publisher.PublishAsync(
- BuildPayloadMessage(job, items[index], itemsInput[index], input.CustomerId, operatorUserId),
+ await eventBus.PublishAsync(
+ OrderBatchJobIntegrationEvent.FromMessage(
+ BuildPayloadMessage(job, items[index], itemsInput[index], input.CustomerId, operatorUserId)),
cancellationToken);
}
@@ -383,7 +377,7 @@ public sealed class OrderBatchJobCoordinator(
public async Task PublishTrackUpsertAsync(SmallBagTrackUpsertRequest request, int orderId, int? customerId)
{
- if (!publisher.IsEnabled)
+ if (!batchOptions.Value.Enabled)
{
logger.LogDebug("RabbitMQ disabled, track upsert will use direct call. orderId={OrderId}", orderId);
return false;
@@ -401,7 +395,7 @@ public sealed class OrderBatchJobCoordinator(
MessageId = $"track:{orderId}:{Guid.NewGuid():N}",
};
- await publisher.PublishAsync(message);
+ await eventBus.PublishAsync(OrderBatchJobIntegrationEvent.FromMessage(message));
return true;
}
@@ -437,7 +431,7 @@ public sealed class OrderBatchJobCoordinator(
int operatorUserId,
CancellationToken cancellationToken = default)
{
- if (!publisher.IsEnabled)
+ if (!batchOptions.Value.Enabled)
{
throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置");
}
@@ -472,7 +466,9 @@ public sealed class OrderBatchJobCoordinator(
item.UpdateUserId = operatorUserId;
await batchJobRepository.UpdateItemAsync(item, cancellationToken);
- await publisher.PublishAsync(BuildMessageFromJobItem(job, item, operatorUserId), cancellationToken);
+ await eventBus.PublishAsync(
+ OrderBatchJobIntegrationEvent.FromMessage(BuildMessageFromJobItem(job, item, operatorUserId)),
+ cancellationToken);
}
job.Status = OrderBatchJobStatusCodes.Running;
diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
index 2e45bea..9789c4b 100644
--- a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
+++ b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
@@ -41,6 +41,12 @@ public class Customs9610OrderService : ApplicationService
private IOptions ItfxOptions =>
LazyServiceProvider.GetRequiredService>();
+ private ICustomerExportQualificationRepository ExportQualificationRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private Customs9610OrderWriter Customs9610Writer =>
+ LazyServiceProvider.GetRequiredService();
+
[HttpPost("/order/customs-9610/list")]
public async Task> ListAsync([FromBody] Customs9610ListInput input)
{
@@ -209,6 +215,113 @@ public class Customs9610OrderService : ApplicationService
};
}
+ ///
+ /// 从已有运单批量创建 9610 汇总申报单:运单改为 9610Summary 并绑定出口资质备案,按运单申报明细生成待申报单。
+ ///
+ /// 运单 Id 列表、出口资质备案 Id(空则用各客户默认备案)、电商平台编码。
+ /// 逐单结果;已删除、已有 9610 单、无申报明细、无可用备案的运单记为失败,不影响其它运单。
+ [HttpPost("/order/customs-9610/create-from-orders")]
+ public async Task CreateFromOrdersAsync(
+ [FromBody] Customs9610CreateFromOrdersInput input)
+ {
+ var platformCode = input.PlatformCode?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(platformCode))
+ {
+ throw new BusinessException("请选择电商平台");
+ }
+
+ if (platformCode.Length > 16)
+ {
+ throw new BusinessException("电商平台编码不能超过 16 个字符");
+ }
+
+ var orderIds = input.OrderIds.Where(x => x > 0).Distinct().ToList();
+ if (orderIds.Count == 0)
+ {
+ throw new BusinessException("请选择运单");
+ }
+
+ var sessionCustomerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
+ CustomerRepository,
+ CurrentUser,
+ null);
+ var orders = (await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)))
+ .ToDictionary(x => x.Id);
+ var declarations = await OrderRepository.GetDeclarationsByOrderIdsAsync(orderIds);
+ var ordersWithItems = declarations.Select(x => x.OrderId).ToHashSet();
+
+ var results = new List();
+ foreach (var orderId in orderIds)
+ {
+ var result = new Customs9610CreateFromOrderResult { OrderId = orderId };
+ results.Add(result);
+ try
+ {
+ if (!orders.TryGetValue(orderId, out var order)
+ || (sessionCustomerId is > 0 && order.CustomerId != sessionCustomerId.Value))
+ {
+ throw new BusinessException("运单不存在或无权访问");
+ }
+
+ result.ServiceNo = order.ServiceNo;
+ if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new BusinessException("运单已删除");
+ }
+
+ if (order.CustomerId is not > 0)
+ {
+ throw new BusinessException("运单未关联客户");
+ }
+
+ if (await HeaderRepository.GetByOrderIdAsync(order.Id) != null)
+ {
+ throw new BusinessException("该运单已存在 9610 申报单");
+ }
+
+ if (!ordersWithItems.Contains(order.Id))
+ {
+ throw new BusinessException("运单没有申报明细");
+ }
+
+ var qualification = input.ExportQualificationId is > 0
+ ? await ExportQualificationRepository.GetOwnedAsync(
+ input.ExportQualificationId.Value,
+ order.CustomerId.Value)
+ : await ExportQualificationRepository.GetDefaultAsync(order.CustomerId.Value);
+ if (qualification == null)
+ {
+ throw new BusinessException(input.ExportQualificationId is > 0
+ ? "所选出口资质备案不属于该运单客户"
+ : "运单客户未维护默认出口资质备案");
+ }
+
+ order.ExportCustomsMode = ExportCustomsModeCodes.Mode9610Summary;
+ order.ExportQualificationId = qualification.Id;
+ await Customs9610Writer.UpsertFromOrderAsync(order, platformCode);
+ order.UpdateTime = DateTime.Now;
+ await OrderRepository.UpdateAsync(order);
+
+ var header = await HeaderRepository.GetByOrderIdAsync(order.Id);
+ result.Customs9610OrderId = header?.Id;
+ result.Success = header != null;
+ result.Message = header == null ? "9610 申报单生成失败" : null;
+ }
+ catch (Exception ex)
+ {
+ result.Success = false;
+ result.Message = string.IsNullOrWhiteSpace(ex.Message) ? "创建失败" : ex.Message;
+ }
+ }
+
+ return new Customs9610CreateFromOrdersOutput
+ {
+ SuccessCount = results.Count(x => x.Success),
+ FailCount = results.Count(x => !x.Success),
+ Items = results,
+ };
+ }
+
///
/// 批量获取清单数据(调 API 中心上游,标准码暂定),成功后回写本地字段。
///
diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs
index e403be4..4754b59 100644
--- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs
+++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs
@@ -199,6 +199,9 @@ public static class AutoTriggerFormulaMeta
yield return ("进口HS编码", "t_order_declaration", "ImHsCode", FormulaValueType.String, false);
yield return ("出口HS编码", "t_order_declaration", "ExHsCode", FormulaValueType.String, false);
yield return ("出口申报要素", "t_order_declaration", "DeclareElement", FormulaValueType.String, false);
+ yield return ("产品标识编码", "t_order_declaration", "ProductId", FormulaValueType.String, false);
+ yield return ("非标产品标识编码", "t_order_declaration", "NonStdProductId", FormulaValueType.String, false);
+ yield return ("标准产品标识编码", "t_order_declaration", "StdProductId", FormulaValueType.String, false);
yield return ("申报类别", "t_order_declaration", "Category", FormulaValueType.String, false);
yield return ("品牌", "t_order_declaration", "Brand", FormulaValueType.String, false);
yield return ("品牌类型", "t_order_declaration", "BrandType", FormulaValueType.String, false);
diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs
index 34d536e..fec95be 100644
--- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs
+++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs
@@ -1,4 +1,5 @@
using Fake.DependencyInjection;
+using Fake.EventBus.Distributed;
using Microsoft.Extensions.Logging;
using Oms.Application.Messaging.OrderSideEffects;
using Oms.Domain.ChannelsAggregate.Consts;
@@ -16,7 +17,7 @@ public class AutoTriggerRuntimeService(
OrderAutoTriggerService orderAutoTriggerService,
OrderExceptionWorkflowService exceptionWorkflowService,
OrderIssueNotificationService issueNotificationService,
- IOrderSideEffectEventPublisher sideEffectEventPublisher,
+ IDistributedEventBus eventBus,
ILogger logger) : IScopedDependency
{
private const string InterceptOperation = "拦截";
@@ -27,7 +28,12 @@ public class AutoTriggerRuntimeService(
public Task EnqueueFireAsync(int orderId, AutoTriggerTiming timing, int operatorUserId) =>
orderId <= 0
? Task.CompletedTask
- : sideEffectEventPublisher.PublishAutoTriggerFireAsync(orderId, timing, operatorUserId);
+ : eventBus.PublishAsync(new OrderAutoTriggerFireIntegrationEvent
+ {
+ OrderId = orderId,
+ Timing = timing.ToString(),
+ OperatorUserId = operatorUserId
+ });
public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId)
{
diff --git a/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs b/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs
index 077a4bf..3393c17 100644
--- a/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs
+++ b/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs
@@ -29,7 +29,7 @@ public static class OrderFormulaFieldResolver
if (fieldRef.Table == "t_order_fee")
{
- return context.Fees.Any(f => ContainsValue(GetPropertyValue(f, fieldRef.Field), needle));
+ return RuleFees(context).Any(f => ContainsValue(GetPropertyValue(f, fieldRef.Field), needle));
}
var raw = GetRawValue(fieldRef, context);
@@ -255,10 +255,16 @@ public static class OrderFormulaFieldResolver
private static object? ResolveFeeField(string field, OrderRuleEvaluationContext context)
{
- var fee = context.Fees.FirstOrDefault();
+ var fee = RuleFees(context).FirstOrDefault();
return fee == null ? null : GetPropertyValue(fee, field);
}
+ ///
+ /// 规则只看计费产生的费用行,忽略支付写入的营销抵扣负数行。
+ ///
+ private static IEnumerable RuleFees(OrderRuleEvaluationContext context) =>
+ context.Fees.Where(f => !OrderFeeDiscountCostItems.IsDiscount(f.CostItem));
+
private static object? GetDeclarationValue(OrderDeclaration declaration, string field) =>
field switch
{
diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs b/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs
index 6649f53..ce3b856 100644
--- a/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs
@@ -1,4 +1,6 @@
using Fake.Application;
+using Fake.EventBus.Distributed;
+using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Oms.Application.Dtos.Orders;
using Oms.Application.Messaging;
@@ -10,8 +12,11 @@ namespace Oms.Application.Services.Orders;
public partial class OrderService
{
- private IOrderBatchJobPublisher BatchJobPublisher =>
- LazyServiceProvider.GetRequiredService();
+ private IDistributedEventBus EventBus =>
+ LazyServiceProvider.GetRequiredService();
+
+ private bool BatchQueueEnabled =>
+ LazyServiceProvider.GetRequiredService>().Value.Enabled;
///
/// 批量 Worker 导入单行 Excel 数据(内部方法,勿暴露为 HTTP API)。
diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
index d15d384..e2e95c9 100644
--- a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
@@ -11,7 +11,7 @@ namespace Oms.Application.Services.Orders;
public partial class OrderService
{
internal Task PayForBatchAsync(int orderId, CancellationToken cancellationToken = default) =>
- PayAsync(new OrderActionInput { Id = orderId });
+ PayAsync(new OrderPayInput { Id = orderId });
internal Task PreDeclareForBatchAsync(int orderId, CancellationToken cancellationToken = default) =>
PreDeclareAsync(new OrderActionInput { Id = orderId });
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index 40f81fa..d61441a 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -263,6 +263,11 @@ public partial class OrderService
HasInsurance = hasInsurance,
ForecastReceivableAmount = order.ForecastReceivableAmount,
ForecastPayableAmount = order.ForecastPayableAmount,
+ ReceivableSubtotal = order.ReceivableSubtotal,
+ LevelDiscountAmount = order.LevelDiscountAmount,
+ CouponDiscountAmount = order.CouponDiscountAmount,
+ ActualPayAmount = order.PayableAmount,
+ CouponInstanceId = order.CouponInstanceId,
SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion)
? null
: order.SalesProductRulesVersion.Trim(),
@@ -554,7 +559,7 @@ public partial class OrderService
var batchInput = await BuildImportBatchJobInputAsync(input);
var operatorUserId = CurrentUser.GetId();
- if (BatchJobPublisher.IsEnabled)
+ if (BatchQueueEnabled)
{
var job = await BatchJobCoordinator.CreatePayloadJobAsync(batchInput, operatorUserId);
return new OrderUploadOutput { JobId = job.JobId, Async = true };
@@ -812,6 +817,9 @@ public partial class OrderService
ImHsCode = x.ImHsCode ?? x.HsCode,
ExHsCode = x.ExHsCode ?? x.HsCode,
DeclareElement = x.DeclareElement,
+ ProductId = x.ProductId,
+ NonStdProductId = x.NonStdProductId,
+ StdProductId = x.StdProductId,
SkuImage = FileStorage.GetPublicUrl(x.SkuImage),
Category = x.Category,
Brand = x.Brand,
@@ -851,6 +859,9 @@ public partial class OrderService
Id = x.Id,
Type = x.Type,
CostItem = x.CostItem,
+ CostItemName = OrderFeeDiscountCostItems.IsDiscount(x.CostItem)
+ ? ParcelBillingCalculator.ResolveFeeDisplayName(x.CostItem, null)
+ : null,
Amount = x.Amount,
Currency = x.Currency,
DownstreamProvider = x.DownstreamProvider,
diff --git a/src/Oms.Application/Services/Orders/OrderService.Monitor.cs b/src/Oms.Application/Services/Orders/OrderService.Monitor.cs
index ac443f2..f5a04fc 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Monitor.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Monitor.cs
@@ -12,9 +12,12 @@ public partial class OrderService
///
/// 实时监控页概览(订单/客户统计与趋势)。
///
+ /// 经营指标维度:day / week / month,默认 day。
[HttpGet("/monitor/overview")]
- public async Task GetMonitorOverviewAsync()
+ public async Task GetMonitorOverviewAsync(
+ [FromQuery] string? dimension = "day")
{
+ var dim = NormalizeMonitorDimension(dimension);
var filter = await BuildScopedListFilterAsync();
var today = DateTime.Today;
var tomorrow = today.AddDays(1);
@@ -28,21 +31,60 @@ public partial class OrderService
int GetCount(string status) =>
counts.TryGetValue(status, out var value) ? value : 0;
- var todayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow);
- var yesterdayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today);
+ var pendingPayment = GetCount(OrderStatusCodes.PendingPayment);
+ var pendingOnline = GetCount(OrderStatusCodes.PendingOnline);
+ var pendingSign = GetCount(OrderStatusCodes.Shipping);
+ var problematic = GetCount(OrderStatusCodes.Problematic);
+
+ var (currentStart, currentEndExclusive, previousStart, previousEndExclusive) =
+ ResolveOpsPeriodRanges(dim, today, tomorrow);
+
+ var orderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(
+ filter, currentStart, currentEndExclusive);
+ var previousOrderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(
+ filter, previousStart, previousEndExclusive);
+
+ var revenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Receivable, currentStart, currentEndExclusive);
+ var previousRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Receivable, previousStart, previousEndExclusive);
+
+ var payable = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Payable, currentStart, currentEndExclusive);
+ var previousPayable = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Payable, previousStart, previousEndExclusive);
+ var grossProfit = revenue - payable;
+ var previousGrossProfit = previousRevenue - previousPayable;
+
+ // 兼容旧字段:始终提供「今日」口径
+ var todayCreated = dim == "day"
+ ? orderCount
+ : await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow);
+ var yesterdayCreated = dim == "day"
+ ? previousOrderCount
+ : await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today);
+ var todayRevenue = dim == "day"
+ ? revenue
+ : await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Receivable, today, tomorrow);
+ var yesterdayRevenue = dim == "day"
+ ? previousRevenue
+ : await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Receivable, yesterday, today);
var monthStart = new DateTime(today.Year, today.Month, 1);
var lastMonthStart = monthStart.AddMonths(-1);
var thisMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, monthStart, tomorrow);
var lastMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, lastMonthStart, monthStart);
- var todayRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
- filter, OrderFeeTypeCodes.Receivable, today, tomorrow);
- var yesterdayRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
- filter, OrderFeeTypeCodes.Receivable, yesterday, today);
-
- var orderTrend7d = await BuildMonitorOrderTrendAsync(filter, 7);
- var revenueTrend7d = await BuildMonitorRevenueTrendAsync(filter, 7);
+ var trendCount = dim switch
+ {
+ "week" => 8,
+ "month" => 6,
+ _ => 7,
+ };
+ var orderTrend = await BuildMonitorOrderTrendAsync(filter, dim, trendCount);
+ var revenueTrend = await BuildMonitorRevenueTrendAsync(filter, dim, trendCount);
var recentFilter = CloneFilterWithCreateTimeStart(filter, today.AddDays(-90));
var recentOrders = await OrderRepository.GetFilteredListAsync(recentFilter);
@@ -83,13 +125,30 @@ public partial class OrderService
TodayCreatedGrowthPercent = CalcGrowthPercent(todayCreated, yesterdayCreated),
Total = counts.Values.Sum(),
TotalMonthGrowthPercent = CalcGrowthPercent(thisMonthCreated, lastMonthCreated),
- PendingPayment = GetCount(OrderStatusCodes.PendingPayment),
- PendingOnline = GetCount(OrderStatusCodes.PendingOnline),
- PendingSign = GetCount(OrderStatusCodes.Shipping),
- Problematic = GetCount(OrderStatusCodes.Problematic),
+ PendingPayment = pendingPayment,
+ PendingOnline = pendingOnline,
+ PendingSign = pendingSign,
+ Problematic = problematic,
TodayRevenue = todayRevenue,
TodayRevenueGrowthPercent = CalcGrowthPercentDecimal(todayRevenue, yesterdayRevenue),
},
+ Ops = new MonitorOpsStatsOutput
+ {
+ Dimension = dim,
+ OrderCount = orderCount,
+ OrderCountGrowthPercent = CalcGrowthPercent(orderCount, previousOrderCount),
+ Revenue = revenue,
+ RevenueGrowthPercent = CalcGrowthPercentDecimal(revenue, previousRevenue),
+ GrossProfit = grossProfit,
+ GrossProfitGrowthPercent = CalcGrowthPercentDecimal(grossProfit, previousGrossProfit),
+ },
+ Queue = new MonitorQueueStatsOutput
+ {
+ PendingPayment = pendingPayment,
+ PendingOnline = pendingOnline,
+ PendingSign = pendingSign,
+ Problematic = problematic,
+ },
Customer = new MonitorCustomerStatsOutput
{
TotalCustomers = totalCustomers,
@@ -100,8 +159,10 @@ public partial class OrderService
InactiveLogin7d = inactiveLogin7d,
InactiveLogin7dDelta = inactiveLogin7d - inactiveLogin14d,
},
- OrderTrend7d = orderTrend7d,
- RevenueTrend7d = revenueTrend7d,
+ OrderTrend = orderTrend,
+ OrderTrend7d = dim == "day" ? orderTrend : await BuildMonitorOrderTrendAsync(filter, "day", 7),
+ RevenueTrend = revenueTrend,
+ RevenueTrend7d = dim == "day" ? revenueTrend : await BuildMonitorRevenueTrendAsync(filter, "day", 7),
RegionOrderTrend = new MonitorRegionTrendBundleOutput
{
Week = new MonitorRegionTrendPeriodOutput { Labels = weekOrderLabels, Series = weekOrderSeries },
@@ -117,11 +178,42 @@ public partial class OrderService
};
}
+ private static string NormalizeMonitorDimension(string? dimension)
+ {
+ var dim = (dimension ?? "day").Trim().ToLowerInvariant();
+ return dim is "day" or "week" or "month" ? dim : "day";
+ }
+
+ ///
+ /// 当前周期(统计到今天结束)与上一完整同尺度周期。
+ ///
+ private static (DateTime CurrentStart, DateTime CurrentEndExclusive, DateTime PreviousStart, DateTime PreviousEndExclusive)
+ ResolveOpsPeriodRanges(string dimension, DateTime today, DateTime tomorrow)
+ {
+ if (dimension == "week")
+ {
+ var weekStart = GetWeekStart(today);
+ var previousWeekStart = weekStart.AddDays(-7);
+ return (weekStart, tomorrow, previousWeekStart, weekStart);
+ }
+
+ if (dimension == "month")
+ {
+ var monthStart = new DateTime(today.Year, today.Month, 1);
+ var previousMonthStart = monthStart.AddMonths(-1);
+ return (monthStart, tomorrow, previousMonthStart, monthStart);
+ }
+
+ var yesterday = today.AddDays(-1);
+ return (today, tomorrow, yesterday, today);
+ }
+
private async Task> BuildMonitorOrderTrendAsync(
OrderListFilter filter,
+ string dimension,
int count)
{
- var buckets = BuildTrendBuckets("day", count);
+ var buckets = BuildTrendBuckets(dimension, count);
var points = new List(buckets.Count);
foreach (var bucket in buckets)
{
@@ -135,9 +227,10 @@ public partial class OrderService
private async Task> BuildMonitorRevenueTrendAsync(
OrderListFilter filter,
+ string dimension,
int count)
{
- var buckets = BuildTrendBuckets("day", count);
+ var buckets = BuildTrendBuckets(dimension, count);
var points = new List(buckets.Count);
foreach (var bucket in buckets)
{
diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
index 6b43287..2fad81d 100644
--- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
@@ -900,6 +900,11 @@ public partial class OrderService
throw new BusinessException("费用项不能为空");
}
+ if (OrderFeeDiscountCostItems.IsDiscount(costItem))
+ {
+ throw new BusinessException("优惠券抵扣、会员折扣由支付自动生成,不能手工添加");
+ }
+
if (input.Amount <= 0)
{
throw new BusinessException("金额必须大于0");
@@ -1355,6 +1360,9 @@ public partial class OrderService
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode ?? x.ImHsCode,
DeclareElement = x.DeclareElement,
+ ProductId = x.ProductId?.Trim(),
+ NonStdProductId = x.NonStdProductId?.Trim(),
+ StdProductId = x.StdProductId?.Trim(),
SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage),
Category = x.Category,
Brand = x.Brand,
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 56ef739..abaa68f 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -41,7 +41,6 @@ public partial class OrderService
private const string InboundBillingProcessingReason = "入库后计费/排货处理中";
private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费";
internal const string ForecastBillingProcessingReason = "预报计费/排货处理中";
- private const string ForecastBillingEnqueueFailReason = "预报计费任务投递失败,请重新预报";
private static readonly ConcurrentDictionary InboundBillingInFlight = new();
@@ -100,11 +99,15 @@ public partial class OrderService
};
}
+ ///
+ /// /order/parcel/create 的实际步骤。调用方已开启事务;这里抛出的异常会回滚订单和已写入的 Outbox。
+ ///
private async Task CreateParcelCoreAsync(
OrderParcelCreateInput input,
int? operatorUserId = null,
ParcelCreateContext? createContext = null)
{
+ // 1. 幂等:同一 RequestId 直接返回已有订单,不再建单、不再发事件。
if (!string.IsNullOrWhiteSpace(input.RequestId))
{
var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId);
@@ -118,6 +121,7 @@ public partial class OrderService
NormalizeParcelCreateInput(input);
createContext ??= BuildParcelCreateContext(input);
+ // 2. 硬校验。拦截类自动触发在落库前执行,命中即失败,订单不会留下。
var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId);
input.CustomerId = sessionCustomer.Id;
@@ -151,112 +155,74 @@ public partial class OrderService
country.CountryCode,
country.ChineseName);
+ // 3. 落库订单、件重尺和收发件等子表。此后才有 OrderId,才能写 Outbox。
var serviceNo = await ResolveServiceNoAsync();
var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0);
- Order? created = null;
- try
+ var order = BuildDraftOrderEntity(
+ input,
+ product,
+ country.CountryCode,
+ country.ChineseName,
+ snapshotBinding,
+ serviceNo,
+ createContext);
+ order = await OrderRepository.InsertAsync(order);
+
+ var weights = BuildInitialWeightRows(order.Id, input);
+ await SaveParcelChildrenAsync(order, input, weights, country);
+
+ // 4. 保存草稿:不做预报计费。9610、上门揽收与订单一起提交。
+ if (input.SaveAsDraft)
{
- var order = BuildDraftOrderEntity(
- input,
- product,
- country.CountryCode,
- country.ChineseName,
- snapshotBinding,
- serviceNo,
- createContext);
- order = await OrderRepository.InsertAsync(order);
- created = order;
-
- var weights = BuildInitialWeightRows(order.Id, input);
- await SaveParcelChildrenAsync(order, input, weights, country);
-
- // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收在事务提交后各自执行。
- if (input.SaveAsDraft)
+ await EventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
{
- var draftOutput = await BuildParcelCreateOutputAsync(order);
- await ScheduleCreateSideEffectsAsync(order.Id, input, draftOutput, includeForecast: false, operatorId);
- return draftOutput;
- }
-
- // 偏远标记轻量同步;计费重/应收/排货走 ForecastBilling MQ(失败不回滚建单)。
- order.IsRemote = await IsRemoteHitAsync(
- product,
- snapshotBinding.Rules,
- country.CountryCode,
- input.Receiver?.Province,
- input.Receiver?.City,
- input.Receiver?.ZipCode) ? (byte)1 : (byte)0;
- order.OrderStatus = OrderStatusCodes.Draft;
- order.PredeclareFailReason = ForecastBillingProcessingReason;
- order.AllocationPendingReason = string.Empty;
- order.UpdateTime = DateTime.Now;
- await OrderRepository.UpdateAsync(order);
-
- var output = await BuildParcelCreateOutputAsync(order);
- await ScheduleCreateSideEffectsAsync(order.Id, input, output, includeForecast: true, operatorId);
- return output;
- }
- catch (BusinessException)
- {
- await TryRollbackFailedParcelCreateAsync(created);
- throw;
- }
- catch (Exception ex)
- {
- // 创建失败不落库草稿:回滚已插入订单,由前端让客户选择继续修改或主动保存草稿。
- await TryRollbackFailedParcelCreateAsync(created);
-
- var cleanReason = StripDraftSavedMarker(ex.Message);
- if (string.IsNullOrWhiteSpace(cleanReason))
- {
- cleanReason = "预报未完成";
- }
-
- throw new BusinessException(cleanReason);
- }
- }
-
- private async Task TryRollbackFailedParcelCreateAsync(Order? created)
- {
- if (created == null)
- {
- return;
+ OrderId = order.Id,
+ PlatformCode = input.PlatformCode
+ });
+ var draftOutput = await BuildParcelCreateOutputAsync(order);
+ await TryAttachPickupAfterParcelCreateAsync(input, draftOutput);
+ return draftOutput;
}
- try
- {
- await RollbackFailedParcelCreateAsync(created);
- }
- catch
- {
- // 回滚失败不覆盖原始业务错误;半成品订单需运维清理
- }
- }
-
- ///
- /// 创建流程中途失败时删除已落库的半成品订单,避免自动变成草稿。
- ///
- private async Task RollbackFailedParcelCreateAsync(Order order)
- {
- try
- {
- var writer = LazyServiceProvider.GetRequiredService();
- // 强制清掉可能已写入的 pending 9610(ExportCustomsMode 临时置空触发删除逻辑)
- var mode = order.ExportCustomsMode;
- order.ExportCustomsMode = null;
- await writer.UpsertFromOrderAsync(order);
- order.ExportCustomsMode = mode;
- }
- catch
- {
- // ignore
- }
-
- order.OrderStatus = OrderStatusCodes.Deleted;
- order.PredeclareFailReason = string.Empty;
+ // 5. 确认下单:偏远标记当场算完;计费重/应收/排货留给预报任务。
+ // 状态先停在草稿 +「预报计费/排货处理中」,接口即可返回。
+ order.IsRemote = await IsRemoteHitAsync(
+ product,
+ snapshotBinding.Rules,
+ country.CountryCode,
+ input.Receiver?.Province,
+ input.Receiver?.City,
+ input.Receiver?.ZipCode) ? (byte)1 : (byte)0;
+ order.OrderStatus = OrderStatusCodes.Draft;
+ order.PredeclareFailReason = ForecastBillingProcessingReason;
+ order.AllocationPendingReason = string.Empty;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
- await OrderRepository.DeleteAsync(order);
+
+ // 6. 与订单同一事务写入 Outbox。Commit 后由 Fake 投递;任一条失败则整单回滚。
+ // 轨迹不在此列:要等预报消费端排货拿到 ChannelCode 再发。
+ var authorization = CaptureRequestAuthorization();
+ await EventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
+ {
+ OrderId = order.Id,
+ PlatformCode = input.PlatformCode
+ });
+ await EnqueueForecastBillingAsync(order, input.CouponInstanceId, operatorId, authorization);
+ await EventBus.PublishAsync(new OrderParcelLabelGenerateIntegrationEvent
+ {
+ OrderId = order.Id,
+ OperatorUserId = operatorId
+ });
+ await AutoTriggerRuntimeService.EnqueueFireAsync(order.Id, AutoTriggerTiming.OrderCreate, operatorId);
+
+ var output = await BuildParcelCreateOutputAsync(order);
+ output.ForecastBillingQueued = string.Equals(
+ order.PredeclareFailReason,
+ ForecastBillingProcessingReason,
+ StringComparison.Ordinal);
+ // 揽收失败只回写 PickupCreateError,不让整单回滚。
+ await TryAttachPickupAfterParcelCreateAsync(input, output);
+ return output;
}
private async Task TryUpsertCustoms9610Async(Order order, string? platformCode = null)
@@ -2247,6 +2213,9 @@ public partial class OrderService
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode,
DeclareElement = x.DeclareElement,
+ ProductId = x.ProductId?.Trim(),
+ NonStdProductId = x.NonStdProductId?.Trim(),
+ StdProductId = x.StdProductId?.Trim(),
SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage),
Category = x.Category,
Brand = x.Brand,
@@ -2330,28 +2299,13 @@ public partial class OrderService
var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0);
var authorization = CaptureRequestAuthorization();
- var publisher = LazyServiceProvider.GetRequiredService();
- if (!publisher.IsEnabled)
+ await EnqueueForecastBillingAsync(order, order.CouponInstanceId, operatorId, authorization);
+ await EventBus.PublishAsync(new OrderParcelLabelGenerateIntegrationEvent
{
- var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload
- {
- CouponInstanceId = order.CouponInstanceId,
- Authorization = authorization
- });
- await ProcessForecastBillingForBatchAsync(
- order.Id,
- payloadJson,
- operatorId,
- authorization: authorization);
- }
- else
- {
- var orderId = order.Id;
- var couponInstanceId = order.CouponInstanceId;
- await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId, authorization);
- }
-
- await ScheduleForecastSideEffectsAsync(order.Id, operatorId);
+ OrderId = order.Id,
+ OperatorUserId = operatorId
+ });
+ await AutoTriggerRuntimeService.EnqueueFireAsync(order.Id, AutoTriggerTiming.OrderCreate, operatorId);
// 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。
var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id);
@@ -2498,6 +2452,12 @@ public partial class OrderService
{
_ = cancellationToken;
var order = await GetOrderOrThrowAsync(orderId);
+ // 已付款订单应收已固化(含营销抵扣行),重复投递的入库计费消息不得重算
+ if (order.IsPaid != 0)
+ {
+ return;
+ }
+
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
order.OrderStatus = OrderStatusCodes.PendingAllocation;
@@ -2679,7 +2639,8 @@ public partial class OrderService
throw new InvalidOperationException($"预报计费时订单尚未可见,稍后重试 OrderId={orderId}");
}
- if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)
+ || order.IsPaid != 0)
{
return;
}
@@ -2846,7 +2807,7 @@ public partial class OrderService
// 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。
if (allocated && !string.IsNullOrWhiteSpace(order.ChannelCode))
{
- await ScheduleTrackAfterAllocationAsync(order.Id, trackAuthorization);
+ await PublishForecastTrackAsync(order.Id, trackAuthorization);
}
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true);
@@ -2872,154 +2833,6 @@ public partial class OrderService
public string? Authorization { get; set; }
}
- ///
- /// 创单事务提交后,9610、预报计费、揽收、面单、自动触发各自提交。
- /// 轨迹订单等排货成功后再创建。
- ///
- private async Task ScheduleCreateSideEffectsAsync(
- int orderId,
- OrderParcelCreateInput input,
- OrderParcelCreateOutput output,
- bool includeForecast,
- int operatorUserId)
- {
- var unitOfWorkManager = LazyServiceProvider.GetRequiredService();
- var scopeFactory = LazyServiceProvider.GetRequiredService();
- var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderParcelCreate");
- var platformCode = input.PlatformCode;
- var couponInstanceId = input.CouponInstanceId;
- // 事务提交后 HttpContext 已不可用,这里先捕获登录态供轨迹网关使用。
- var authorization = CaptureRequestAuthorization();
-
- await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () =>
- {
- try
- {
- await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp =>
- sp.GetRequiredService().PublishCustomsForOrderAsync(orderId, platformCode));
-
- var forecastQueued = true;
- if (includeForecast)
- {
- forecastQueued = await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp =>
- {
- var service = sp.GetRequiredService();
- await service.EnqueueForecastBillingByIdAsync(
- orderId,
- couponInstanceId,
- operatorUserId,
- authorization);
- });
- await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp =>
- sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId));
- await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp =>
- sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId));
- }
-
- await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp =>
- sp.GetRequiredService().TryAttachPickupAfterParcelCreateAsync(input, output));
-
- if (includeForecast && !forecastQueued)
- {
- await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp =>
- sp.GetRequiredService().MarkForecastEnqueueFailedIfStillProcessingAsync(orderId));
- }
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "创单后置步骤调度失败 OrderId={OrderId}", orderId);
- }
- });
- }
-
- private async Task ScheduleForecastEnqueueAsync(
- int orderId,
- int? couponInstanceId,
- int operatorUserId,
- string? authorization)
- {
- var unitOfWorkManager = LazyServiceProvider.GetRequiredService();
- var scopeFactory = LazyServiceProvider.GetRequiredService();
- var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling");
- await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () =>
- OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", sp =>
- sp.GetRequiredService().EnqueueForecastBillingByIdAsync(
- orderId,
- couponInstanceId,
- operatorUserId,
- authorization)));
- }
-
- ///
- /// 草稿重新预报提交后,面单和下单自动触发各自入队。轨迹等排货成功后再创建。
- ///
- private async Task ScheduleForecastSideEffectsAsync(int orderId, int operatorUserId)
- {
- var unitOfWorkManager = LazyServiceProvider.GetRequiredService();
- var scopeFactory = LazyServiceProvider.GetRequiredService();
- var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling");
- await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () =>
- {
- await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp =>
- sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId));
- await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp =>
- sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId));
- });
- }
-
- ///
- /// 预报计费事务提交后,再创建轨迹订单(此时已有渠道和 ApiCode)。
- ///
- private async Task ScheduleTrackAfterAllocationAsync(int orderId, string? authorization)
- {
- var unitOfWorkManager = LazyServiceProvider.GetRequiredService();
- var scopeFactory = LazyServiceProvider.GetRequiredService();
- var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling");
- await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () =>
- OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp =>
- sp.GetRequiredService().PublishForecastTrackAsync(orderId, authorization)));
- }
-
- private async Task PublishCustomsForOrderAsync(int orderId, string? platformCode)
- {
- var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
- if (order == null
- || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
- {
- return;
- }
-
- await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode);
- }
-
- private async Task EnqueueForecastBillingByIdAsync(
- int orderId,
- int? couponInstanceId,
- int operatorUserId,
- string? authorization)
- {
- var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
- if (order == null
- || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
- {
- return;
- }
-
- await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId, authorization);
- }
-
- private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId)
- {
- var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
- if (order == null
- || !string.Equals(order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal))
- {
- return;
- }
-
- await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason);
- }
-
private async Task PublishForecastTrackAsync(int orderId, string? authorization = null)
{
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
@@ -3036,41 +2849,26 @@ public partial class OrderService
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null);
request.AppendInitialTrackOnSuccess = true;
ApplyTrackAuthorization(request, authorization);
- await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
- }
-
- private async Task PublishForecastLabelAsync(int orderId, int operatorUserId)
- {
- var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
- if (order == null
- || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
+ await EventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent
{
- return;
- }
-
- await ParcelCreateEventPublisher.PublishLabelGenerateAsync(order, operatorUserId);
- }
-
- private async Task PublishForecastAutoTriggerAsync(int orderId, int operatorUserId)
- {
- var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
- if (order == null
- || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
- {
- return;
- }
-
- await AutoTriggerRuntimeService.EnqueueFireAsync(orderId, AutoTriggerTiming.OrderCreate, operatorUserId);
+ OrderId = order.Id,
+ CustomerId = customer?.Id,
+ PayloadJson = JsonConvert.SerializeObject(request),
+ AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess,
+ Authorization = request.Authorization
+ });
}
+ ///
+ /// 预报计费入队。队列开着写 ForecastBilling Outbox;关着则在当前事务里同步跑同一套计费。
+ /// Authorization 放进任务载荷,供排货成功后转发轨迹网关。
+ ///
private async Task EnqueueForecastBillingAsync(
Order order,
int? couponInstanceId,
int operatorUserId,
string? authorization = null)
{
- var logger = LazyServiceProvider.GetRequiredService()
- .CreateLogger("Oms.ForecastBilling");
authorization ??= CaptureRequestAuthorization();
var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload
{
@@ -3078,62 +2876,27 @@ public partial class OrderService
Authorization = authorization
});
- try
+ if (!BatchQueueEnabled)
{
- var publisher = LazyServiceProvider.GetRequiredService();
- if (!publisher.IsEnabled)
- {
- try
- {
- await ProcessForecastBillingForBatchAsync(
- order.Id,
- payloadJson,
- operatorUserId,
- authorization: authorization);
- }
- catch (Exception ex)
- {
- logger.LogError(
- ex,
- "预报计费同步执行失败 OrderId={OrderId} ServiceNo={ServiceNo}",
- order.Id,
- order.ServiceNo);
- await MarkParcelDraftFailedAsync(
- order,
- TrimPendingReason($"预报计费失败:{ex.Message}"));
- }
-
- return;
- }
-
- var requestId = $"forecast-billing:{order.Id}:{Guid.NewGuid():N}";
- await BatchJobCoordinator.CreateAndEnqueueAsync(
- new OrderBatchJobCreateInput
- {
- Action = OrderBatchActionCodes.ForecastBilling,
- OrderIds = [order.Id],
- RequestId = requestId,
- PayloadJson = payloadJson
- },
- operatorUserId,
- authorization);
- }
- catch (Exception ex)
- {
- logger.LogError(
- ex,
- "预报计费任务投递失败 OrderId={OrderId} ServiceNo={ServiceNo}",
+ await ProcessForecastBillingForBatchAsync(
order.Id,
- order.ServiceNo);
- try
- {
- await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason);
- }
- catch
- {
- // ignore
- }
+ payloadJson,
+ operatorUserId,
+ authorization: authorization);
+ return;
}
+
+ var requestId = $"forecast-billing:{order.Id}:{Guid.NewGuid():N}";
+ await BatchJobCoordinator.CreateAndEnqueueAsync(
+ new OrderBatchJobCreateInput
+ {
+ Action = OrderBatchActionCodes.ForecastBilling,
+ OrderIds = [order.Id],
+ RequestId = requestId,
+ PayloadJson = payloadJson
+ },
+ operatorUserId,
+ authorization);
}
private async Task EnqueueInboundBillingAsync(Order order)
@@ -3142,8 +2905,7 @@ public partial class OrderService
.CreateLogger("Oms.InboundBilling");
try
{
- var publisher = LazyServiceProvider.GetRequiredService();
- if (!publisher.IsEnabled)
+ if (!BatchQueueEnabled)
{
// 本地未开队列:同步跑异步计费逻辑,仍不向外抛(入库主流程已完成)
try
@@ -3397,12 +3159,6 @@ public partial class OrderService
};
}
- private IOrderParcelCreateEventPublisher ParcelCreateEventPublisher =>
- LazyServiceProvider.GetRequiredService();
-
- private IOrderSideEffectEventPublisher SideEffectEventPublisher =>
- LazyServiceProvider.GetRequiredService();
-
///
/// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。
///
@@ -3415,7 +3171,14 @@ public partial class OrderService
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver);
request.AppendInitialTrackOnSuccess = appendInitialTrack;
ApplyTrackAuthorization(request);
- await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
+ await EventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent
+ {
+ OrderId = order.Id,
+ CustomerId = customer?.Id,
+ PayloadJson = JsonConvert.SerializeObject(request),
+ AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess,
+ Authorization = request.Authorization
+ });
}
private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request, string? authorization = null)
@@ -3486,11 +3249,13 @@ public partial class OrderService
.Authorization
.ToString();
- await SideEffectEventPublisher.PublishLifecycleTrackAppendAsync(
- order.Id,
- order.ServiceNo,
- trackCode,
- string.IsNullOrWhiteSpace(authorization) ? null : authorization);
+ await EventBus.PublishAsync(new OrderLifecycleTrackAppendIntegrationEvent
+ {
+ OrderId = order.Id,
+ OrderNo = order.ServiceNo,
+ TrackCode = trackCode,
+ Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization
+ });
}
///
@@ -3623,7 +3388,8 @@ public partial class OrderService
receivableSubtotal,
baseFreightAmount,
order.CouponInstanceId,
- order.ConsigneeCountry);
+ order.ConsigneeCountry,
+ order.Id);
marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId);
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs b/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs
new file mode 100644
index 0000000..e47a62b
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs
@@ -0,0 +1,212 @@
+using Fake.Application;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Marketing;
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Shared;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Application.Services.Orders;
+
+public partial class OrderService
+{
+ private const string CouponNotBetterThanLevelReason = "优惠不高于会员折扣";
+
+ ///
+ /// 支付预览:按当前应收计算会员折/券互斥后的应付,并返回本单可选券。
+ ///
+ /// 入参:订单 Id;couponInstanceId 为 null 自动推荐,0 不用券,大于 0 试算指定券。
+ /// 应付拆解、实际生效券与可选券列表;指定券不可用(未解锁/过期/线路不符等)时抛业务异常。
+ [HttpGet("/order/pay-preview")]
+ public async Task GetPayPreviewAsync([FromQuery] OrderPayPreviewInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.Id);
+ var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
+ var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees);
+ var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees);
+ var marketingHelper = LazyServiceProvider.GetRequiredService();
+
+ var memberOnly = await marketingHelper.BuildBreakdownAsync(
+ order.CustomerId,
+ receivableSubtotal,
+ baseFreightAmount,
+ null,
+ order.ConsigneeCountry,
+ order.Id);
+ var coupons = await marketingHelper.BuildAvailableCouponsAsync(
+ order.CustomerId,
+ baseFreightAmount,
+ order.ConsigneeCountry,
+ order.Id);
+ foreach (var coupon in coupons.Where(x => x.CanUse
+ && (x.EstimatedDiscount ?? 0M) <= memberOnly.LevelDiscountAmount))
+ {
+ coupon.CanUse = false;
+ coupon.UnavailableReason = CouponNotBetterThanLevelReason;
+ }
+
+ coupons = coupons
+ .OrderByDescending(x => x.CanUse)
+ .ThenByDescending(x => x.EstimatedDiscount ?? 0M)
+ .ThenBy(x => x.ValidEndTime)
+ .ToList();
+
+ var targetCouponId = input.CouponInstanceId switch
+ {
+ 0 => null,
+ > 0 => input.CouponInstanceId,
+ _ => ResolveRecommendedPayCouponId(order, coupons)
+ };
+
+ var breakdown = targetCouponId.HasValue
+ ? await marketingHelper.BuildBreakdownAsync(
+ order.CustomerId,
+ receivableSubtotal,
+ baseFreightAmount,
+ targetCouponId,
+ order.ConsigneeCountry,
+ order.Id)
+ : memberOnly;
+
+ return new OrderPayPreviewOutput
+ {
+ Id = order.Id,
+ ReceivableSubtotal = breakdown.ReceivableSubtotal,
+ BaseFreightAmount = breakdown.BaseFreightAmount,
+ LevelDiscountAmount = breakdown.LevelDiscountAmount,
+ CouponDiscountAmount = breakdown.CouponDiscountAmount,
+ PayableAmount = breakdown.PayableAmount,
+ MemberLevelName = breakdown.MemberLevelName,
+ AppliedCouponInstanceId = breakdown.CouponInstanceId,
+ AvailableCoupons = coupons
+ };
+ }
+
+ ///
+ /// 计算支付应付:couponInstanceId 为 null 沿用订单已绑定券(不可用则不用券),0 不用券,大于 0 使用指定券。
+ ///
+ private async Task BuildPayBreakdownAsync(
+ Order order,
+ IEnumerable fees,
+ int? couponInstanceId)
+ {
+ var feeList = fees as IList ?? fees.ToList();
+ var receivableSubtotal = MarketingFeeHelper.SumReceivable(feeList);
+ var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(feeList);
+ var marketingHelper = LazyServiceProvider.GetRequiredService();
+
+ Task Build(int? couponId) =>
+ marketingHelper.BuildBreakdownAsync(
+ order.CustomerId,
+ receivableSubtotal,
+ baseFreightAmount,
+ couponId,
+ order.ConsigneeCountry,
+ order.Id);
+
+ if (couponInstanceId is > 0)
+ {
+ var breakdown = await Build(couponInstanceId);
+ if (breakdown.CouponInstanceId != couponInstanceId)
+ {
+ throw new BusinessException("所选优惠券未达使用门槛或优惠不高于会员折扣,请重新选择");
+ }
+
+ return breakdown;
+ }
+
+ if (couponInstanceId == 0 || order.CouponInstanceId is not > 0)
+ {
+ return await Build(null);
+ }
+
+ try
+ {
+ return await Build(order.CouponInstanceId);
+ }
+ catch (BusinessException)
+ {
+ return await Build(null);
+ }
+ }
+
+ ///
+ /// 支付成功后把会员折扣 / 优惠券抵扣写成负数应收行,使订单应收与实付一致;币种与计费阶段跟随基础运费行。
+ ///
+ private async Task UpsertMarketingDiscountFeesAsync(
+ Order order,
+ IEnumerable fees,
+ MarketingPriceBreakdown breakdown,
+ string? couponCode)
+ {
+ var discountRows = new List();
+ if (breakdown.CouponDiscountAmount <= 0 && breakdown.LevelDiscountAmount <= 0)
+ {
+ return;
+ }
+
+ var receivable = fees
+ .Where(x => x.Type == OrderFeeTypeCodes.Receivable
+ && !OrderFeeDiscountCostItems.IsDiscount(x.CostItem))
+ .ToList();
+ var anchor = receivable
+ .Where(x => MarketingFeeHelper.IsBaseFreightCostItem(x.CostItem))
+ .OrderByDescending(x => ChargingStageRank(x.ChargingStage))
+ .FirstOrDefault()
+ ?? receivable.OrderByDescending(x => ChargingStageRank(x.ChargingStage)).FirstOrDefault();
+ var currency = string.IsNullOrWhiteSpace(anchor?.Currency) ? "CNY" : anchor!.Currency;
+ var stage = anchor?.ChargingStage;
+
+ if (breakdown.CouponDiscountAmount > 0)
+ {
+ discountRows.Add(new OrderFee
+ {
+ Type = OrderFeeTypeCodes.Receivable,
+ CostItem = OrderFeeDiscountCostItems.CouponDiscount,
+ Amount = -breakdown.CouponDiscountAmount,
+ Currency = currency,
+ ChargingStage = stage,
+ PriceNo = couponCode,
+ PriceEffectiveTime = DateTime.Now
+ });
+ }
+
+ if (breakdown.LevelDiscountAmount > 0)
+ {
+ discountRows.Add(new OrderFee
+ {
+ Type = OrderFeeTypeCodes.Receivable,
+ CostItem = OrderFeeDiscountCostItems.MemberDiscount,
+ Amount = -breakdown.LevelDiscountAmount,
+ Currency = currency,
+ ChargingStage = stage,
+ PriceNo = breakdown.MemberLevelName,
+ PriceEffectiveTime = DateTime.Now
+ });
+ }
+
+ await OrderRepository.UpsertFeesAsync(order.Id, discountRows);
+ }
+
+ ///
+ /// 抵扣行取入库优先、其次预报的阶段;出库阶段客户侧不展示,排最后。
+ ///
+ private static int ChargingStageRank(string? stage) => stage?.Trim().ToLowerInvariant() switch
+ {
+ OrderFeeChargingStageCodes.Outbound => -1,
+ OrderFeeChargingStageCodes.Inbound => 2,
+ OrderFeeChargingStageCodes.Forecast => 1,
+ _ => 0
+ };
+
+ private static int? ResolveRecommendedPayCouponId(Order order, List coupons)
+ {
+ if (order.CouponInstanceId is > 0
+ && coupons.Any(x => x.CanUse && x.CouponInstanceId == order.CouponInstanceId.Value))
+ {
+ return order.CouponInstanceId;
+ }
+
+ return coupons.FirstOrDefault(x => x.CanUse)?.CouponInstanceId;
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index 1932297..61eaddd 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -127,12 +127,12 @@ public partial class OrderService : ApplicationService
}
///
- /// 执行订单支付。
+ /// 执行订单支付(余额扣款,支持支付时选券)。
///
- /// 入参:订单操作参数。
- /// 无返回体,仅完成订单支付处理。
+ /// 入参:订单 Id;couponInstanceId 为 null 沿用已绑定券,0 不用券,大于 0 使用指定券。
+ /// 无返回体;指定券不可用、余额不足或订单状态不可支付时抛业务异常。
[HttpPost("/order/pay")]
- public async Task PayAsync(OrderActionInput input)
+ public async Task PayAsync(OrderPayInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
if (order.IsPaid > 0)
@@ -152,35 +152,48 @@ public partial class OrderService : ApplicationService
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order);
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
- var payAmount = order.PayableAmount ?? fees
- .Where(x => x.Type == OrderFeeTypeCodes.Receivable)
- .Sum(x => x.Amount);
- if (payAmount <= 0)
+ var breakdown = await BuildPayBreakdownAsync(order, fees, input.CouponInstanceId);
+ if (breakdown.ReceivableSubtotal <= 0)
{
throw new BusinessException("订单应付金额无效,无法支付");
}
+ var payAmount = breakdown.PayableAmount;
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
- var balanceMutator = LazyServiceProvider.GetRequiredService();
- await balanceMutator.DebitOrderPayAsync(
- customer,
- payAmount,
- order.Id,
- order.ServiceNo,
- $"订单支付 {order.ServiceNo}");
-
- if (order.CouponInstanceId.HasValue)
+ if (payAmount > 0)
{
- var couponManager = LazyServiceProvider.GetRequiredService();
- var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id);
- await couponManager.MarkUsedAsync(order.CouponInstanceId.Value, mainCustomerId, order.Id);
+ var balanceMutator = LazyServiceProvider.GetRequiredService();
+ await balanceMutator.DebitOrderPayAsync(
+ customer,
+ payAmount,
+ order.Id,
+ order.ServiceNo,
+ $"订单支付 {order.ServiceNo}");
}
+ var couponManager = LazyServiceProvider.GetRequiredService();
+ if (order.CouponInstanceId is > 0 && order.CouponInstanceId != breakdown.CouponInstanceId)
+ {
+ await couponManager.ReleaseLockAsync(order.CouponInstanceId.Value, order.Id);
+ }
+
+ LazyServiceProvider.GetRequiredService()
+ .ApplySnapshotToOrder(order, breakdown, null);
+ string? couponCode = null;
+ if (breakdown.CouponInstanceId.HasValue)
+ {
+ var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id);
+ var coupon = await couponManager.MarkUsedAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id);
+ couponCode = coupon.CouponCode;
+ }
+
+ await UpsertMarketingDiscountFeesAsync(order, fees, breakdown, couponCode);
+
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer);
var pointsManager = LazyServiceProvider.GetRequiredService();
var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight(
@@ -197,12 +210,15 @@ public partial class OrderService : ApplicationService
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order);
var amountText = payAmount.ToString("0.####", CultureInfo.InvariantCulture);
+ var couponText = breakdown.CouponDiscountAmount > 0
+ ? $",优惠券抵扣 {breakdown.CouponDiscountAmount.ToString("0.####", CultureInfo.InvariantCulture)}"
+ : string.Empty;
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.Pay,
beforeSnap,
afterSnap,
- $"支付金额 {amountText}");
+ $"支付金额 {amountText}{couponText}");
}
///
@@ -603,34 +619,20 @@ public partial class OrderService : ApplicationService
}
///
- /// 创建订单包裹(PC/H5 确认下单或保存草稿)。
+ /// 创建订单包裹(PC/H5 确认下单或保存草稿)。整段在一个事务里,异常直接抛出并回滚。
///
- /// 【同步,本接口事务内】RequestId 幂等 → 校验入参/销售产品 → 黑名单 →
- /// 自动触发「拦截」规则(命中直接失败)→ 生成运单号 → 落库订单与子表。
- /// 确认下单:同步写偏远标记,状态草稿 +「预报计费/排货处理中」后返回;
- /// 保存草稿:仅落库,不做预报计费。失败会回滚订单,不落草稿。
+ /// 【本请求内】RequestId 幂等 → 校验 → 黑名单 → 下单前拦截规则 → 落库订单与子表。
+ /// 保存草稿只再写 9610 与上门揽收;确认下单额外标记「预报处理中」,并写入预报计费、面单、自动触发。
///
///
- /// 【创单事务提交后各自投递 MQ(互不等待;失败不回滚订单)】
- /// 1) 9610 订单 Upsert(customs-9610)— 草稿与确认下单都会投;
- /// 2) 预报计费/排货任务(forecast-billing 批量任务)— 仅确认下单;
- /// 队列关闭时在后台同步跑同一套计费逻辑;
- /// 3) 系统面单 PDF(label-generate)— 仅确认下单;
- /// 4) 下单自动触发(OrderCreate:警告/问题件等,不含拦截)— 仅确认下单;
- /// 5) 上门揽收挂载(pickup-attach)— 仅上门揽收且地址/时间/仓库齐全时;
- /// 投递失败则同步回落建揽收单。
- ///
- ///
- /// 【预报计费消费后】计费重、应收、排货应付、营销快照 → 订单改已预报。
- /// 排货成功且已有 ChannelCode 时,再投递轨迹订单 + A0101(track-upsert);
- /// 轨迹网关需要登录 Authorization,创单时写入预报任务 Payload,消费端原样转发;
- /// 排货失败不阻断预报,也不建轨迹(可页面补做)。
+ /// 【同一事务的 Outbox,Commit 后才投递】9610、预报计费、面单、OrderCreate 自动触发、上门揽收。
+ /// 队列关闭时预报计费在本事务内同步执行。轨迹不在这里发,等预报消费端排货成功后再发。
///
///
/// 入参:订单包裹创建参数。SaveAsDraft=true 为仅保存草稿。
///
- /// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已排队;
- /// 上门揽收时 PickupQueued / PickupNo 视投递或同步回落结果而定。
+ /// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已入队;
+ /// 上门揽收入队成功时 PickupQueued=true,失败只写 PickupCreateError,不回滚订单。
///
[HttpPost("/order/parcel/create")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
@@ -680,6 +682,9 @@ public partial class OrderService : ApplicationService
}
}
+ ///
+ /// 仅上门揽收。地址、期望时间和仓库齐全才入队;发布失败写入返回值,不抛回创单事务。
+ ///
private async Task TryAttachPickupAfterParcelCreateAsync(
OrderParcelCreateInput input,
OrderParcelCreateOutput output)
@@ -715,27 +720,20 @@ public partial class OrderService : ApplicationService
return;
}
- var publisher = LazyServiceProvider.GetRequiredService();
try
{
- // 优先入 Fake.EventBus;发布失败则同步回落建揽收并回写单号。
- var queued = await publisher.PublishPickupAttachAsync(input, output.OrderId);
- if (queued)
+ await EventBus.PublishAsync(new OrderParcelPickupAttachIntegrationEvent
{
- output.PickupQueued = true;
- return;
- }
-
- var pickup = await publisher.ExecutePickupAttachAsync(input, output.OrderId);
- if (pickup == null)
- {
- output.PickupCreateError = "揽收单创建失败";
- return;
- }
-
- output.PickupNo = pickup.PickupNo;
- output.PickupOrderId = pickup.Id;
- output.CarrierWaybillNo = pickup.CarrierWaybillNo;
+ OrderId = output.OrderId,
+ CustomerId = input.CustomerId,
+ SenderAddressId = input.SenderAddressId.Value,
+ WarehouseId = input.WarehouseId.Value,
+ PickupDirection = input.PickupDirection,
+ CarrierCode = input.CarrierCode,
+ CarrierService = input.CarrierService,
+ ExpectPickupTime = input.ExpectPickupTime.Value
+ });
+ output.PickupQueued = true;
}
catch (Exception ex)
{
@@ -762,7 +760,7 @@ public partial class OrderService : ApplicationService
throw new BusinessException($"单次批量创建不能超过{ParcelBatchMaxCount}条");
}
- if (BatchJobPublisher.IsEnabled)
+ if (BatchQueueEnabled)
{
return await BatchCreateParcelViaQueueAsync(input);
}
diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
index 1f836a7..a1dea3b 100644
--- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
+++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
@@ -1075,6 +1075,8 @@ internal static class ParcelBillingCalculator
"handling" => "操作费",
"proxyPacking" => "代打包费",
"insurance" => "保险费",
+ OrderFeeDiscountCostItems.CouponDiscount => "优惠券抵扣",
+ OrderFeeDiscountCostItems.MemberDiscount => "会员折扣",
_ => costItem
};
}
diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs
index e8e06de..72049d4 100644
--- a/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs
+++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs
@@ -187,6 +187,23 @@ internal sealed class PlatformStoreOrderFulfillmentService(
try
{
+ var detail = await fulfillmentClient.FetchOrderDetailAsync(store, order.PlatformOrderNo);
+ if (detail != null)
+ {
+ await UpsertPulledOrderAsync(order.CustomerId, store, detail);
+ var refreshed = await storeOrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id) ?? order;
+ return new PlatformStoreOrderBatchActionItemOutput
+ {
+ Id = refreshed.Id,
+ PlatformOrderNo = refreshed.PlatformOrderNo,
+ Success = true,
+ Message = "同步成功",
+ FulfillStatus = refreshed.FulfillStatus,
+ ShippingLabelNo = refreshed.ShippingLabelNo,
+ ShippingLabelUrl = refreshed.ShippingLabelUrl
+ };
+ }
+
var result = await fulfillmentClient.FetchOrderStatusAsync(store, order.PlatformOrderNo);
if (!result.Success)
{
@@ -263,6 +280,11 @@ internal sealed class PlatformStoreOrderFulfillmentService(
{
order.ShippingLabelNo = result.ShippingLabelNo;
}
+ else if (result.LabelPending)
+ {
+ // 对齐 WMS:准备发货后先标记面单待获取
+ order.ShippingLabelNo = "系统获取中";
+ }
if (!string.IsNullOrWhiteSpace(result.ShippingLabelUrl))
{
@@ -271,6 +293,36 @@ internal sealed class PlatformStoreOrderFulfillmentService(
order.LastSyncTime = DateTime.Now;
await storeOrderRepository.UpdateAsync(order);
+
+ // TikTok:发货后再拉详情,回写运单号等;面单未就绪时保留「系统获取中」
+ if (string.Equals(order.PlatformCode, "tiktok", StringComparison.OrdinalIgnoreCase))
+ {
+ try
+ {
+ var detail = await fulfillmentClient.FetchOrderDetailAsync(store, order.PlatformOrderNo);
+ if (detail != null)
+ {
+ if (result.LabelPending && string.IsNullOrWhiteSpace(detail.ShippingLabelUrl))
+ {
+ detail.ShippingLabelUrl = "系统获取中";
+ detail.ShippingLabelNo ??= "系统获取中";
+ }
+
+ detail.PlatformStatus ??= "AWAITING_COLLECTION";
+ detail.PlatformStatusLabel ??= detail.PlatformStatus;
+ detail.FulfillStatus = string.IsNullOrWhiteSpace(detail.FulfillStatus)
+ ? "toShip"
+ : detail.FulfillStatus;
+ await UpsertPulledOrderAsync(order.CustomerId, store, detail);
+ order = await storeOrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id) ?? order;
+ }
+ }
+ catch
+ {
+ // 详情回写失败不影响 RTS 成功结果
+ }
+ }
+
return new PlatformStoreOrderBatchActionItemOutput
{
Id = order.Id,
@@ -307,6 +359,15 @@ internal sealed class PlatformStoreOrderFulfillmentService(
}
entity.OrderCountry = pulled.OrderCountry ?? entity.OrderCountry;
+ if (!string.IsNullOrWhiteSpace(pulled.RecipientName)) entity.RecipientName = pulled.RecipientName;
+ if (!string.IsNullOrWhiteSpace(pulled.RecipientPhone)) entity.RecipientPhone = pulled.RecipientPhone;
+ if (!string.IsNullOrWhiteSpace(pulled.RecipientEmail)) entity.RecipientEmail = pulled.RecipientEmail;
+ if (!string.IsNullOrWhiteSpace(pulled.RecipientCountry)) entity.RecipientCountry = pulled.RecipientCountry;
+ if (!string.IsNullOrWhiteSpace(pulled.RecipientProvince)) entity.RecipientProvince = pulled.RecipientProvince;
+ if (!string.IsNullOrWhiteSpace(pulled.RecipientCity)) entity.RecipientCity = pulled.RecipientCity;
+ if (!string.IsNullOrWhiteSpace(pulled.RecipientZipCode)) entity.RecipientZipCode = pulled.RecipientZipCode;
+ if (!string.IsNullOrWhiteSpace(pulled.RecipientAddress1)) entity.RecipientAddress1 = pulled.RecipientAddress1;
+ if (!string.IsNullOrWhiteSpace(pulled.RecipientAddress2)) entity.RecipientAddress2 = pulled.RecipientAddress2;
entity.ItemCount = pulled.ItemCount > 0 ? pulled.ItemCount : entity.ItemCount;
entity.TotalAmount = pulled.TotalAmount ?? entity.TotalAmount;
entity.Currency = pulled.Currency ?? entity.Currency;
@@ -316,6 +377,8 @@ internal sealed class PlatformStoreOrderFulfillmentService(
entity.FulfillStatus = pulled.FulfillStatus;
entity.ShippingLabelNo = pulled.ShippingLabelNo ?? entity.ShippingLabelNo;
entity.ShippingLabelUrl = pulled.ShippingLabelUrl ?? entity.ShippingLabelUrl;
+ if (!string.IsNullOrWhiteSpace(pulled.ShippingProvider)) entity.ShippingProvider = pulled.ShippingProvider;
+ if (!string.IsNullOrWhiteSpace(pulled.FulfillmentType)) entity.FulfillmentType = pulled.FulfillmentType;
entity.PlatformExtraJson = pulled.ExtraJson ?? entity.PlatformExtraJson;
entity.LastSyncTime = now;
PlatformStoreOrderParcelBuilder.TryFillRecipientFields(entity, pulled.ExtraJson);
@@ -348,7 +411,9 @@ internal sealed class PlatformStoreOrderFulfillmentService(
PlatformLineId = line.PlatformLineId,
PlatformSku = line.PlatformSku,
ProductName = line.ProductName,
- Quantity = line.Quantity,
+ ProductImageUrl = line.ProductImageUrl,
+ VariantText = line.VariantText,
+ Quantity = line.Quantity > 0 ? line.Quantity : 1,
UnitPrice = line.UnitPrice,
Currency = line.Currency
});
diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs
index c4828d8..244ad2f 100644
--- a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs
+++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs
@@ -107,7 +107,7 @@ public static class PlatformStoreOrderParcelBuilder
IReadOnlyCollection lines)
{
var items = new List();
- foreach (var line in lines.Where(x => !x.IsShipExcluded))
+ foreach (var line in lines)
{
var cnName = string.IsNullOrWhiteSpace(line.ProductName)
? (string.IsNullOrWhiteSpace(line.PlatformSku) ? "Platform Item" : line.PlatformSku.Trim())
@@ -157,7 +157,7 @@ public static class PlatformStoreOrderParcelBuilder
IReadOnlyDictionary skuMap)
{
var items = new List();
- foreach (var line in lines.Where(x => !x.IsShipExcluded))
+ foreach (var line in lines)
{
if (!line.MappedSkuId.HasValue || !skuMap.TryGetValue(line.MappedSkuId.Value, out var sku))
{
@@ -201,7 +201,7 @@ public static class PlatformStoreOrderParcelBuilder
decimal maxHeight = DefaultHeightCm;
decimal totalWeightKg = 0m;
- foreach (var line in lines.Where(x => !x.IsShipExcluded && x.MappedSkuId.HasValue))
+ foreach (var line in lines.Where(x => x.MappedSkuId.HasValue))
{
if (!skuMap.TryGetValue(line.MappedSkuId!.Value, out var sku))
{
@@ -306,7 +306,10 @@ public static class PlatformStoreOrderParcelBuilder
private static OrderParcelReceiverInput? ParseReceiverNode(JsonNode node)
{
var candidates = new List { node };
- foreach (var key in new[] { "recipient", "receiver", "shippingAddress", "address" })
+ foreach (var key in new[]
+ {
+ "recipient_address", "recipientAddress", "recipient", "receiver", "shippingAddress", "address"
+ })
{
if (node[key] != null)
{
@@ -321,10 +324,22 @@ public static class PlatformStoreOrderParcelBuilder
continue;
}
- var name = ReadText(candidate, "recipientsName", "recipientName", "receiverName", "name", "fullName");
- var country = ReadText(candidate, "countryCode", "country", "region", "orderCountry") ?? ReadText(node, "orderCountry");
- var zip = ReadText(candidate, "zipCode", "zip", "postalCode", "postcode");
- var address = ReadText(candidate, "streetAddress1", "address", "address1", "fullAddress", "detailAddress");
+ var (province, city, countryFromDistrict) = ParseTiktokDistrictInfo(candidate);
+ var name = ReadText(candidate, "recipientsName", "recipientName", "receiverName", "name", "fullName", "first_name");
+ var country = ReadText(candidate, "countryCode", "country", "region_code", "region", "orderCountry")
+ ?? countryFromDistrict
+ ?? ReadText(node, "orderCountry");
+ var zip = ReadText(candidate, "zipCode", "zip", "postal_code", "postalCode", "postcode");
+ var address = ReadText(
+ candidate,
+ "streetAddress1",
+ "address_line1",
+ "address_detail",
+ "address",
+ "address1",
+ "full_address",
+ "fullAddress",
+ "detailAddress");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(country) || string.IsNullOrWhiteSpace(zip) || string.IsNullOrWhiteSpace(address))
{
continue;
@@ -334,14 +349,14 @@ public static class PlatformStoreOrderParcelBuilder
{
RecipientsName = name,
CompanyName = ReadText(candidate, "companyName", "company"),
- Phone = ReadText(candidate, "phone", "mobile", "phoneNumber"),
+ Phone = ReadText(candidate, "phone", "mobile", "phone_number", "phoneNumber"),
TelePhone = ReadText(candidate, "telePhone", "tel"),
- Email = ReadText(candidate, "email"),
+ Email = ReadText(candidate, "email") ?? ReadText(node, "buyer_email"),
CountryCode = country,
- Province = ReadText(candidate, "province", "state", "regionState"),
- City = ReadText(candidate, "city"),
+ Province = ReadText(candidate, "province", "state", "regionState") ?? province,
+ City = ReadText(candidate, "city") ?? city,
StreetAddress1 = address,
- StreetAddress2 = ReadText(candidate, "streetAddress2", "address2"),
+ StreetAddress2 = ReadText(candidate, "streetAddress2", "address_line2", "address2"),
ZipCode = zip
};
}
@@ -349,6 +364,46 @@ public static class PlatformStoreOrderParcelBuilder
return null;
}
+ ///
+ /// 解析 TikTok recipient_address.district_info:L0 国家、L1 州省、L3 城市。
+ ///
+ private static (string? Province, string? City, string? CountryCode) ParseTiktokDistrictInfo(JsonNode candidate)
+ {
+ var districts = candidate["district_info"]?.AsArray();
+ if (districts == null || districts.Count == 0)
+ {
+ return (null, null, null);
+ }
+
+ string? province = null;
+ string? city = null;
+ string? country = null;
+ foreach (var district in districts)
+ {
+ if (district == null) continue;
+ var level = district["address_level"]?.ToString();
+ var name = district["address_name"]?.ToString()?.Trim();
+ var iso = district["iso_code"]?.ToString()?.Trim();
+ switch (level)
+ {
+ case "L0":
+ country = !string.IsNullOrWhiteSpace(iso) ? iso : name;
+ break;
+ case "L1":
+ province = name;
+ break;
+ case "L3":
+ city = name;
+ break;
+ case "L2" when string.IsNullOrWhiteSpace(city):
+ city = name;
+ break;
+ }
+ }
+
+ return (province, city, country);
+ }
+
private static string? ReadText(JsonNode node, params string[] keys)
{
foreach (var key in keys)
diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs
index e949a82..2f99043 100644
--- a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs
+++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs
@@ -553,7 +553,7 @@ public class PlatformStoreOrderService : ApplicationService
var order = await GetOrderOrThrowAsync(customerId, storeOrderId);
await EnsureCanCreateLogisticsAsync(order);
- var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id && !x.IsShipExcluded);
+ var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id);
if (lines.Count == 0)
{
throw new BusinessException("没有可发货的商品行");
@@ -710,7 +710,7 @@ public class PlatformStoreOrderService : ApplicationService
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == resolvedWarehouseId.Value)
?? throw new BusinessException("仓库不存在");
- var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id && !x.IsShipExcluded);
+ var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id);
if (lines.Count == 0)
{
throw new BusinessException("没有可发货的商品行");
@@ -1103,8 +1103,8 @@ public class PlatformStoreOrderService : ApplicationService
PlatformStatusLabel = order.PlatformStatusLabel,
ShippingLabelNo = order.ShippingLabelNo,
ShippingLabelUrl = order.ShippingLabelUrl,
- IsMultiLabel = order.IsMultiLabel,
- CustomTags = order.CustomTags,
+ ShippingProvider = order.ShippingProvider,
+ FulfillmentType = order.FulfillmentType,
IsSuspended = order.IsSuspended,
WarehouseId = order.WarehouseId,
LinkedOrderId = order.LinkedOrderId,
@@ -1129,7 +1129,6 @@ public class PlatformStoreOrderService : ApplicationService
UnitPrice = line.UnitPrice,
Quantity = line.Quantity,
Currency = line.Currency,
- IsShipExcluded = line.IsShipExcluded,
MappedSkuId = line.MappedSkuId,
MappedSkuCode = mappedSkuCode
};
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
index b5be7bd..ede5f25 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
@@ -215,6 +215,9 @@ public static class OrderTemplateFieldRegistry
Add("items[0].weight", "总重量(kg)", false, "number", group: "declaration", example: "自动计算", hint: "不填=单重×数量");
Add("items[0].imHsCode", "进口HsCode", false, group: "declaration", hint: "选填");
Add("items[0].exHsCode", "出口HsCode", false, group: "declaration", hint: "选填");
+ Add("items[0].productId", "产品标识编码", false, group: "declaration", hint: "选填");
+ Add("items[0].nonStdProductId", "非标产品标识编码", false, group: "declaration", hint: "选填");
+ Add("items[0].stdProductId", "标准产品标识编码", false, group: "declaration", hint: "选填");
Add("items[0].skuImage", "商品图片URL", false, group: "declaration", hint: "选填");
Add("items[0].category", "类别", false, group: "declaration", example: "Accessories", hint: "选填");
Add("items[0].brand", "品牌", false, group: "declaration", example: "DemoBrand", hint: "选填");
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs
index b1470a6..c308c56 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs
@@ -20,6 +20,7 @@ public static class OrderTemplateRowMapper
"sender.countryCode", "sender.province", "sender.city", "sender.zipCode", "sender.streetAddress1",
"sender.streetAddress2", "sender.streetAddress3", "sender.houseNumber", "items[0].cnName", "items[0].enName",
"items[0].sku", "items[0].unit", "items[0].imHsCode", "items[0].exHsCode", "items[0].skuImage",
+ "items[0].productId", "items[0].nonStdProductId", "items[0].stdProductId",
"items[0].category", "items[0].brand", "items[0].brandType", "items[0].distribution", "items[0].material",
"items[0].enMaterial", "items[0].purpose", "items[0].attributes", "items[0].originCountry", "items[0].salesLink",
"items[0].model", "items[0].specifications", "items[0].comment"
diff --git a/src/Oms.Application/Services/System/CountryService.cs b/src/Oms.Application/Services/System/CountryService.cs
index 51c93d6..77fa27b 100644
--- a/src/Oms.Application/Services/System/CountryService.cs
+++ b/src/Oms.Application/Services/System/CountryService.cs
@@ -1,6 +1,9 @@
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.System;
+using Oms.Application.Shared;
+using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.System;
@@ -16,20 +19,249 @@ public class CountryService : ApplicationService
///
/// 查询国家列表。
///
- /// 国家列表,返回全部有效国家主数据。
+ /// 国家列表,返回全部有效国家主数据,按二字码、英文名排序。
[HttpGet("/system/country/list")]
public async Task> GetListAsync()
{
return (await CountryRepository.GetListAsync())
.OrderBy(x => x.CountryCode)
.ThenBy(x => x.EnglishName)
- .Select(x => new CountryOutput
- {
- Id = x.Id,
- ChineseName = x.ChineseName,
- EnglishName = x.EnglishName,
- CountryCode = x.CountryCode
- })
+ .Select(ToOutput)
.ToList();
}
+
+ ///
+ /// 新增国家/地区。二字码已存在时失败;若仅存在同码软删除记录则恢复该记录。
+ ///
+ /// 中文名、英文名、二字码。
+ /// 无返回体,仅完成新增。
+ [HttpPost("/system/country/create")]
+ public async Task CreateAsync(CreateCountryInput input)
+ {
+ var row = ToImportRow(input.ChineseName, input.EnglishName, input.CountryCode);
+ await SaveAsync(row, excludeId: null, updateWhenCodeExists: false);
+ }
+
+ ///
+ /// 更新国家/地区。二字码与其他有效记录冲突时失败。
+ ///
+ /// 主键及中文名、英文名、二字码。
+ /// 无返回体,仅完成更新。
+ [HttpPost("/system/country/update")]
+ public async Task UpdateAsync(UpdateCountryInput input)
+ {
+ var entity = await GetOrThrowAsync(input.Id);
+ var row = ToImportRow(input.ChineseName, input.EnglishName, input.CountryCode);
+ await SaveAsync(row, excludeId: entity.Id, updateWhenCodeExists: false);
+ }
+
+ ///
+ /// 删除国家/地区(软删除)。
+ ///
+ /// 目标主键。
+ /// 无返回体,仅完成删除。
+ [HttpPost("/system/country/delete")]
+ public async Task DeleteAsync(IdInput input)
+ {
+ var entity = await GetOrThrowAsync(input.Id);
+ await CountryRepository.DeleteAsync(entity);
+ }
+
+ ///
+ /// 下载国家/地区导入模板。
+ ///
+ /// xlsx 文件,列为国家二字码、中文名、英文名。
+ [HttpGet("/system/country/import-template")]
+ public async Task DownloadImportTemplateAsync()
+ {
+ var bytes = await CountryImportHelper.BuildTemplateBytesAsync();
+ return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+ {
+ FileDownloadName = CountryImportHelper.TemplateFileName
+ };
+ }
+
+ ///
+ /// 导入国家/地区(Excel)。增量按二字码新增或更新;全量还会软删除文件中未出现的国家。
+ ///
+ /// Excel 文件与导入模式。
+ /// 成功、跳过、失败及全量删除条数。
+ [HttpPost("/system/country/import")]
+ public async Task ImportAsync([FromForm] CountryImportInput input)
+ {
+ if (input.File == null || input.File.Length == 0)
+ {
+ throw new BusinessException("请上传 Excel 文件");
+ }
+
+ var importMode = NormalizeImportMode(input.ImportMode);
+ await using var stream = input.File.OpenReadStream();
+ var parsedRows = await CountryImportHelper.ParseImportRowsAsync(stream);
+ if (parsedRows.Count == 0)
+ {
+ throw new BusinessException("Excel 中无有效数据行");
+ }
+
+ var output = new CountryImportOutput();
+ var touchedCodes = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var seenCodes = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var (rowNumber, row) in parsedRows)
+ {
+ var rawCode = row.CountryCode?.Trim();
+ if (!string.IsNullOrWhiteSpace(rawCode))
+ {
+ touchedCodes.Add(rawCode.ToUpperInvariant());
+ }
+
+ try
+ {
+ CountryImportHelper.Normalize(row);
+ var code = row.CountryCode ?? string.Empty;
+ if (!seenCodes.Add(code))
+ {
+ output.SkipCount++;
+ output.Failures.Add(new CountryImportFailRow
+ {
+ RowNumber = rowNumber,
+ Reason = "文件内二字码重复,已跳过"
+ });
+ continue;
+ }
+
+ await SaveAsync(row, excludeId: null, updateWhenCodeExists: true);
+ output.SuccessCount++;
+ }
+ catch (Exception ex)
+ {
+ output.FailCount++;
+ output.Failures.Add(new CountryImportFailRow
+ {
+ RowNumber = rowNumber,
+ Reason = ex.Message
+ });
+ }
+ }
+
+ if (importMode == CountryImportInput.ImportModes.Full && output.SuccessCount > 0)
+ {
+ var existing = await CountryRepository.GetListAsync();
+ foreach (var entity in existing)
+ {
+ var code = entity.CountryCode?.Trim().ToUpperInvariant() ?? string.Empty;
+ if (touchedCodes.Contains(code))
+ {
+ continue;
+ }
+
+ await CountryRepository.DeleteAsync(entity);
+ output.DeletedCount++;
+ }
+ }
+
+ return output;
+ }
+
+ private async Task SaveAsync(CountryImportRow row, int? excludeId, bool updateWhenCodeExists)
+ {
+ try
+ {
+ CountryImportHelper.Normalize(row);
+ }
+ catch (InvalidOperationException ex)
+ {
+ throw new BusinessException(ex.Message);
+ }
+
+ var active = await CountryRepository.GetByCountryCodeAsync(row.CountryCode);
+ if (active != null && active.Id != excludeId)
+ {
+ if (updateWhenCodeExists)
+ {
+ ApplyNames(active, row);
+ await CountryRepository.UpdateAsync(active);
+ return;
+ }
+
+ throw new BusinessException("国家二字码已存在");
+ }
+
+ var deleted = await CountryRepository.GetByCountryCodeIncludingDeletedAsync(row.CountryCode);
+ if (deleted != null && deleted.IsDeleted && deleted.Id != excludeId)
+ {
+ if (excludeId != null)
+ {
+ throw new BusinessException("国家二字码已被删除记录占用,请新增同码以恢复");
+ }
+
+ deleted.IsDeleted = false;
+ deleted.CountryCode = row.CountryCode;
+ ApplyNames(deleted, row);
+ await CountryRepository.UpdateAsync(deleted);
+ return;
+ }
+
+ if (excludeId != null)
+ {
+ var entity = await GetOrThrowAsync(excludeId.Value);
+ entity.CountryCode = row.CountryCode;
+ ApplyNames(entity, row);
+ await CountryRepository.UpdateAsync(entity);
+ return;
+ }
+
+ await CountryRepository.InsertAsync(new Country
+ {
+ CountryCode = row.CountryCode,
+ ChineseName = row.ChineseName,
+ EnglishName = row.EnglishName
+ });
+ }
+
+ private static void ApplyNames(Country entity, CountryImportRow row)
+ {
+ entity.ChineseName = row.ChineseName;
+ entity.EnglishName = row.EnglishName;
+ }
+
+ private static CountryImportRow ToImportRow(string chineseName, string englishName, string countryCode) =>
+ new()
+ {
+ ChineseName = chineseName,
+ EnglishName = englishName,
+ CountryCode = countryCode
+ };
+
+ private async Task GetOrThrowAsync(int id)
+ {
+ var entity = await CountryRepository.FirstOrDefaultAsync(x => x.Id == id);
+ if (entity == null)
+ {
+ throw new BusinessException("国家不存在");
+ }
+
+ return entity;
+ }
+
+ private static CountryOutput ToOutput(Country x) => new()
+ {
+ Id = x.Id,
+ ChineseName = x.ChineseName,
+ EnglishName = x.EnglishName,
+ CountryCode = x.CountryCode,
+ CreateTime = x.CreateTime,
+ UpdateTime = x.UpdateTime == default ? null : x.UpdateTime
+ };
+
+ private static string NormalizeImportMode(string? mode)
+ {
+ if (string.Equals(mode, CountryImportInput.ImportModes.Full, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(mode, "全量", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(mode, "replace", StringComparison.OrdinalIgnoreCase))
+ {
+ return CountryImportInput.ImportModes.Full;
+ }
+
+ return CountryImportInput.ImportModes.Incremental;
+ }
}
diff --git a/src/Oms.Application/Services/System/NoticeService.cs b/src/Oms.Application/Services/System/NoticeService.cs
index ca82fe3..e3238b6 100644
--- a/src/Oms.Application/Services/System/NoticeService.cs
+++ b/src/Oms.Application/Services/System/NoticeService.cs
@@ -49,35 +49,41 @@ public class NoticeService : ApplicationService
[HttpGet("/system/notice/list")]
public async Task> GetListAsync([FromQuery] NoticeListInput input)
{
- var receiverUserId = CurrentUser.GetId();
+ var receiverUserId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
if (receiverUserId <= 0)
{
return new OmsPageResult { Items = [], Total = 0 };
}
- var expectedReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser)
- ? ReceiverTypeCustomer
- : ReceiverTypeUser;
+ var isCustomer = OmsSessionHelper.IsCustomerLogin(CurrentUser);
+ var expectedReceiverType = isCustomer ? ReceiverTypeCustomer : ReceiverTypeUser;
+ // 管理端公告页:管理员可看全部;工作台/H5/未读仍按接收范围过滤
+ var manageAll = input.Manage == true && !isCustomer;
var myReadNoticeIds = (await NoticeReadRepository.GetListAsync(x =>
x.ReceiverType == expectedReceiverType && x.ReceiverId == receiverUserId))
.Select(x => x.NoticeId)
.Distinct()
.ToList();
+ var myReadIdSet = myReadNoticeIds.ToHashSet();
- var predicate = BuildListPredicate(
- input,
- receiverUserId,
- expectedReceiverType,
- input.OnlyUnread == true ? myReadNoticeIds : null);
+ // 先按类型/关键词查,再在内存按 ReceiverIds 判可见性。
+ // MySQL JSON 列与字符串 "[]" 比较会失败,导致广播对客户/收件箱不可见。
+ var basePredicate = BuildManageListPredicate(input);
+ var candidates = await NoticeRepository.GetListAsync(basePredicate);
+ var visible = candidates
+ .Where(x => manageAll || IsVisibleToReceiver(x, receiverUserId, expectedReceiverType))
+ .Where(x => input.OnlyUnread != true || !myReadIdSet.Contains(x.Id))
+ .OrderByDescending(x => x.CreateTime)
+ .ThenByDescending(x => x.Id)
+ .ToList();
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
- var notices = await NoticeRepository.GetPagedListAsync(
- predicate,
- pageIndex,
- pageSize,
- new Dictionary { [nameof(Notice.CreateTime)] = false, [nameof(Notice.Id)] = false });
- var total = await NoticeRepository.CountAsync(predicate);
+ var total = visible.Count;
+ var notices = visible
+ .Skip(Math.Max(pageIndex - 1, 0) * pageSize)
+ .Take(pageSize)
+ .ToList();
var noticeIds = notices.Select(x => x.Id).Distinct().ToArray();
var reads = noticeIds.Length == 0
@@ -88,8 +94,6 @@ public class NoticeService : ApplicationService
.ToDictionary(
x => x.Key,
x => (int)x.Select(item => $"{item.ReceiverType}:{item.ReceiverId}").Distinct(StringComparer.Ordinal).Count());
- var myReadIdSet = myReadNoticeIds.ToHashSet();
-
var selectionIdMap = await BuildReceiverSelectionIdMapAsync(notices);
return new OmsPageResult
@@ -197,7 +201,7 @@ public class NoticeService : ApplicationService
var currentReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser)
? ReceiverTypeCustomer
: ReceiverTypeUser;
- var currentReceiverId = CurrentUser.GetId();
+ var currentReceiverId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
if (currentReceiverId <= 0)
{
throw new BusinessException("当前登录主体无效");
@@ -349,33 +353,32 @@ public class NoticeService : ApplicationService
return result;
}
- private static Expression> BuildListPredicate(
- NoticeListInput input,
- int receiverUserId,
- string expectedReceiverType,
- IReadOnlyCollection? excludeNoticeIds)
+ private static Expression> BuildManageListPredicate(NoticeListInput input)
{
var noticeType = input.NoticeType?.Trim();
var receiverType = input.ReceiverType?.Trim();
var keyword = input.Keyword?.Trim();
- var receiverIdToken = receiverUserId.ToString();
- var excluded = excludeNoticeIds?.Where(x => x > 0).Distinct().ToList() ?? [];
return x =>
(string.IsNullOrWhiteSpace(noticeType) || x.NoticeType == noticeType) &&
(string.IsNullOrWhiteSpace(receiverType) || x.ReceiverType == receiverType) &&
(string.IsNullOrWhiteSpace(keyword) ||
x.Title.Contains(keyword) ||
- x.Content.Contains(keyword)) &&
- (
- // 空接收人列表 = 全体广播(员工/客户均可见)
- x.ReceiverIds == "[]" ||
- (x.ReceiverType == expectedReceiverType &&
- (x.ReceiverIds == $"[{receiverIdToken}]" ||
- x.ReceiverIds.StartsWith($"[{receiverIdToken},") ||
- x.ReceiverIds.Contains($",{receiverIdToken},") ||
- x.ReceiverIds.EndsWith($",{receiverIdToken}]")))
- ) &&
- (excluded.Count == 0 || !excluded.Contains(x.Id));
+ x.Content.Contains(keyword));
+ }
+
+ ///
+ /// 空接收人 = 全体广播(员工/客户均可见);否则须类型匹配且 mapId 在名单内。
+ ///
+ private static bool IsVisibleToReceiver(Notice notice, int receiverUserId, string expectedReceiverType)
+ {
+ var receiverIds = OmsDocHelper.DeserializeJson>(notice.ReceiverIds) ?? [];
+ if (receiverIds.Count == 0)
+ {
+ return true;
+ }
+
+ return string.Equals(notice.ReceiverType, expectedReceiverType, StringComparison.OrdinalIgnoreCase)
+ && receiverIds.Contains(receiverUserId);
}
private async Task GetNoticeOrThrowAsync(int id)
diff --git a/src/Oms.Application/Shared/CountryImportHelper.cs b/src/Oms.Application/Shared/CountryImportHelper.cs
new file mode 100644
index 0000000..240f913
--- /dev/null
+++ b/src/Oms.Application/Shared/CountryImportHelper.cs
@@ -0,0 +1,179 @@
+using System.Text.RegularExpressions;
+using MiniExcelLibs;
+
+namespace Oms.Application.Shared;
+
+///
+/// 国家/地区 Excel 模板与导入解析。
+///
+public static class CountryImportHelper
+{
+ public const string TemplateSheetName = "导入模板";
+ public const string TemplateFileName = "国家地区导入模板.xlsx";
+
+ private static readonly Regex CountryCodePattern = new("^[A-Za-z]{2}$", RegexOptions.Compiled);
+
+ private static readonly Dictionary HeaderAliases = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["国家二字码"] = nameof(CountryImportRow.CountryCode),
+ ["二字码"] = nameof(CountryImportRow.CountryCode),
+ ["国家代码"] = nameof(CountryImportRow.CountryCode),
+ ["CountryCode"] = nameof(CountryImportRow.CountryCode),
+ ["countryCode"] = nameof(CountryImportRow.CountryCode),
+ ["中文名"] = nameof(CountryImportRow.ChineseName),
+ ["国家中文名"] = nameof(CountryImportRow.ChineseName),
+ ["ChineseName"] = nameof(CountryImportRow.ChineseName),
+ ["chineseName"] = nameof(CountryImportRow.ChineseName),
+ ["英文名"] = nameof(CountryImportRow.EnglishName),
+ ["国家英文名"] = nameof(CountryImportRow.EnglishName),
+ ["EnglishName"] = nameof(CountryImportRow.EnglishName),
+ ["englishName"] = nameof(CountryImportRow.EnglishName),
+ };
+
+ public static async Task BuildTemplateBytesAsync()
+ {
+ var templateRows = new List>
+ {
+ new()
+ {
+ ["国家二字码"] = "CN",
+ ["中文名"] = "中国",
+ ["英文名"] = "China"
+ },
+ new()
+ {
+ ["国家二字码"] = "US",
+ ["中文名"] = "美国",
+ ["英文名"] = "United States"
+ }
+ };
+
+ var sheets = new Dictionary
+ {
+ [TemplateSheetName] = templateRows
+ };
+
+ await using var stream = new MemoryStream();
+ await MiniExcel.SaveAsAsync(stream, sheets);
+ return stream.ToArray();
+ }
+
+ public static async Task> ParseImportRowsAsync(Stream fileStream)
+ {
+ if (fileStream.CanSeek)
+ {
+ fileStream.Position = 0;
+ }
+
+ var sheetNames = MiniExcel.GetSheetNames(fileStream);
+ var sheetName = sheetNames.FirstOrDefault(x =>
+ string.Equals(x, TemplateSheetName, StringComparison.OrdinalIgnoreCase))
+ ?? sheetNames.FirstOrDefault();
+
+ if (fileStream.CanSeek)
+ {
+ fileStream.Position = 0;
+ }
+
+ var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true, sheetName: sheetName);
+ var result = new List<(int RowNumber, CountryImportRow Row)>();
+ var rowNumber = 1;
+
+ foreach (var row in rows)
+ {
+ rowNumber++;
+ if (row is not IDictionary rowDict || IsEmptyRow(rowDict))
+ {
+ continue;
+ }
+
+ result.Add((rowNumber, ParseRow(rowDict)));
+ }
+
+ return result;
+ }
+
+ public static void Normalize(CountryImportRow row)
+ {
+ row.CountryCode = row.CountryCode?.Trim().ToUpperInvariant() ?? string.Empty;
+ row.ChineseName = row.ChineseName?.Trim() ?? string.Empty;
+ row.EnglishName = row.EnglishName?.Trim() ?? string.Empty;
+
+ if (string.IsNullOrWhiteSpace(row.CountryCode))
+ {
+ throw new InvalidOperationException("国家二字码不能为空");
+ }
+
+ if (!CountryCodePattern.IsMatch(row.CountryCode))
+ {
+ throw new InvalidOperationException("国家二字码须为 2 位字母,如 CN、US");
+ }
+
+ if (string.IsNullOrWhiteSpace(row.ChineseName))
+ {
+ throw new InvalidOperationException("中文名不能为空");
+ }
+
+ if (row.ChineseName.Length > 128)
+ {
+ throw new InvalidOperationException("中文名不可超过 128 个字符");
+ }
+
+ if (string.IsNullOrWhiteSpace(row.EnglishName))
+ {
+ throw new InvalidOperationException("英文名不能为空");
+ }
+
+ if (row.EnglishName.Length > 128)
+ {
+ throw new InvalidOperationException("英文名不可超过 128 个字符");
+ }
+ }
+
+ private static CountryImportRow ParseRow(IDictionary rowDict)
+ {
+ var mapped = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (header, value) in rowDict)
+ {
+ if (string.IsNullOrWhiteSpace(header) || !HeaderAliases.TryGetValue(header.Trim(), out var field))
+ {
+ continue;
+ }
+
+ mapped[field] = NormalizeCell(value);
+ }
+
+ return new CountryImportRow
+ {
+ CountryCode = mapped.GetValueOrDefault(nameof(CountryImportRow.CountryCode)) ?? string.Empty,
+ ChineseName = mapped.GetValueOrDefault(nameof(CountryImportRow.ChineseName)) ?? string.Empty,
+ EnglishName = mapped.GetValueOrDefault(nameof(CountryImportRow.EnglishName)) ?? string.Empty
+ };
+ }
+
+ private static string? NormalizeCell(object? value)
+ {
+ if (value is null)
+ {
+ return null;
+ }
+
+ var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)?.Trim();
+ return string.IsNullOrWhiteSpace(text) ? null : text;
+ }
+
+ private static bool IsEmptyRow(IDictionary rowDict) =>
+ rowDict.Values.All(value => string.IsNullOrWhiteSpace(NormalizeCell(value)));
+}
+
+///
+/// 国家导入行。
+///
+public class CountryImportRow
+{
+ public string CountryCode { get; set; } = string.Empty;
+
+ public string ChineseName { get; set; } = string.Empty;
+
+ public string EnglishName { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Shared/MarketingCouponManager.cs b/src/Oms.Application/Shared/MarketingCouponManager.cs
index e6ac36c..328b537 100644
--- a/src/Oms.Application/Shared/MarketingCouponManager.cs
+++ b/src/Oms.Application/Shared/MarketingCouponManager.cs
@@ -266,7 +266,10 @@ public class MarketingCouponManager : IScopedDependency
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
- public async Task MarkUsedAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default)
+ ///
+ /// 核销券并返回券实例(调用方可取券码写入费用行)。
+ ///
+ public async Task MarkUsedAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default)
{
var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken);
instance.Status = CouponInstanceStatus.Used;
@@ -274,6 +277,7 @@ public class MarketingCouponManager : IScopedDependency
instance.UseTime = DateTime.Now;
instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken);
+ return instance;
}
public async Task RestoreOnOrderRefundAsync(Order order, bool isFullRefund, CancellationToken cancellationToken = default)
@@ -314,7 +318,10 @@ public class MarketingCouponManager : IScopedDependency
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
- public async Task ReleaseLockAsync(int couponInstanceId, CancellationToken cancellationToken = default)
+ ///
+ /// 释放券锁定;传 时仅释放被该订单锁定的券。
+ ///
+ public async Task ReleaseLockAsync(int couponInstanceId, int? ownerOrderId = null, CancellationToken cancellationToken = default)
{
var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == couponInstanceId, cancellationToken);
if (instance == null || instance.Status != CouponInstanceStatus.Locked)
@@ -322,6 +329,11 @@ public class MarketingCouponManager : IScopedDependency
return;
}
+ if (ownerOrderId.HasValue && instance.OrderId != ownerOrderId.Value)
+ {
+ return;
+ }
+
instance.Status = CouponInstanceStatus.Available;
instance.OrderId = null;
instance.LockExpireTime = null;
@@ -383,10 +395,14 @@ public class MarketingCouponManager : IScopedDependency
}
}
+ ///
+ /// 解析券抵扣参数; 为当前订单时,被该订单锁定的券视为可用。
+ ///
public async Task ResolveCouponDiscountInputAsync(
int? couponInstanceId,
int mainCustomerId,
string? destinationCountry = null,
+ int? ownerOrderId = null,
CancellationToken cancellationToken = default)
{
if (!couponInstanceId.HasValue)
@@ -395,7 +411,7 @@ public class MarketingCouponManager : IScopedDependency
}
var instance = await GetOwnedInstanceAsync(couponInstanceId.Value, mainCustomerId, cancellationToken);
- EnsureUsable(instance);
+ EnsureUsable(instance, ownerOrderId);
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken)
?? throw new BusinessException("券模板不存在");
if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry))
@@ -422,13 +438,31 @@ public class MarketingCouponManager : IScopedDependency
return await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(_customerRepository, customer, cancellationToken);
}
+ ///
+ /// 构建可选券列表;传 时额外包含被该订单锁定且仍在有效期内的券。
+ ///
public async Task> BuildAvailableCouponsAsync(
int mainCustomerId,
decimal? baseFreightAmount,
string? destinationCountry = null,
+ int? ownerOrderId = null,
CancellationToken cancellationToken = default)
{
var instances = await _instanceRepository.GetAvailableByCustomerAsync(mainCustomerId, cancellationToken);
+ if (ownerOrderId is > 0)
+ {
+ var now = DateTime.Now;
+ var orderId = ownerOrderId.Value;
+ var ownLocked = await _instanceRepository.GetListAsync(
+ x => x.CustomerId == mainCustomerId
+ && x.Status == CouponInstanceStatus.Locked
+ && x.OrderId == orderId
+ && x.ValidStartTime <= now
+ && x.ValidEndTime >= now);
+ var knownIds = instances.Select(x => x.Id).ToHashSet();
+ instances.AddRange(ownLocked.Where(x => !knownIds.Contains(x.Id)));
+ }
+
if (instances.Count == 0)
{
return [];
@@ -541,7 +575,7 @@ public class MarketingCouponManager : IScopedDependency
return instance;
}
- private static void EnsureUsable(CouponInstance instance)
+ private static void EnsureUsable(CouponInstance instance, int? ownerOrderId = null)
{
var now = DateTime.Now;
if (instance.Status == CouponInstanceStatus.PendingUnlock)
@@ -566,7 +600,8 @@ public class MarketingCouponManager : IScopedDependency
if (instance.Status == CouponInstanceStatus.Locked
&& instance.LockExpireTime.HasValue
- && instance.LockExpireTime.Value >= now)
+ && instance.LockExpireTime.Value >= now
+ && !(ownerOrderId.HasValue && instance.OrderId == ownerOrderId.Value))
{
throw new BusinessException("优惠券已被其他订单占用");
}
diff --git a/src/Oms.Application/Shared/MarketingFeeHelper.cs b/src/Oms.Application/Shared/MarketingFeeHelper.cs
index 52a8bf1..5ff47ca 100644
--- a/src/Oms.Application/Shared/MarketingFeeHelper.cs
+++ b/src/Oms.Application/Shared/MarketingFeeHelper.cs
@@ -12,8 +12,13 @@ public static class MarketingFeeHelper
public static bool IsBaseFreightCostItem(string? costItem) =>
!string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem);
+ ///
+ /// 折前应收小计:排除支付时写入的营销抵扣行,避免重算营销金额时重复扣减。
+ ///
public static decimal SumReceivable(IEnumerable fees) =>
- fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable).Sum(x => x.Amount);
+ fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable
+ && !OrderFeeDiscountCostItems.IsDiscount(x.CostItem))
+ .Sum(x => x.Amount);
public static decimal SumReceivableBaseFreight(IEnumerable fees) =>
fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem))
diff --git a/src/Oms.Application/Shared/MarketingOrderHelper.cs b/src/Oms.Application/Shared/MarketingOrderHelper.cs
index f06a035..ce60a9b 100644
--- a/src/Oms.Application/Shared/MarketingOrderHelper.cs
+++ b/src/Oms.Application/Shared/MarketingOrderHelper.cs
@@ -53,15 +53,19 @@ public class MarketingOrderHelper : IScopedDependency
baseFreightAmount,
couponInstanceId,
null,
- cancellationToken);
+ cancellationToken: cancellationToken);
}
+ ///
+ /// 计算会员折/券互斥后的应付拆解; 为当前订单时允许使用被该订单锁定的券。
+ ///
public async Task BuildBreakdownAsync(
int? customerId,
decimal receivableSubtotal,
decimal baseFreightAmount,
int? couponInstanceId,
string? destinationCountry,
+ int? ownerOrderId = null,
CancellationToken cancellationToken = default)
{
if (!customerId.HasValue || customerId.Value <= 0)
@@ -87,7 +91,7 @@ public class MarketingOrderHelper : IScopedDependency
var priceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey);
var couponInput = await _couponManager.ResolveCouponDiscountInputAsync(
- couponInstanceId, mainCustomerId, destinationCountry, cancellationToken);
+ couponInstanceId, mainCustomerId, destinationCountry, ownerOrderId, cancellationToken);
return MarketingBillingCalculator.Calculate(
receivableSubtotal,
@@ -114,6 +118,7 @@ public class MarketingOrderHelper : IScopedDependency
int? customerId,
decimal? baseFreightAmount,
string? destinationCountry = null,
+ int? ownerOrderId = null,
CancellationToken cancellationToken = default)
{
if (!customerId.HasValue || customerId.Value <= 0)
@@ -132,6 +137,6 @@ public class MarketingOrderHelper : IScopedDependency
sessionCustomer,
cancellationToken);
return await _couponManager.BuildAvailableCouponsAsync(
- mainCustomer.Id, baseFreightAmount, destinationCountry, cancellationToken);
+ mainCustomer.Id, baseFreightAmount, destinationCountry, ownerOrderId, cancellationToken);
}
}
diff --git a/src/Oms.Application/Shared/OmsSessionHelper.cs b/src/Oms.Application/Shared/OmsSessionHelper.cs
index 3b603fd..c5ff2b4 100644
--- a/src/Oms.Application/Shared/OmsSessionHelper.cs
+++ b/src/Oms.Application/Shared/OmsSessionHelper.cs
@@ -43,6 +43,27 @@ public static class OmsSessionHelper
return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase);
}
+ ///
+ /// 解析当前会话的 RBAC mapId(user.userid)。
+ /// JWT nameidentifier 多为 Guid,GetId<int> 常为 0,需回退读 mapId claim。
+ ///
+ public static int ResolveMapUserId(ICurrentUser currentUser)
+ {
+ var userId = currentUser.GetId();
+ if (userId > 0)
+ {
+ return userId;
+ }
+
+ var mapIdRaw = currentUser.FindClaimOrNull(MapIdClaim)?.Value;
+ if (int.TryParse(mapIdRaw, out var mapId) && mapId > 0)
+ {
+ return mapId;
+ }
+
+ return 0;
+ }
+
///
/// 客户登录时返回 mapId(对应 t_customer.UserId / 订单 CreateUserId);管理员返回 null。
///
@@ -53,20 +74,8 @@ public static class OmsSessionHelper
return null;
}
- var userId = currentUser.GetId();
- if (userId > 0)
- {
- return userId;
- }
-
- // GetId 未绑到 mapId 时,直接读 claim,避免客户会话被当成管理员(不过滤)
- var mapIdRaw = currentUser.FindClaimOrNull(MapIdClaim)?.Value;
- if (int.TryParse(mapIdRaw, out var mapId) && mapId > 0)
- {
- return mapId;
- }
-
- return null;
+ var mapId = ResolveMapUserId(currentUser);
+ return mapId > 0 ? mapId : null;
}
///
diff --git a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs
index 9908371..501a2a9 100644
--- a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs
+++ b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs
@@ -201,6 +201,9 @@ public static class OrderDownstreamCreateOneBodyBuilder
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode ?? x.HsCode,
DeclareElement = x.DeclareElement,
+ ProductId = x.ProductId,
+ NonStdProductId = x.NonStdProductId,
+ StdProductId = x.StdProductId,
SkuImage = fileStorage?.GetPublicUrl(x.SkuImage) ?? x.SkuImage,
Category = x.Category,
Brand = x.Brand,
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs
index 0cb6040..8039945 100644
--- a/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs
+++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs
@@ -6,6 +6,23 @@ public static class OrderFeeTypeCodes
public const string Payable = "payable";
}
+///
+/// 营销抵扣费用项(支付时写入的负数应收行,fee_item 字典同 code)。
+///
+public static class OrderFeeDiscountCostItems
+{
+ /// 优惠券抵扣。
+ public const string CouponDiscount = "coupon_discount";
+
+ /// 会员折扣。
+ public const string MemberDiscount = "member_discount";
+
+ public static bool IsDiscount(string? costItem) =>
+ !string.IsNullOrWhiteSpace(costItem)
+ && (string.Equals(costItem.Trim(), CouponDiscount, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(costItem.Trim(), MemberDiscount, StringComparison.OrdinalIgnoreCase));
+}
+
public static class OrderFeeChargingStageCodes
{
public const string Forecast = "forecast";
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs
index ab158c3..39544ca 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs
@@ -37,6 +37,21 @@ public class OrderDeclaration : OmsAuditedEntity
///
public string? DeclareElement { get; set; }
+ ///
+ /// 产品标识编码。
+ ///
+ public string? ProductId { get; set; }
+
+ ///
+ /// 非标产品标识编码。
+ ///
+ public string? NonStdProductId { get; set; }
+
+ ///
+ /// 标准产品标识编码。
+ ///
+ public string? StdProductId { get; set; }
+
public string? SkuImage { get; set; }
public string? Category { get; set; }
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrder.cs b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrder.cs
index a297d48..263d4f7 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrder.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrder.cs
@@ -56,9 +56,11 @@ public class SalesStoreOrder : OmsAuditedAggregateRoot
public string? ShippingLabelUrl { get; set; }
- public bool IsMultiLabel { get; set; }
+ /// 平台物流商,如 TikTok shipping_provider。
+ public string? ShippingProvider { get; set; }
- public string? CustomTags { get; set; }
+ /// 平台履约类型,如 TikTok fulfillment_type。
+ public string? FulfillmentType { get; set; }
public bool IsSuspended { get; set; }
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrderLine.cs b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrderLine.cs
index dab91b5..58a657a 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrderLine.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrderLine.cs
@@ -24,7 +24,5 @@ public class SalesStoreOrderLine : OmsAuditedAggregateRoot
public string? Currency { get; set; }
- public bool IsShipExcluded { get; set; }
-
public int? MappedSkuId { get; set; }
}
diff --git a/src/Oms.Domain/SystemAggregate/Entities/Notice.cs b/src/Oms.Domain/SystemAggregate/Entities/Notice.cs
index e8fb134..3b2afa6 100644
--- a/src/Oms.Domain/SystemAggregate/Entities/Notice.cs
+++ b/src/Oms.Domain/SystemAggregate/Entities/Notice.cs
@@ -16,5 +16,9 @@ public class Notice : OmsAuditedAggregateRoot
public string ReceiverType { get; set; } = string.Empty;
+ ///
+ /// 接收人 mapId 列表 JSON 数组字符串,如 [] / [1,2]。用 text 存储,避免 MySQL JSON 列与字符串比较失败导致广播不可见。
+ ///
+ [SugarColumn(ColumnDataType = "text")]
public string ReceiverIds { get; set; } = "[]";
}
diff --git a/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs
index 3ca1add..8f5367e 100644
--- a/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs
+++ b/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs
@@ -6,4 +6,9 @@ namespace Oms.Domain.SystemAggregate.Repositories;
public interface ICountryRepository : IRepository
{
Task GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default);
+
+ ///
+ /// 按二字码查询,包含已软删除记录,用于恢复同码数据。
+ ///
+ Task GetByCountryCodeIncludingDeletedAsync(string countryCode, CancellationToken cancellationToken = default);
}
diff --git a/src/Oms.SqlSugarCore/System/CountryRepository.cs b/src/Oms.SqlSugarCore/System/CountryRepository.cs
index 988c6ac..8a7c2f7 100644
--- a/src/Oms.SqlSugarCore/System/CountryRepository.cs
+++ b/src/Oms.SqlSugarCore/System/CountryRepository.cs
@@ -11,4 +11,15 @@ public class CountryRepository : OmsSqlSugarRepository, ICountryReposit
{
return FirstOrDefaultAsync(x => x.CountryCode == countryCode, cancellationToken);
}
+
+ public async Task GetByCountryCodeIncludingDeletedAsync(
+ string countryCode,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.CountryCode == countryCode,
+ cancellationToken);
+ }
}
diff --git a/src/Oms.Tests/Messaging/OrderBatchJobOutboxPublisherTests.cs b/src/Oms.Tests/Messaging/OrderBatchJobOutboxPublisherTests.cs
index da907e3..eeac041 100644
--- a/src/Oms.Tests/Messaging/OrderBatchJobOutboxPublisherTests.cs
+++ b/src/Oms.Tests/Messaging/OrderBatchJobOutboxPublisherTests.cs
@@ -6,8 +6,6 @@ using Fake.Testing;
using Fake.UnitOfWork;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging.Abstractions;
-using Microsoft.Extensions.Options;
using Oms.Application.Messaging;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Tests.Messaging.Fakes;
@@ -18,8 +16,8 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools _dbProvider;
- private readonly OrderBatchJobPublisher _publisher;
protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options)
{
@@ -29,13 +27,9 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools();
+ _eventBus = ServiceProvider.GetRequiredService();
_physicalBus = ServiceProvider.GetRequiredService();
_dbProvider = ServiceProvider.GetRequiredService>();
- _publisher = new OrderBatchJobPublisher(
- ServiceProvider.GetRequiredService(),
- _uowManager,
- Options.Create(new OrderBatchRabbitMqOptions { Enabled = true }),
- NullLogger.Instance);
}
[Fact]
@@ -56,7 +50,7 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools(),
- _uowManager,
- Options.Create(new OrderBatchRabbitMqOptions { Enabled = false }),
- NullLogger.Instance);
-
- var act = () => disabled.PublishAsync(new OrderBatchJobMessage
- {
- Action = OrderBatchActionCodes.ForecastBilling,
- OrderId = 1,
- MessageId = "x"
- });
-
- await act.Should().ThrowAsync()
- .WithMessage("*disabled*");
- }
}
diff --git a/src/Oms.Tests/Messaging/OrderParcelCreateOutboxPublisherTests.cs b/src/Oms.Tests/Messaging/OrderParcelCreateOutboxPublisherTests.cs
index f4d036e..6cda93d 100644
--- a/src/Oms.Tests/Messaging/OrderParcelCreateOutboxPublisherTests.cs
+++ b/src/Oms.Tests/Messaging/OrderParcelCreateOutboxPublisherTests.cs
@@ -6,7 +6,7 @@ using Fake.Testing;
using Fake.UnitOfWork;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging.Abstractions;
+using Newtonsoft.Json;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Messaging;
using Oms.Application.Messaging.ParcelCreate;
@@ -22,7 +22,6 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools _dbProvider;
- private readonly OrderParcelCreateEventPublisher _publisher;
protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options)
{
@@ -36,11 +35,6 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools();
_outbox = ServiceProvider.GetRequiredService();
_dbProvider = ServiceProvider.GetRequiredService>();
- _publisher = new OrderParcelCreateEventPublisher(
- _distributedEventBus,
- _uowManager,
- ServiceProvider.GetRequiredService(),
- NullLogger.Instance);
}
[Fact]
@@ -58,7 +52,11 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools _distributedEventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
+ {
+ OrderId = 404,
+ PlatformCode = "shopee"
+ });
- _physicalBus.Published.Should().ContainSingle()
- .Which.Should().BeOfType()
- .Which.OrderId.Should().Be(404);
+ await act.Should().ThrowAsync();
}
}
diff --git a/src/Oms.Tests/Messaging/OrderSideEffectOutboxPublisherTests.cs b/src/Oms.Tests/Messaging/OrderSideEffectOutboxPublisherTests.cs
index 8f3abf8..84fd172 100644
--- a/src/Oms.Tests/Messaging/OrderSideEffectOutboxPublisherTests.cs
+++ b/src/Oms.Tests/Messaging/OrderSideEffectOutboxPublisherTests.cs
@@ -6,7 +6,6 @@ using Fake.Testing;
using Fake.UnitOfWork;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging.Abstractions;
using Oms.Application.Messaging;
using Oms.Application.Messaging.OrderSideEffects;
using Oms.Application.Services.Orders.Issues;
@@ -20,7 +19,6 @@ public class OrderSideEffectOutboxPublisherTests : ApplicationTestWithTools _dbProvider;
- private readonly OrderSideEffectEventPublisher _publisher;
protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options)
{
@@ -33,10 +31,6 @@ public class OrderSideEffectOutboxPublisherTests : ApplicationTestWithTools();
_physicalBus = ServiceProvider.GetRequiredService();
_dbProvider = ServiceProvider.GetRequiredService>();
- _publisher = new OrderSideEffectEventPublisher(
- _distributedEventBus,
- _uowManager,
- NullLogger.Instance);
}
[Fact]
@@ -47,8 +41,18 @@ public class OrderSideEffectOutboxPublisherTests : ApplicationTestWithTools
-
-
-
-
-
-
-
diff --git a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
index c48e471..02dd698 100644
--- a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
+++ b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
@@ -1,7 +1,7 @@
using FluentAssertions;
using Fake.Application;
+using Fake.EventBus.Distributed;
using Fake.ObjectStorage;
-using Fake.UnitOfWork;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
@@ -23,13 +23,12 @@ public class OrderBatchJobCoordinatorGovernanceTests
private readonly Mock _countryRepository = new(MockBehavior.Loose);
private readonly Mock _serviceChannelRepository = new(MockBehavior.Loose);
private readonly Mock _fileStorage = new(MockBehavior.Loose);
- private readonly Mock _publisher = new(MockBehavior.Strict);
+ private readonly Mock _eventBus = new(MockBehavior.Strict);
private readonly OrderBatchJobCoordinator _coordinator;
public OrderBatchJobCoordinatorGovernanceTests()
{
- _publisher.SetupGet(x => x.IsEnabled).Returns(true);
- var batchOptions = Options.Create(new OrderBatchRabbitMqOptions());
+ var batchOptions = Options.Create(new OrderBatchRabbitMqOptions { Enabled = true });
var actionExecutor = new Mock(MockBehavior.Loose);
var processor = new OrderBatchJobProcessor(
_batchJobRepository.Object,
@@ -37,17 +36,14 @@ public class OrderBatchJobCoordinatorGovernanceTests
actionExecutor.Object,
batchOptions,
NullLogger.Instance);
- var unitOfWorkManager = new Mock(MockBehavior.Loose);
- unitOfWorkManager.SetupGet(x => x.Current).Returns((IUnitOfWork?)null);
_coordinator = new OrderBatchJobCoordinator(
_batchJobRepository.Object,
_orderRepository.Object,
_countryRepository.Object,
_serviceChannelRepository.Object,
_fileStorage.Object,
- _publisher.Object,
+ _eventBus.Object,
processor,
- unitOfWorkManager.Object,
batchOptions,
NullLogger.Instance);
}
@@ -71,8 +67,8 @@ public class OrderBatchJobCoordinatorGovernanceTests
_batchJobRepository
.Setup(x => x.UpdateJobAsync(job, It.IsAny()))
.Returns(Task.CompletedTask);
- _publisher
- .Setup(x => x.PublishAsync(It.IsAny(), It.IsAny()))
+ _eventBus
+ .Setup(x => x.PublishAsync(It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
var output = await _coordinator.RetryFailedAsync(job.Id, null, 99);
@@ -83,9 +79,9 @@ public class OrderBatchJobCoordinatorGovernanceTests
failedItem.RetryCount.Should().Be(0);
failedItem.FailReason.Should().BeNull();
job.Status.Should().Be(OrderBatchJobStatusCodes.Running);
- _publisher.Verify(
+ _eventBus.Verify(
x => x.PublishAsync(
- It.Is(m => m.JobItemId == failedItem.Id),
+ It.Is(m => m.JobItemId == failedItem.Id),
It.IsAny()),
Times.Once);
}
diff --git a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj
index 6b854bb..2686b92 100644
--- a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj
+++ b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj
@@ -17,31 +17,12 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+