1.06 #10

Merged
zzh merged 18 commits from test into master 2026-10-19 17:03:15 +08:00
80 changed files with 3057 additions and 1269 deletions

View File

@ -1,9 +1,9 @@
<Project> <Project>
<Import Project="Fake.Local.EventBus.props" Condition="Exists('Fake.Local.EventBus.props')" /> <Import Project="Fake.Local.props" Condition="Exists('Fake.Local.props')" />
<PropertyGroup> <PropertyGroup>
<!-- All Fake packages --> <!-- All Fake packages -->
<!-- 清本地nuget缓存:dotnet nuget locals -c http-cache --> <!-- 清本地nuget缓存:dotnet nuget locals -c http-cache -->
<FakePackageVersion>8.0.5.27</FakePackageVersion> <FakePackageVersion>8.0.5.29</FakePackageVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

26
Directory.Build.targets Normal file
View 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>

View File

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

View File

@ -158,6 +158,8 @@ public async Task<PlatformStoreOrderBatchCreateLogisticsOutput> BatchCreateFulfi
存量 DDL、字典初始化等脚本放在 `sql/`(说明见 `sql/README.md`),与运行时 ORM 查询分离;**运行时查询与写入必须 ORM**。 存量 DDL、字典初始化等脚本放在 `sql/`(说明见 `sql/README.md`),与运行时 ORM 查询分离;**运行时查询与写入必须 ORM**。
新增增量迁移文件命名:`migrate_<主题>_YYYYMMDD.sql`(日期在末尾,例如 `migrate_xxx_20261016.sql`)。详细约定见 `sql/README.md`。
### 3. 上传代码前先同步远端 ### 3. 上传代码前先同步远端
`test`、`master` 等共享分支会有其他人提交。**推送前必须先拉取远端,再上传**,避免覆盖或漏掉别人的提交。 `test`、`master` 等共享分支会有其他人提交。**推送前必须先拉取远端,再上传**,避免覆盖或漏掉别人的提交。

View File

@ -16,7 +16,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "items", "items", "{BE5132AD
db.sql = db.sql db.sql = db.sql
Directory.Build.props = Directory.Build.props Directory.Build.props = Directory.Build.props
.gitea\workflows\deploy.yml = .gitea\workflows\deploy.yml .gitea\workflows\deploy.yml = .gitea\workflows\deploy.yml
Fake.Local.EventBus.props = Fake.Local.EventBus.props Fake.Local.props = Fake.Local.props
EndProjectSection EndProjectSection
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleAdmin.Api", "src\SimpleAdmin.Api\SimpleAdmin.Api.csproj", "{229ED167-9ECC-4736-8DFB-522EEA8FF680}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleAdmin.Api", "src\SimpleAdmin.Api\SimpleAdmin.Api.csproj", "{229ED167-9ECC-4736-8DFB-522EEA8FF680}"

5
db.sql
View File

@ -432,6 +432,9 @@ CREATE TABLE `t_order_declaration` (
`ImHsCode` VARCHAR(50) NULL COMMENT '进口HsCode', `ImHsCode` VARCHAR(50) NULL COMMENT '进口HsCode',
`ExHsCode` VARCHAR(50) NULL COMMENT '出口HsCode', `ExHsCode` VARCHAR(50) NULL COMMENT '出口HsCode',
`DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素', `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)', `SkuImage` VARCHAR(512) NULL COMMENT '图片(存 storage key 或短 URL)',
`Category` VARCHAR(20) NULL COMMENT '类别', `Category` VARCHAR(20) NULL COMMENT '类别',
`Brand` VARCHAR(50) NULL COMMENT '品牌', `Brand` VARCHAR(50) NULL COMMENT '品牌',
@ -1220,7 +1223,7 @@ CREATE TABLE `t_notice` (
`Content` TEXT NOT NULL COMMENT '通知内容', `Content` TEXT NOT NULL COMMENT '通知内容',
`IsPopup` TINYINT NOT NULL DEFAULT 0 COMMENT '是否弹窗:0-否,1-是', `IsPopup` TINYINT NOT NULL DEFAULT 0 COMMENT '是否弹窗:0-否,1-是',
`ReceiverType` VARCHAR(16) NOT NULL COMMENT '接收对象:user/customer', `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-是', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',

View File

@ -14,4 +14,6 @@ OMS / 共用库的建表脚本已统一到仓库根目录 `db.sql`。
- 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行) - 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行)
- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过) - 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过)
- 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落 - 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落
- 单次增量迁移命名:`migrate_<主题>.sql`,尽量可重复执行(先判断列/表/字典是否已存在) - 单次增量迁移命名:`migrate_<主题>_YYYYMMDD.sql`(日期放在文件名**末尾**,如 `migrate_sales_store_order_provider_fulfillment_20261016.sql`)
- 尽量可重复执行(先判断列/表/字典是否已存在)
- **后续新增脚本一律带日期后缀**;历史未带日期的脚本不强制改名

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

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

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

View File

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

View File

@ -10,12 +10,32 @@ public class MonitorOverviewOutput
public MonitorCustomerStatsOutput Customer { get; set; } = new(); public MonitorCustomerStatsOutput Customer { get; set; } = new();
/// <summary> /// <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> /// </summary>
public List<MonitorTrendPointOutput> OrderTrend7d { get; set; } = []; public List<MonitorTrendPointOutput> OrderTrend7d { get; set; } = [];
/// <summary> /// <summary>
/// 近 7 日营收/毛利趋势。 /// 营收/毛利趋势(随 dimension 变化)。
/// </summary>
public List<MonitorRevenueTrendPointOutput> RevenueTrend { get; set; } = [];
/// <summary>
/// 近 7 日营收/毛利趋势(兼容旧前端;dimension=day 时与 RevenueTrend 相同)。
/// </summary> /// </summary>
public List<MonitorRevenueTrendPointOutput> RevenueTrend7d { get; set; } = []; public List<MonitorRevenueTrendPointOutput> RevenueTrend7d { get; set; } = [];
@ -40,6 +60,47 @@ public class MonitorOverviewOutput
public MonitorTrackRatesOutput TrackRates { get; set; } = new(); 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 class MonitorOrderStatsOutput
{ {
public int TodayCreated { get; set; } public int TodayCreated { get; set; }

View File

@ -319,3 +319,68 @@ public class Customs9610DeclareBatchOutput
public List<Customs9610DeclareResult> Items { get; set; } = []; 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; } = [];
}

View File

@ -20,6 +20,11 @@ public class OrderFeeOutput
/// </summary> /// </summary>
public string CostItem { get; set; } = string.Empty; public string CostItem { get; set; } = string.Empty;
/// <summary>
/// 费用项显示名:仅营销抵扣行(优惠券抵扣/会员折扣)由后端给出,其余为空由前端按 fee_item 字典解析。
/// </summary>
public string? CostItemName { get; set; }
/// <summary> /// <summary>
/// 金额。 /// 金额。
/// </summary> /// </summary>

View File

@ -70,6 +70,21 @@ public class OrderItemOutput
/// </summary> /// </summary>
public string? DeclareElement { get; set; } 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> /// <summary>
/// SKU 图片。 /// SKU 图片。
/// </summary> /// </summary>

View File

@ -368,6 +368,31 @@ public class OrderOutput
/// </summary> /// </summary>
public decimal? ForecastPayableAmount { get; set; } 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>
/// 订单绑定的销售产品规则发布版本。 /// 订单绑定的销售产品规则发布版本。
/// </summary> /// </summary>

View File

@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.Orders; namespace Oms.Application.Dtos.Orders;
/// <summary> /// <summary>
@ -65,6 +67,24 @@ public class OrderParcelItemInput
/// </summary> /// </summary>
public string? DeclareElement { get; set; } 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> /// <summary>
/// SKU 图片。 /// SKU 图片。
/// </summary> /// </summary>

View 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; } = [];
}

View File

@ -180,8 +180,6 @@ public class PlatformStoreOrderLineOutput
public string? Currency { get; set; } public string? Currency { get; set; }
public bool IsShipExcluded { get; set; }
public int? MappedSkuId { get; set; } public int? MappedSkuId { get; set; }
public string? MappedSkuCode { get; set; } public string? MappedSkuCode { get; set; }
@ -245,9 +243,11 @@ public class PlatformStoreOrderOutput
public string? ShippingLabelUrl { get; set; } 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; } public bool IsSuspended { get; set; }

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

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

View File

@ -24,4 +24,14 @@ public class CountryOutput
/// ISO 3166-1 alpha-2 国家二字码。 /// ISO 3166-1 alpha-2 国家二字码。
/// </summary> /// </summary>
public string CountryCode { get; set; } = string.Empty; public string CountryCode { get; set; } = string.Empty;
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 更新时间;未更新时为空。
/// </summary>
public DateTime? UpdateTime { get; set; }
} }

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

View File

@ -34,4 +34,9 @@ public class NoticeListInput
/// 仅未读(相对当前登录主体)。 /// 仅未读(相对当前登录主体)。
/// </summary> /// </summary>
public bool? OnlyUnread { get; set; } public bool? OnlyUnread { get; set; }
/// <summary>
/// 管理端查看全部通知(仅非客户登录生效)。默认按当前主体可见范围过滤。
/// </summary>
public bool? Manage { get; set; }
} }

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

View File

@ -20,6 +20,29 @@ internal static class PlatformShopCrypto
} }
public static long UnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); 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> /// <summary>

View File

@ -1,5 +1,7 @@
using Fake.EventBus.Distributed;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Oms.Application.Messaging.OrderSideEffects; using Oms.Application.Messaging.OrderSideEffects;
using Oms.Application.Messaging.ParcelCreate; using Oms.Application.Messaging.ParcelCreate;
using Oms.Application.Services.Orders.Batch; using Oms.Application.Services.Orders.Batch;
@ -25,8 +27,15 @@ internal sealed class SmallBagTrackOrderNotifier(
return; return;
} }
var publisher = scope.ServiceProvider.GetRequiredService<IOrderParcelCreateEventPublisher>(); var eventBus = scope.ServiceProvider.GetRequiredService<IDistributedEventBus>();
await publisher.PublishTrackUpsertAsync(order, request, customerId); await eventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent
{
OrderId = order.Id,
CustomerId = customerId,
PayloadJson = JsonConvert.SerializeObject(request),
AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess,
Authorization = request.Authorization
});
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -75,8 +84,19 @@ internal sealed class SmallBagTrackOrderNotifier(
try try
{ {
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
var publisher = scope.ServiceProvider.GetRequiredService<IOrderSideEffectEventPublisher>(); if (orderId <= 0 || string.IsNullOrWhiteSpace(orderNo) || string.IsNullOrWhiteSpace(trackCode))
await publisher.PublishLifecycleTrackAppendAsync(orderId, orderNo, trackCode, authorization); {
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) catch (Exception ex)
{ {

View File

@ -1,8 +0,0 @@
namespace Oms.Application.Messaging;
public interface IOrderBatchJobPublisher
{
Task PublishAsync(OrderBatchJobMessage message, CancellationToken cancellationToken = default);
bool IsEnabled { get; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,11 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Fake.ObjectMapping.AutoMapper" Version="$(FakePackageVersion)" /> <PackageReference Include="Fake.ObjectMapping.AutoMapper" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.ObjectStorage" 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="DocumentFormat.OpenXml" Version="3.0.2" />
<PackageReference Include="FreeRedis" Version="1.5.5" /> <PackageReference Include="FreeRedis" Version="1.5.5" />
<PackageReference Include="MailKit" Version="4.17.0" /> <PackageReference Include="MailKit" Version="4.17.0" />
@ -30,29 +35,6 @@
<PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.15" /> <PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.15" />
</ItemGroup> </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> <ItemGroup>
<InternalsVisibleTo Include="Oms.Tests" /> <InternalsVisibleTo Include="Oms.Tests" />
</ItemGroup> </ItemGroup>

View File

@ -1,8 +1,6 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Oms.Application.Messaging; using Oms.Application.Messaging;
using Oms.Application.Messaging.OrderSideEffects;
using Oms.Application.Messaging.ParcelCreate;
namespace Oms.Application; namespace Oms.Application;
@ -11,10 +9,7 @@ public static class OrderBatchMessagingServiceExtensions
public static IServiceCollection AddOmsOrderBatchMessaging(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddOmsOrderBatchMessaging(this IServiceCollection services, IConfiguration configuration)
{ {
services.Configure<OrderBatchRabbitMqOptions>(configuration.GetSection(OrderBatchRabbitMqOptions.SectionName)); services.Configure<OrderBatchRabbitMqOptions>(configuration.GetSection(OrderBatchRabbitMqOptions.SectionName));
services.AddSingleton<IOrderBatchJobPublisher, OrderBatchJobPublisher>();
services.AddSingleton<IOrderBatchQueueInspector, OrderBatchQueueInspector>(); services.AddSingleton<IOrderBatchQueueInspector, OrderBatchQueueInspector>();
services.AddSingleton<IOrderParcelCreateEventPublisher, OrderParcelCreateEventPublisher>();
services.AddSingleton<IOrderSideEffectEventPublisher, OrderSideEffectEventPublisher>();
// 消费端由 Fake.EventBus.RabbitMQ(RabbitMqEventBus HostedService)托管 // 消费端由 Fake.EventBus.RabbitMQ(RabbitMqEventBus HostedService)托管
services.AddHostedService<OrderBatchDlxMonitorHostedService>(); services.AddHostedService<OrderBatchDlxMonitorHostedService>();
return services; return services;

View File

@ -1,5 +1,6 @@
using Fake.Application; using Fake.Application;
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Fake.EventBus.Distributed;
using Fake.ObjectStorage; using Fake.ObjectStorage;
using Newtonsoft.Json; using Newtonsoft.Json;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
@ -24,7 +25,7 @@ public sealed class OrderBatchActionExecutor(
IObjectStorage fileStorage, IObjectStorage fileStorage,
ISmallBagTrackOrderClient smallBagTrackOrderClient, ISmallBagTrackOrderClient smallBagTrackOrderClient,
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder, ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
IOrderParcelCreateEventPublisher parcelCreateEventPublisher, IDistributedEventBus eventBus,
OrderDownstreamCredentialResolver downstreamCredentialResolver, OrderDownstreamCredentialResolver downstreamCredentialResolver,
IItfxForwardClient itfxForwardClient, IItfxForwardClient itfxForwardClient,
OrderFileManager orderFileManager, OrderFileManager orderFileManager,
@ -252,11 +253,14 @@ public sealed class OrderBatchActionExecutor(
trackRequest.Authorization = authorization.Trim(); trackRequest.Authorization = authorization.Trim();
} }
await parcelCreateEventPublisher.PublishTrackUpsertAsync( await eventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent
order, {
trackRequest, OrderId = order.Id,
customerId: order.CustomerId, CustomerId = order.CustomerId,
cancellationToken); PayloadJson = JsonConvert.SerializeObject(trackRequest),
AppendInitialTrackOnSuccess = trackRequest.AppendInitialTrackOnSuccess,
Authorization = trackRequest.Authorization
}, cancellationToken);
} }
} }

View File

@ -1,6 +1,6 @@
using Fake.Application; using Fake.Application;
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Fake.UnitOfWork; using Fake.EventBus.Distributed;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -29,9 +29,8 @@ public sealed class OrderBatchJobCoordinator(
ICountryRepository countryRepository, ICountryRepository countryRepository,
IServiceChannelRepository serviceChannelRepository, IServiceChannelRepository serviceChannelRepository,
IObjectStorage fileStorage, IObjectStorage fileStorage,
IOrderBatchJobPublisher publisher, IDistributedEventBus eventBus,
OrderBatchJobProcessor batchJobProcessor, OrderBatchJobProcessor batchJobProcessor,
IUnitOfWorkManager unitOfWorkManager,
IOptions<OrderBatchRabbitMqOptions> batchOptions, IOptions<OrderBatchRabbitMqOptions> batchOptions,
ILogger<OrderBatchJobCoordinator> logger) : IScopedDependency ILogger<OrderBatchJobCoordinator> logger) : IScopedDependency
{ {
@ -44,7 +43,7 @@ public sealed class OrderBatchJobCoordinator(
string? authorization = null, string? authorization = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!publisher.IsEnabled) if (!batchOptions.Value.Enabled)
{ {
throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置"); 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 now = DateTime.Now;
var job = new OrderBatchJob var job = new OrderBatchJob
{ {
@ -161,7 +154,7 @@ public sealed class OrderBatchJobCoordinator(
MessageId = item.MessageId ?? $"{job.Id}:{item.OrderId}:{action}", MessageId = item.MessageId ?? $"{job.Id}:{item.OrderId}:{action}",
}; };
await publisher.PublishAsync(message, cancellationToken); await eventBus.PublishAsync(OrderBatchJobIntegrationEvent.FromMessage(message), cancellationToken);
} }
logger.LogInformation( logger.LogInformation(
@ -181,7 +174,7 @@ public sealed class OrderBatchJobCoordinator(
int operatorUserId, int operatorUserId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!publisher.IsEnabled) if (!batchOptions.Value.Enabled)
{ {
throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置"); throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置");
} }
@ -194,8 +187,9 @@ public sealed class OrderBatchJobCoordinator(
for (var index = 0; index < items.Count; index++) for (var index = 0; index < items.Count; index++)
{ {
await publisher.PublishAsync( await eventBus.PublishAsync(
BuildPayloadMessage(job, items[index], itemsInput[index], input.CustomerId, operatorUserId), OrderBatchJobIntegrationEvent.FromMessage(
BuildPayloadMessage(job, items[index], itemsInput[index], input.CustomerId, operatorUserId)),
cancellationToken); cancellationToken);
} }
@ -383,7 +377,7 @@ public sealed class OrderBatchJobCoordinator(
public async Task<bool> PublishTrackUpsertAsync(SmallBagTrackUpsertRequest request, int orderId, int? customerId) 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); logger.LogDebug("RabbitMQ disabled, track upsert will use direct call. orderId={OrderId}", orderId);
return false; return false;
@ -401,7 +395,7 @@ public sealed class OrderBatchJobCoordinator(
MessageId = $"track:{orderId}:{Guid.NewGuid():N}", MessageId = $"track:{orderId}:{Guid.NewGuid():N}",
}; };
await publisher.PublishAsync(message); await eventBus.PublishAsync(OrderBatchJobIntegrationEvent.FromMessage(message));
return true; return true;
} }
@ -437,7 +431,7 @@ public sealed class OrderBatchJobCoordinator(
int operatorUserId, int operatorUserId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!publisher.IsEnabled) if (!batchOptions.Value.Enabled)
{ {
throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置"); throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置");
} }
@ -472,7 +466,9 @@ public sealed class OrderBatchJobCoordinator(
item.UpdateUserId = operatorUserId; item.UpdateUserId = operatorUserId;
await batchJobRepository.UpdateItemAsync(item, cancellationToken); 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; job.Status = OrderBatchJobStatusCodes.Running;

View File

@ -41,6 +41,12 @@ public class Customs9610OrderService : ApplicationService
private IOptions<ItfxGatewayOptions> ItfxOptions => private IOptions<ItfxGatewayOptions> ItfxOptions =>
LazyServiceProvider.GetRequiredService<IOptions<ItfxGatewayOptions>>(); LazyServiceProvider.GetRequiredService<IOptions<ItfxGatewayOptions>>();
private ICustomerExportQualificationRepository ExportQualificationRepository =>
LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>();
private Customs9610OrderWriter Customs9610Writer =>
LazyServiceProvider.GetRequiredService<Customs9610OrderWriter>();
[HttpPost("/order/customs-9610/list")] [HttpPost("/order/customs-9610/list")]
public async Task<OmsPageResult<Customs9610OrderOutput>> ListAsync([FromBody] Customs9610ListInput input) 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> /// <summary>
/// 批量获取清单数据(调 API 中心上游,标准码暂定),成功后回写本地字段。 /// 批量获取清单数据(调 API 中心上游,标准码暂定),成功后回写本地字段。
/// </summary> /// </summary>

View File

@ -199,6 +199,9 @@ public static class AutoTriggerFormulaMeta
yield return ("进口HS编码", "t_order_declaration", "ImHsCode", FormulaValueType.String, false); yield return ("进口HS编码", "t_order_declaration", "ImHsCode", FormulaValueType.String, false);
yield return ("出口HS编码", "t_order_declaration", "ExHsCode", 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", "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", "Category", FormulaValueType.String, false);
yield return ("品牌", "t_order_declaration", "Brand", FormulaValueType.String, false); yield return ("品牌", "t_order_declaration", "Brand", FormulaValueType.String, false);
yield return ("品牌类型", "t_order_declaration", "BrandType", FormulaValueType.String, false); yield return ("品牌类型", "t_order_declaration", "BrandType", FormulaValueType.String, false);

View File

@ -1,4 +1,5 @@
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Fake.EventBus.Distributed;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Oms.Application.Messaging.OrderSideEffects; using Oms.Application.Messaging.OrderSideEffects;
using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Consts;
@ -16,7 +17,7 @@ public class AutoTriggerRuntimeService(
OrderAutoTriggerService orderAutoTriggerService, OrderAutoTriggerService orderAutoTriggerService,
OrderExceptionWorkflowService exceptionWorkflowService, OrderExceptionWorkflowService exceptionWorkflowService,
OrderIssueNotificationService issueNotificationService, OrderIssueNotificationService issueNotificationService,
IOrderSideEffectEventPublisher sideEffectEventPublisher, IDistributedEventBus eventBus,
ILogger<AutoTriggerRuntimeService> logger) : IScopedDependency ILogger<AutoTriggerRuntimeService> logger) : IScopedDependency
{ {
private const string InterceptOperation = "拦截"; private const string InterceptOperation = "拦截";
@ -27,7 +28,12 @@ public class AutoTriggerRuntimeService(
public Task EnqueueFireAsync(int orderId, AutoTriggerTiming timing, int operatorUserId) => public Task EnqueueFireAsync(int orderId, AutoTriggerTiming timing, int operatorUserId) =>
orderId <= 0 orderId <= 0
? Task.CompletedTask ? 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) public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId)
{ {

View File

@ -29,7 +29,7 @@ public static class OrderFormulaFieldResolver
if (fieldRef.Table == "t_order_fee") 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); var raw = GetRawValue(fieldRef, context);
@ -255,10 +255,16 @@ public static class OrderFormulaFieldResolver
private static object? ResolveFeeField(string field, OrderRuleEvaluationContext context) 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); 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) => private static object? GetDeclarationValue(OrderDeclaration declaration, string field) =>
field switch field switch
{ {

View File

@ -1,4 +1,6 @@
using Fake.Application; using Fake.Application;
using Fake.EventBus.Distributed;
using Microsoft.Extensions.Options;
using Newtonsoft.Json; using Newtonsoft.Json;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
using Oms.Application.Messaging; using Oms.Application.Messaging;
@ -10,8 +12,11 @@ namespace Oms.Application.Services.Orders;
public partial class OrderService public partial class OrderService
{ {
private IOrderBatchJobPublisher BatchJobPublisher => private IDistributedEventBus EventBus =>
LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>(); LazyServiceProvider.GetRequiredService<IDistributedEventBus>();
private bool BatchQueueEnabled =>
LazyServiceProvider.GetRequiredService<IOptions<OrderBatchRabbitMqOptions>>().Value.Enabled;
/// <summary> /// <summary>
/// 批量 Worker 导入单行 Excel 数据(内部方法,勿暴露为 HTTP API)。 /// 批量 Worker 导入单行 Excel 数据(内部方法,勿暴露为 HTTP API)。

View File

@ -11,7 +11,7 @@ namespace Oms.Application.Services.Orders;
public partial class OrderService public partial class OrderService
{ {
internal Task PayForBatchAsync(int orderId, CancellationToken cancellationToken = default) => 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) => internal Task PreDeclareForBatchAsync(int orderId, CancellationToken cancellationToken = default) =>
PreDeclareAsync(new OrderActionInput { Id = orderId }); PreDeclareAsync(new OrderActionInput { Id = orderId });

View File

@ -263,6 +263,11 @@ public partial class OrderService
HasInsurance = hasInsurance, HasInsurance = hasInsurance,
ForecastReceivableAmount = order.ForecastReceivableAmount, ForecastReceivableAmount = order.ForecastReceivableAmount,
ForecastPayableAmount = order.ForecastPayableAmount, ForecastPayableAmount = order.ForecastPayableAmount,
ReceivableSubtotal = order.ReceivableSubtotal,
LevelDiscountAmount = order.LevelDiscountAmount,
CouponDiscountAmount = order.CouponDiscountAmount,
ActualPayAmount = order.PayableAmount,
CouponInstanceId = order.CouponInstanceId,
SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion) SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion)
? null ? null
: order.SalesProductRulesVersion.Trim(), : order.SalesProductRulesVersion.Trim(),
@ -554,7 +559,7 @@ public partial class OrderService
var batchInput = await BuildImportBatchJobInputAsync(input); var batchInput = await BuildImportBatchJobInputAsync(input);
var operatorUserId = CurrentUser.GetId<int>(); var operatorUserId = CurrentUser.GetId<int>();
if (BatchJobPublisher.IsEnabled) if (BatchQueueEnabled)
{ {
var job = await BatchJobCoordinator.CreatePayloadJobAsync(batchInput, operatorUserId); var job = await BatchJobCoordinator.CreatePayloadJobAsync(batchInput, operatorUserId);
return new OrderUploadOutput { JobId = job.JobId, Async = true }; return new OrderUploadOutput { JobId = job.JobId, Async = true };
@ -812,6 +817,9 @@ public partial class OrderService
ImHsCode = x.ImHsCode ?? x.HsCode, ImHsCode = x.ImHsCode ?? x.HsCode,
ExHsCode = x.ExHsCode ?? x.HsCode, ExHsCode = x.ExHsCode ?? x.HsCode,
DeclareElement = x.DeclareElement, DeclareElement = x.DeclareElement,
ProductId = x.ProductId,
NonStdProductId = x.NonStdProductId,
StdProductId = x.StdProductId,
SkuImage = FileStorage.GetPublicUrl(x.SkuImage), SkuImage = FileStorage.GetPublicUrl(x.SkuImage),
Category = x.Category, Category = x.Category,
Brand = x.Brand, Brand = x.Brand,
@ -851,6 +859,9 @@ public partial class OrderService
Id = x.Id, Id = x.Id,
Type = x.Type, Type = x.Type,
CostItem = x.CostItem, CostItem = x.CostItem,
CostItemName = OrderFeeDiscountCostItems.IsDiscount(x.CostItem)
? ParcelBillingCalculator.ResolveFeeDisplayName(x.CostItem, null)
: null,
Amount = x.Amount, Amount = x.Amount,
Currency = x.Currency, Currency = x.Currency,
DownstreamProvider = x.DownstreamProvider, DownstreamProvider = x.DownstreamProvider,

View File

@ -12,9 +12,12 @@ public partial class OrderService
/// <summary> /// <summary>
/// 实时监控页概览(订单/客户统计与趋势)。 /// 实时监控页概览(订单/客户统计与趋势)。
/// </summary> /// </summary>
/// <param name="dimension">经营指标维度:day / week / month,默认 day。</param>
[HttpGet("/monitor/overview")] [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 filter = await BuildScopedListFilterAsync();
var today = DateTime.Today; var today = DateTime.Today;
var tomorrow = today.AddDays(1); var tomorrow = today.AddDays(1);
@ -28,21 +31,60 @@ public partial class OrderService
int GetCount(string status) => int GetCount(string status) =>
counts.TryGetValue(status, out var value) ? value : 0; counts.TryGetValue(status, out var value) ? value : 0;
var todayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow); var pendingPayment = GetCount(OrderStatusCodes.PendingPayment);
var yesterdayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today); 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 monthStart = new DateTime(today.Year, today.Month, 1);
var lastMonthStart = monthStart.AddMonths(-1); var lastMonthStart = monthStart.AddMonths(-1);
var thisMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, monthStart, tomorrow); var thisMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, monthStart, tomorrow);
var lastMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, lastMonthStart, monthStart); var lastMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, lastMonthStart, monthStart);
var todayRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( var trendCount = dim switch
filter, OrderFeeTypeCodes.Receivable, today, tomorrow); {
var yesterdayRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( "week" => 8,
filter, OrderFeeTypeCodes.Receivable, yesterday, today); "month" => 6,
_ => 7,
var orderTrend7d = await BuildMonitorOrderTrendAsync(filter, 7); };
var revenueTrend7d = await BuildMonitorRevenueTrendAsync(filter, 7); var orderTrend = await BuildMonitorOrderTrendAsync(filter, dim, trendCount);
var revenueTrend = await BuildMonitorRevenueTrendAsync(filter, dim, trendCount);
var recentFilter = CloneFilterWithCreateTimeStart(filter, today.AddDays(-90)); var recentFilter = CloneFilterWithCreateTimeStart(filter, today.AddDays(-90));
var recentOrders = await OrderRepository.GetFilteredListAsync(recentFilter); var recentOrders = await OrderRepository.GetFilteredListAsync(recentFilter);
@ -83,13 +125,30 @@ public partial class OrderService
TodayCreatedGrowthPercent = CalcGrowthPercent(todayCreated, yesterdayCreated), TodayCreatedGrowthPercent = CalcGrowthPercent(todayCreated, yesterdayCreated),
Total = counts.Values.Sum(), Total = counts.Values.Sum(),
TotalMonthGrowthPercent = CalcGrowthPercent(thisMonthCreated, lastMonthCreated), TotalMonthGrowthPercent = CalcGrowthPercent(thisMonthCreated, lastMonthCreated),
PendingPayment = GetCount(OrderStatusCodes.PendingPayment), PendingPayment = pendingPayment,
PendingOnline = GetCount(OrderStatusCodes.PendingOnline), PendingOnline = pendingOnline,
PendingSign = GetCount(OrderStatusCodes.Shipping), PendingSign = pendingSign,
Problematic = GetCount(OrderStatusCodes.Problematic), Problematic = problematic,
TodayRevenue = todayRevenue, TodayRevenue = todayRevenue,
TodayRevenueGrowthPercent = CalcGrowthPercentDecimal(todayRevenue, yesterdayRevenue), 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 Customer = new MonitorCustomerStatsOutput
{ {
TotalCustomers = totalCustomers, TotalCustomers = totalCustomers,
@ -100,8 +159,10 @@ public partial class OrderService
InactiveLogin7d = inactiveLogin7d, InactiveLogin7d = inactiveLogin7d,
InactiveLogin7dDelta = inactiveLogin7d - inactiveLogin14d, InactiveLogin7dDelta = inactiveLogin7d - inactiveLogin14d,
}, },
OrderTrend7d = orderTrend7d, OrderTrend = orderTrend,
RevenueTrend7d = revenueTrend7d, OrderTrend7d = dim == "day" ? orderTrend : await BuildMonitorOrderTrendAsync(filter, "day", 7),
RevenueTrend = revenueTrend,
RevenueTrend7d = dim == "day" ? revenueTrend : await BuildMonitorRevenueTrendAsync(filter, "day", 7),
RegionOrderTrend = new MonitorRegionTrendBundleOutput RegionOrderTrend = new MonitorRegionTrendBundleOutput
{ {
Week = new MonitorRegionTrendPeriodOutput { Labels = weekOrderLabels, Series = weekOrderSeries }, 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( private async Task<List<MonitorTrendPointOutput>> BuildMonitorOrderTrendAsync(
OrderListFilter filter, OrderListFilter filter,
string dimension,
int count) int count)
{ {
var buckets = BuildTrendBuckets("day", count); var buckets = BuildTrendBuckets(dimension, count);
var points = new List<MonitorTrendPointOutput>(buckets.Count); var points = new List<MonitorTrendPointOutput>(buckets.Count);
foreach (var bucket in buckets) foreach (var bucket in buckets)
{ {
@ -135,9 +227,10 @@ public partial class OrderService
private async Task<List<MonitorRevenueTrendPointOutput>> BuildMonitorRevenueTrendAsync( private async Task<List<MonitorRevenueTrendPointOutput>> BuildMonitorRevenueTrendAsync(
OrderListFilter filter, OrderListFilter filter,
string dimension,
int count) int count)
{ {
var buckets = BuildTrendBuckets("day", count); var buckets = BuildTrendBuckets(dimension, count);
var points = new List<MonitorRevenueTrendPointOutput>(buckets.Count); var points = new List<MonitorRevenueTrendPointOutput>(buckets.Count);
foreach (var bucket in buckets) foreach (var bucket in buckets)
{ {

View File

@ -900,6 +900,11 @@ public partial class OrderService
throw new BusinessException("费用项不能为空"); throw new BusinessException("费用项不能为空");
} }
if (OrderFeeDiscountCostItems.IsDiscount(costItem))
{
throw new BusinessException("优惠券抵扣、会员折扣由支付自动生成,不能手工添加");
}
if (input.Amount <= 0) if (input.Amount <= 0)
{ {
throw new BusinessException("金额必须大于0"); throw new BusinessException("金额必须大于0");
@ -1355,6 +1360,9 @@ public partial class OrderService
ImHsCode = x.ImHsCode, ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode ?? x.ImHsCode, ExHsCode = x.ExHsCode ?? x.ImHsCode,
DeclareElement = x.DeclareElement, DeclareElement = x.DeclareElement,
ProductId = x.ProductId?.Trim(),
NonStdProductId = x.NonStdProductId?.Trim(),
StdProductId = x.StdProductId?.Trim(),
SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage), SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage),
Category = x.Category, Category = x.Category,
Brand = x.Brand, Brand = x.Brand,

View File

@ -41,7 +41,6 @@ public partial class OrderService
private const string InboundBillingProcessingReason = "入库后计费/排货处理中"; private const string InboundBillingProcessingReason = "入库后计费/排货处理中";
private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费"; private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费";
internal const string ForecastBillingProcessingReason = "预报计费/排货处理中"; internal const string ForecastBillingProcessingReason = "预报计费/排货处理中";
private const string ForecastBillingEnqueueFailReason = "预报计费任务投递失败,请重新预报";
private static readonly ConcurrentDictionary<int, byte> InboundBillingInFlight = new(); 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( private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(
OrderParcelCreateInput input, OrderParcelCreateInput input,
int? operatorUserId = null, int? operatorUserId = null,
ParcelCreateContext? createContext = null) ParcelCreateContext? createContext = null)
{ {
// 1. 幂等:同一 RequestId 直接返回已有订单,不再建单、不再发事件。
if (!string.IsNullOrWhiteSpace(input.RequestId)) if (!string.IsNullOrWhiteSpace(input.RequestId))
{ {
var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId); var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId);
@ -118,6 +121,7 @@ public partial class OrderService
NormalizeParcelCreateInput(input); NormalizeParcelCreateInput(input);
createContext ??= BuildParcelCreateContext(input); createContext ??= BuildParcelCreateContext(input);
// 2. 硬校验。拦截类自动触发在落库前执行,命中即失败,订单不会留下。
var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId); var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId);
input.CustomerId = sessionCustomer.Id; input.CustomerId = sessionCustomer.Id;
@ -151,11 +155,9 @@ public partial class OrderService
country.CountryCode, country.CountryCode,
country.ChineseName); country.ChineseName);
// 3. 落库订单、件重尺和收发件等子表。此后才有 OrderId,才能写 Outbox。
var serviceNo = await ResolveServiceNoAsync(); var serviceNo = await ResolveServiceNoAsync();
var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0); var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
Order? created = null;
try
{
var order = BuildDraftOrderEntity( var order = BuildDraftOrderEntity(
input, input,
product, product,
@ -165,20 +167,25 @@ public partial class OrderService
serviceNo, serviceNo,
createContext); createContext);
order = await OrderRepository.InsertAsync(order); order = await OrderRepository.InsertAsync(order);
created = order;
var weights = BuildInitialWeightRows(order.Id, input); var weights = BuildInitialWeightRows(order.Id, input);
await SaveParcelChildrenAsync(order, input, weights, country); await SaveParcelChildrenAsync(order, input, weights, country);
// 客户主动「先保存为草稿」:落库后返回。9610 / 揽收在事务提交后各自执行。 // 4. 保存草稿:不做预报计费。9610、上门揽收与订单一起提交。
if (input.SaveAsDraft) if (input.SaveAsDraft)
{ {
await EventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
{
OrderId = order.Id,
PlatformCode = input.PlatformCode
});
var draftOutput = await BuildParcelCreateOutputAsync(order); var draftOutput = await BuildParcelCreateOutputAsync(order);
await ScheduleCreateSideEffectsAsync(order.Id, input, draftOutput, includeForecast: false, operatorId); await TryAttachPickupAfterParcelCreateAsync(input, draftOutput);
return draftOutput; return draftOutput;
} }
// 偏远标记轻量同步;计费重/应收/排货走 ForecastBilling MQ(失败不回滚建单)。 // 5. 确认下单:偏远标记当场算完;计费重/应收/排货留给预报任务。
// 状态先停在草稿 +「预报计费/排货处理中」,接口即可返回。
order.IsRemote = await IsRemoteHitAsync( order.IsRemote = await IsRemoteHitAsync(
product, product,
snapshotBinding.Rules, snapshotBinding.Rules,
@ -192,72 +199,31 @@ public partial class OrderService
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(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); var output = await BuildParcelCreateOutputAsync(order);
await ScheduleCreateSideEffectsAsync(order.Id, input, output, includeForecast: true, operatorId); output.ForecastBillingQueued = string.Equals(
order.PredeclareFailReason,
ForecastBillingProcessingReason,
StringComparison.Ordinal);
// 揽收失败只回写 PickupCreateError,不让整单回滚。
await TryAttachPickupAfterParcelCreateAsync(input, output);
return output; 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;
}
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;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
await OrderRepository.DeleteAsync(order);
}
private async Task TryUpsertCustoms9610Async(Order order, string? platformCode = null) private async Task TryUpsertCustoms9610Async(Order order, string? platformCode = null)
{ {
@ -2247,6 +2213,9 @@ public partial class OrderService
ImHsCode = x.ImHsCode, ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode, ExHsCode = x.ExHsCode,
DeclareElement = x.DeclareElement, DeclareElement = x.DeclareElement,
ProductId = x.ProductId?.Trim(),
NonStdProductId = x.NonStdProductId?.Trim(),
StdProductId = x.StdProductId?.Trim(),
SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage), SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage),
Category = x.Category, Category = x.Category,
Brand = x.Brand, Brand = x.Brand,
@ -2330,28 +2299,13 @@ public partial class OrderService
var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0); var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
var authorization = CaptureRequestAuthorization(); var authorization = CaptureRequestAuthorization();
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>(); await EnqueueForecastBillingAsync(order, order.CouponInstanceId, operatorId, authorization);
if (!publisher.IsEnabled) await EventBus.PublishAsync(new OrderParcelLabelGenerateIntegrationEvent
{ {
var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload OrderId = order.Id,
{ OperatorUserId = operatorId
CouponInstanceId = order.CouponInstanceId,
Authorization = authorization
}); });
await ProcessForecastBillingForBatchAsync( await AutoTriggerRuntimeService.EnqueueFireAsync(order.Id, AutoTriggerTiming.OrderCreate, operatorId);
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);
// 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。 // 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。
var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id);
@ -2498,6 +2452,12 @@ public partial class OrderService
{ {
_ = cancellationToken; _ = cancellationToken;
var order = await GetOrderOrThrowAsync(orderId); var order = await GetOrderOrThrowAsync(orderId);
// 已付款订单应收已固化(含营销抵扣行),重复投递的入库计费消息不得重算
if (order.IsPaid != 0)
{
return;
}
if (string.IsNullOrWhiteSpace(order.ProductCode)) if (string.IsNullOrWhiteSpace(order.ProductCode))
{ {
order.OrderStatus = OrderStatusCodes.PendingAllocation; order.OrderStatus = OrderStatusCodes.PendingAllocation;
@ -2679,7 +2639,8 @@ public partial class OrderService
throw new InvalidOperationException($"预报计费时订单尚未可见,稍后重试 OrderId={orderId}"); 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; return;
} }
@ -2846,7 +2807,7 @@ public partial class OrderService
// 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。 // 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。
if (allocated && !string.IsNullOrWhiteSpace(order.ChannelCode)) 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); var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true);
@ -2872,154 +2833,6 @@ public partial class OrderService
public string? Authorization { get; set; } 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) private async Task PublishForecastTrackAsync(int orderId, string? authorization = null)
{ {
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
@ -3036,41 +2849,26 @@ public partial class OrderService
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null);
request.AppendInitialTrackOnSuccess = true; request.AppendInitialTrackOnSuccess = true;
ApplyTrackAuthorization(request, authorization); ApplyTrackAuthorization(request, authorization);
await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); await EventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent
}
private async Task PublishForecastLabelAsync(int orderId, int operatorUserId)
{ {
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); OrderId = order.Id,
if (order == null CustomerId = customer?.Id,
|| string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) PayloadJson = JsonConvert.SerializeObject(request),
{ AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess,
return; Authorization = request.Authorization
} });
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);
} }
/// <summary>
/// 预报计费入队。队列开着写 ForecastBilling Outbox;关着则在当前事务里同步跑同一套计费。
/// Authorization 放进任务载荷,供排货成功后转发轨迹网关。
/// </summary>
private async Task EnqueueForecastBillingAsync( private async Task EnqueueForecastBillingAsync(
Order order, Order order,
int? couponInstanceId, int? couponInstanceId,
int operatorUserId, int operatorUserId,
string? authorization = null) string? authorization = null)
{ {
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("Oms.ForecastBilling");
authorization ??= CaptureRequestAuthorization(); authorization ??= CaptureRequestAuthorization();
var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload
{ {
@ -3078,31 +2876,13 @@ public partial class OrderService
Authorization = authorization Authorization = authorization
}); });
try if (!BatchQueueEnabled)
{
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>();
if (!publisher.IsEnabled)
{
try
{ {
await ProcessForecastBillingForBatchAsync( await ProcessForecastBillingForBatchAsync(
order.Id, order.Id,
payloadJson, payloadJson,
operatorUserId, operatorUserId,
authorization: authorization); authorization: authorization);
}
catch (Exception ex)
{
logger.LogError(
ex,
"预报计费同步执行失败 OrderId={OrderId} ServiceNo={ServiceNo}",
order.Id,
order.ServiceNo);
await MarkParcelDraftFailedAsync(
order,
TrimPendingReason($"预报计费失败:{ex.Message}"));
}
return; return;
} }
@ -3118,23 +2898,6 @@ public partial class OrderService
operatorUserId, operatorUserId,
authorization); authorization);
} }
catch (Exception ex)
{
logger.LogError(
ex,
"预报计费任务投递失败 OrderId={OrderId} ServiceNo={ServiceNo}",
order.Id,
order.ServiceNo);
try
{
await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason);
}
catch
{
// ignore
}
}
}
private async Task EnqueueInboundBillingAsync(Order order) private async Task EnqueueInboundBillingAsync(Order order)
{ {
@ -3142,8 +2905,7 @@ public partial class OrderService
.CreateLogger("Oms.InboundBilling"); .CreateLogger("Oms.InboundBilling");
try try
{ {
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>(); if (!BatchQueueEnabled)
if (!publisher.IsEnabled)
{ {
// 本地未开队列:同步跑异步计费逻辑,仍不向外抛(入库主流程已完成) // 本地未开队列:同步跑异步计费逻辑,仍不向外抛(入库主流程已完成)
try try
@ -3397,12 +3159,6 @@ public partial class OrderService
}; };
} }
private IOrderParcelCreateEventPublisher ParcelCreateEventPublisher =>
LazyServiceProvider.GetRequiredService<IOrderParcelCreateEventPublisher>();
private IOrderSideEffectEventPublisher SideEffectEventPublisher =>
LazyServiceProvider.GetRequiredService<IOrderSideEffectEventPublisher>();
/// <summary> /// <summary>
/// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。 /// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。
/// </summary> /// </summary>
@ -3415,7 +3171,14 @@ public partial class OrderService
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver);
request.AppendInitialTrackOnSuccess = appendInitialTrack; request.AppendInitialTrackOnSuccess = appendInitialTrack;
ApplyTrackAuthorization(request); 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) private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request, string? authorization = null)
@ -3486,11 +3249,13 @@ public partial class OrderService
.Authorization .Authorization
.ToString(); .ToString();
await SideEffectEventPublisher.PublishLifecycleTrackAppendAsync( await EventBus.PublishAsync(new OrderLifecycleTrackAppendIntegrationEvent
order.Id, {
order.ServiceNo, OrderId = order.Id,
trackCode, OrderNo = order.ServiceNo,
string.IsNullOrWhiteSpace(authorization) ? null : authorization); TrackCode = trackCode,
Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization
});
} }
/// <summary> /// <summary>
@ -3623,7 +3388,8 @@ public partial class OrderService
receivableSubtotal, receivableSubtotal,
baseFreightAmount, baseFreightAmount,
order.CouponInstanceId, order.CouponInstanceId,
order.ConsigneeCountry); order.ConsigneeCountry,
order.Id);
marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId); marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId);
} }

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

View File

@ -127,12 +127,12 @@ public partial class OrderService : ApplicationService
} }
/// <summary> /// <summary>
/// 执行订单支付。 /// 执行订单支付(余额扣款,支持支付时选券)。
/// </summary> /// </summary>
/// <param name="input">入参:订单操作参数。</param> /// <param name="input">入参:订单 Id;couponInstanceId 为 null 沿用已绑定券,0 不用券,大于 0 使用指定券。</param>
/// <returns>无返回体,仅完成订单支付处理。</returns> /// <returns>无返回体;指定券不可用、余额不足或订单状态不可支付时抛业务异常。</returns>
[HttpPost("/order/pay")] [HttpPost("/order/pay")]
public async Task PayAsync(OrderActionInput input) public async Task PayAsync(OrderPayInput input)
{ {
var order = await GetOrderOrThrowAsync(input.Id); var order = await GetOrderOrThrowAsync(input.Id);
if (order.IsPaid > 0) if (order.IsPaid > 0)
@ -152,20 +152,21 @@ public partial class OrderService : ApplicationService
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order); var beforeSnap = await CaptureOrderAuditSnapshotAsync(order);
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
var payAmount = order.PayableAmount ?? fees var breakdown = await BuildPayBreakdownAsync(order, fees, input.CouponInstanceId);
.Where(x => x.Type == OrderFeeTypeCodes.Receivable) if (breakdown.ReceivableSubtotal <= 0)
.Sum(x => x.Amount);
if (payAmount <= 0)
{ {
throw new BusinessException("订单应付金额无效,无法支付"); throw new BusinessException("订单应付金额无效,无法支付");
} }
var payAmount = breakdown.PayableAmount;
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value); var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer == null) if (customer == null)
{ {
throw new BusinessException("客户不存在"); throw new BusinessException("客户不存在");
} }
if (payAmount > 0)
{
var balanceMutator = LazyServiceProvider.GetRequiredService<CustomerBalanceMutator>(); var balanceMutator = LazyServiceProvider.GetRequiredService<CustomerBalanceMutator>();
await balanceMutator.DebitOrderPayAsync( await balanceMutator.DebitOrderPayAsync(
customer, customer,
@ -173,14 +174,26 @@ public partial class OrderService : ApplicationService
order.Id, order.Id,
order.ServiceNo, order.ServiceNo,
$"订单支付 {order.ServiceNo}"); $"订单支付 {order.ServiceNo}");
if (order.CouponInstanceId.HasValue)
{
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id);
await couponManager.MarkUsedAsync(order.CouponInstanceId.Value, mainCustomerId, order.Id);
} }
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 mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer);
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>(); var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight( var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight(
@ -197,12 +210,15 @@ public partial class OrderService : ApplicationService
order = await GetOrderOrThrowAsync(order.Id); order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order); var afterSnap = await CaptureOrderAuditSnapshotAsync(order);
var amountText = payAmount.ToString("0.####", CultureInfo.InvariantCulture); var amountText = payAmount.ToString("0.####", CultureInfo.InvariantCulture);
var couponText = breakdown.CouponDiscountAmount > 0
? $",优惠券抵扣 {breakdown.CouponDiscountAmount.ToString("0.####", CultureInfo.InvariantCulture)}"
: string.Empty;
await OperationLogWriter.TryWriteAsync( await OperationLogWriter.TryWriteAsync(
order, order,
OrderOperationActionCodes.Pay, OrderOperationActionCodes.Pay,
beforeSnap, beforeSnap,
afterSnap, afterSnap,
$"支付金额 {amountText}"); $"支付金额 {amountText}{couponText}");
} }
/// <summary> /// <summary>
@ -603,34 +619,20 @@ public partial class OrderService : ApplicationService
} }
/// <summary> /// <summary>
/// 创建订单包裹(PC/H5 确认下单或保存草稿)。 /// 创建订单包裹(PC/H5 确认下单或保存草稿)。整段在一个事务里,异常直接抛出并回滚。
/// <para> /// <para>
/// 【同步,本接口事务内】RequestId 幂等 → 校验入参/销售产品 → 黑名单 → /// 【本请求内】RequestId 幂等 → 校验 → 黑名单 → 下单前拦截规则 → 落库订单与子表。
/// 自动触发「拦截」规则(命中直接失败)→ 生成运单号 → 落库订单与子表。 /// 保存草稿只再写 9610 与上门揽收;确认下单额外标记「预报处理中」,并写入预报计费、面单、自动触发。
/// 确认下单:同步写偏远标记,状态草稿 +「预报计费/排货处理中」后返回;
/// 保存草稿:仅落库,不做预报计费。失败会回滚订单,不落草稿。
/// </para> /// </para>
/// <para> /// <para>
/// 【创单事务提交后各自投递 MQ(互不等待;失败不回滚订单)】 /// 【同一事务的 Outbox,Commit 后才投递】9610、预报计费、面单、OrderCreate 自动触发、上门揽收。
/// 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,消费端原样转发;
/// 排货失败不阻断预报,也不建轨迹(可页面补做)。
/// </para> /// </para>
/// </summary> /// </summary>
/// <param name="input">入参:订单包裹创建参数。SaveAsDraft=true 为仅保存草稿。</param> /// <param name="input">入参:订单包裹创建参数。SaveAsDraft=true 为仅保存草稿。</param>
/// <returns> /// <returns>
/// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已排队; /// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已入队;
/// 上门揽收时 PickupQueued / PickupNo 视投递或同步回落结果而定。 /// 上门揽收入队成功时 PickupQueued=true,失败只写 PickupCreateError,不回滚订单。
/// </returns> /// </returns>
[HttpPost("/order/parcel/create")] [HttpPost("/order/parcel/create")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
@ -680,6 +682,9 @@ public partial class OrderService : ApplicationService
} }
} }
/// <summary>
/// 仅上门揽收。地址、期望时间和仓库齐全才入队;发布失败写入返回值,不抛回创单事务。
/// </summary>
private async Task TryAttachPickupAfterParcelCreateAsync( private async Task TryAttachPickupAfterParcelCreateAsync(
OrderParcelCreateInput input, OrderParcelCreateInput input,
OrderParcelCreateOutput output) OrderParcelCreateOutput output)
@ -715,27 +720,20 @@ public partial class OrderService : ApplicationService
return; return;
} }
var publisher = LazyServiceProvider.GetRequiredService<IOrderParcelCreateEventPublisher>();
try try
{ {
// 优先入 Fake.EventBus;发布失败则同步回落建揽收并回写单号。 await EventBus.PublishAsync(new OrderParcelPickupAttachIntegrationEvent
var queued = await publisher.PublishPickupAttachAsync(input, output.OrderId);
if (queued)
{ {
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; 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;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -762,7 +760,7 @@ public partial class OrderService : ApplicationService
throw new BusinessException($"单次批量创建不能超过{ParcelBatchMaxCount}条"); throw new BusinessException($"单次批量创建不能超过{ParcelBatchMaxCount}条");
} }
if (BatchJobPublisher.IsEnabled) if (BatchQueueEnabled)
{ {
return await BatchCreateParcelViaQueueAsync(input); return await BatchCreateParcelViaQueueAsync(input);
} }

View File

@ -1075,6 +1075,8 @@ internal static class ParcelBillingCalculator
"handling" => "操作费", "handling" => "操作费",
"proxyPacking" => "代打包费", "proxyPacking" => "代打包费",
"insurance" => "保险费", "insurance" => "保险费",
OrderFeeDiscountCostItems.CouponDiscount => "优惠券抵扣",
OrderFeeDiscountCostItems.MemberDiscount => "会员折扣",
_ => costItem _ => costItem
}; };
} }

View File

@ -187,6 +187,23 @@ internal sealed class PlatformStoreOrderFulfillmentService(
try 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); var result = await fulfillmentClient.FetchOrderStatusAsync(store, order.PlatformOrderNo);
if (!result.Success) if (!result.Success)
{ {
@ -263,6 +280,11 @@ internal sealed class PlatformStoreOrderFulfillmentService(
{ {
order.ShippingLabelNo = result.ShippingLabelNo; order.ShippingLabelNo = result.ShippingLabelNo;
} }
else if (result.LabelPending)
{
// 对齐 WMS:准备发货后先标记面单待获取
order.ShippingLabelNo = "系统获取中";
}
if (!string.IsNullOrWhiteSpace(result.ShippingLabelUrl)) if (!string.IsNullOrWhiteSpace(result.ShippingLabelUrl))
{ {
@ -271,6 +293,36 @@ internal sealed class PlatformStoreOrderFulfillmentService(
order.LastSyncTime = DateTime.Now; order.LastSyncTime = DateTime.Now;
await storeOrderRepository.UpdateAsync(order); 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 return new PlatformStoreOrderBatchActionItemOutput
{ {
Id = order.Id, Id = order.Id,
@ -307,6 +359,15 @@ internal sealed class PlatformStoreOrderFulfillmentService(
} }
entity.OrderCountry = pulled.OrderCountry ?? entity.OrderCountry; 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.ItemCount = pulled.ItemCount > 0 ? pulled.ItemCount : entity.ItemCount;
entity.TotalAmount = pulled.TotalAmount ?? entity.TotalAmount; entity.TotalAmount = pulled.TotalAmount ?? entity.TotalAmount;
entity.Currency = pulled.Currency ?? entity.Currency; entity.Currency = pulled.Currency ?? entity.Currency;
@ -316,6 +377,8 @@ internal sealed class PlatformStoreOrderFulfillmentService(
entity.FulfillStatus = pulled.FulfillStatus; entity.FulfillStatus = pulled.FulfillStatus;
entity.ShippingLabelNo = pulled.ShippingLabelNo ?? entity.ShippingLabelNo; entity.ShippingLabelNo = pulled.ShippingLabelNo ?? entity.ShippingLabelNo;
entity.ShippingLabelUrl = pulled.ShippingLabelUrl ?? entity.ShippingLabelUrl; 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.PlatformExtraJson = pulled.ExtraJson ?? entity.PlatformExtraJson;
entity.LastSyncTime = now; entity.LastSyncTime = now;
PlatformStoreOrderParcelBuilder.TryFillRecipientFields(entity, pulled.ExtraJson); PlatformStoreOrderParcelBuilder.TryFillRecipientFields(entity, pulled.ExtraJson);
@ -348,7 +411,9 @@ internal sealed class PlatformStoreOrderFulfillmentService(
PlatformLineId = line.PlatformLineId, PlatformLineId = line.PlatformLineId,
PlatformSku = line.PlatformSku, PlatformSku = line.PlatformSku,
ProductName = line.ProductName, ProductName = line.ProductName,
Quantity = line.Quantity, ProductImageUrl = line.ProductImageUrl,
VariantText = line.VariantText,
Quantity = line.Quantity > 0 ? line.Quantity : 1,
UnitPrice = line.UnitPrice, UnitPrice = line.UnitPrice,
Currency = line.Currency Currency = line.Currency
}); });

View File

@ -107,7 +107,7 @@ public static class PlatformStoreOrderParcelBuilder
IReadOnlyCollection<SalesStoreOrderLine> lines) IReadOnlyCollection<SalesStoreOrderLine> lines)
{ {
var items = new List<OrderParcelItemInput>(); 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) var cnName = string.IsNullOrWhiteSpace(line.ProductName)
? (string.IsNullOrWhiteSpace(line.PlatformSku) ? "Platform Item" : line.PlatformSku.Trim()) ? (string.IsNullOrWhiteSpace(line.PlatformSku) ? "Platform Item" : line.PlatformSku.Trim())
@ -157,7 +157,7 @@ public static class PlatformStoreOrderParcelBuilder
IReadOnlyDictionary<int, CSku> skuMap) IReadOnlyDictionary<int, CSku> skuMap)
{ {
var items = new List<OrderParcelItemInput>(); 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)) if (!line.MappedSkuId.HasValue || !skuMap.TryGetValue(line.MappedSkuId.Value, out var sku))
{ {
@ -201,7 +201,7 @@ public static class PlatformStoreOrderParcelBuilder
decimal maxHeight = DefaultHeightCm; decimal maxHeight = DefaultHeightCm;
decimal totalWeightKg = 0m; 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)) if (!skuMap.TryGetValue(line.MappedSkuId!.Value, out var sku))
{ {
@ -306,7 +306,10 @@ public static class PlatformStoreOrderParcelBuilder
private static OrderParcelReceiverInput? ParseReceiverNode(JsonNode node) private static OrderParcelReceiverInput? ParseReceiverNode(JsonNode node)
{ {
var candidates = new List<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) if (node[key] != null)
{ {
@ -321,10 +324,22 @@ public static class PlatformStoreOrderParcelBuilder
continue; continue;
} }
var name = ReadText(candidate, "recipientsName", "recipientName", "receiverName", "name", "fullName"); var (province, city, countryFromDistrict) = ParseTiktokDistrictInfo(candidate);
var country = ReadText(candidate, "countryCode", "country", "region", "orderCountry") ?? ReadText(node, "orderCountry"); var name = ReadText(candidate, "recipientsName", "recipientName", "receiverName", "name", "fullName", "first_name");
var zip = ReadText(candidate, "zipCode", "zip", "postalCode", "postcode"); var country = ReadText(candidate, "countryCode", "country", "region_code", "region", "orderCountry")
var address = ReadText(candidate, "streetAddress1", "address", "address1", "fullAddress", "detailAddress"); ?? 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)) if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(country) || string.IsNullOrWhiteSpace(zip) || string.IsNullOrWhiteSpace(address))
{ {
continue; continue;
@ -334,14 +349,14 @@ public static class PlatformStoreOrderParcelBuilder
{ {
RecipientsName = name, RecipientsName = name,
CompanyName = ReadText(candidate, "companyName", "company"), CompanyName = ReadText(candidate, "companyName", "company"),
Phone = ReadText(candidate, "phone", "mobile", "phoneNumber"), Phone = ReadText(candidate, "phone", "mobile", "phone_number", "phoneNumber"),
TelePhone = ReadText(candidate, "telePhone", "tel"), TelePhone = ReadText(candidate, "telePhone", "tel"),
Email = ReadText(candidate, "email"), Email = ReadText(candidate, "email") ?? ReadText(node, "buyer_email"),
CountryCode = country, CountryCode = country,
Province = ReadText(candidate, "province", "state", "regionState"), Province = ReadText(candidate, "province", "state", "regionState") ?? province,
City = ReadText(candidate, "city"), City = ReadText(candidate, "city") ?? city,
StreetAddress1 = address, StreetAddress1 = address,
StreetAddress2 = ReadText(candidate, "streetAddress2", "address2"), StreetAddress2 = ReadText(candidate, "streetAddress2", "address_line2", "address2"),
ZipCode = zip ZipCode = zip
}; };
} }
@ -349,6 +364,46 @@ public static class PlatformStoreOrderParcelBuilder
return null; 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) private static string? ReadText(JsonNode node, params string[] keys)
{ {
foreach (var key in keys) foreach (var key in keys)

View File

@ -553,7 +553,7 @@ public class PlatformStoreOrderService : ApplicationService
var order = await GetOrderOrThrowAsync(customerId, storeOrderId); var order = await GetOrderOrThrowAsync(customerId, storeOrderId);
await EnsureCanCreateLogisticsAsync(order); 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) if (lines.Count == 0)
{ {
throw new BusinessException("没有可发货的商品行"); throw new BusinessException("没有可发货的商品行");
@ -710,7 +710,7 @@ public class PlatformStoreOrderService : ApplicationService
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == resolvedWarehouseId.Value) var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == resolvedWarehouseId.Value)
?? throw new BusinessException("仓库不存在"); ?? 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) if (lines.Count == 0)
{ {
throw new BusinessException("没有可发货的商品行"); throw new BusinessException("没有可发货的商品行");
@ -1103,8 +1103,8 @@ public class PlatformStoreOrderService : ApplicationService
PlatformStatusLabel = order.PlatformStatusLabel, PlatformStatusLabel = order.PlatformStatusLabel,
ShippingLabelNo = order.ShippingLabelNo, ShippingLabelNo = order.ShippingLabelNo,
ShippingLabelUrl = order.ShippingLabelUrl, ShippingLabelUrl = order.ShippingLabelUrl,
IsMultiLabel = order.IsMultiLabel, ShippingProvider = order.ShippingProvider,
CustomTags = order.CustomTags, FulfillmentType = order.FulfillmentType,
IsSuspended = order.IsSuspended, IsSuspended = order.IsSuspended,
WarehouseId = order.WarehouseId, WarehouseId = order.WarehouseId,
LinkedOrderId = order.LinkedOrderId, LinkedOrderId = order.LinkedOrderId,
@ -1129,7 +1129,6 @@ public class PlatformStoreOrderService : ApplicationService
UnitPrice = line.UnitPrice, UnitPrice = line.UnitPrice,
Quantity = line.Quantity, Quantity = line.Quantity,
Currency = line.Currency, Currency = line.Currency,
IsShipExcluded = line.IsShipExcluded,
MappedSkuId = line.MappedSkuId, MappedSkuId = line.MappedSkuId,
MappedSkuCode = mappedSkuCode MappedSkuCode = mappedSkuCode
}; };

View File

@ -215,6 +215,9 @@ public static class OrderTemplateFieldRegistry
Add("items[0].weight", "总重量(kg)", false, "number", group: "declaration", example: "自动计算", hint: "不填=单重×数量"); Add("items[0].weight", "总重量(kg)", false, "number", group: "declaration", example: "自动计算", hint: "不填=单重×数量");
Add("items[0].imHsCode", "进口HsCode", false, group: "declaration", hint: "选填"); Add("items[0].imHsCode", "进口HsCode", false, group: "declaration", hint: "选填");
Add("items[0].exHsCode", "出口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].skuImage", "商品图片URL", false, group: "declaration", hint: "选填");
Add("items[0].category", "类别", false, group: "declaration", example: "Accessories", hint: "选填"); Add("items[0].category", "类别", false, group: "declaration", example: "Accessories", hint: "选填");
Add("items[0].brand", "品牌", false, group: "declaration", example: "DemoBrand", hint: "选填"); Add("items[0].brand", "品牌", false, group: "declaration", example: "DemoBrand", hint: "选填");

View File

@ -20,6 +20,7 @@ public static class OrderTemplateRowMapper
"sender.countryCode", "sender.province", "sender.city", "sender.zipCode", "sender.streetAddress1", "sender.countryCode", "sender.province", "sender.city", "sender.zipCode", "sender.streetAddress1",
"sender.streetAddress2", "sender.streetAddress3", "sender.houseNumber", "items[0].cnName", "items[0].enName", "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].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].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].enMaterial", "items[0].purpose", "items[0].attributes", "items[0].originCountry", "items[0].salesLink",
"items[0].model", "items[0].specifications", "items[0].comment" "items[0].model", "items[0].specifications", "items[0].comment"

View File

@ -1,6 +1,9 @@
using Fake.Application; using Fake.Application;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.System; using Oms.Application.Dtos.System;
using Oms.Application.Shared;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories; using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.System; namespace Oms.Application.Services.System;
@ -16,20 +19,249 @@ public class CountryService : ApplicationService
/// <summary> /// <summary>
/// 查询国家列表。 /// 查询国家列表。
/// </summary> /// </summary>
/// <returns>国家列表,返回全部有效国家主数据。</returns> /// <returns>国家列表,返回全部有效国家主数据,按二字码、英文名排序。</returns>
[HttpGet("/system/country/list")] [HttpGet("/system/country/list")]
public async Task<List<CountryOutput>> GetListAsync() public async Task<List<CountryOutput>> GetListAsync()
{ {
return (await CountryRepository.GetListAsync()) return (await CountryRepository.GetListAsync())
.OrderBy(x => x.CountryCode) .OrderBy(x => x.CountryCode)
.ThenBy(x => x.EnglishName) .ThenBy(x => x.EnglishName)
.Select(x => new CountryOutput .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, Id = x.Id,
ChineseName = x.ChineseName, ChineseName = x.ChineseName,
EnglishName = x.EnglishName, EnglishName = x.EnglishName,
CountryCode = x.CountryCode CountryCode = x.CountryCode,
}) CreateTime = x.CreateTime,
.ToList(); 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;
} }
} }

View File

@ -49,35 +49,41 @@ public class NoticeService : ApplicationService
[HttpGet("/system/notice/list")] [HttpGet("/system/notice/list")]
public async Task<OmsPageResult<NoticeOutput>> GetListAsync([FromQuery] NoticeListInput input) public async Task<OmsPageResult<NoticeOutput>> GetListAsync([FromQuery] NoticeListInput input)
{ {
var receiverUserId = CurrentUser.GetId<int>(); var receiverUserId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
if (receiverUserId <= 0) if (receiverUserId <= 0)
{ {
return new OmsPageResult<NoticeOutput> { Items = [], Total = 0 }; return new OmsPageResult<NoticeOutput> { Items = [], Total = 0 };
} }
var expectedReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser) var isCustomer = OmsSessionHelper.IsCustomerLogin(CurrentUser);
? ReceiverTypeCustomer var expectedReceiverType = isCustomer ? ReceiverTypeCustomer : ReceiverTypeUser;
: ReceiverTypeUser; // 管理端公告页:管理员可看全部;工作台/H5/未读仍按接收范围过滤
var manageAll = input.Manage == true && !isCustomer;
var myReadNoticeIds = (await NoticeReadRepository.GetListAsync(x => var myReadNoticeIds = (await NoticeReadRepository.GetListAsync(x =>
x.ReceiverType == expectedReceiverType && x.ReceiverId == receiverUserId)) x.ReceiverType == expectedReceiverType && x.ReceiverId == receiverUserId))
.Select(x => x.NoticeId) .Select(x => x.NoticeId)
.Distinct() .Distinct()
.ToList(); .ToList();
var myReadIdSet = myReadNoticeIds.ToHashSet();
var predicate = BuildListPredicate( // 先按类型/关键词查,再在内存按 ReceiverIds 判可见性。
input, // MySQL JSON 列与字符串 "[]" 比较会失败,导致广播对客户/收件箱不可见。
receiverUserId, var basePredicate = BuildManageListPredicate(input);
expectedReceiverType, var candidates = await NoticeRepository.GetListAsync(basePredicate);
input.OnlyUnread == true ? myReadNoticeIds : null); 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 pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var notices = await NoticeRepository.GetPagedListAsync( var total = visible.Count;
predicate, var notices = visible
pageIndex, .Skip(Math.Max(pageIndex - 1, 0) * pageSize)
pageSize, .Take(pageSize)
new Dictionary<string, bool> { [nameof(Notice.CreateTime)] = false, [nameof(Notice.Id)] = false }); .ToList();
var total = await NoticeRepository.CountAsync(predicate);
var noticeIds = notices.Select(x => x.Id).Distinct().ToArray(); var noticeIds = notices.Select(x => x.Id).Distinct().ToArray();
var reads = noticeIds.Length == 0 var reads = noticeIds.Length == 0
@ -88,8 +94,6 @@ public class NoticeService : ApplicationService
.ToDictionary( .ToDictionary(
x => x.Key, x => x.Key,
x => (int)x.Select(item => $"{item.ReceiverType}:{item.ReceiverId}").Distinct(StringComparer.Ordinal).Count()); x => (int)x.Select(item => $"{item.ReceiverType}:{item.ReceiverId}").Distinct(StringComparer.Ordinal).Count());
var myReadIdSet = myReadNoticeIds.ToHashSet();
var selectionIdMap = await BuildReceiverSelectionIdMapAsync(notices); var selectionIdMap = await BuildReceiverSelectionIdMapAsync(notices);
return new OmsPageResult<NoticeOutput> return new OmsPageResult<NoticeOutput>
@ -197,7 +201,7 @@ public class NoticeService : ApplicationService
var currentReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser) var currentReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser)
? ReceiverTypeCustomer ? ReceiverTypeCustomer
: ReceiverTypeUser; : ReceiverTypeUser;
var currentReceiverId = CurrentUser.GetId<int>(); var currentReceiverId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
if (currentReceiverId <= 0) if (currentReceiverId <= 0)
{ {
throw new BusinessException("当前登录主体无效"); throw new BusinessException("当前登录主体无效");
@ -349,33 +353,32 @@ public class NoticeService : ApplicationService
return result; return result;
} }
private static Expression<Func<Notice, bool>> BuildListPredicate( private static Expression<Func<Notice, bool>> BuildManageListPredicate(NoticeListInput input)
NoticeListInput input,
int receiverUserId,
string expectedReceiverType,
IReadOnlyCollection<int>? excludeNoticeIds)
{ {
var noticeType = input.NoticeType?.Trim(); var noticeType = input.NoticeType?.Trim();
var receiverType = input.ReceiverType?.Trim(); var receiverType = input.ReceiverType?.Trim();
var keyword = input.Keyword?.Trim(); var keyword = input.Keyword?.Trim();
var receiverIdToken = receiverUserId.ToString();
var excluded = excludeNoticeIds?.Where(x => x > 0).Distinct().ToList() ?? [];
return x => return x =>
(string.IsNullOrWhiteSpace(noticeType) || x.NoticeType == noticeType) && (string.IsNullOrWhiteSpace(noticeType) || x.NoticeType == noticeType) &&
(string.IsNullOrWhiteSpace(receiverType) || x.ReceiverType == receiverType) && (string.IsNullOrWhiteSpace(receiverType) || x.ReceiverType == receiverType) &&
(string.IsNullOrWhiteSpace(keyword) || (string.IsNullOrWhiteSpace(keyword) ||
x.Title.Contains(keyword) || x.Title.Contains(keyword) ||
x.Content.Contains(keyword)) && x.Content.Contains(keyword));
( }
// 空接收人列表 = 全体广播(员工/客户均可见)
x.ReceiverIds == "[]" || /// <summary>
(x.ReceiverType == expectedReceiverType && /// 空接收人 = 全体广播(员工/客户均可见);否则须类型匹配且 mapId 在名单内。
(x.ReceiverIds == $"[{receiverIdToken}]" || /// </summary>
x.ReceiverIds.StartsWith($"[{receiverIdToken},") || private static bool IsVisibleToReceiver(Notice notice, int receiverUserId, string expectedReceiverType)
x.ReceiverIds.Contains($",{receiverIdToken},") || {
x.ReceiverIds.EndsWith($",{receiverIdToken}]"))) var receiverIds = OmsDocHelper.DeserializeJson<List<int>>(notice.ReceiverIds) ?? [];
) && if (receiverIds.Count == 0)
(excluded.Count == 0 || !excluded.Contains(x.Id)); {
return true;
}
return string.Equals(notice.ReceiverType, expectedReceiverType, StringComparison.OrdinalIgnoreCase)
&& receiverIds.Contains(receiverUserId);
} }
private async Task<Notice> GetNoticeOrThrowAsync(int id) private async Task<Notice> GetNoticeOrThrowAsync(int id)

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

View File

@ -266,7 +266,10 @@ public class MarketingCouponManager : IScopedDependency
await _instanceRepository.UpdateAsync(instance, cancellationToken); 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); var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken);
instance.Status = CouponInstanceStatus.Used; instance.Status = CouponInstanceStatus.Used;
@ -274,6 +277,7 @@ public class MarketingCouponManager : IScopedDependency
instance.UseTime = DateTime.Now; instance.UseTime = DateTime.Now;
instance.LockExpireTime = null; instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken); await _instanceRepository.UpdateAsync(instance, cancellationToken);
return instance;
} }
public async Task RestoreOnOrderRefundAsync(Order order, bool isFullRefund, CancellationToken cancellationToken = default) public async Task RestoreOnOrderRefundAsync(Order order, bool isFullRefund, CancellationToken cancellationToken = default)
@ -314,7 +318,10 @@ public class MarketingCouponManager : IScopedDependency
await _instanceRepository.UpdateAsync(instance, cancellationToken); 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); var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == couponInstanceId, cancellationToken);
if (instance == null || instance.Status != CouponInstanceStatus.Locked) if (instance == null || instance.Status != CouponInstanceStatus.Locked)
@ -322,6 +329,11 @@ public class MarketingCouponManager : IScopedDependency
return; return;
} }
if (ownerOrderId.HasValue && instance.OrderId != ownerOrderId.Value)
{
return;
}
instance.Status = CouponInstanceStatus.Available; instance.Status = CouponInstanceStatus.Available;
instance.OrderId = null; instance.OrderId = null;
instance.LockExpireTime = null; instance.LockExpireTime = null;
@ -383,10 +395,14 @@ public class MarketingCouponManager : IScopedDependency
} }
} }
/// <summary>
/// 解析券抵扣参数;<paramref name="ownerOrderId"/> 为当前订单时,被该订单锁定的券视为可用。
/// </summary>
public async Task<CouponDiscountInput?> ResolveCouponDiscountInputAsync( public async Task<CouponDiscountInput?> ResolveCouponDiscountInputAsync(
int? couponInstanceId, int? couponInstanceId,
int mainCustomerId, int mainCustomerId,
string? destinationCountry = null, string? destinationCountry = null,
int? ownerOrderId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!couponInstanceId.HasValue) if (!couponInstanceId.HasValue)
@ -395,7 +411,7 @@ public class MarketingCouponManager : IScopedDependency
} }
var instance = await GetOwnedInstanceAsync(couponInstanceId.Value, mainCustomerId, cancellationToken); var instance = await GetOwnedInstanceAsync(couponInstanceId.Value, mainCustomerId, cancellationToken);
EnsureUsable(instance); EnsureUsable(instance, ownerOrderId);
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken) var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken)
?? throw new BusinessException("券模板不存在"); ?? throw new BusinessException("券模板不存在");
if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry)) if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry))
@ -422,13 +438,31 @@ public class MarketingCouponManager : IScopedDependency
return await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(_customerRepository, customer, cancellationToken); return await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(_customerRepository, customer, cancellationToken);
} }
/// <summary>
/// 构建可选券列表;传 <paramref name="ownerOrderId"/> 时额外包含被该订单锁定且仍在有效期内的券。
/// </summary>
public async Task<List<CouponAvailableOutput>> BuildAvailableCouponsAsync( public async Task<List<CouponAvailableOutput>> BuildAvailableCouponsAsync(
int mainCustomerId, int mainCustomerId,
decimal? baseFreightAmount, decimal? baseFreightAmount,
string? destinationCountry = null, string? destinationCountry = null,
int? ownerOrderId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var instances = await _instanceRepository.GetAvailableByCustomerAsync(mainCustomerId, cancellationToken); 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) if (instances.Count == 0)
{ {
return []; return [];
@ -541,7 +575,7 @@ public class MarketingCouponManager : IScopedDependency
return instance; return instance;
} }
private static void EnsureUsable(CouponInstance instance) private static void EnsureUsable(CouponInstance instance, int? ownerOrderId = null)
{ {
var now = DateTime.Now; var now = DateTime.Now;
if (instance.Status == CouponInstanceStatus.PendingUnlock) if (instance.Status == CouponInstanceStatus.PendingUnlock)
@ -566,7 +600,8 @@ public class MarketingCouponManager : IScopedDependency
if (instance.Status == CouponInstanceStatus.Locked if (instance.Status == CouponInstanceStatus.Locked
&& instance.LockExpireTime.HasValue && instance.LockExpireTime.HasValue
&& instance.LockExpireTime.Value >= now) && instance.LockExpireTime.Value >= now
&& !(ownerOrderId.HasValue && instance.OrderId == ownerOrderId.Value))
{ {
throw new BusinessException("优惠券已被其他订单占用"); throw new BusinessException("优惠券已被其他订单占用");
} }

View File

@ -12,8 +12,13 @@ public static class MarketingFeeHelper
public static bool IsBaseFreightCostItem(string? costItem) => public static bool IsBaseFreightCostItem(string? costItem) =>
!string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem); !string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem);
/// <summary>
/// 折前应收小计:排除支付时写入的营销抵扣行,避免重算营销金额时重复扣减。
/// </summary>
public static decimal SumReceivable(IEnumerable<OrderFee> fees) => 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) => public static decimal SumReceivableBaseFreight(IEnumerable<OrderFee> fees) =>
fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem)) fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem))

View File

@ -53,15 +53,19 @@ public class MarketingOrderHelper : IScopedDependency
baseFreightAmount, baseFreightAmount,
couponInstanceId, couponInstanceId,
null, null,
cancellationToken); cancellationToken: cancellationToken);
} }
/// <summary>
/// 计算会员折/券互斥后的应付拆解;<paramref name="ownerOrderId"/> 为当前订单时允许使用被该订单锁定的券。
/// </summary>
public async Task<MarketingPriceBreakdown> BuildBreakdownAsync( public async Task<MarketingPriceBreakdown> BuildBreakdownAsync(
int? customerId, int? customerId,
decimal receivableSubtotal, decimal receivableSubtotal,
decimal baseFreightAmount, decimal baseFreightAmount,
int? couponInstanceId, int? couponInstanceId,
string? destinationCountry, string? destinationCountry,
int? ownerOrderId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!customerId.HasValue || customerId.Value <= 0) if (!customerId.HasValue || customerId.Value <= 0)
@ -87,7 +91,7 @@ public class MarketingOrderHelper : IScopedDependency
var priceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey); var priceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey);
var couponInput = await _couponManager.ResolveCouponDiscountInputAsync( var couponInput = await _couponManager.ResolveCouponDiscountInputAsync(
couponInstanceId, mainCustomerId, destinationCountry, cancellationToken); couponInstanceId, mainCustomerId, destinationCountry, ownerOrderId, cancellationToken);
return MarketingBillingCalculator.Calculate( return MarketingBillingCalculator.Calculate(
receivableSubtotal, receivableSubtotal,
@ -114,6 +118,7 @@ public class MarketingOrderHelper : IScopedDependency
int? customerId, int? customerId,
decimal? baseFreightAmount, decimal? baseFreightAmount,
string? destinationCountry = null, string? destinationCountry = null,
int? ownerOrderId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!customerId.HasValue || customerId.Value <= 0) if (!customerId.HasValue || customerId.Value <= 0)
@ -132,6 +137,6 @@ public class MarketingOrderHelper : IScopedDependency
sessionCustomer, sessionCustomer,
cancellationToken); cancellationToken);
return await _couponManager.BuildAvailableCouponsAsync( return await _couponManager.BuildAvailableCouponsAsync(
mainCustomer.Id, baseFreightAmount, destinationCountry, cancellationToken); mainCustomer.Id, baseFreightAmount, destinationCountry, ownerOrderId, cancellationToken);
} }
} }

View File

@ -43,6 +43,27 @@ public static class OmsSessionHelper
return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase); return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase);
} }
/// <summary>
/// 解析当前会话的 RBAC mapId(user.userid)。
/// JWT nameidentifier 多为 Guid,GetId&lt;int&gt; 常为 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> /// <summary>
/// 客户登录时返回 mapId(对应 t_customer.UserId / 订单 CreateUserId);管理员返回 null。 /// 客户登录时返回 mapId(对应 t_customer.UserId / 订单 CreateUserId);管理员返回 null。
/// </summary> /// </summary>
@ -53,20 +74,8 @@ public static class OmsSessionHelper
return null; return null;
} }
var userId = currentUser.GetId<int>(); var mapId = ResolveMapUserId(currentUser);
if (userId > 0) return mapId > 0 ? mapId : null;
{
return userId;
}
// GetId 未绑到 mapId 时,直接读 claim,避免客户会话被当成管理员(不过滤)
var mapIdRaw = currentUser.FindClaimOrNull(MapIdClaim)?.Value;
if (int.TryParse(mapIdRaw, out var mapId) && mapId > 0)
{
return mapId;
}
return null;
} }
/// <summary> /// <summary>

View File

@ -201,6 +201,9 @@ public static class OrderDownstreamCreateOneBodyBuilder
ImHsCode = x.ImHsCode, ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode ?? x.HsCode, ExHsCode = x.ExHsCode ?? x.HsCode,
DeclareElement = x.DeclareElement, DeclareElement = x.DeclareElement,
ProductId = x.ProductId,
NonStdProductId = x.NonStdProductId,
StdProductId = x.StdProductId,
SkuImage = fileStorage?.GetPublicUrl(x.SkuImage) ?? x.SkuImage, SkuImage = fileStorage?.GetPublicUrl(x.SkuImage) ?? x.SkuImage,
Category = x.Category, Category = x.Category,
Brand = x.Brand, Brand = x.Brand,

View File

@ -6,6 +6,23 @@ public static class OrderFeeTypeCodes
public const string Payable = "payable"; 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 static class OrderFeeChargingStageCodes
{ {
public const string Forecast = "forecast"; public const string Forecast = "forecast";

View File

@ -37,6 +37,21 @@ public class OrderDeclaration : OmsAuditedEntity
/// </summary> /// </summary>
public string? DeclareElement { get; set; } 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? SkuImage { get; set; }
public string? Category { get; set; } public string? Category { get; set; }

View File

@ -56,9 +56,11 @@ public class SalesStoreOrder : OmsAuditedAggregateRoot
public string? ShippingLabelUrl { get; set; } 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; } public bool IsSuspended { get; set; }

View File

@ -24,7 +24,5 @@ public class SalesStoreOrderLine : OmsAuditedAggregateRoot
public string? Currency { get; set; } public string? Currency { get; set; }
public bool IsShipExcluded { get; set; }
public int? MappedSkuId { get; set; } public int? MappedSkuId { get; set; }
} }

View File

@ -16,5 +16,9 @@ public class Notice : OmsAuditedAggregateRoot
public string ReceiverType { get; set; } = string.Empty; public string ReceiverType { get; set; } = string.Empty;
/// <summary>
/// 接收人 mapId 列表 JSON 数组字符串,如 [] / [1,2]。用 text 存储,避免 MySQL JSON 列与字符串比较失败导致广播不可见。
/// </summary>
[SugarColumn(ColumnDataType = "text")]
public string ReceiverIds { get; set; } = "[]"; public string ReceiverIds { get; set; } = "[]";
} }

View File

@ -6,4 +6,9 @@ namespace Oms.Domain.SystemAggregate.Repositories;
public interface ICountryRepository : IRepository<Country> public interface ICountryRepository : IRepository<Country>
{ {
Task<Country?> GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default); Task<Country?> GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default);
/// <summary>
/// 按二字码查询,包含已软删除记录,用于恢复同码数据。
/// </summary>
Task<Country?> GetByCountryCodeIncludingDeletedAsync(string countryCode, CancellationToken cancellationToken = default);
} }

View File

@ -11,4 +11,15 @@ public class CountryRepository : OmsSqlSugarRepository<Country>, ICountryReposit
{ {
return FirstOrDefaultAsync(x => x.CountryCode == countryCode, cancellationToken); 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);
}
} }

View File

@ -6,8 +6,6 @@ using Fake.Testing;
using Fake.UnitOfWork; using Fake.UnitOfWork;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Oms.Application.Messaging; using Oms.Application.Messaging;
using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Consts;
using Oms.Tests.Messaging.Fakes; using Oms.Tests.Messaging.Fakes;
@ -18,8 +16,8 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools<OmsOut
{ {
private readonly IUnitOfWorkManager _uowManager; private readonly IUnitOfWorkManager _uowManager;
private readonly RecordingPhysicalDistributedEventBus _physicalBus; private readonly RecordingPhysicalDistributedEventBus _physicalBus;
private readonly IDistributedEventBus _eventBus;
private readonly IEfDbContextProvider<IntegrationEventLogContext> _dbProvider; private readonly IEfDbContextProvider<IntegrationEventLogContext> _dbProvider;
private readonly OrderBatchJobPublisher _publisher;
protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options) protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options)
{ {
@ -29,13 +27,9 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools<OmsOut
public OrderBatchJobOutboxPublisherTests() public OrderBatchJobOutboxPublisherTests()
{ {
_uowManager = ServiceProvider.GetRequiredService<IUnitOfWorkManager>(); _uowManager = ServiceProvider.GetRequiredService<IUnitOfWorkManager>();
_eventBus = ServiceProvider.GetRequiredService<IDistributedEventBus>();
_physicalBus = ServiceProvider.GetRequiredService<RecordingPhysicalDistributedEventBus>(); _physicalBus = ServiceProvider.GetRequiredService<RecordingPhysicalDistributedEventBus>();
_dbProvider = ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>(); _dbProvider = ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>();
_publisher = new OrderBatchJobPublisher(
ServiceProvider.GetRequiredService<IDistributedEventBus>(),
_uowManager,
Options.Create(new OrderBatchRabbitMqOptions { Enabled = true }),
NullLogger<OrderBatchJobPublisher>.Instance);
} }
[Fact] [Fact]
@ -56,7 +50,7 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools<OmsOut
using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional())) using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional()))
{ {
await _dbProvider.GetDbContextAsync(); await _dbProvider.GetDbContextAsync();
await _publisher.PublishAsync(message); await _eventBus.PublishAsync(OrderBatchJobIntegrationEvent.FromMessage(message));
_physicalBus.Published.Should().BeEmpty(); _physicalBus.Published.Should().BeEmpty();
await uow.CompleteAsync(); await uow.CompleteAsync();
} }
@ -68,23 +62,4 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools<OmsOut
evt.MessageId.Should().Be(message.MessageId); 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*");
}
} }

View File

@ -6,7 +6,7 @@ using Fake.Testing;
using Fake.UnitOfWork; using Fake.UnitOfWork;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Newtonsoft.Json;
using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Messaging; using Oms.Application.Messaging;
using Oms.Application.Messaging.ParcelCreate; using Oms.Application.Messaging.ParcelCreate;
@ -22,7 +22,6 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools<Om
private readonly RecordingPhysicalDistributedEventBus _physicalBus; private readonly RecordingPhysicalDistributedEventBus _physicalBus;
private readonly IOutboxEventLogService _outbox; private readonly IOutboxEventLogService _outbox;
private readonly IEfDbContextProvider<IntegrationEventLogContext> _dbProvider; private readonly IEfDbContextProvider<IntegrationEventLogContext> _dbProvider;
private readonly OrderParcelCreateEventPublisher _publisher;
protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options) protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options)
{ {
@ -36,11 +35,6 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools<Om
_physicalBus = ServiceProvider.GetRequiredService<RecordingPhysicalDistributedEventBus>(); _physicalBus = ServiceProvider.GetRequiredService<RecordingPhysicalDistributedEventBus>();
_outbox = ServiceProvider.GetRequiredService<IOutboxEventLogService>(); _outbox = ServiceProvider.GetRequiredService<IOutboxEventLogService>();
_dbProvider = ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>(); _dbProvider = ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>();
_publisher = new OrderParcelCreateEventPublisher(
_distributedEventBus,
_uowManager,
ServiceProvider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<OrderParcelCreateEventPublisher>.Instance);
} }
[Fact] [Fact]
@ -58,7 +52,11 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools<Om
using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional())) using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional()))
{ {
await _dbProvider.GetDbContextAsync(); await _dbProvider.GetDbContextAsync();
await _publisher.PublishCustoms9610UpsertAsync(order, "lazada"); await _distributedEventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
{
OrderId = order.Id,
PlatformCode = "lazada"
});
_physicalBus.Published.Should().BeEmpty(); _physicalBus.Published.Should().BeEmpty();
await uow.CompleteAsync(); await uow.CompleteAsync();
} }
@ -86,8 +84,19 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools<Om
using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional())) using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional()))
{ {
await _dbProvider.GetDbContextAsync(); await _dbProvider.GetDbContextAsync();
await _publisher.PublishTrackUpsertAsync(order, request, customerId: 9); await _distributedEventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent
await _publisher.PublishLabelGenerateAsync(order, operatorUserId: 7); {
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(); _physicalBus.Published.Should().BeEmpty();
await uow.CompleteAsync(); await uow.CompleteAsync();
} }
@ -108,7 +117,10 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools<Om
using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional())) using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional()))
{ {
await _dbProvider.GetDbContextAsync(); await _dbProvider.GetDbContextAsync();
await _publisher.PublishCustoms9610UpsertAsync(order); await _distributedEventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
{
OrderId = order.Id
});
await uow.RollbackAsync(); await uow.RollbackAsync();
} }
@ -116,16 +128,16 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools<Om
} }
[Fact] [Fact]
public async Task 无环境事务时_短事务仍能写入并投递() public async Task 无环境事务时_总线应拒绝发布()
{ {
_physicalBus.Clear();
_uowManager.Current.Should().BeNull(); _uowManager.Current.Should().BeNull();
var order = new Order { Id = 404, ServiceNo = "SN404" }; var act = () => _distributedEventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
await _publisher.PublishCustoms9610UpsertAsync(order, "shopee"); {
OrderId = 404,
PlatformCode = "shopee"
});
_physicalBus.Published.Should().ContainSingle() await act.Should().ThrowAsync<InvalidOperationException>();
.Which.Should().BeOfType<OrderParcelCustoms9610UpsertIntegrationEvent>()
.Which.OrderId.Should().Be(404);
} }
} }

View File

@ -6,7 +6,6 @@ using Fake.Testing;
using Fake.UnitOfWork; using Fake.UnitOfWork;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Oms.Application.Messaging; using Oms.Application.Messaging;
using Oms.Application.Messaging.OrderSideEffects; using Oms.Application.Messaging.OrderSideEffects;
using Oms.Application.Services.Orders.Issues; using Oms.Application.Services.Orders.Issues;
@ -20,7 +19,6 @@ public class OrderSideEffectOutboxPublisherTests : ApplicationTestWithTools<OmsO
private readonly IDistributedEventBus _distributedEventBus; private readonly IDistributedEventBus _distributedEventBus;
private readonly RecordingPhysicalDistributedEventBus _physicalBus; private readonly RecordingPhysicalDistributedEventBus _physicalBus;
private readonly IEfDbContextProvider<IntegrationEventLogContext> _dbProvider; private readonly IEfDbContextProvider<IntegrationEventLogContext> _dbProvider;
private readonly OrderSideEffectEventPublisher _publisher;
protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options) protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options)
{ {
@ -33,10 +31,6 @@ public class OrderSideEffectOutboxPublisherTests : ApplicationTestWithTools<OmsO
_distributedEventBus = ServiceProvider.GetRequiredService<IDistributedEventBus>(); _distributedEventBus = ServiceProvider.GetRequiredService<IDistributedEventBus>();
_physicalBus = ServiceProvider.GetRequiredService<RecordingPhysicalDistributedEventBus>(); _physicalBus = ServiceProvider.GetRequiredService<RecordingPhysicalDistributedEventBus>();
_dbProvider = ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>(); _dbProvider = ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>();
_publisher = new OrderSideEffectEventPublisher(
_distributedEventBus,
_uowManager,
NullLogger<OrderSideEffectEventPublisher>.Instance);
} }
[Fact] [Fact]
@ -47,8 +41,18 @@ public class OrderSideEffectOutboxPublisherTests : ApplicationTestWithTools<OmsO
using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional())) using (var uow = _uowManager.Begin(OmsOutboxUnitOfWork.Transactional()))
{ {
await _dbProvider.GetDbContextAsync(); await _dbProvider.GetDbContextAsync();
await _publisher.PublishAutoTriggerFireAsync(11, AutoTriggerTiming.OrderCreate, 3); await _distributedEventBus.PublishAsync(new OrderAutoTriggerFireIntegrationEvent
await _publisher.PublishLifecycleTrackAppendAsync(11, "SN11", "A0301"); {
OrderId = 11,
Timing = AutoTriggerTiming.OrderCreate.ToString(),
OperatorUserId = 3
});
await _distributedEventBus.PublishAsync(new OrderLifecycleTrackAppendIntegrationEvent
{
OrderId = 11,
OrderNo = "SN11",
TrackCode = "A0301"
});
_physicalBus.Published.Should().BeEmpty(); _physicalBus.Published.Should().BeEmpty();
await uow.CompleteAsync(); await uow.CompleteAsync();
} }

View File

@ -15,13 +15,6 @@
</PackageReference> </PackageReference>
<PackageReference Include="Fake.Autofac" Version="$(FakePackageVersion)" /> <PackageReference Include="Fake.Autofac" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.Testing" 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)" /> <PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
</ItemGroup> </ItemGroup>

View File

@ -1,7 +1,7 @@
using FluentAssertions; using FluentAssertions;
using Fake.Application; using Fake.Application;
using Fake.EventBus.Distributed;
using Fake.ObjectStorage; using Fake.ObjectStorage;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moq; using Moq;
@ -23,13 +23,12 @@ public class OrderBatchJobCoordinatorGovernanceTests
private readonly Mock<ICountryRepository> _countryRepository = new(MockBehavior.Loose); private readonly Mock<ICountryRepository> _countryRepository = new(MockBehavior.Loose);
private readonly Mock<IServiceChannelRepository> _serviceChannelRepository = new(MockBehavior.Loose); private readonly Mock<IServiceChannelRepository> _serviceChannelRepository = new(MockBehavior.Loose);
private readonly Mock<IObjectStorage> _fileStorage = 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; private readonly OrderBatchJobCoordinator _coordinator;
public OrderBatchJobCoordinatorGovernanceTests() public OrderBatchJobCoordinatorGovernanceTests()
{ {
_publisher.SetupGet(x => x.IsEnabled).Returns(true); var batchOptions = Options.Create(new OrderBatchRabbitMqOptions { Enabled = true });
var batchOptions = Options.Create(new OrderBatchRabbitMqOptions());
var actionExecutor = new Mock<IOrderBatchActionExecutor>(MockBehavior.Loose); var actionExecutor = new Mock<IOrderBatchActionExecutor>(MockBehavior.Loose);
var processor = new OrderBatchJobProcessor( var processor = new OrderBatchJobProcessor(
_batchJobRepository.Object, _batchJobRepository.Object,
@ -37,17 +36,14 @@ public class OrderBatchJobCoordinatorGovernanceTests
actionExecutor.Object, actionExecutor.Object,
batchOptions, batchOptions,
NullLogger<OrderBatchJobProcessor>.Instance); NullLogger<OrderBatchJobProcessor>.Instance);
var unitOfWorkManager = new Mock<IUnitOfWorkManager>(MockBehavior.Loose);
unitOfWorkManager.SetupGet(x => x.Current).Returns((IUnitOfWork?)null);
_coordinator = new OrderBatchJobCoordinator( _coordinator = new OrderBatchJobCoordinator(
_batchJobRepository.Object, _batchJobRepository.Object,
_orderRepository.Object, _orderRepository.Object,
_countryRepository.Object, _countryRepository.Object,
_serviceChannelRepository.Object, _serviceChannelRepository.Object,
_fileStorage.Object, _fileStorage.Object,
_publisher.Object, _eventBus.Object,
processor, processor,
unitOfWorkManager.Object,
batchOptions, batchOptions,
NullLogger<OrderBatchJobCoordinator>.Instance); NullLogger<OrderBatchJobCoordinator>.Instance);
} }
@ -71,8 +67,8 @@ public class OrderBatchJobCoordinatorGovernanceTests
_batchJobRepository _batchJobRepository
.Setup(x => x.UpdateJobAsync(job, It.IsAny<CancellationToken>())) .Setup(x => x.UpdateJobAsync(job, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
_publisher _eventBus
.Setup(x => x.PublishAsync(It.IsAny<OrderBatchJobMessage>(), It.IsAny<CancellationToken>())) .Setup(x => x.PublishAsync(It.IsAny<OrderBatchJobIntegrationEvent>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
var output = await _coordinator.RetryFailedAsync(job.Id, null, 99); var output = await _coordinator.RetryFailedAsync(job.Id, null, 99);
@ -83,9 +79,9 @@ public class OrderBatchJobCoordinatorGovernanceTests
failedItem.RetryCount.Should().Be(0); failedItem.RetryCount.Should().Be(0);
failedItem.FailReason.Should().BeNull(); failedItem.FailReason.Should().BeNull();
job.Status.Should().Be(OrderBatchJobStatusCodes.Running); job.Status.Should().Be(OrderBatchJobStatusCodes.Running);
_publisher.Verify( _eventBus.Verify(
x => x.PublishAsync( x => x.PublishAsync(
It.Is<OrderBatchJobMessage>(m => m.JobItemId == failedItem.Id), It.Is<OrderBatchJobIntegrationEvent>(m => m.JobItemId == failedItem.Id),
It.IsAny<CancellationToken>()), It.IsAny<CancellationToken>()),
Times.Once); Times.Once);
} }

View File

@ -17,31 +17,12 @@
<PackageReference Include="Fake.Consul" Version="$(FakePackageVersion)" /> <PackageReference Include="Fake.Consul" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.ObjectStorage.TencentCos" Version="$(FakePackageVersion)" /> <PackageReference Include="Fake.ObjectStorage.TencentCos" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.Serilog" 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.IntegrationEventLog" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.EventBus.Dashboard" 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.Application" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.FileManagement.Infrastructure" Version="$(FakePackageVersion)" /> <PackageReference Include="Fake.FileManagement.Infrastructure" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" /> <PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>