Compare commits
19 Commits
d6da655dae
...
34d46ed492
| Author | SHA1 | Date | |
|---|---|---|---|
| 34d46ed492 | |||
| e931606a75 | |||
| 314c5ac02f | |||
| d38963055b | |||
| ce75fca721 | |||
| 4418217b01 | |||
| 223d32f62a | |||
| 983dff12c5 | |||
| 7a611f8cf7 | |||
| 9c97a218ec | |||
| a1a7524a0e | |||
| 8f64bbf02d | |||
| 5afd204d36 | |||
| 1540a74d52 | |||
| b6f0d3ac13 | |||
| d72637e472 | |||
| 7a0affe2d4 | |||
| c6e765e6e2 | |||
| a9b15a2b5c |
@ -1,9 +1,9 @@
|
||||
<Project>
|
||||
<Import Project="Fake.Local.EventBus.props" Condition="Exists('Fake.Local.EventBus.props')" />
|
||||
<Import Project="Fake.Local.props" Condition="Exists('Fake.Local.props')" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- All Fake packages -->
|
||||
<!-- 清本地nuget缓存:dotnet nuget locals -c http-cache -->
|
||||
<FakePackageVersion>8.0.5.27</FakePackageVersion>
|
||||
<FakePackageVersion>8.0.5.29</FakePackageVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
26
Directory.Build.targets
Normal file
26
Directory.Build.targets
Normal file
@ -0,0 +1,26 @@
|
||||
<Project>
|
||||
<!--
|
||||
UseLocalFake=true:每个 Fake.* PackageReference 换成本地工程。
|
||||
NuGet 资产全部排除,避免同名 1.0.0.0 程序集盖掉本地输出。
|
||||
-->
|
||||
<ItemGroup Condition="'$(UseLocalFake)' == 'true'">
|
||||
<PackageReference Update="Fake.*" ExcludeAssets="all" PrivateAssets="all" />
|
||||
<_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')" />
|
||||
<!-- 变换会带上 PackageReference 的 ExcludeAssets/PrivateAssets,必须清掉,否则传递引用进不了编译。 -->
|
||||
<ProjectReference Include="@(_LocalFakeCandidate)">
|
||||
<ExcludeAssets></ExcludeAssets>
|
||||
<PrivateAssets></PrivateAssets>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="DropMissingLocalFakeProjects"
|
||||
BeforeTargets="AssignProjectConfiguration"
|
||||
Condition="'$(UseLocalFake)' == 'true'">
|
||||
<ItemGroup>
|
||||
<_MissingLocalFake Include="@(_LocalFakeCandidate)" Condition="!Exists('%(Identity)')" />
|
||||
<ProjectReference Remove="@(_MissingLocalFake)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
@ -1,13 +0,0 @@
|
||||
<Project>
|
||||
<!--
|
||||
本地调试 Fake 框架模块:不发包,直接工程引用。
|
||||
切回 NuGet:UseLocalFakeEventBus / UseLocalFakeModules = false
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<FakeRepoRoot Condition="'$(FakeRepoRoot)' == ''">C:\Users\BSI\RiderProjects\fake2</FakeRepoRoot>
|
||||
<!-- EventBus / Dashboard / IntegrationEventLog -->
|
||||
<UseLocalFakeEventBus Condition="'$(UseLocalFakeEventBus)' == ''">false</UseLocalFakeEventBus>
|
||||
<!-- RBAC / FileManagement(表前缀 fk_ 等框架改动需同源) -->
|
||||
<UseLocalFakeModules Condition="'$(UseLocalFakeModules)' == ''">false</UseLocalFakeModules>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
10
Fake.Local.props
Normal file
10
Fake.Local.props
Normal file
@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<!--
|
||||
UseLocalFake=true:所有 Fake.* 包改为引用 $(FakeRepoRoot) 下的本地工程。
|
||||
UseLocalFake=false:全部走 NuGet(FakePackageVersion)。
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<FakeRepoRoot Condition="'$(FakeRepoRoot)' == ''">C:\Users\BSI\RiderProjects\fake2</FakeRepoRoot>
|
||||
<UseLocalFake Condition="'$(UseLocalFake)' == ''">false</UseLocalFake>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@ -158,6 +158,8 @@ public async Task<PlatformStoreOrderBatchCreateLogisticsOutput> BatchCreateFulfi
|
||||
|
||||
存量 DDL、字典初始化等脚本放在 `sql/`(说明见 `sql/README.md`),与运行时 ORM 查询分离;**运行时查询与写入必须 ORM**。
|
||||
|
||||
新增增量迁移文件命名:`migrate_<主题>_YYYYMMDD.sql`(日期在末尾,例如 `migrate_xxx_20261016.sql`)。详细约定见 `sql/README.md`。
|
||||
|
||||
### 3. 上传代码前先同步远端
|
||||
|
||||
`test`、`master` 等共享分支会有其他人提交。**推送前必须先拉取远端,再上传**,避免覆盖或漏掉别人的提交。
|
||||
|
||||
@ -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}"
|
||||
|
||||
5
db.sql
5
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 '创建人',
|
||||
|
||||
@ -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`)
|
||||
- 尽量可重复执行(先判断列/表/字典是否已存在)
|
||||
- **后续新增脚本一律带日期后缀**;历史未带日期的脚本不强制改名
|
||||
|
||||
34
sql/migrate_fee_item_marketing_discount_20261019.sql
Normal file
34
sql/migrate_fee_item_marketing_discount_20261019.sql
Normal file
@ -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'
|
||||
);
|
||||
25
sql/migrate_notice_receiver_ids_text.sql
Normal file
25
sql/migrate_notice_receiver_ids_text.sql
Normal file
@ -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;
|
||||
55
sql/migrate_order_declaration_product_ids_20261019.sql
Normal file
55
sql/migrate_order_declaration_product_ids_20261019.sql
Normal file
@ -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;
|
||||
@ -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;
|
||||
@ -10,12 +10,32 @@ public class MonitorOverviewOutput
|
||||
public MonitorCustomerStatsOutput Customer { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 近 7 日新增订单趋势。
|
||||
/// 经营指标(订单量/营收/毛利)按日周月维度。
|
||||
/// </summary>
|
||||
public MonitorOpsStatsOutput Ops { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 实时积压队列(不随维度变化)。
|
||||
/// </summary>
|
||||
public MonitorQueueStatsOutput Queue { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 订单趋势(随 dimension 变化;兼容字段名 OrderTrend7d 在 day 时等同本字段)。
|
||||
/// </summary>
|
||||
public List<MonitorTrendPointOutput> OrderTrend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 7 日新增订单趋势(兼容旧前端;dimension=day 时与 OrderTrend 相同)。
|
||||
/// </summary>
|
||||
public List<MonitorTrendPointOutput> OrderTrend7d { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 7 日营收/毛利趋势。
|
||||
/// 营收/毛利趋势(随 dimension 变化)。
|
||||
/// </summary>
|
||||
public List<MonitorRevenueTrendPointOutput> RevenueTrend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 7 日营收/毛利趋势(兼容旧前端;dimension=day 时与 RevenueTrend 相同)。
|
||||
/// </summary>
|
||||
public List<MonitorRevenueTrendPointOutput> RevenueTrend7d { get; set; } = [];
|
||||
|
||||
@ -40,6 +60,47 @@ public class MonitorOverviewOutput
|
||||
public MonitorTrackRatesOutput TrackRates { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营 KPI:订单量 / 营收 / 毛利(随日周月)。
|
||||
/// </summary>
|
||||
public class MonitorOpsStatsOutput
|
||||
{
|
||||
/// <summary>day / week / month</summary>
|
||||
public string Dimension { get; set; } = "day";
|
||||
|
||||
/// <summary>当前周期新建订单数。</summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>订单量环比增长百分比。</summary>
|
||||
public decimal? OrderCountGrowthPercent { get; set; }
|
||||
|
||||
/// <summary>当前周期应收(已付)营收。</summary>
|
||||
public decimal Revenue { get; set; }
|
||||
|
||||
/// <summary>营收环比增长百分比。</summary>
|
||||
public decimal? RevenueGrowthPercent { get; set; }
|
||||
|
||||
/// <summary>当前周期毛利 = 应收 - 应付。</summary>
|
||||
public decimal GrossProfit { get; set; }
|
||||
|
||||
/// <summary>毛利环比增长百分比。</summary>
|
||||
public decimal? GrossProfitGrowthPercent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 实时积压快照(不受日周月影响)。
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
@ -319,3 +319,68 @@ public class Customs9610DeclareBatchOutput
|
||||
|
||||
public List<Customs9610DeclareResult> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从已有运单批量创建 9610 汇总申报单入参。
|
||||
/// </summary>
|
||||
public class Customs9610CreateFromOrdersInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 运单 Id 列表(t_order.Id)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public List<int> OrderIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 出口资质备案 Id;为空时按各运单客户的默认备案。
|
||||
/// </summary>
|
||||
public int? ExportQualificationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 电商平台编码(e-commerce_platform 字典),最长 16。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(16)]
|
||||
public string PlatformCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单个运单创建 9610 申报单结果。
|
||||
/// </summary>
|
||||
public class Customs9610CreateFromOrderResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 运单 Id。
|
||||
/// </summary>
|
||||
public int OrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 运单号。
|
||||
/// </summary>
|
||||
public string? ServiceNo { get; set; }
|
||||
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败原因;成功为空。
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 生成的 9610 申报单 Id。
|
||||
/// </summary>
|
||||
public int? Customs9610OrderId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量创建 9610 申报单结果。
|
||||
/// </summary>
|
||||
public class Customs9610CreateFromOrdersOutput
|
||||
{
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
public int FailCount { get; set; }
|
||||
|
||||
public List<Customs9610CreateFromOrderResult> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
@ -20,6 +20,11 @@ public class OrderFeeOutput
|
||||
/// </summary>
|
||||
public string CostItem { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 费用项显示名:仅营销抵扣行(优惠券抵扣/会员折扣)由后端给出,其余为空由前端按 fee_item 字典解析。
|
||||
/// </summary>
|
||||
public string? CostItemName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
|
||||
@ -70,6 +70,21 @@ public class OrderItemOutput
|
||||
/// </summary>
|
||||
public string? DeclareElement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 产品标识编码。
|
||||
/// </summary>
|
||||
public string? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 非标产品标识编码。
|
||||
/// </summary>
|
||||
public string? NonStdProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标准产品标识编码。
|
||||
/// </summary>
|
||||
public string? StdProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU 图片。
|
||||
/// </summary>
|
||||
|
||||
@ -368,6 +368,31 @@ public class OrderOutput
|
||||
/// </summary>
|
||||
public decimal? ForecastPayableAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营销快照:折前应收小计。
|
||||
/// </summary>
|
||||
public decimal? ReceivableSubtotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营销快照:会员折扣金额(与券互斥)。
|
||||
/// </summary>
|
||||
public decimal LevelDiscountAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营销快照:优惠券抵扣金额。
|
||||
/// </summary>
|
||||
public decimal CouponDiscountAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营销快照:客户实付金额(支付扣款口径;区别于应付成本)。
|
||||
/// </summary>
|
||||
public decimal? ActualPayAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营销快照:使用的优惠券实例 Id。
|
||||
/// </summary>
|
||||
public int? CouponInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单绑定的销售产品规则发布版本。
|
||||
/// </summary>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
@ -65,6 +67,24 @@ public class OrderParcelItemInput
|
||||
/// </summary>
|
||||
public string? DeclareElement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 产品标识编码(选填)。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 非标产品标识编码(选填)。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? NonStdProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标准产品标识编码(选填)。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? StdProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU 图片。
|
||||
/// </summary>
|
||||
|
||||
81
src/Oms.Application/Dtos/Orders/OrderPayDtos.cs
Normal file
81
src/Oms.Application/Dtos/Orders/OrderPayDtos.cs
Normal file
@ -0,0 +1,81 @@
|
||||
using Oms.Application.Dtos.Marketing;
|
||||
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 订单支付入参(支付时选券)。
|
||||
/// </summary>
|
||||
public class OrderPayInput : OrderActionInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 选用的优惠券实例 Id:null 沿用订单已绑定的券(不可用则不使用);0 不使用券;大于 0 使用指定券。
|
||||
/// </summary>
|
||||
public int? CouponInstanceId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订单支付预览入参。
|
||||
/// </summary>
|
||||
public class OrderPayPreviewInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单主键。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 试算用券:null 自动推荐(优先已绑定券,否则选优惠最大且优于会员折的券);0 不使用券;大于 0 使用指定券。
|
||||
/// </summary>
|
||||
public int? CouponInstanceId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订单支付预览:应付拆解与可选券。
|
||||
/// </summary>
|
||||
public class OrderPayPreviewOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单主键。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 应收小计(折前)。
|
||||
/// </summary>
|
||||
public decimal ReceivableSubtotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 基础运费(券门槛与抵扣口径)。
|
||||
/// </summary>
|
||||
public decimal BaseFreightAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员折扣金额(与券互斥,用券时为 0)。
|
||||
/// </summary>
|
||||
public decimal LevelDiscountAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券抵扣金额。
|
||||
/// </summary>
|
||||
public decimal CouponDiscountAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实际应付金额(支付扣款金额)。
|
||||
/// </summary>
|
||||
public decimal PayableAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级名称。
|
||||
/// </summary>
|
||||
public string? MemberLevelName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次试算实际生效的券实例 Id;为空表示未用券。
|
||||
/// </summary>
|
||||
public int? AppliedCouponInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前客户可选券(CanUse=false 时附不可用原因,含「优惠不高于会员折扣」)。
|
||||
/// </summary>
|
||||
public List<CouponAvailableOutput> AvailableCoupons { get; set; } = [];
|
||||
}
|
||||
@ -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; }
|
||||
/// <summary>平台物流商。</summary>
|
||||
public string? ShippingProvider { get; set; }
|
||||
|
||||
public string? CustomTags { get; set; }
|
||||
/// <summary>平台履约类型。</summary>
|
||||
public string? FulfillmentType { get; set; }
|
||||
|
||||
public bool IsSuspended { get; set; }
|
||||
|
||||
|
||||
25
src/Oms.Application/Dtos/System/CountryImportInput.cs
Normal file
25
src/Oms.Application/Dtos/System/CountryImportInput.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 国家/地区 Excel 导入入参。
|
||||
/// </summary>
|
||||
public class CountryImportInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 导入模式:incremental=按二字码新增或更新;full=按二字码新增或更新,并软删除文件中未出现的国家。
|
||||
/// </summary>
|
||||
public string ImportMode { get; set; } = ImportModes.Incremental;
|
||||
|
||||
/// <summary>
|
||||
/// Excel 文件。
|
||||
/// </summary>
|
||||
public IFormFile File { get; set; } = null!;
|
||||
|
||||
public static class ImportModes
|
||||
{
|
||||
public const string Incremental = "incremental";
|
||||
public const string Full = "full";
|
||||
}
|
||||
}
|
||||
48
src/Oms.Application/Dtos/System/CountryImportOutput.cs
Normal file
48
src/Oms.Application/Dtos/System/CountryImportOutput.cs
Normal file
@ -0,0 +1,48 @@
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 国家/地区 Excel 导入结果。
|
||||
/// </summary>
|
||||
public class CountryImportOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 成功写入条数。
|
||||
/// </summary>
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 跳过条数。
|
||||
/// </summary>
|
||||
public int SkipCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败条数。
|
||||
/// </summary>
|
||||
public int FailCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 全量导入时软删除的条数。
|
||||
/// </summary>
|
||||
public int DeletedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败行明细。
|
||||
/// </summary>
|
||||
public List<CountryImportFailRow> Failures { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入失败行。
|
||||
/// </summary>
|
||||
public class CountryImportFailRow
|
||||
{
|
||||
/// <summary>
|
||||
/// Excel 行号(含表头,从 2 起为数据行)。
|
||||
/// </summary>
|
||||
public int RowNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败原因。
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
@ -24,4 +24,14 @@ public class CountryOutput
|
||||
/// ISO 3166-1 alpha-2 国家二字码。
|
||||
/// </summary>
|
||||
public string CountryCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间;未更新时为空。
|
||||
/// </summary>
|
||||
public DateTime? UpdateTime { get; set; }
|
||||
}
|
||||
|
||||
27
src/Oms.Application/Dtos/System/CreateCountryInput.cs
Normal file
27
src/Oms.Application/Dtos/System/CreateCountryInput.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 新增国家/地区入参。
|
||||
/// </summary>
|
||||
public class CreateCountryInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 国家中文名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ChineseName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 国家英文名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string EnglishName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// ISO 3166-1 alpha-2 国家二字码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string CountryCode { get; set; } = string.Empty;
|
||||
}
|
||||
@ -34,4 +34,9 @@ public class NoticeListInput
|
||||
/// 仅未读(相对当前登录主体)。
|
||||
/// </summary>
|
||||
public bool? OnlyUnread { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 管理端查看全部通知(仅非客户登录生效)。默认按当前主体可见范围过滤。
|
||||
/// </summary>
|
||||
public bool? Manage { get; set; }
|
||||
}
|
||||
|
||||
33
src/Oms.Application/Dtos/System/UpdateCountryInput.cs
Normal file
33
src/Oms.Application/Dtos/System/UpdateCountryInput.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 更新国家/地区入参。
|
||||
/// </summary>
|
||||
public class UpdateCountryInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 主键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 国家中文名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ChineseName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 国家英文名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string EnglishName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// ISO 3166-1 alpha-2 国家二字码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string CountryCode { get; set; } = string.Empty;
|
||||
}
|
||||
@ -20,6 +20,29 @@ internal static class PlatformShopCrypto
|
||||
}
|
||||
|
||||
public static long UnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
/// <summary>
|
||||
/// TikTok Shop 202309 签名,对齐 WMS <c>TiktokApi.makeTiktokSign</c>:
|
||||
/// 查询参数按 key 排序后拼接 key+value,有请求体则原样接在后面,两侧包 appSecret,HMAC-SHA256 十六进制。
|
||||
/// </summary>
|
||||
public static string TiktokSign(string path, IReadOnlyDictionary<string, string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<IOrderParcelCreateEventPublisher>();
|
||||
await publisher.PublishTrackUpsertAsync(order, request, customerId);
|
||||
var eventBus = scope.ServiceProvider.GetRequiredService<IDistributedEventBus>();
|
||||
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<IOrderSideEffectEventPublisher>();
|
||||
await publisher.PublishLifecycleTrackAppendAsync(orderId, orderNo, trackCode, authorization);
|
||||
if (orderId <= 0 || string.IsNullOrWhiteSpace(orderNo) || string.IsNullOrWhiteSpace(trackCode))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var eventBus = scope.ServiceProvider.GetRequiredService<IDistributedEventBus>();
|
||||
await eventBus.PublishAsync(new OrderLifecycleTrackAppendIntegrationEvent
|
||||
{
|
||||
OrderId = orderId,
|
||||
OrderNo = orderNo.Trim(),
|
||||
TrackCode = trackCode.Trim(),
|
||||
Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
namespace Oms.Application.Messaging;
|
||||
|
||||
public interface IOrderBatchJobPublisher
|
||||
{
|
||||
Task PublishAsync(OrderBatchJobMessage message, CancellationToken cancellationToken = default);
|
||||
|
||||
bool IsEnabled { get; }
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Fake.UnitOfWork;
|
||||
|
||||
namespace Oms.Application.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。
|
||||
/// </summary>
|
||||
internal static class OmsAfterCommit
|
||||
{
|
||||
public static Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func<Task> action)
|
||||
{
|
||||
var current = unitOfWorkManager.Current;
|
||||
if (current != null && !current.IsCompleted)
|
||||
{
|
||||
// OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。
|
||||
// 等工作单元释放后再开独立事务。
|
||||
EventHandler<UnitOfWorkEventArgs> handler = null!;
|
||||
handler = (_, _) =>
|
||||
{
|
||||
current.Disposed -= handler;
|
||||
_ = Task.Run(action);
|
||||
};
|
||||
current.Disposed += handler;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return action();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新开独立事务执行一步。失败只记日志,不抛回调用方。
|
||||
/// </summary>
|
||||
public static async Task<bool> RunStepAsync(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger logger,
|
||||
string step,
|
||||
Func<IServiceProvider, Task> work)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var unitOfWorkManager = scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 可靠发布 IntegrationEvent:事务 UoW 内写入 Outbox(由 Fake.EventBus.IntegrationEventLog 投递)。
|
||||
/// SqlSugar 与 EF 默认各持连接;若环境事务已由 SqlSugar 占用,则改用 RequiresNew 短事务只写 Outbox。
|
||||
/// </summary>
|
||||
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<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 业务写库前先打开 Outbox DbContext,尽量让 EF 先占住环境事务连接,供 SqlSugar 征用。
|
||||
/// </summary>
|
||||
public static Task EnsureOutboxDbContextAsync(IUnitOfWorkManager unitOfWorkManager)
|
||||
{
|
||||
var current = unitOfWorkManager.Current
|
||||
?? throw new InvalidOperationException("No ambient unit of work");
|
||||
var dbProvider = current.ServiceProvider
|
||||
.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
using Fake.EventBus.Distributed;
|
||||
using Fake.UnitOfWork;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Oms.Application.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 通过 Fake 分布式事件总线(Outbox)发布订单批量任务。
|
||||
/// </summary>
|
||||
public sealed class OrderBatchJobPublisher(
|
||||
IDistributedEventBus eventBus,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
IOptions<OrderBatchRabbitMqOptions> options,
|
||||
ILogger<OrderBatchJobPublisher> 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);
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
using Oms.Application.Services.Orders.Issues;
|
||||
|
||||
namespace Oms.Application.Messaging.OrderSideEffects;
|
||||
|
||||
/// <summary>
|
||||
/// 订单侧后置副作用发布(生命周期轨迹、自动触发等)。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 订单旁路副作用事件:事务内写 Outbox,Commit 后投递 RabbitMQ。
|
||||
/// </summary>
|
||||
public sealed class OrderSideEffectEventPublisher(
|
||||
IDistributedEventBus eventBus,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
ILogger<OrderSideEffectEventPublisher> 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);
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Integrations.SmallBagTrack;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Messaging.ParcelCreate;
|
||||
|
||||
/// <summary>
|
||||
/// 创单后置副作用:发布到 Fake.EventBus.RabbitMQ;发布失败时回落同步执行。
|
||||
/// </summary>
|
||||
public interface IOrderParcelCreateEventPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// 轨迹订单 + 创建成功轨迹(A0101)合并为一个事件。
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 发布揽收挂接事件。返回 true 表示已入队;false 表示已同步回落执行(或参数不足未执行)。
|
||||
/// </summary>
|
||||
Task<bool> PublishPickupAttachAsync(
|
||||
OrderParcelCreateInput input,
|
||||
int orderId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 同步执行揽收挂接(用于创单 HTTP 需要回写 PickupNo 的场景)。
|
||||
/// </summary>
|
||||
Task<PickupOrderOutput?> ExecutePickupAttachAsync(
|
||||
OrderParcelCreateInput input,
|
||||
int orderId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 创单后置事件发布器:事务内写 Outbox(Fake.EventBus.IntegrationEventLog),Commit 后投递 RabbitMQ。
|
||||
/// 揽收 Publish 失败时由调用方同步 ExecutePickupAttachAsync 回落。
|
||||
/// </summary>
|
||||
public sealed class OrderParcelCreateEventPublisher(
|
||||
IDistributedEventBus eventBus,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<OrderParcelCreateEventPublisher> 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<bool> 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<PickupOrderOutput?> 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<PickupOrderService>();
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Fake.ObjectMapping.AutoMapper" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.ObjectStorage" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.EventBus.RabbitMQ" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.RabbitMQ" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Infrastructure" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.0.2" />
|
||||
<PackageReference Include="FreeRedis" Version="1.5.5" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
@ -30,29 +35,6 @@
|
||||
<PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.15" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- EventBus / RabbitMQ:本地工程引用(调试) / NuGet(发包后) -->
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' == 'true'">
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus\Fake.EventBus.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus.RabbitMQ\Fake.EventBus.RabbitMQ.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus.IntegrationEventLog\Fake.EventBus.IntegrationEventLog.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.RabbitMQ\Fake.RabbitMQ.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' != 'true'">
|
||||
<PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.EventBus.RabbitMQ" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.RabbitMQ" Version="$(FakePackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- RBAC:本地工程(表前缀) / NuGet -->
|
||||
<ItemGroup Condition="'$(UseLocalFakeModules)' == 'true'">
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\rbac\Fake.Rbac.Application\Fake.Rbac.Application.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\rbac\Fake.Rbac.Infrastructure\Fake.Rbac.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseLocalFakeModules)' != 'true'">
|
||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Infrastructure" Version="$(FakePackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Oms.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -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<OrderBatchRabbitMqOptions>(configuration.GetSection(OrderBatchRabbitMqOptions.SectionName));
|
||||
services.AddSingleton<IOrderBatchJobPublisher, OrderBatchJobPublisher>();
|
||||
services.AddSingleton<IOrderBatchQueueInspector, OrderBatchQueueInspector>();
|
||||
services.AddSingleton<IOrderParcelCreateEventPublisher, OrderParcelCreateEventPublisher>();
|
||||
services.AddSingleton<IOrderSideEffectEventPublisher, OrderSideEffectEventPublisher>();
|
||||
// 消费端由 Fake.EventBus.RabbitMQ(RabbitMqEventBus HostedService)托管
|
||||
services.AddHostedService<OrderBatchDlxMonitorHostedService>();
|
||||
return services;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<OrderBatchRabbitMqOptions> batchOptions,
|
||||
ILogger<OrderBatchJobCoordinator> 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<bool> 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;
|
||||
|
||||
@ -41,6 +41,12 @@ public class Customs9610OrderService : ApplicationService
|
||||
private IOptions<ItfxGatewayOptions> ItfxOptions =>
|
||||
LazyServiceProvider.GetRequiredService<IOptions<ItfxGatewayOptions>>();
|
||||
|
||||
private ICustomerExportQualificationRepository ExportQualificationRepository =>
|
||||
LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>();
|
||||
|
||||
private Customs9610OrderWriter Customs9610Writer =>
|
||||
LazyServiceProvider.GetRequiredService<Customs9610OrderWriter>();
|
||||
|
||||
[HttpPost("/order/customs-9610/list")]
|
||||
public async Task<OmsPageResult<Customs9610OrderOutput>> ListAsync([FromBody] Customs9610ListInput input)
|
||||
{
|
||||
@ -209,6 +215,113 @@ public class Customs9610OrderService : ApplicationService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从已有运单批量创建 9610 汇总申报单:运单改为 9610Summary 并绑定出口资质备案,按运单申报明细生成待申报单。
|
||||
/// </summary>
|
||||
/// <param name="input">运单 Id 列表、出口资质备案 Id(空则用各客户默认备案)、电商平台编码。</param>
|
||||
/// <returns>逐单结果;已删除、已有 9610 单、无申报明细、无可用备案的运单记为失败,不影响其它运单。</returns>
|
||||
[HttpPost("/order/customs-9610/create-from-orders")]
|
||||
public async Task<Customs9610CreateFromOrdersOutput> 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<Customs9610CreateFromOrderResult>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取清单数据(调 API 中心上游,标准码暂定),成功后回写本地字段。
|
||||
/// </summary>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<AutoTriggerRuntimeService> 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)
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 规则只看计费产生的费用行,忽略支付写入的营销抵扣负数行。
|
||||
/// </summary>
|
||||
private static IEnumerable<OrderFee> RuleFees(OrderRuleEvaluationContext context) =>
|
||||
context.Fees.Where(f => !OrderFeeDiscountCostItems.IsDiscount(f.CostItem));
|
||||
|
||||
private static object? GetDeclarationValue(OrderDeclaration declaration, string field) =>
|
||||
field switch
|
||||
{
|
||||
|
||||
@ -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<IOrderBatchJobPublisher>();
|
||||
private IDistributedEventBus EventBus =>
|
||||
LazyServiceProvider.GetRequiredService<IDistributedEventBus>();
|
||||
|
||||
private bool BatchQueueEnabled =>
|
||||
LazyServiceProvider.GetRequiredService<IOptions<OrderBatchRabbitMqOptions>>().Value.Enabled;
|
||||
|
||||
/// <summary>
|
||||
/// 批量 Worker 导入单行 Excel 数据(内部方法,勿暴露为 HTTP API)。
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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<int>();
|
||||
|
||||
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,
|
||||
|
||||
@ -12,9 +12,12 @@ public partial class OrderService
|
||||
/// <summary>
|
||||
/// 实时监控页概览(订单/客户统计与趋势)。
|
||||
/// </summary>
|
||||
/// <param name="dimension">经营指标维度:day / week / month,默认 day。</param>
|
||||
[HttpGet("/monitor/overview")]
|
||||
public async Task<MonitorOverviewOutput> GetMonitorOverviewAsync()
|
||||
public async Task<MonitorOverviewOutput> 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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前周期(统计到今天结束)与上一完整同尺度周期。
|
||||
/// </summary>
|
||||
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<List<MonitorTrendPointOutput>> BuildMonitorOrderTrendAsync(
|
||||
OrderListFilter filter,
|
||||
string dimension,
|
||||
int count)
|
||||
{
|
||||
var buckets = BuildTrendBuckets("day", count);
|
||||
var buckets = BuildTrendBuckets(dimension, count);
|
||||
var points = new List<MonitorTrendPointOutput>(buckets.Count);
|
||||
foreach (var bucket in buckets)
|
||||
{
|
||||
@ -135,9 +227,10 @@ public partial class OrderService
|
||||
|
||||
private async Task<List<MonitorRevenueTrendPointOutput>> BuildMonitorRevenueTrendAsync(
|
||||
OrderListFilter filter,
|
||||
string dimension,
|
||||
int count)
|
||||
{
|
||||
var buckets = BuildTrendBuckets("day", count);
|
||||
var buckets = BuildTrendBuckets(dimension, count);
|
||||
var points = new List<MonitorRevenueTrendPointOutput>(buckets.Count);
|
||||
foreach (var bucket in buckets)
|
||||
{
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<int, byte> InboundBillingInFlight = new();
|
||||
|
||||
@ -100,11 +99,15 @@ public partial class OrderService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /order/parcel/create 的实际步骤。调用方已开启事务;这里抛出的异常会回滚订单和已写入的 Outbox。
|
||||
/// </summary>
|
||||
private async Task<OrderParcelCreateOutput> 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<int>() : 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
|
||||
{
|
||||
// 回滚失败不覆盖原始业务错误;半成品订单需运维清理
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建流程中途失败时删除已落库的半成品订单,避免自动变成草稿。
|
||||
/// </summary>
|
||||
private async Task RollbackFailedParcelCreateAsync(Order order)
|
||||
{
|
||||
try
|
||||
{
|
||||
var writer = LazyServiceProvider.GetRequiredService<Customs9610OrderWriter>();
|
||||
// 强制清掉可能已写入的 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<int>() : 0);
|
||||
var authorization = CaptureRequestAuthorization();
|
||||
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>();
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创单事务提交后,9610、预报计费、揽收、面单、自动触发各自提交。
|
||||
/// 轨迹订单等排货成功后再创建。
|
||||
/// </summary>
|
||||
private async Task ScheduleCreateSideEffectsAsync(
|
||||
int orderId,
|
||||
OrderParcelCreateInput input,
|
||||
OrderParcelCreateOutput output,
|
||||
bool includeForecast,
|
||||
int operatorUserId)
|
||||
{
|
||||
var unitOfWorkManager = LazyServiceProvider.GetRequiredService<IUnitOfWorkManager>();
|
||||
var scopeFactory = LazyServiceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>().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<OrderService>().PublishCustomsForOrderAsync(orderId, platformCode));
|
||||
|
||||
var forecastQueued = true;
|
||||
if (includeForecast)
|
||||
{
|
||||
forecastQueued = await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp =>
|
||||
{
|
||||
var service = sp.GetRequiredService<OrderService>();
|
||||
await service.EnqueueForecastBillingByIdAsync(
|
||||
orderId,
|
||||
couponInstanceId,
|
||||
operatorUserId,
|
||||
authorization);
|
||||
});
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp =>
|
||||
sp.GetRequiredService<OrderService>().PublishForecastLabelAsync(orderId, operatorUserId));
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp =>
|
||||
sp.GetRequiredService<OrderService>().PublishForecastAutoTriggerAsync(orderId, operatorUserId));
|
||||
}
|
||||
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp =>
|
||||
sp.GetRequiredService<OrderService>().TryAttachPickupAfterParcelCreateAsync(input, output));
|
||||
|
||||
if (includeForecast && !forecastQueued)
|
||||
{
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp =>
|
||||
sp.GetRequiredService<OrderService>().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<IUnitOfWorkManager>();
|
||||
var scopeFactory = LazyServiceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("Oms.ForecastBilling");
|
||||
await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () =>
|
||||
OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", sp =>
|
||||
sp.GetRequiredService<OrderService>().EnqueueForecastBillingByIdAsync(
|
||||
orderId,
|
||||
couponInstanceId,
|
||||
operatorUserId,
|
||||
authorization)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 草稿重新预报提交后,面单和下单自动触发各自入队。轨迹等排货成功后再创建。
|
||||
/// </summary>
|
||||
private async Task ScheduleForecastSideEffectsAsync(int orderId, int operatorUserId)
|
||||
{
|
||||
var unitOfWorkManager = LazyServiceProvider.GetRequiredService<IUnitOfWorkManager>();
|
||||
var scopeFactory = LazyServiceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("Oms.ForecastBilling");
|
||||
await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () =>
|
||||
{
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp =>
|
||||
sp.GetRequiredService<OrderService>().PublishForecastLabelAsync(orderId, operatorUserId));
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp =>
|
||||
sp.GetRequiredService<OrderService>().PublishForecastAutoTriggerAsync(orderId, operatorUserId));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预报计费事务提交后,再创建轨迹订单(此时已有渠道和 ApiCode)。
|
||||
/// </summary>
|
||||
private async Task ScheduleTrackAfterAllocationAsync(int orderId, string? authorization)
|
||||
{
|
||||
var unitOfWorkManager = LazyServiceProvider.GetRequiredService<IUnitOfWorkManager>();
|
||||
var scopeFactory = LazyServiceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("Oms.ForecastBilling");
|
||||
await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () =>
|
||||
OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp =>
|
||||
sp.GetRequiredService<OrderService>().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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预报计费入队。队列开着写 ForecastBilling Outbox;关着则在当前事务里同步跑同一套计费。
|
||||
/// Authorization 放进任务载荷,供排货成功后转发轨迹网关。
|
||||
/// </summary>
|
||||
private async Task EnqueueForecastBillingAsync(
|
||||
Order order,
|
||||
int? couponInstanceId,
|
||||
int operatorUserId,
|
||||
string? authorization = null)
|
||||
{
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.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<IOrderBatchJobPublisher>();
|
||||
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<IOrderBatchJobPublisher>();
|
||||
if (!publisher.IsEnabled)
|
||||
if (!BatchQueueEnabled)
|
||||
{
|
||||
// 本地未开队列:同步跑异步计费逻辑,仍不向外抛(入库主流程已完成)
|
||||
try
|
||||
@ -3397,12 +3159,6 @@ public partial class OrderService
|
||||
};
|
||||
}
|
||||
|
||||
private IOrderParcelCreateEventPublisher ParcelCreateEventPublisher =>
|
||||
LazyServiceProvider.GetRequiredService<IOrderParcelCreateEventPublisher>();
|
||||
|
||||
private IOrderSideEffectEventPublisher SideEffectEventPublisher =>
|
||||
LazyServiceProvider.GetRequiredService<IOrderSideEffectEventPublisher>();
|
||||
|
||||
/// <summary>
|
||||
/// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。
|
||||
/// </summary>
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -3623,7 +3388,8 @@ public partial class OrderService
|
||||
receivableSubtotal,
|
||||
baseFreightAmount,
|
||||
order.CouponInstanceId,
|
||||
order.ConsigneeCountry);
|
||||
order.ConsigneeCountry,
|
||||
order.Id);
|
||||
marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId);
|
||||
}
|
||||
|
||||
|
||||
212
src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs
Normal file
212
src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs
Normal file
@ -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 = "优惠不高于会员折扣";
|
||||
|
||||
/// <summary>
|
||||
/// 支付预览:按当前应收计算会员折/券互斥后的应付,并返回本单可选券。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:订单 Id;couponInstanceId 为 null 自动推荐,0 不用券,大于 0 试算指定券。</param>
|
||||
/// <returns>应付拆解、实际生效券与可选券列表;指定券不可用(未解锁/过期/线路不符等)时抛业务异常。</returns>
|
||||
[HttpGet("/order/pay-preview")]
|
||||
public async Task<OrderPayPreviewOutput> 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<MarketingOrderHelper>();
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算支付应付:couponInstanceId 为 null 沿用订单已绑定券(不可用则不用券),0 不用券,大于 0 使用指定券。
|
||||
/// </summary>
|
||||
private async Task<MarketingPriceBreakdown> BuildPayBreakdownAsync(
|
||||
Order order,
|
||||
IEnumerable<OrderFee> fees,
|
||||
int? couponInstanceId)
|
||||
{
|
||||
var feeList = fees as IList<OrderFee> ?? fees.ToList();
|
||||
var receivableSubtotal = MarketingFeeHelper.SumReceivable(feeList);
|
||||
var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(feeList);
|
||||
var marketingHelper = LazyServiceProvider.GetRequiredService<MarketingOrderHelper>();
|
||||
|
||||
Task<MarketingPriceBreakdown> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 支付成功后把会员折扣 / 优惠券抵扣写成负数应收行,使订单应收与实付一致;币种与计费阶段跟随基础运费行。
|
||||
/// </summary>
|
||||
private async Task UpsertMarketingDiscountFeesAsync(
|
||||
Order order,
|
||||
IEnumerable<OrderFee> fees,
|
||||
MarketingPriceBreakdown breakdown,
|
||||
string? couponCode)
|
||||
{
|
||||
var discountRows = new List<OrderFee>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抵扣行取入库优先、其次预报的阶段;出库阶段客户侧不展示,排最后。
|
||||
/// </summary>
|
||||
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<CouponAvailableOutput> 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;
|
||||
}
|
||||
}
|
||||
@ -127,12 +127,12 @@ public partial class OrderService : ApplicationService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行订单支付。
|
||||
/// 执行订单支付(余额扣款,支持支付时选券)。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:订单操作参数。</param>
|
||||
/// <returns>无返回体,仅完成订单支付处理。</returns>
|
||||
/// <param name="input">入参:订单 Id;couponInstanceId 为 null 沿用已绑定券,0 不用券,大于 0 使用指定券。</param>
|
||||
/// <returns>无返回体;指定券不可用、余额不足或订单状态不可支付时抛业务异常。</returns>
|
||||
[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<CustomerBalanceMutator>();
|
||||
await balanceMutator.DebitOrderPayAsync(
|
||||
customer,
|
||||
payAmount,
|
||||
order.Id,
|
||||
order.ServiceNo,
|
||||
$"订单支付 {order.ServiceNo}");
|
||||
|
||||
if (order.CouponInstanceId.HasValue)
|
||||
if (payAmount > 0)
|
||||
{
|
||||
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
||||
var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id);
|
||||
await couponManager.MarkUsedAsync(order.CouponInstanceId.Value, mainCustomerId, order.Id);
|
||||
var balanceMutator = LazyServiceProvider.GetRequiredService<CustomerBalanceMutator>();
|
||||
await balanceMutator.DebitOrderPayAsync(
|
||||
customer,
|
||||
payAmount,
|
||||
order.Id,
|
||||
order.ServiceNo,
|
||||
$"订单支付 {order.ServiceNo}");
|
||||
}
|
||||
|
||||
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
||||
if (order.CouponInstanceId is > 0 && order.CouponInstanceId != breakdown.CouponInstanceId)
|
||||
{
|
||||
await couponManager.ReleaseLockAsync(order.CouponInstanceId.Value, order.Id);
|
||||
}
|
||||
|
||||
LazyServiceProvider.GetRequiredService<MarketingOrderHelper>()
|
||||
.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<MarketingPointsManager>();
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -603,34 +619,20 @@ public partial class OrderService : ApplicationService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建订单包裹(PC/H5 确认下单或保存草稿)。
|
||||
/// 创建订单包裹(PC/H5 确认下单或保存草稿)。整段在一个事务里,异常直接抛出并回滚。
|
||||
/// <para>
|
||||
/// 【同步,本接口事务内】RequestId 幂等 → 校验入参/销售产品 → 黑名单 →
|
||||
/// 自动触发「拦截」规则(命中直接失败)→ 生成运单号 → 落库订单与子表。
|
||||
/// 确认下单:同步写偏远标记,状态草稿 +「预报计费/排货处理中」后返回;
|
||||
/// 保存草稿:仅落库,不做预报计费。失败会回滚订单,不落草稿。
|
||||
/// 【本请求内】RequestId 幂等 → 校验 → 黑名单 → 下单前拦截规则 → 落库订单与子表。
|
||||
/// 保存草稿只再写 9610 与上门揽收;确认下单额外标记「预报处理中」,并写入预报计费、面单、自动触发。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 【创单事务提交后各自投递 MQ(互不等待;失败不回滚订单)】
|
||||
/// 1) 9610 订单 Upsert(customs-9610)— 草稿与确认下单都会投;
|
||||
/// 2) 预报计费/排货任务(forecast-billing 批量任务)— 仅确认下单;
|
||||
/// 队列关闭时在后台同步跑同一套计费逻辑;
|
||||
/// 3) 系统面单 PDF(label-generate)— 仅确认下单;
|
||||
/// 4) 下单自动触发(OrderCreate:警告/问题件等,不含拦截)— 仅确认下单;
|
||||
/// 5) 上门揽收挂载(pickup-attach)— 仅上门揽收且地址/时间/仓库齐全时;
|
||||
/// 投递失败则同步回落建揽收单。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 【预报计费消费后】计费重、应收、排货应付、营销快照 → 订单改已预报。
|
||||
/// 排货成功且已有 ChannelCode 时,再投递轨迹订单 + A0101(track-upsert);
|
||||
/// 轨迹网关需要登录 Authorization,创单时写入预报任务 Payload,消费端原样转发;
|
||||
/// 排货失败不阻断预报,也不建轨迹(可页面补做)。
|
||||
/// 【同一事务的 Outbox,Commit 后才投递】9610、预报计费、面单、OrderCreate 自动触发、上门揽收。
|
||||
/// 队列关闭时预报计费在本事务内同步执行。轨迹不在这里发,等预报消费端排货成功后再发。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="input">入参:订单包裹创建参数。SaveAsDraft=true 为仅保存草稿。</param>
|
||||
/// <returns>
|
||||
/// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已排队;
|
||||
/// 上门揽收时 PickupQueued / PickupNo 视投递或同步回落结果而定。
|
||||
/// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已入队;
|
||||
/// 上门揽收入队成功时 PickupQueued=true,失败只写 PickupCreateError,不回滚订单。
|
||||
/// </returns>
|
||||
[HttpPost("/order/parcel/create")]
|
||||
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||
@ -680,6 +682,9 @@ public partial class OrderService : ApplicationService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅上门揽收。地址、期望时间和仓库齐全才入队;发布失败写入返回值,不抛回创单事务。
|
||||
/// </summary>
|
||||
private async Task TryAttachPickupAfterParcelCreateAsync(
|
||||
OrderParcelCreateInput input,
|
||||
OrderParcelCreateOutput output)
|
||||
@ -715,27 +720,20 @@ public partial class OrderService : ApplicationService
|
||||
return;
|
||||
}
|
||||
|
||||
var publisher = LazyServiceProvider.GetRequiredService<IOrderParcelCreateEventPublisher>();
|
||||
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);
|
||||
}
|
||||
|
||||
@ -1075,6 +1075,8 @@ internal static class ParcelBillingCalculator
|
||||
"handling" => "操作费",
|
||||
"proxyPacking" => "代打包费",
|
||||
"insurance" => "保险费",
|
||||
OrderFeeDiscountCostItems.CouponDiscount => "优惠券抵扣",
|
||||
OrderFeeDiscountCostItems.MemberDiscount => "会员折扣",
|
||||
_ => costItem
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -107,7 +107,7 @@ public static class PlatformStoreOrderParcelBuilder
|
||||
IReadOnlyCollection<SalesStoreOrderLine> lines)
|
||||
{
|
||||
var items = new List<OrderParcelItemInput>();
|
||||
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<int, CSku> skuMap)
|
||||
{
|
||||
var items = new List<OrderParcelItemInput>();
|
||||
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<JsonNode?> { 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 TikTok recipient_address.district_info:L0 国家、L1 州省、L3 城市。
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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: "选填");
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
/// <summary>
|
||||
/// 查询国家列表。
|
||||
/// </summary>
|
||||
/// <returns>国家列表,返回全部有效国家主数据。</returns>
|
||||
/// <returns>国家列表,返回全部有效国家主数据,按二字码、英文名排序。</returns>
|
||||
[HttpGet("/system/country/list")]
|
||||
public async Task<List<CountryOutput>> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增国家/地区。二字码已存在时失败;若仅存在同码软删除记录则恢复该记录。
|
||||
/// </summary>
|
||||
/// <param name="input">中文名、英文名、二字码。</param>
|
||||
/// <returns>无返回体,仅完成新增。</returns>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新国家/地区。二字码与其他有效记录冲突时失败。
|
||||
/// </summary>
|
||||
/// <param name="input">主键及中文名、英文名、二字码。</param>
|
||||
/// <returns>无返回体,仅完成更新。</returns>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除国家/地区(软删除)。
|
||||
/// </summary>
|
||||
/// <param name="input">目标主键。</param>
|
||||
/// <returns>无返回体,仅完成删除。</returns>
|
||||
[HttpPost("/system/country/delete")]
|
||||
public async Task DeleteAsync(IdInput input)
|
||||
{
|
||||
var entity = await GetOrThrowAsync(input.Id);
|
||||
await CountryRepository.DeleteAsync(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载国家/地区导入模板。
|
||||
/// </summary>
|
||||
/// <returns>xlsx 文件,列为国家二字码、中文名、英文名。</returns>
|
||||
[HttpGet("/system/country/import-template")]
|
||||
public async Task<FileContentResult> DownloadImportTemplateAsync()
|
||||
{
|
||||
var bytes = await CountryImportHelper.BuildTemplateBytesAsync();
|
||||
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
{
|
||||
FileDownloadName = CountryImportHelper.TemplateFileName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入国家/地区(Excel)。增量按二字码新增或更新;全量还会软删除文件中未出现的国家。
|
||||
/// </summary>
|
||||
/// <param name="input">Excel 文件与导入模式。</param>
|
||||
/// <returns>成功、跳过、失败及全量删除条数。</returns>
|
||||
[HttpPost("/system/country/import")]
|
||||
public async Task<CountryImportOutput> 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<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var seenCodes = new HashSet<string>(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<Country> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,35 +49,41 @@ public class NoticeService : ApplicationService
|
||||
[HttpGet("/system/notice/list")]
|
||||
public async Task<OmsPageResult<NoticeOutput>> GetListAsync([FromQuery] NoticeListInput input)
|
||||
{
|
||||
var receiverUserId = CurrentUser.GetId<int>();
|
||||
var receiverUserId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
|
||||
if (receiverUserId <= 0)
|
||||
{
|
||||
return new OmsPageResult<NoticeOutput> { 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<string, bool> { [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<NoticeOutput>
|
||||
@ -197,7 +201,7 @@ public class NoticeService : ApplicationService
|
||||
var currentReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser)
|
||||
? ReceiverTypeCustomer
|
||||
: ReceiverTypeUser;
|
||||
var currentReceiverId = CurrentUser.GetId<int>();
|
||||
var currentReceiverId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
|
||||
if (currentReceiverId <= 0)
|
||||
{
|
||||
throw new BusinessException("当前登录主体无效");
|
||||
@ -349,33 +353,32 @@ public class NoticeService : ApplicationService
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Expression<Func<Notice, bool>> BuildListPredicate(
|
||||
NoticeListInput input,
|
||||
int receiverUserId,
|
||||
string expectedReceiverType,
|
||||
IReadOnlyCollection<int>? excludeNoticeIds)
|
||||
private static Expression<Func<Notice, bool>> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 空接收人 = 全体广播(员工/客户均可见);否则须类型匹配且 mapId 在名单内。
|
||||
/// </summary>
|
||||
private static bool IsVisibleToReceiver(Notice notice, int receiverUserId, string expectedReceiverType)
|
||||
{
|
||||
var receiverIds = OmsDocHelper.DeserializeJson<List<int>>(notice.ReceiverIds) ?? [];
|
||||
if (receiverIds.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(notice.ReceiverType, expectedReceiverType, StringComparison.OrdinalIgnoreCase)
|
||||
&& receiverIds.Contains(receiverUserId);
|
||||
}
|
||||
|
||||
private async Task<Notice> GetNoticeOrThrowAsync(int id)
|
||||
|
||||
179
src/Oms.Application/Shared/CountryImportHelper.cs
Normal file
179
src/Oms.Application/Shared/CountryImportHelper.cs
Normal file
@ -0,0 +1,179 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using MiniExcelLibs;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 国家/地区 Excel 模板与导入解析。
|
||||
/// </summary>
|
||||
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<string, string> 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<byte[]> BuildTemplateBytesAsync()
|
||||
{
|
||||
var templateRows = new List<Dictionary<string, object?>>
|
||||
{
|
||||
new()
|
||||
{
|
||||
["国家二字码"] = "CN",
|
||||
["中文名"] = "中国",
|
||||
["英文名"] = "China"
|
||||
},
|
||||
new()
|
||||
{
|
||||
["国家二字码"] = "US",
|
||||
["中文名"] = "美国",
|
||||
["英文名"] = "United States"
|
||||
}
|
||||
};
|
||||
|
||||
var sheets = new Dictionary<string, object>
|
||||
{
|
||||
[TemplateSheetName] = templateRows
|
||||
};
|
||||
|
||||
await using var stream = new MemoryStream();
|
||||
await MiniExcel.SaveAsAsync(stream, sheets);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static async Task<List<(int RowNumber, CountryImportRow Row)>> 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<string, object> 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<string, object> rowDict)
|
||||
{
|
||||
var mapped = new Dictionary<string, string?>(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<string, object> rowDict) =>
|
||||
rowDict.Values.All(value => string.IsNullOrWhiteSpace(NormalizeCell(value)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 国家导入行。
|
||||
/// </summary>
|
||||
public class CountryImportRow
|
||||
{
|
||||
public string CountryCode { get; set; } = string.Empty;
|
||||
|
||||
public string ChineseName { get; set; } = string.Empty;
|
||||
|
||||
public string EnglishName { get; set; } = string.Empty;
|
||||
}
|
||||
@ -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)
|
||||
/// <summary>
|
||||
/// 核销券并返回券实例(调用方可取券码写入费用行)。
|
||||
/// </summary>
|
||||
public async Task<CouponInstance> 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)
|
||||
/// <summary>
|
||||
/// 释放券锁定;传 <paramref name="ownerOrderId"/> 时仅释放被该订单锁定的券。
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析券抵扣参数;<paramref name="ownerOrderId"/> 为当前订单时,被该订单锁定的券视为可用。
|
||||
/// </summary>
|
||||
public async Task<CouponDiscountInput?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建可选券列表;传 <paramref name="ownerOrderId"/> 时额外包含被该订单锁定且仍在有效期内的券。
|
||||
/// </summary>
|
||||
public async Task<List<CouponAvailableOutput>> 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("优惠券已被其他订单占用");
|
||||
}
|
||||
|
||||
@ -12,8 +12,13 @@ public static class MarketingFeeHelper
|
||||
public static bool IsBaseFreightCostItem(string? costItem) =>
|
||||
!string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem);
|
||||
|
||||
/// <summary>
|
||||
/// 折前应收小计:排除支付时写入的营销抵扣行,避免重算营销金额时重复扣减。
|
||||
/// </summary>
|
||||
public static decimal SumReceivable(IEnumerable<OrderFee> 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<OrderFee> fees) =>
|
||||
fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem))
|
||||
|
||||
@ -53,15 +53,19 @@ public class MarketingOrderHelper : IScopedDependency
|
||||
baseFreightAmount,
|
||||
couponInstanceId,
|
||||
null,
|
||||
cancellationToken);
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算会员折/券互斥后的应付拆解;<paramref name="ownerOrderId"/> 为当前订单时允许使用被该订单锁定的券。
|
||||
/// </summary>
|
||||
public async Task<MarketingPriceBreakdown> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,6 +43,27 @@ public static class OmsSessionHelper
|
||||
return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析当前会话的 RBAC mapId(user.userid)。
|
||||
/// JWT nameidentifier 多为 Guid,GetId<int> 常为 0,需回退读 mapId claim。
|
||||
/// </summary>
|
||||
public static int ResolveMapUserId(ICurrentUser currentUser)
|
||||
{
|
||||
var userId = currentUser.GetId<int>();
|
||||
if (userId > 0)
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
|
||||
var mapIdRaw = currentUser.FindClaimOrNull(MapIdClaim)?.Value;
|
||||
if (int.TryParse(mapIdRaw, out var mapId) && mapId > 0)
|
||||
{
|
||||
return mapId;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户登录时返回 mapId(对应 t_customer.UserId / 订单 CreateUserId);管理员返回 null。
|
||||
/// </summary>
|
||||
@ -53,20 +74,8 @@ public static class OmsSessionHelper
|
||||
return null;
|
||||
}
|
||||
|
||||
var userId = currentUser.GetId<int>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -6,6 +6,23 @@ public static class OrderFeeTypeCodes
|
||||
public const string Payable = "payable";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 营销抵扣费用项(支付时写入的负数应收行,fee_item 字典同 code)。
|
||||
/// </summary>
|
||||
public static class OrderFeeDiscountCostItems
|
||||
{
|
||||
/// <summary>优惠券抵扣。</summary>
|
||||
public const string CouponDiscount = "coupon_discount";
|
||||
|
||||
/// <summary>会员折扣。</summary>
|
||||
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";
|
||||
|
||||
@ -37,6 +37,21 @@ public class OrderDeclaration : OmsAuditedEntity
|
||||
/// </summary>
|
||||
public string? DeclareElement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 产品标识编码。
|
||||
/// </summary>
|
||||
public string? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 非标产品标识编码。
|
||||
/// </summary>
|
||||
public string? NonStdProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标准产品标识编码。
|
||||
/// </summary>
|
||||
public string? StdProductId { get; set; }
|
||||
|
||||
public string? SkuImage { get; set; }
|
||||
|
||||
public string? Category { get; set; }
|
||||
|
||||
@ -56,9 +56,11 @@ public class SalesStoreOrder : OmsAuditedAggregateRoot
|
||||
|
||||
public string? ShippingLabelUrl { get; set; }
|
||||
|
||||
public bool IsMultiLabel { get; set; }
|
||||
/// <summary>平台物流商,如 TikTok shipping_provider。</summary>
|
||||
public string? ShippingProvider { get; set; }
|
||||
|
||||
public string? CustomTags { get; set; }
|
||||
/// <summary>平台履约类型,如 TikTok fulfillment_type。</summary>
|
||||
public string? FulfillmentType { get; set; }
|
||||
|
||||
public bool IsSuspended { get; set; }
|
||||
|
||||
|
||||
@ -24,7 +24,5 @@ public class SalesStoreOrderLine : OmsAuditedAggregateRoot
|
||||
|
||||
public string? Currency { get; set; }
|
||||
|
||||
public bool IsShipExcluded { get; set; }
|
||||
|
||||
public int? MappedSkuId { get; set; }
|
||||
}
|
||||
|
||||
@ -16,5 +16,9 @@ public class Notice : OmsAuditedAggregateRoot
|
||||
|
||||
public string ReceiverType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收人 mapId 列表 JSON 数组字符串,如 [] / [1,2]。用 text 存储,避免 MySQL JSON 列与字符串比较失败导致广播不可见。
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnDataType = "text")]
|
||||
public string ReceiverIds { get; set; } = "[]";
|
||||
}
|
||||
|
||||
@ -6,4 +6,9 @@ namespace Oms.Domain.SystemAggregate.Repositories;
|
||||
public interface ICountryRepository : IRepository<Country>
|
||||
{
|
||||
Task<Country?> GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按二字码查询,包含已软删除记录,用于恢复同码数据。
|
||||
/// </summary>
|
||||
Task<Country?> GetByCountryCodeIncludingDeletedAsync(string countryCode, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -11,4 +11,15 @@ public class CountryRepository : OmsSqlSugarRepository<Country>, ICountryReposit
|
||||
{
|
||||
return FirstOrDefaultAsync(x => x.CountryCode == countryCode, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Country?> GetByCountryCodeIncludingDeletedAsync(
|
||||
string countryCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await SoftDeleteQuery.FirstIncludingDeletedAsync<Country>(
|
||||
db,
|
||||
x => x.CountryCode == countryCode,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<OmsOut
|
||||
{
|
||||
private readonly IUnitOfWorkManager _uowManager;
|
||||
private readonly RecordingPhysicalDistributedEventBus _physicalBus;
|
||||
private readonly IDistributedEventBus _eventBus;
|
||||
private readonly IEfDbContextProvider<IntegrationEventLogContext> _dbProvider;
|
||||
private readonly OrderBatchJobPublisher _publisher;
|
||||
|
||||
protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options)
|
||||
{
|
||||
@ -29,13 +27,9 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools<OmsOut
|
||||
public OrderBatchJobOutboxPublisherTests()
|
||||
{
|
||||
_uowManager = ServiceProvider.GetRequiredService<IUnitOfWorkManager>();
|
||||
_eventBus = ServiceProvider.GetRequiredService<IDistributedEventBus>();
|
||||
_physicalBus = ServiceProvider.GetRequiredService<RecordingPhysicalDistributedEventBus>();
|
||||
_dbProvider = ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>();
|
||||
_publisher = new OrderBatchJobPublisher(
|
||||
ServiceProvider.GetRequiredService<IDistributedEventBus>(),
|
||||
_uowManager,
|
||||
Options.Create(new OrderBatchRabbitMqOptions { Enabled = true }),
|
||||
NullLogger<OrderBatchJobPublisher>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -56,7 +50,7 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools<OmsOut
|
||||
using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional()))
|
||||
{
|
||||
await _dbProvider.GetDbContextAsync();
|
||||
await _publisher.PublishAsync(message);
|
||||
await _eventBus.PublishAsync(OrderBatchJobIntegrationEvent.FromMessage(message));
|
||||
_physicalBus.Published.Should().BeEmpty();
|
||||
await uow.CompleteAsync();
|
||||
}
|
||||
@ -68,23 +62,4 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools<OmsOut
|
||||
evt.MessageId.Should().Be(message.MessageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 队列未启用时_Publish应抛错()
|
||||
{
|
||||
var disabled = new OrderBatchJobPublisher(
|
||||
ServiceProvider.GetRequiredService<IDistributedEventBus>(),
|
||||
_uowManager,
|
||||
Options.Create(new OrderBatchRabbitMqOptions { Enabled = false }),
|
||||
NullLogger<OrderBatchJobPublisher>.Instance);
|
||||
|
||||
var act = () => disabled.PublishAsync(new OrderBatchJobMessage
|
||||
{
|
||||
Action = OrderBatchActionCodes.ForecastBilling,
|
||||
OrderId = 1,
|
||||
MessageId = "x"
|
||||
});
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*disabled*");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Om
|
||||
private readonly RecordingPhysicalDistributedEventBus _physicalBus;
|
||||
private readonly IOutboxEventLogService _outbox;
|
||||
private readonly IEfDbContextProvider<IntegrationEventLogContext> _dbProvider;
|
||||
private readonly OrderParcelCreateEventPublisher _publisher;
|
||||
|
||||
protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options)
|
||||
{
|
||||
@ -36,11 +35,6 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools<Om
|
||||
_physicalBus = ServiceProvider.GetRequiredService<RecordingPhysicalDistributedEventBus>();
|
||||
_outbox = ServiceProvider.GetRequiredService<IOutboxEventLogService>();
|
||||
_dbProvider = ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>();
|
||||
_publisher = new OrderParcelCreateEventPublisher(
|
||||
_distributedEventBus,
|
||||
_uowManager,
|
||||
ServiceProvider.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<OrderParcelCreateEventPublisher>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -58,7 +52,11 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools<Om
|
||||
using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional()))
|
||||
{
|
||||
await _dbProvider.GetDbContextAsync();
|
||||
await _publisher.PublishCustoms9610UpsertAsync(order, "lazada");
|
||||
await _distributedEventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
|
||||
{
|
||||
OrderId = order.Id,
|
||||
PlatformCode = "lazada"
|
||||
});
|
||||
_physicalBus.Published.Should().BeEmpty();
|
||||
await uow.CompleteAsync();
|
||||
}
|
||||
@ -86,8 +84,19 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools<Om
|
||||
using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional()))
|
||||
{
|
||||
await _dbProvider.GetDbContextAsync();
|
||||
await _publisher.PublishTrackUpsertAsync(order, request, customerId: 9);
|
||||
await _publisher.PublishLabelGenerateAsync(order, operatorUserId: 7);
|
||||
await _distributedEventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent
|
||||
{
|
||||
OrderId = order.Id,
|
||||
CustomerId = 9,
|
||||
PayloadJson = JsonConvert.SerializeObject(request),
|
||||
AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess,
|
||||
Authorization = request.Authorization
|
||||
});
|
||||
await _distributedEventBus.PublishAsync(new OrderParcelLabelGenerateIntegrationEvent
|
||||
{
|
||||
OrderId = order.Id,
|
||||
OperatorUserId = 7
|
||||
});
|
||||
_physicalBus.Published.Should().BeEmpty();
|
||||
await uow.CompleteAsync();
|
||||
}
|
||||
@ -108,7 +117,10 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools<Om
|
||||
using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional()))
|
||||
{
|
||||
await _dbProvider.GetDbContextAsync();
|
||||
await _publisher.PublishCustoms9610UpsertAsync(order);
|
||||
await _distributedEventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
|
||||
{
|
||||
OrderId = order.Id
|
||||
});
|
||||
await uow.RollbackAsync();
|
||||
}
|
||||
|
||||
@ -116,16 +128,16 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools<Om
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 无环境事务时_短事务仍能写入并投递()
|
||||
public async Task 无环境事务时_总线应拒绝发布()
|
||||
{
|
||||
_physicalBus.Clear();
|
||||
_uowManager.Current.Should().BeNull();
|
||||
|
||||
var order = new Order { Id = 404, ServiceNo = "SN404" };
|
||||
await _publisher.PublishCustoms9610UpsertAsync(order, "shopee");
|
||||
var act = () => _distributedEventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
|
||||
{
|
||||
OrderId = 404,
|
||||
PlatformCode = "shopee"
|
||||
});
|
||||
|
||||
_physicalBus.Published.Should().ContainSingle()
|
||||
.Which.Should().BeOfType<OrderParcelCustoms9610UpsertIntegrationEvent>()
|
||||
.Which.OrderId.Should().Be(404);
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<OmsO
|
||||
private readonly IDistributedEventBus _distributedEventBus;
|
||||
private readonly RecordingPhysicalDistributedEventBus _physicalBus;
|
||||
private readonly IEfDbContextProvider<IntegrationEventLogContext> _dbProvider;
|
||||
private readonly OrderSideEffectEventPublisher _publisher;
|
||||
|
||||
protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options)
|
||||
{
|
||||
@ -33,10 +31,6 @@ public class OrderSideEffectOutboxPublisherTests : ApplicationTestWithTools<OmsO
|
||||
_distributedEventBus = ServiceProvider.GetRequiredService<IDistributedEventBus>();
|
||||
_physicalBus = ServiceProvider.GetRequiredService<RecordingPhysicalDistributedEventBus>();
|
||||
_dbProvider = ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>();
|
||||
_publisher = new OrderSideEffectEventPublisher(
|
||||
_distributedEventBus,
|
||||
_uowManager,
|
||||
NullLogger<OrderSideEffectEventPublisher>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -47,8 +41,18 @@ public class OrderSideEffectOutboxPublisherTests : ApplicationTestWithTools<OmsO
|
||||
using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional()))
|
||||
{
|
||||
await _dbProvider.GetDbContextAsync();
|
||||
await _publisher.PublishAutoTriggerFireAsync(11, AutoTriggerTiming.OrderCreate, 3);
|
||||
await _publisher.PublishLifecycleTrackAppendAsync(11, "SN11", "A0301");
|
||||
await _distributedEventBus.PublishAsync(new OrderAutoTriggerFireIntegrationEvent
|
||||
{
|
||||
OrderId = 11,
|
||||
Timing = AutoTriggerTiming.OrderCreate.ToString(),
|
||||
OperatorUserId = 3
|
||||
});
|
||||
await _distributedEventBus.PublishAsync(new OrderLifecycleTrackAppendIntegrationEvent
|
||||
{
|
||||
OrderId = 11,
|
||||
OrderNo = "SN11",
|
||||
TrackCode = "A0301"
|
||||
});
|
||||
_physicalBus.Published.Should().BeEmpty();
|
||||
await uow.CompleteAsync();
|
||||
}
|
||||
|
||||
@ -15,13 +15,6 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Fake.Autofac" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Testing" Version="$(FakePackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- EventBus:与 Oms.Application / SimpleAdmin.Api 保持一致(本地工程或 NuGet) -->
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' == 'true'">
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus.IntegrationEventLog\Fake.EventBus.IntegrationEventLog.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' != 'true'">
|
||||
<PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -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<ICountryRepository> _countryRepository = new(MockBehavior.Loose);
|
||||
private readonly Mock<IServiceChannelRepository> _serviceChannelRepository = new(MockBehavior.Loose);
|
||||
private readonly Mock<IObjectStorage> _fileStorage = new(MockBehavior.Loose);
|
||||
private readonly Mock<IOrderBatchJobPublisher> _publisher = new(MockBehavior.Strict);
|
||||
private readonly Mock<IDistributedEventBus> _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<IOrderBatchActionExecutor>(MockBehavior.Loose);
|
||||
var processor = new OrderBatchJobProcessor(
|
||||
_batchJobRepository.Object,
|
||||
@ -37,17 +36,14 @@ public class OrderBatchJobCoordinatorGovernanceTests
|
||||
actionExecutor.Object,
|
||||
batchOptions,
|
||||
NullLogger<OrderBatchJobProcessor>.Instance);
|
||||
var unitOfWorkManager = new Mock<IUnitOfWorkManager>(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<OrderBatchJobCoordinator>.Instance);
|
||||
}
|
||||
@ -71,8 +67,8 @@ public class OrderBatchJobCoordinatorGovernanceTests
|
||||
_batchJobRepository
|
||||
.Setup(x => x.UpdateJobAsync(job, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_publisher
|
||||
.Setup(x => x.PublishAsync(It.IsAny<OrderBatchJobMessage>(), It.IsAny<CancellationToken>()))
|
||||
_eventBus
|
||||
.Setup(x => x.PublishAsync(It.IsAny<OrderBatchJobIntegrationEvent>(), It.IsAny<CancellationToken>()))
|
||||
.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<OrderBatchJobMessage>(m => m.JobItemId == failedItem.Id),
|
||||
It.Is<OrderBatchJobIntegrationEvent>(m => m.JobItemId == failedItem.Id),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@ -17,31 +17,12 @@
|
||||
<PackageReference Include="Fake.Consul" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.ObjectStorage.TencentCos" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Serilog" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- EventBus:本地工程引用(调试) / NuGet(发包后) -->
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' == 'true'">
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus\Fake.EventBus.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus.IntegrationEventLog\Fake.EventBus.IntegrationEventLog.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus.Dashboard\Fake.EventBus.Dashboard.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' != 'true'">
|
||||
<PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.EventBus.Dashboard" Version="$(FakePackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- RBAC / FileManagement:本地工程(表前缀 fk_ 等) / NuGet -->
|
||||
<ItemGroup Condition="'$(UseLocalFakeModules)' == 'true'">
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\rbac\Fake.Rbac.Application\Fake.Rbac.Application.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\rbac\Fake.Rbac.Infrastructure\Fake.Rbac.Infrastructure.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\file-management\Fake.FileManagement.Application\Fake.FileManagement.Application.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\file-management\Fake.FileManagement.Infrastructure\Fake.FileManagement.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseLocalFakeModules)' != 'true'">
|
||||
<PackageReference Include="Fake.FileManagement.Application" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.FileManagement.Infrastructure" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user