Compare commits

..

No commits in common. "master" and "mq" have entirely different histories.
master ... mq

223 changed files with 1790 additions and 12600 deletions

View File

@ -40,10 +40,6 @@ jobs:
IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}"
echo "TARGET=$TARGET REF=$REF REPO=$REPO TAG=$TAG host=$(hostname)"
# FakePackageVersion bump 后私服元数据可能仍被 NuGet http-cache 命中旧索引
echo "==> clear NuGet http-cache (Fake packages / BaGet)"
dotnet nuget locals http-cache --clear
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
unset NAMESPACE
cd /data/k3s

View File

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

View File

@ -1,26 +0,0 @@
<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,10 +0,0 @@
<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

@ -122,63 +122,6 @@ SimpleAdmin.Api/
└── README.md
```
## 开发规范(强制)
更完整的分层 / 路由 / DTO 约定见 `开发复刻说明.md`、`design.md`。下列为新增代码与合入共享分支的硬性标准,评审与上传前必须满足。
### 1. 写接口必须写注释
对外暴露的 Application API(`ApplicationService` 上的 `[HttpGet]` / `[HttpPost]` 等方法)**必须**有 XML / 中文注释,至少说明:
- 接口用途(做什么)
- 关键入参含义与约束(可写在方法注释或 DTO 属性注释上)
- 关键返回语义或业务失败场景(如抛 `BusinessException` 的条件)
DTO、领域常量、非显而易见的私有辅助方法也应补注释,避免「只有路由名能猜业务」。
```csharp
/// <summary>
/// 批量创建仓储代发订单:建单并绑定库存/包裹,产品须为分组 wms。
/// </summary>
/// <param name="input">仓库、产品编码与店铺订单绑定明细。</param>
[HttpPost("/order/platform-store-order/batch-create-fulfill-wms")]
public async Task<PlatformStoreOrderBatchCreateLogisticsOutput> BatchCreateFulfillWmsAsync(
PlatformStoreOrderBatchCreateFulfillInput input)
```
### 2. 写 SQL 必须走 ORM(SqlSugar)
业务读写、列表筛选、联表、聚合、更新**一律通过 SqlSugar / Repository**,禁止在 Application / Domain 服务里手写拼装 SQL 字符串或 `Ado.SqlQuery` 作为常规实现。
| 允许 | 禁止(业务代码) |
|------|------------------|
| `Repository` / `ISugarQueryable` / 表达式 `Where` | 服务内大段 `"SELECT ... " +` 拼接 |
| 实体 + 仓储封装复杂查询 | 绕过仓储直接 ADO 执行业务 SQL |
| `sql/*.sql` 仅作建库 / 存量 DDL 迁移 | 用脚本替代日常业务 CRUD |
存量 DDL、字典初始化等脚本放在 `sql/`(说明见 `sql/README.md`),与运行时 ORM 查询分离;**运行时查询与写入必须 ORM**。
新增增量迁移文件命名:`migrate_<主题>_YYYYMMDD.sql`(日期在末尾,例如 `migrate_xxx_20261016.sql`)。详细约定见 `sql/README.md`。
### 3. 上传代码前先同步远端
`test`、`master` 等共享分支会有其他人提交。**推送前必须先拉取远端,再上传**,避免覆盖或漏掉别人的提交。
```bash
git fetch origin
git pull --ff-only origin <当前分支>
git push origin <当前分支>
```
- 工作区有未提交改动时,先暂存(stash)或提交,再同步,同步完成后再恢复。
- 本地已有未推送提交、远端也有新提交时,先 rebase 或 merge 并解决冲突,确认与远端对齐后再 `push`。
- 禁止在未同步远端的情况下直接 `push` 共享分支。
### 4. 相关约定速查
- **禁止**新建库级唯一索引(`UNIQUE KEY`):见 `.cursor/rules/no-unique-indexes.mdc`,业务唯一性在 Application 校验。
- 路由显式声明;分页统一 `totalCount` + `items`;勿把 Entity 直接返回前端。
## Fake 框架特性
### 自动 API 生成

View File

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

8
db.sql
View File

@ -432,9 +432,6 @@ CREATE TABLE `t_order_declaration` (
`ImHsCode` VARCHAR(50) NULL COMMENT '进口HsCode',
`ExHsCode` VARCHAR(50) NULL COMMENT '出口HsCode',
`DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素',
`ProductId` VARCHAR(100) NULL COMMENT '产品标识编码',
`NonStdProductId` VARCHAR(100) NULL COMMENT '非标产品标识编码',
`StdProductId` VARCHAR(100) NULL COMMENT '标准产品标识编码',
`SkuImage` VARCHAR(512) NULL COMMENT '图片(存 storage key 或短 URL)',
`Category` VARCHAR(20) NULL COMMENT '类别',
`Brand` VARCHAR(50) NULL COMMENT '品牌',
@ -1223,7 +1220,7 @@ CREATE TABLE `t_notice` (
`Content` TEXT NOT NULL COMMENT '通知内容',
`IsPopup` TINYINT NOT NULL DEFAULT 0 COMMENT '是否弹窗:0-否,1-是',
`ReceiverType` VARCHAR(16) NOT NULL COMMENT '接收对象:user/customer',
`ReceiverIds` TEXT NOT NULL COMMENT '接收对象ID列表(JSON数组字符串)',
`ReceiverIds` JSON NOT NULL COMMENT '接收对象ID列表(JSON数组)',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
@ -1413,9 +1410,6 @@ CREATE TABLE `c_sku` (
`ExHsCode` VARCHAR(64) NULL COMMENT '出口HSCode',
`ImHsCode` VARCHAR(64) NULL COMMENT '进口HSCode',
`DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素',
`ProductId` VARCHAR(100) NULL COMMENT '产品标识编码',
`NonStdProductId` VARCHAR(100) NULL COMMENT '非标产品标识编码',
`StdProductId` VARCHAR(100) NULL COMMENT '标准产品标识编码',
`WeightG` DECIMAL(10,2) NULL COMMENT '重量(g)',
`LengthCm` DECIMAL(10,2) NULL COMMENT '长(cm)',
`WidthCm` DECIMAL(10,2) NULL COMMENT '宽(cm)',

View File

@ -2,18 +2,6 @@
OMS / 共用库的建表脚本已统一到仓库根目录 `db.sql`。
## 职责边界
本目录**只放 DDL / 存量迁移 / 字典种子**等库结构与一次性数据脚本。
- **运行时业务查询与写入必须走 SqlSugar ORM(仓储)**,不要在 Application 里用手写 SQL 替代 CRUD。
- 完整编码标准见仓库根目录 `README.md` →「开发规范」。
## 使用说明
- 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行)
- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过)
- 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落
- 单次增量迁移命名:`migrate_<主题>_YYYYMMDD.sql`(日期放在文件名**末尾**,如 `migrate_sales_store_order_provider_fulfillment_20261016.sql`)
- 尽量可重复执行(先判断列/表/字典是否已存在)
- **后续新增脚本一律带日期后缀**;历史未带日期的脚本不强制改名

View File

@ -1,20 +0,0 @@
-- 删除创单后置步骤表(已不再使用)。可重复执行。
SET @db := DATABASE();
SET @exists := (
SELECT COUNT(1)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order_side_effect'
);
SET @sql := IF(
@exists > 0,
'DROP TABLE `t_order_side_effect`',
'SELECT ''t_order_side_effect already dropped'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -1,105 +0,0 @@
-- 渠道代理与返佣(首版轻量)。可重复执行。
-- 含:t_agent、t_agent_commission_detail;t_customer / t_order 扩展 AgentId。
-- AggregateRoot 需要 ConcurrencyStamp。
SET @db := DATABASE();
-- ---------- t_agent ----------
CREATE TABLE IF NOT EXISTS `t_agent` (
`Id` INT NOT NULL AUTO_INCREMENT,
`UserId` INT NOT NULL COMMENT 'RBAC user.userid / JWT mapId',
`AgentCode` VARCHAR(32) NOT NULL COMMENT '登录账号+注册链接编码',
`AgentName` VARCHAR(64) NOT NULL COMMENT '显示名',
`Phone` VARCHAR(32) NULL,
`Email` VARCHAR(128) NULL,
`Password` VARCHAR(64) NOT NULL COMMENT 'MD5 密码',
`Status` TINYINT NOT NULL DEFAULT 2 COMMENT '1停用 2启用',
`BaseType` VARCHAR(32) NOT NULL DEFAULT 'freight' COMMENT 'freight/receivable',
`CommissionRate` DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '返佣比例',
`SalesProductIds` VARCHAR(512) NULL COMMENT '可选销售产品Id列表 JSON/[1,2] 或逗号;空=不限',
`Remark` VARCHAR(512) NULL,
`IsDeleted` TINYINT(1) NOT NULL DEFAULT 0,
`CreateUserId` INT NOT NULL DEFAULT 0,
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`UpdateUserId` INT NOT NULL DEFAULT 0,
`UpdateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 不用 DEFAULT(UUID()):statement 复制下会报 1674;运行时由 ORM 写入
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳',
PRIMARY KEY (`Id`),
KEY `ix_agent_code` (`AgentCode`),
KEY `ix_agent_userid` (`UserId`)
) COMMENT='渠道代理主数据';
-- 已建表补 ConcurrencyStamp(可重复)
SET @exists := (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_agent' AND COLUMN_NAME = 'ConcurrencyStamp'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_agent` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '''' COMMENT ''并发戳'' AFTER `UpdateTime`',
'SELECT ''t_agent.ConcurrencyStamp already exists'' AS info'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------- t_agent_commission_detail ----------
CREATE TABLE IF NOT EXISTS `t_agent_commission_detail` (
`Id` INT NOT NULL AUTO_INCREMENT,
`AgentId` INT NOT NULL,
`OrderId` INT NOT NULL,
`CustomerId` INT NOT NULL,
`SalesProductId` INT NULL,
`BaseType` VARCHAR(32) NOT NULL,
`BaseAmount` DECIMAL(18,4) NOT NULL DEFAULT 0,
`CommissionRate` DECIMAL(18,6) NOT NULL DEFAULT 0,
`CommissionAmount` DECIMAL(18,4) NOT NULL DEFAULT 0,
`Status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid',
`TriggerOrderStatus` VARCHAR(32) NOT NULL,
`PaidTime` DATETIME NULL,
`Remark` VARCHAR(512) NULL,
`IsDeleted` TINYINT(1) NOT NULL DEFAULT 0,
`CreateUserId` INT NOT NULL DEFAULT 0,
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`UpdateUserId` INT NOT NULL DEFAULT 0,
`UpdateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳',
PRIMARY KEY (`Id`),
KEY `ix_agent_comm_agent` (`AgentId`),
KEY `ix_agent_comm_order` (`OrderId`),
KEY `ix_agent_comm_status` (`Status`)
) COMMENT='代理返佣明细(首版无余额入账)';
SET @exists := (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_agent_commission_detail' AND COLUMN_NAME = 'ConcurrencyStamp'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_agent_commission_detail` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '''' COMMENT ''并发戳'' AFTER `UpdateTime`',
'SELECT ''t_agent_commission_detail.ConcurrencyStamp already exists'' AS info'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------- t_customer.AgentId ----------
SET @exists := (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_customer' AND COLUMN_NAME = 'AgentId'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_customer` ADD COLUMN `AgentId` INT NULL COMMENT ''所属渠道代理'' AFTER `CommissionBalance`, ADD COLUMN `AgentBindTime` DATETIME NULL COMMENT ''代理绑定时间'' AFTER `AgentId`, ADD KEY `ix_customer_agent` (`AgentId`)',
'SELECT ''t_customer.AgentId already exists'' AS info'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------- t_order.AgentId ----------
SET @exists := (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'AgentId'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_order` ADD COLUMN `AgentId` INT NULL COMMENT ''下单时代理快照'' AFTER `CustomerId`, ADD KEY `ix_order_agent` (`AgentId`)',
'SELECT ''t_order.AgentId already exists'' AS info'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -1,55 +0,0 @@
-- c_sku:新增产品标识编码 / 非标产品标识编码 / 标准产品标识编码(对齐订单申报明细)。
-- 可重复执行。
SET @db := DATABASE();
-- c_sku.ProductId
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 'c_sku'
AND COLUMN_NAME = 'ProductId'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `c_sku` ADD COLUMN `ProductId` VARCHAR(100) NULL COMMENT ''产品标识编码'' AFTER `DeclareElement`',
'SELECT ''c_sku.ProductId already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- c_sku.NonStdProductId
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 'c_sku'
AND COLUMN_NAME = 'NonStdProductId'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `c_sku` ADD COLUMN `NonStdProductId` VARCHAR(100) NULL COMMENT ''非标产品标识编码'' AFTER `ProductId`',
'SELECT ''c_sku.NonStdProductId already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- c_sku.StdProductId
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 'c_sku'
AND COLUMN_NAME = 'StdProductId'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `c_sku` ADD COLUMN `StdProductId` VARCHAR(100) NULL COMMENT ''标准产品标识编码'' AFTER `NonStdProductId`',
'SELECT ''c_sku.StdProductId already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -1,34 +0,0 @@
-- 费用项字典:营销抵扣(支付时写入的负数应收行)。可重复执行。
-- 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

@ -1,25 +0,0 @@
-- =============================================================================
-- 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

@ -1,55 +0,0 @@
-- 申报明细:新增产品标识编码 / 非标产品标识编码 / 标准产品标识编码。
-- 可重复执行。
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

@ -1,24 +0,0 @@
-- 销售产品分组字典:仓储代发(wms)。可重复执行。
-- 用于店铺订单「创建代发订单」选品过滤;销售产品 ProductGroup 填 wms。
SET @db := DATABASE();
-- 确保字典类型存在
INSERT INTO `t_dict_type` (`DictType`, `Remark`, `TypeSort`, `IsDeleted`, `CreateUserId`)
SELECT 'sales_product_group', '销售产品分组', 0, 0, 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `t_dict_type`
WHERE `DictType` = 'sales_product_group' AND `IsDeleted` = 0
);
-- 写入 wms 字典项
INSERT INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `IsDeleted`, `CreateUserId`)
SELECT 'sales_product_group', 90, 'wms', '仓储代发', 0, 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `t_dict`
WHERE `DictType` = 'sales_product_group'
AND `DictCode` = 'wms'
AND `IsDeleted` = 0
);

View File

@ -1,89 +0,0 @@
-- 店铺订单:落库 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

@ -1,24 +0,0 @@
-- 后台用户启用/禁用(可重复执行)。
-- 表名:fk_user(Fake RBAC)。默认启用;禁用后拒绝账号密码登录与仓配 Token 换取。
SET @db := DATABASE();
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 'fk_user'
AND COLUMN_NAME = 'IsEnabled'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `fk_user` ADD COLUMN `IsEnabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:1启用 0禁用'' AFTER `IsDeleted`',
'SELECT ''fk_user.IsEnabled already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE `fk_user` SET `IsEnabled` = 1 WHERE `IsEnabled` IS NULL;

View File

@ -1,6 +1,5 @@
using Fake.DependencyInjection;
using Fake.Rbac.Infrastructure;
using Fake.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Oms.Application.Dtos.DataPerm;
@ -12,7 +11,7 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
public async Task<RbacUserSnapshot?> GetUserSnapshotAsync(Guid userId, CancellationToken cancellationToken = default)
{
var sql = $@"
const string sql = """
SELECT
u.`Id` AS `Id`,
u.`userid` AS `MapId`,
@ -20,25 +19,26 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
u.`Name` AS `Name`,
u.`OrganizationId` AS `OrganizationId`,
o.`Name` AS `OrganizationName`
FROM `{FakeTables.User}` u
LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0
WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0
FROM `user` u
LEFT JOIN `organization` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0
WHERE u.`Id` = {0} AND u.`IsDeleted` = 0
LIMIT 1
";
""";
return await Db.Database.SqlQueryRaw<RbacUserSnapshot>(sql, userId.ToString())
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<bool> IsPureCustomerUserAsync(Guid userId, CancellationToken cancellationToken = default)
{
var sql = $@"
const string sql = """
SELECT COUNT(*) AS `Value`
FROM `{FakeTables.UserRole}` ur
INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0
WHERE ur.`UserId` = {{0}}
FROM `user_role` ur
INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0
WHERE ur.`UserId` = {0}
AND r.`Code` <> 'Customer'
AND r.`Code` NOT LIKE 'CustomerSub_%'
";
""";
var nonCustomerRoleCount = await Db.Database.SqlQueryRaw<int>(sql, userId.ToString())
.FirstAsync(cancellationToken);
@ -47,11 +47,12 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
return false;
}
var hasRoleSql = $@"
const string hasRoleSql = """
SELECT COUNT(*) AS `Value`
FROM `{FakeTables.UserRole}` ur
WHERE ur.`UserId` = {{0}}
";
FROM `user_role` ur
WHERE ur.`UserId` = {0}
""";
var roleCount = await Db.Database.SqlQueryRaw<int>(hasRoleSql, userId.ToString())
.FirstAsync(cancellationToken);
return roleCount > 0;
@ -59,13 +60,13 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
public async Task<bool> IsAdminUserAsync(Guid userId, CancellationToken cancellationToken = default)
{
var sql = $@"
const string sql = """
SELECT COUNT(*) AS `Value`
FROM `{FakeTables.UserRole}` ur
INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0
WHERE ur.`UserId` = {{0}}
FROM `user_role` ur
INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0
WHERE ur.`UserId` = {0}
AND r.`Code` = 'Admin'
";
""";
var count = await Db.Database.SqlQueryRaw<int>(sql, userId.ToString()).FirstAsync(cancellationToken);
return count > 0;
@ -73,22 +74,23 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
public async Task<List<Guid>> GetUserRoleIdsAsync(Guid userId, CancellationToken cancellationToken = default)
{
var sql = $@"
const string sql = """
SELECT ur.`RoleId` AS `Value`
FROM `{FakeTables.UserRole}` ur
WHERE ur.`UserId` = {{0}}
";
FROM `user_role` ur
WHERE ur.`UserId` = {0}
""";
return await Db.Database.SqlQueryRaw<Guid>(sql, userId.ToString())
.ToListAsync(cancellationToken);
}
public async Task<List<Guid>> GetUserIdsByRoleAsync(Guid roleId, CancellationToken cancellationToken = default)
{
var sql = $@"
const string sql = """
SELECT ur.`UserId` AS `Value`
FROM `{FakeTables.UserRole}` ur
WHERE ur.`RoleId` = {{0}}
";
FROM `user_role` ur
WHERE ur.`RoleId` = {0}
""";
return await Db.Database.SqlQueryRaw<Guid>(sql, roleId.ToString())
.ToListAsync(cancellationToken);
@ -113,6 +115,7 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
WHERE p.`IsDeleted` = 0
AND p.`RoleId` IN ({inClause})
""";
return await Db.Database.SqlQueryRaw<OrgPermRow>(sql).ToListAsync(cancellationToken);
}
@ -382,19 +385,19 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
public async Task<List<Guid>> GetOrganizationSubtreeIdsAsync(Guid rootOrganizationId, CancellationToken cancellationToken = default)
{
var sql = $@"
const string sql = """
WITH RECURSIVE org_tree AS (
SELECT o.`Id`
FROM `{FakeTables.Organization}` o
WHERE o.`Id` = {{0}} AND o.`IsDeleted` = 0
FROM `organization` o
WHERE o.`Id` = {0} AND o.`IsDeleted` = 0
UNION ALL
SELECT c.`Id`
FROM `{FakeTables.Organization}` c
FROM `organization` c
INNER JOIN org_tree t ON c.`ParentId` = t.`Id`
WHERE c.`IsDeleted` = 0
)
SELECT `Id` AS `Value` FROM org_tree
";
""";
return await Db.Database.SqlQueryRaw<Guid>(sql, rootOrganizationId.ToString())
.ToListAsync(cancellationToken);
@ -411,24 +414,25 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
}
var inClause = string.Join(",", ids.Select(x => $"'{x}'"));
var sql = $@"
var sql = $"""
SELECT o.`Id` AS `Id`, o.`Name` AS `Name`
FROM `{FakeTables.Organization}` o
FROM `organization` o
WHERE o.`IsDeleted` = 0
AND o.`Id` IN ({inClause})
";
""";
var rows = await Db.Database.SqlQueryRaw<OrganizationNameRow>(sql).ToListAsync(cancellationToken);
return rows.ToDictionary(x => x.Id, x => x.Name ?? x.Id.ToString());
}
public async Task<bool> OrganizationExistsAndEnabledAsync(Guid organizationId, CancellationToken cancellationToken = default)
{
var sql = $@"
const string sql = """
SELECT COUNT(*) AS `Value`
FROM `{FakeTables.Organization}` o
WHERE o.`Id` = {{0}}
FROM `organization` o
WHERE o.`Id` = {0}
AND o.`IsDeleted` = 0
";
""";
var count = await Db.Database.SqlQueryRaw<int>(sql, organizationId.ToString()).FirstAsync(cancellationToken);
return count > 0;
@ -446,12 +450,13 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
if (dataScope == DataPermConstants.DataScopeTeam)
{
var sql = $@"
const string sql = """
SELECT u.`userid` AS `Value`
FROM `{FakeTables.User}` u
FROM `user` u
WHERE u.`IsDeleted` = 0
AND u.`OrganizationId` = {{0}}
";
AND u.`OrganizationId` = {0}
""";
return await Db.Database.SqlQueryRaw<int>(sql, organizationId.ToString())
.ToListAsync(cancellationToken);
}
@ -465,12 +470,12 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
}
var inClause = string.Join(",", orgIds.Select(x => $"'{x}'"));
var sql = $@"
var sql = $"""
SELECT u.`userid` AS `Value`
FROM `{FakeTables.User}` u
FROM `user` u
WHERE u.`IsDeleted` = 0
AND u.`OrganizationId` IN ({inClause})
";
""";
return await Db.Database.SqlQueryRaw<int>(sql).ToListAsync(cancellationToken);
}
@ -480,7 +485,7 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
public async Task<List<DataResourceMetaDto>> GetDataResourcesAsync(CancellationToken cancellationToken = default)
{
var sql = $"""
const string sql = """
SELECT
r.`Code` AS `Code`,
r.`Name` AS `Name`,

View File

@ -2,7 +2,6 @@ using System.Text.Json;
using Fake.Application;
using Fake.DependencyInjection;
using Fake.Rbac.Infrastructure;
using Fake.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Oms.Application.Dtos.DataPerm;
@ -16,28 +15,26 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac
PropertyNameCaseInsensitive = true
};
// EF Core 原始 SQL 参数不接受 DBNull;object[] 元素类型为 object,运行时仍可传 null
private static object SqlParam(object? value) => value ?? null!;
// EF Core 原始 SQL 参数不接受 DBNull,空值直接传 null
private static object? SqlParam(object? value) => value;
public async Task<UserDataPermDto> GetUserDataPermAsync(Guid userId, CancellationToken cancellationToken = default)
{
var userSql = $@"
var user = await dbContext.Database.SqlQueryRaw<UserHeaderRow>("""
SELECT
u.`Id` AS `UserId`,
u.`Name` AS `UserName`,
u.`OrganizationId` AS `OrganizationId`,
o.`Name` AS `OrganizationName`
FROM `{FakeTables.User}` u
LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0
WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0
FROM `user` u
LEFT JOIN `organization` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0
WHERE u.`Id` = {0} AND u.`IsDeleted` = 0
LIMIT 1
";
var user = await dbContext.Database.SqlQueryRaw<UserHeaderRow>(userSql, userId.ToString())
.FirstOrDefaultAsync(cancellationToken)
""", userId.ToString()).FirstOrDefaultAsync(cancellationToken)
?? throw new BusinessException("用户不存在");
var now = DateTime.UtcNow;
var orgSql = $@"
var orgRows = await dbContext.Database.SqlQueryRaw<UserOrgPermRow>("""
SELECT
p.`Id` AS `Id`,
p.`OrganizationId` AS `OrganizationId`,
@ -47,11 +44,9 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac
p.`ExpireTime` AS `ExpireTime`,
p.`IsTemporary` AS `IsTemporary`
FROM `user_org_perm` p
LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0
WHERE p.`IsDeleted` = 0 AND p.`UserId` = {{0}}
";
var orgRows = await dbContext.Database.SqlQueryRaw<UserOrgPermRow>(orgSql, userId.ToString())
.ToListAsync(cancellationToken);
LEFT JOIN `organization` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0
WHERE p.`IsDeleted` = 0 AND p.`UserId` = {0}
""", userId.ToString()).ToListAsync(cancellationToken);
var scopeRows = await dbContext.Database.SqlQueryRaw<UserDataScopeRow>("""
SELECT p.`Id` AS `Id`, p.`DataScope` AS `DataScope`, p.`DataResourceCode` AS `DataResourceCode`, p.`ExpireTime` AS `ExpireTime`
@ -132,24 +127,20 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac
public async Task<RoleDataPermDto> GetRoleDataPermAsync(Guid roleId, CancellationToken cancellationToken = default)
{
var roleSql = $@"
var role = await dbContext.Database.SqlQueryRaw<RoleHeaderRow>("""
SELECT r.`Id` AS `RoleId`, r.`Name` AS `RoleName`
FROM `{FakeTables.Role}` r
WHERE r.`Id` = {{0}} AND r.`IsDeleted` = 0
FROM `role` r
WHERE r.`Id` = {0} AND r.`IsDeleted` = 0
LIMIT 1
";
var role = await dbContext.Database.SqlQueryRaw<RoleHeaderRow>(roleSql, roleId.ToString())
.FirstOrDefaultAsync(cancellationToken)
""", roleId.ToString()).FirstOrDefaultAsync(cancellationToken)
?? throw new BusinessException("角色不存在");
var orgSql = $@"
var orgRows = await dbContext.Database.SqlQueryRaw<RoleOrgPermRow>("""
SELECT p.`Id` AS `Id`, p.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName`, p.`OrgScope` AS `OrgScope`
FROM `role_org_perm` p
LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0
WHERE p.`IsDeleted` = 0 AND p.`RoleId` = {{0}}
";
var orgRows = await dbContext.Database.SqlQueryRaw<RoleOrgPermRow>(orgSql, roleId.ToString())
.ToListAsync(cancellationToken);
LEFT JOIN `organization` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0
WHERE p.`IsDeleted` = 0 AND p.`RoleId` = {0}
""", roleId.ToString()).ToListAsync(cancellationToken);
var scopeRows = await dbContext.Database.SqlQueryRaw<RoleDataScopeRow>("""
SELECT p.`Id` AS `Id`, p.`DataScope` AS `DataScope`, p.`DataResourceCode` AS `DataResourceCode`
@ -529,7 +520,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac
public async Task<List<FieldMaskItemDto>> GetFieldMasksAsync(string dataResourceCode, CancellationToken cancellationToken = default)
{
var sql = """
const string sql = """
SELECT m.`FieldName` AS `FieldName`, m.`SecretLevel` AS `SecretLevel`, m.`MaskType` AS `MaskType`, m.`MaskPattern` AS `MaskPattern`
FROM `data_perm_field_mask` m
WHERE m.`IsDeleted` = 0 AND m.`IsEnabled` = 1 AND m.`DataResourceCode` = {0}
@ -649,7 +640,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac
public async Task<DataPermTemplateDetailDto> GetTemplateDetailAsync(Guid templateId, CancellationToken cancellationToken = default)
{
var headerSql = """
const string headerSql = """
SELECT t.`Id` AS `Id`, t.`Name` AS `Name`, t.`Code` AS `Code`, t.`Description` AS `Description`,
t.`RoleCode` AS `RoleCode`, t.`IsBuiltIn` AS `IsBuiltIn`
FROM `data_perm_template` t
@ -661,7 +652,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac
.FirstOrDefaultAsync(cancellationToken)
?? throw new BusinessException("模板不存在");
var itemSql = """
const string itemSql = """
SELECT i.`Id` AS `Id`, i.`DimensionType` AS `DimensionType`, CAST(i.`ItemContent` AS CHAR) AS `ItemContentJson`, i.`SortOrder` AS `SortOrder`
FROM `data_perm_template_item` i
WHERE i.`TemplateId` = {0} AND i.`IsDeleted` = 0

View File

@ -1,362 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Dtos.Agents;
public class AgentListInput
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
public string? Keyword { get; set; }
public EnableStatus? Status { get; set; }
}
public class AgentOutput
{
public int Id { get; set; }
public string AgentCode { get; set; } = string.Empty;
public string AgentName { get; set; } = string.Empty;
public string? Phone { get; set; }
public string? Email { get; set; }
public EnableStatus Status { get; set; }
public string BaseType { get; set; } = string.Empty;
/// <summary>基数类型展示名(运费/应收)。</summary>
public string? BaseTypeName { get; set; }
public decimal CommissionRate { get; set; }
public string? SalesProductIds { get; set; }
/// <summary>适用销售产品名称;空表示不限产品。</summary>
public List<string> SalesProductNames { get; set; } = [];
public string? Remark { get; set; }
public DateTime CreateTime { get; set; }
}
public class AgentCreateInput
{
[Required]
[StringLength(32)]
public string AgentCode { get; set; } = string.Empty;
[Required]
[StringLength(64)]
public string AgentName { get; set; } = string.Empty;
[StringLength(32)]
public string? Phone { get; set; }
[StringLength(128)]
public string? Email { get; set; }
[Required]
[MinLength(6)]
public string Password { get; set; } = string.Empty;
/// <summary>freight / receivable。</summary>
[Required]
[StringLength(32)]
public string BaseType { get; set; } = "freight";
[Range(0, 1)]
public decimal CommissionRate { get; set; }
/// <summary>可选销售产品 Id,JSON 或逗号分隔;空=不限。</summary>
[StringLength(512)]
public string? SalesProductIds { get; set; }
[StringLength(512)]
public string? Remark { get; set; }
}
public class AgentUpdateInput
{
[Required]
public int Id { get; set; }
[Required]
[StringLength(64)]
public string AgentName { get; set; } = string.Empty;
[StringLength(32)]
public string? Phone { get; set; }
[StringLength(128)]
public string? Email { get; set; }
/// <summary>不传则不改密码。</summary>
[MinLength(6)]
public string? Password { get; set; }
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
[Required]
[StringLength(32)]
public string BaseType { get; set; } = "freight";
[Range(0, 1)]
public decimal CommissionRate { get; set; }
[StringLength(512)]
public string? SalesProductIds { get; set; }
[StringLength(512)]
public string? Remark { get; set; }
}
public class AgentRebindCustomerInput
{
[Required]
public int CustomerId { get; set; }
/// <summary>空则解绑。</summary>
public int? AgentId { get; set; }
}
public class AgentCommissionListInput
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
public int? AgentId { get; set; }
public int? OrderId { get; set; }
public string? Status { get; set; }
}
public class AgentCommissionDetailOutput
{
public int Id { get; set; }
public int AgentId { get; set; }
public string? AgentCode { get; set; }
public string? AgentName { get; set; }
public int OrderId { get; set; }
public string? ServiceNo { get; set; }
public int CustomerId { get; set; }
public string? CustomerCode { get; set; }
public string? CustomerName { get; set; }
public int? SalesProductId { get; set; }
public string BaseType { get; set; } = string.Empty;
public decimal BaseAmount { get; set; }
public decimal CommissionRate { get; set; }
public decimal CommissionAmount { get; set; }
public string Status { get; set; } = string.Empty;
public string TriggerOrderStatus { get; set; } = string.Empty;
public DateTime? PaidTime { get; set; }
public string? Remark { get; set; }
public DateTime CreateTime { get; set; }
}
public class AgentCommissionMarkPaidInput
{
[Required]
public List<int> Ids { get; set; } = [];
[StringLength(512)]
public string? Remark { get; set; }
}
public class AgentPortalCustomerOutput
{
public int Id { get; set; }
public string CustomerCode { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string? Phone { get; set; }
public DateTime? AgentBindTime { get; set; }
public DateTime CreateTime { get; set; }
/// <summary>最近下单时间(含名下子客户)。</summary>
public DateTime? LastOrderTime { get; set; }
/// <summary>近 30 天运单数(含名下子客户,不含已删除)。</summary>
public int OrderCount30d { get; set; }
/// <summary>待付款运单数(含名下子客户)。</summary>
public int PendingPaymentCount { get; set; }
}
/// <summary>代理门户返佣明细分页筛选。</summary>
public class AgentPortalCommissionListInput
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
/// <summary>pending / paid;空=全部。</summary>
public string? Status { get; set; }
/// <summary>运单号关键字。</summary>
public string? Keyword { get; set; }
public DateTime? CreateTimeStart { get; set; }
public DateTime? CreateTimeEnd { get; set; }
}
public class AgentPortalOrderOutput
{
public int Id { get; set; }
public string ServiceNo { get; set; } = string.Empty;
public string? TrackNo { get; set; }
public int? CustomerId { get; set; }
public string? CustomerCode { get; set; }
public string? CustomerName { get; set; }
public string? ProductCode { get; set; }
public string? OrderStatus { get; set; }
public string? ConsigneeCountryName { get; set; }
public decimal? ReceivableSubtotal { get; set; }
public decimal? PayableAmount { get; set; }
public DateTime CreateTime { get; set; }
}
public class AgentPortalPageInput
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
public string? Keyword { get; set; }
}
/// <summary>匿名校验代理编号(客户注册归因)。</summary>
public class AgentValidateCodeOutput
{
public bool Valid { get; set; }
public string? AgentCode { get; set; }
/// <summary>脱敏代理名称,供注册页展示。</summary>
public string? AgentNameMasked { get; set; }
public string? Message { get; set; }
}
/// <summary>
/// 代理工作台统计概览。
/// </summary>
public class AgentWorkplaceStatsOutput
{
/// <summary>名下主客户数。</summary>
public int CustomerCount { get; set; }
/// <summary>今日代客下单数。</summary>
public int TodayCreated { get; set; }
/// <summary>昨日代客下单数。</summary>
public int YesterdayCreated { get; set; }
/// <summary>较昨日新增百分比。</summary>
public decimal? DayOverDayGrowthPercent { get; set; }
/// <summary>待付款运单数。</summary>
public int PendingPayment { get; set; }
/// <summary>运输中运单数。</summary>
public int Shipping { get; set; }
/// <summary>问题件运单数。</summary>
public int Problematic { get; set; }
/// <summary>派送成功运单数(全量状态桶)。</summary>
public int DeliverySuccess { get; set; }
/// <summary>预计返佣笔数(未锁定)。</summary>
public int EstimatedCommissionCount { get; set; }
/// <summary>预计返佣金额(未锁定,不计入应打款)。</summary>
public decimal EstimatedCommissionAmount { get; set; }
/// <summary>待结算返佣笔数。</summary>
public int PendingCommissionCount { get; set; }
/// <summary>待结算返佣金额。</summary>
public decimal PendingCommissionAmount { get; set; }
/// <summary>已打款返佣金额(累计)。</summary>
public decimal PaidCommissionAmount { get; set; }
/// <summary>本周新增绑定主客户数(按 AgentBindTime)。</summary>
public int WeekNewCustomers { get; set; }
/// <summary>派送失败运单数。</summary>
public int DeliveryFailed { get; set; }
/// <summary>按 OrderStatus 分组数量。</summary>
public Dictionary<string, int> Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// 代理工作台趋势点。
/// </summary>
public class AgentWorkplaceTrendPoint
{
public string Label { get; set; } = string.Empty;
public DateTime Start { get; set; }
public DateTime End { get; set; }
/// <summary>区间内代客下单数。</summary>
public int OrderCount { get; set; }
/// <summary>区间内计提返佣金额。</summary>
public decimal CommissionAmount { get; set; }
}
/// <summary>
/// 代理工作台趋势。
/// </summary>
public class AgentWorkplaceTrendOutput
{
public string Dimension { get; set; } = "day";
public List<AgentWorkplaceTrendPoint> Points { get; set; } = [];
}

View File

@ -16,9 +16,6 @@ public class AiProviderOutput
public string? ApiKey { get; set; }
/// <summary>
/// 默认模型名(历史字段名 ApiSecret,实际不当密钥脱敏)。
/// </summary>
public string? ApiSecret { get; set; }
public bool IsDefault { get; set; }
@ -42,9 +39,6 @@ public class AiProviderSaveInput
public string? ApiKey { get; set; }
/// <summary>
/// 默认文字模型 ID;空表示保持原值。
/// </summary>
public string? ApiSecret { get; set; }
public bool IsDefault { get; set; }

View File

@ -1,25 +0,0 @@
using Microsoft.AspNetCore.Http;
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 自动触发规则 Excel 导入入参。
/// </summary>
public class AutoTriggerImportInput
{
/// <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

@ -1,25 +0,0 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 自动触发规则 Excel 导入结果。
/// </summary>
public class AutoTriggerImportOutput
{
public int SuccessCount { get; set; }
public int SkipCount { get; set; }
public int FailCount { get; set; }
public List<AutoTriggerImportFailRow> Failures { get; set; } = [];
}
/// <summary>
/// 导入失败行。
/// </summary>
public class AutoTriggerImportFailRow
{
public int RowNumber { get; set; }
public string Reason { get; set; } = string.Empty;
}

View File

@ -19,49 +19,4 @@ public class KpiAreaInfoListInput
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 国家/地区(匹配国家名称或国家代码,模糊)。
/// </summary>
public string? Country { get; set; }
/// <summary>
/// 匹配维度(如 zip/state/city/country,模糊)。
/// </summary>
public string? MatchDim { get; set; }
/// <summary>
/// 匹配方式(如 prefix/exact/range/all,模糊)。
/// </summary>
public string? MatchMode { get; set; }
/// <summary>
/// 州/省(模糊)。
/// </summary>
public string? Province { get; set; }
/// <summary>
/// 城市(模糊)。
/// </summary>
public string? City { get; set; }
/// <summary>
/// 起始邮编(模糊)。
/// </summary>
public string? LeftPostcode { get; set; }
/// <summary>
/// 截止邮编(模糊)。
/// </summary>
public string? RightPostcode { get; set; }
/// <summary>
/// 排序字段:leftPostcode / rightPostcode;空则按 Id 降序。
/// </summary>
public string? SortField { get; set; }
/// <summary>
/// 排序方向:asc / desc。
/// </summary>
public string? SortOrder { get; set; }
}

View File

@ -124,10 +124,4 @@ public class CreateCustomerInput
[StringLength(64)]
public string? AdvisorCode { get; set; }
/// <summary>
/// 渠道代理编号(注册绑定;有值时不走 C2C 邀请码)。
/// </summary>
[StringLength(32)]
public string? AgentCode { get; set; }
}

View File

@ -78,7 +78,7 @@ public class CustomerOutput
public string? Remark { get; set; }
/// <summary>
/// 销售人员(遗留字段,管理端已改用 AgentId)。
/// 销售人员。
/// </summary>
public string? Salesman { get; set; }
@ -87,15 +87,6 @@ public class CustomerOutput
/// </summary>
public string? CustomerService { get; set; }
/// <summary>绑定渠道代理 Id。</summary>
public int? AgentId { get; set; }
/// <summary>绑定代理编号。</summary>
public string? AgentCode { get; set; }
/// <summary>绑定代理名称。</summary>
public string? AgentName { get; set; }
/// <summary>
/// 客户分组。
/// </summary>

View File

@ -33,7 +33,7 @@ public class CustomerPaginatedInput
public CustomerGroup? CustomerGroup { get; set; }
/// <summary>
/// 销售人员(遗留筛选)。
/// 销售人员。
/// </summary>
public string? Salesman { get; set; }
@ -42,9 +42,6 @@ public class CustomerPaginatedInput
/// </summary>
public string? CustomerService { get; set; }
/// <summary>按绑定代理 Id 筛选。</summary>
public int? AgentId { get; set; }
/// <summary>
/// 排序字段:createTime / updateTime / customerCode / customerName / balance。
/// </summary>

View File

@ -78,7 +78,7 @@ public class CustomerPaginatedOutput
public string? Remark { get; set; }
/// <summary>
/// 销售人员(遗留字段,管理端已改用 AgentId)。
/// 销售人员。
/// </summary>
public string? Salesman { get; set; }
@ -87,15 +87,6 @@ public class CustomerPaginatedOutput
/// </summary>
public string? CustomerService { get; set; }
/// <summary>绑定渠道代理 Id。</summary>
public int? AgentId { get; set; }
/// <summary>绑定代理编号。</summary>
public string? AgentCode { get; set; }
/// <summary>绑定代理名称。</summary>
public string? AgentName { get; set; }
/// <summary>
/// 客户分组。
/// </summary>

View File

@ -15,64 +15,67 @@ public class UpdateCustomerInput
public int Id { get; set; }
/// <summary>
/// 客户编号;不传或空则保留原值(编辑页编号只读,允许局部更新省略)。
/// 客户编号。
/// </summary>
[Required]
[StringLength(32)]
public string? CustomerCode { get; set; }
public string CustomerCode { get; set; } = string.Empty;
/// <summary>
/// 客户名称;不传或空则保留原值。
/// 客户名称。
/// </summary>
[Required]
[StringLength(128)]
public string? CustomerName { get; set; }
public string CustomerName { get; set; } = string.Empty;
/// <summary>
/// 手机号;不传或空则保留原值。
/// 手机号。
/// </summary>
[Required]
[StringLength(16)]
public string? Phone { get; set; }
public string Phone { get; set; } = string.Empty;
/// <summary>
/// 邮箱;不传则保留原值,传空串可清空。
/// 邮箱。
/// </summary>
[EmailAddress]
[StringLength(64)]
public string? Email { get; set; }
/// <summary>
/// 微信头像地址;不传则保留原值,传空串可清空。
/// 微信头像地址。
/// </summary>
[StringLength(255)]
public string? WechatAvatar { get; set; }
/// <summary>
/// 微信昵称;不传则保留原值,传空串可清空。
/// 微信昵称。
/// </summary>
[StringLength(64)]
public string? WechatNickname { get; set; }
/// <summary>
/// 账户余额;不传则保留原值。
/// 账户余额。
/// </summary>
[Range(typeof(decimal), "0", "999999999999")]
public decimal? Balance { get; set; }
public decimal Balance { get; set; }
/// <summary>
/// 总额度;不传则保留原值。
/// 总额度。
/// </summary>
[Range(typeof(decimal), "0", "999999999999")]
public decimal? TotalQuota { get; set; }
public decimal TotalQuota { get; set; }
/// <summary>
/// 已用额度;不传则保留原值。
/// 已用额度。
/// </summary>
[Range(typeof(decimal), "0", "999999999999")]
public decimal? UsedQuota { get; set; }
public decimal UsedQuota { get; set; }
/// <summary>
/// 状态;不传则保留原值。
/// 状态。
/// </summary>
public CustomerStatus? Status { get; set; }
public CustomerStatus Status { get; set; } = CustomerStatus.Normal;
/// <summary>
/// 备注。
@ -81,7 +84,7 @@ public class UpdateCustomerInput
public string? Remark { get; set; }
/// <summary>
/// 销售人员(遗留;新流程请用 AgentCode)。
/// 销售人员。
/// </summary>
[StringLength(32)]
public string? Salesman { get; set; }
@ -92,12 +95,6 @@ public class UpdateCustomerInput
[StringLength(32)]
public string? CustomerService { get; set; }
/// <summary>
/// 渠道代理编号;传空串解绑;不传则保留原绑定。仅主客户可改。
/// </summary>
[StringLength(32)]
public string? AgentCode { get; set; }
/// <summary>
/// 客户分组(遗留字段,计价优先 MemberLevelId)。
/// </summary>

View File

@ -10,32 +10,12 @@ public class MonitorOverviewOutput
public MonitorCustomerStatsOutput Customer { get; set; } = new();
/// <summary>
/// 经营指标(订单量/营收/毛利)按日周月维度。
/// </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 相同)。
/// 近 7 日新增订单趋势。
/// </summary>
public List<MonitorTrendPointOutput> OrderTrend7d { get; set; } = [];
/// <summary>
/// 营收/毛利趋势(随 dimension 变化)。
/// </summary>
public List<MonitorRevenueTrendPointOutput> RevenueTrend { get; set; } = [];
/// <summary>
/// 近 7 日营收/毛利趋势(兼容旧前端;dimension=day 时与 RevenueTrend 相同)。
/// 近 7 日营收/毛利趋势。
/// </summary>
public List<MonitorRevenueTrendPointOutput> RevenueTrend7d { get; set; } = [];
@ -60,47 +40,6 @@ public class MonitorOverviewOutput
public MonitorTrackRatesOutput TrackRates { get; set; } = new();
}
/// <summary>
/// 经营 KPI:订单量 / 营收 / 毛利(随日周月)。
/// </summary>
public class MonitorOpsStatsOutput
{
/// <summary>day / week / month</summary>
public string Dimension { get; set; } = "day";
/// <summary>当前周期新建订单数。</summary>
public int OrderCount { get; set; }
/// <summary>订单量环比增长百分比。</summary>
public decimal? OrderCountGrowthPercent { get; set; }
/// <summary>当前周期应收(已付)营收。</summary>
public decimal Revenue { get; set; }
/// <summary>营收环比增长百分比。</summary>
public decimal? RevenueGrowthPercent { get; set; }
/// <summary>当前周期毛利 = 应收 - 应付。</summary>
public decimal GrossProfit { get; set; }
/// <summary>毛利环比增长百分比。</summary>
public decimal? GrossProfitGrowthPercent { get; set; }
}
/// <summary>
/// 实时积压快照(不受日周月影响)。
/// </summary>
public class MonitorQueueStatsOutput
{
public int PendingPayment { get; set; }
public int PendingOnline { get; set; }
public int PendingSign { get; set; }
public int Problematic { get; set; }
}
public class MonitorOrderStatsOutput
{
public int TodayCreated { get; set; }

View File

@ -1,108 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.OpenApi;
/// <summary>
/// 开放接口获取 Token 入参。
/// </summary>
public class OpenApiGetTokenInput
{
/// <summary>
/// 用户名(OMS 后台账号)。
/// </summary>
[Required]
[StringLength(64)]
public string Username { get; set; } = string.Empty;
/// <summary>
/// 密码。
/// </summary>
[Required]
[StringLength(128)]
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 开放接口简易结果(件重尺上传等)。
/// </summary>
public class OpenApiSimpleResult
{
public bool Success { get; set; }
public string Msg { get; set; } = string.Empty;
public static OpenApiSimpleResult Ok(string msg = "success") =>
new() { Success = true, Msg = msg };
public static OpenApiSimpleResult Fail(string msg) =>
new() { Success = false, Msg = msg };
}
/// <summary>
/// 仓配上传件重尺入参(入库接口可忽略 mode,固定走入库扫描「入库」逻辑)。
/// </summary>
public class OpenApiUploadWeightInput
{
/// <summary>
/// 订单号(服务单号 / 跟踪号 / 下游单号,同入库扫描扫码框)。
/// </summary>
[Required]
[StringLength(64)]
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 收货实重(同入库扫描「实重」)。
/// </summary>
public decimal Weight { get; set; }
/// <summary>
/// 长(同入库扫描尺寸)。
/// </summary>
public decimal Length { get; set; }
/// <summary>
/// 宽。
/// </summary>
public decimal Width { get; set; }
/// <summary>
/// 高。
/// </summary>
public decimal Height { get; set; }
/// <summary>
/// 模式:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。调用 /inbound 时可省略。
/// </summary>
public int Mode { get; set; }
/// <summary>
/// 重量单位:KG(默认)/ G / LB。
/// </summary>
[StringLength(8)]
public string? WeightUnit { get; set; }
/// <summary>
/// 长度单位:CM(默认)/ MM / IN / M。
/// </summary>
[StringLength(8)]
public string? LengthUnit { get; set; }
}
/// <summary>
/// 仓配上传红外图片入参(落订单附件 infraredImage,转存 COS)。
/// </summary>
public class OpenApiUploadImageInput
{
/// <summary>
/// 订单号(服务单号 / 跟踪号 / 下游单号)。
/// </summary>
[Required]
[StringLength(64)]
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 图片 Base64(可带 data:image/...;base64, 前缀;jpg/png/gif/webp/bmp/pdf)。
/// </summary>
[Required]
public string ImageBase64 { get; set; } = string.Empty;
}

View File

@ -319,68 +319,3 @@ public class Customs9610DeclareBatchOutput
public List<Customs9610DeclareResult> Items { get; set; } = [];
}
/// <summary>
/// 从已有运单批量创建 9610 汇总申报单入参。
/// </summary>
public class Customs9610CreateFromOrdersInput
{
/// <summary>
/// 运单 Id 列表(t_order.Id)。
/// </summary>
[Required]
[MinLength(1)]
public List<int> OrderIds { get; set; } = [];
/// <summary>
/// 出口资质备案 Id;为空时按各运单客户的默认备案。
/// </summary>
public int? ExportQualificationId { get; set; }
/// <summary>
/// 电商平台编码(e-commerce_platform 字典),最长 16。
/// </summary>
[Required]
[StringLength(16)]
public string PlatformCode { get; set; } = string.Empty;
}
/// <summary>
/// 单个运单创建 9610 申报单结果。
/// </summary>
public class Customs9610CreateFromOrderResult
{
/// <summary>
/// 运单 Id。
/// </summary>
public int OrderId { get; set; }
/// <summary>
/// 运单号。
/// </summary>
public string? ServiceNo { get; set; }
public bool Success { get; set; }
/// <summary>
/// 失败原因;成功为空。
/// </summary>
public string? Message { get; set; }
/// <summary>
/// 生成的 9610 申报单 Id。
/// </summary>
public int? Customs9610OrderId { get; set; }
}
/// <summary>
/// 批量创建 9610 申报单结果。
/// </summary>
public class Customs9610CreateFromOrdersOutput
{
public int SuccessCount { get; set; }
public int FailCount { get; set; }
public List<Customs9610CreateFromOrderResult> Items { get; set; } = [];
}

View File

@ -1,27 +0,0 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// itfx getFile(ForwardWithSubscribe)标准请求体。
/// </summary>
public class ItfxGetFileBody
{
/// <summary>
/// 客户单号。
/// </summary>
public string? ClientNo { get; set; }
/// <summary>
/// 跟踪号。
/// </summary>
public string? TrackNo { get; set; }
/// <summary>
/// 服务商单号。
/// </summary>
public string? ServiceNo { get; set; }
/// <summary>
/// 文件类型:下游面单=21,末端面单=31,POD=180。
/// </summary>
public int Type { get; set; }
}

View File

@ -1,29 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 出库后状态校正入参:仅允许在待上网 / 运输中 / 派送成功 / 派送失败之间变更;派送成功只进不出。
/// </summary>
public class OrderChangePostShipStatusInput
{
/// <summary>
/// 订单主键。
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// 目标订单状态(pendingOnline / shipping / deliverySuccess / deliveryFailed)。
/// </summary>
[Required]
[StringLength(32)]
public string TargetStatus { get; set; } = string.Empty;
/// <summary>
/// 变更备注(必填,落操作日志)。
/// </summary>
[Required]
[StringLength(512)]
public string Remark { get; set; } = string.Empty;
}

View File

@ -1,22 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 修改订单销售产品入参。
/// </summary>
public class OrderChangeProductInput
{
/// <summary>
/// 订单主键。
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// 新销售产品编码。
/// </summary>
[Required]
[StringLength(64)]
public string ProductCode { get; set; } = string.Empty;
}

View File

@ -1,3 +1,5 @@
using Oms.Application.Dtos.Orders;
namespace Oms.Application.Dtos.Orders;
/// <summary>
@ -41,56 +43,3 @@ public class OrderDownstreamForwardResult
/// </summary>
public string? TrackNo { get; set; }
}
/// <summary>
/// 下游 getFile 入参。
/// </summary>
public class OrderDownstreamGetFileInput
{
/// <summary>
/// 订单 Id。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 文件类型:下游面单=21,末端面单=31,POD=180。
/// </summary>
public int Type { get; set; }
}
/// <summary>
/// 下游 getFile 转发结果。
/// </summary>
public class OrderDownstreamGetFileResult
{
public bool Success { get; set; }
public string SupplierCode { get; set; } = string.Empty;
public int Type { get; set; }
public string? FileType { get; set; }
public string Url { get; set; } = string.Empty;
public string RequestJson { get; set; } = string.Empty;
public int? StatusCode { get; set; }
public string ResponseBody { get; set; } = string.Empty;
/// <summary>
/// 转存到自有 COS 后的公开地址。
/// </summary>
public string? StoredFileUrl { get; set; }
/// <summary>
/// 回写后的服务商单号(Data.serviceNo)。
/// </summary>
public string? DownstreamServiceNo { get; set; }
/// <summary>
/// 回写后的跟踪号(Data.trackNo)。
/// </summary>
public string? TrackNo { get; set; }
}

View File

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

View File

@ -70,21 +70,6 @@ public class OrderItemOutput
/// </summary>
public string? DeclareElement { get; set; }
/// <summary>
/// 产品标识编码。
/// </summary>
public string? ProductId { get; set; }
/// <summary>
/// 非标产品标识编码。
/// </summary>
public string? NonStdProductId { get; set; }
/// <summary>
/// 标准产品标识编码。
/// </summary>
public string? StdProductId { get; set; }
/// <summary>
/// SKU 图片。
/// </summary>

View File

@ -368,31 +368,6 @@ public class OrderOutput
/// </summary>
public decimal? ForecastPayableAmount { get; set; }
/// <summary>
/// 营销快照:折前应收小计。
/// </summary>
public decimal? ReceivableSubtotal { get; set; }
/// <summary>
/// 营销快照:会员折扣金额(与券互斥)。
/// </summary>
public decimal LevelDiscountAmount { get; set; }
/// <summary>
/// 营销快照:优惠券抵扣金额。
/// </summary>
public decimal CouponDiscountAmount { get; set; }
/// <summary>
/// 营销快照:客户实付金额(支付扣款口径;区别于应付成本)。
/// </summary>
public decimal? ActualPayAmount { get; set; }
/// <summary>
/// 营销快照:使用的优惠券实例 Id。
/// </summary>
public int? CouponInstanceId { get; set; }
/// <summary>
/// 订单绑定的销售产品规则发布版本。
/// </summary>

View File

@ -1,6 +1,3 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Oms.Application.Dtos.Orders;
/// <summary>
@ -68,27 +65,6 @@ public class OrderParcelItemInput
/// </summary>
public string? DeclareElement { get; set; }
/// <summary>
/// 产品标识编码(选填)。预报下游始终序列化,无值时为 null。
/// </summary>
[StringLength(100)]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? ProductId { get; set; }
/// <summary>
/// 非标产品标识编码(选填)。预报下游始终序列化,无值时为 null。
/// </summary>
[StringLength(100)]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? NonStdProductId { get; set; }
/// <summary>
/// 标准产品标识编码(选填)。预报下游始终序列化,无值时为 null。
/// </summary>
[StringLength(100)]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? StdProductId { get; set; }
/// <summary>
/// SKU 图片。
/// </summary>

View File

@ -1,81 +0,0 @@
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

@ -1,49 +0,0 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 查价表单下拉选项(国家 / 发货仓 / 货物属性),可供访客匿名加载。
/// </summary>
public class OrderPriceCheckFormOptionsOutput
{
/// <summary>目的国家列表。</summary>
public List<OrderPriceCheckCountryOptionOutput> Countries { get; set; } = [];
/// <summary>发货仓列表(仅 id + 名称,不含地址电话)。</summary>
public List<OrderPriceCheckWarehouseOptionOutput> Warehouses { get; set; } = [];
/// <summary>货物属性(字典 cargo_attribute)。</summary>
public List<OrderPriceCheckCargoOptionOutput> CargoAttributes { get; set; } = [];
}
/// <summary>查价目的国家选项。</summary>
public class OrderPriceCheckCountryOptionOutput
{
/// <summary>ISO 二字码。</summary>
public string CountryCode { get; set; } = string.Empty;
/// <summary>中文名。</summary>
public string ChineseName { get; set; } = string.Empty;
/// <summary>英文名。</summary>
public string EnglishName { get; set; } = string.Empty;
}
/// <summary>查价发货仓选项。</summary>
public class OrderPriceCheckWarehouseOptionOutput
{
/// <summary>仓库主键。</summary>
public int Id { get; set; }
/// <summary>仓库名称。</summary>
public string WarehouseName { get; set; } = string.Empty;
}
/// <summary>查价货物属性选项。</summary>
public class OrderPriceCheckCargoOptionOutput
{
/// <summary>字典编码。</summary>
public string DictCode { get; set; } = string.Empty;
/// <summary>字典名称。</summary>
public string DictName { get; set; } = string.Empty;
}

View File

@ -25,12 +25,6 @@ public class OrderUpdateInput
[StringLength(64)]
public string? TrackNo { get; set; }
/// <summary>
/// 服务商单号。
/// </summary>
[StringLength(64)]
public string? DownstreamServiceNo { get; set; }
/// <summary>
/// 渠道编码。
/// </summary>

View File

@ -11,9 +11,6 @@ public class PlatformStorePageInput
public int? CustomerId { get; set; }
/// <summary>
/// 平台编码:tiktok / temu / shopee / lazada;空或 all 表示全部。
/// </summary>
public string? PlatformCode { get; set; }
public string? StoreNickname { get; set; }
@ -150,6 +147,11 @@ public class PlatformStoreOrderListInput : PlatformStorePageInput
{
public int? StoreId { get; set; }
/// <summary>
/// 平台编码:tiktok / temu / shopee / lazada;空或 all 表示全部。
/// </summary>
public string? PlatformCode { get; set; }
/// <summary>
/// 履约状态:pending / toShip / shipped / canceled / suspended / completed;空或 all 表示全部。
/// </summary>
@ -180,6 +182,8 @@ public class PlatformStoreOrderLineOutput
public string? Currency { get; set; }
public bool IsShipExcluded { get; set; }
public int? MappedSkuId { get; set; }
public string? MappedSkuCode { get; set; }
@ -243,11 +247,9 @@ public class PlatformStoreOrderOutput
public string? ShippingLabelUrl { get; set; }
/// <summary>平台物流商。</summary>
public string? ShippingProvider { get; set; }
public bool IsMultiLabel { get; set; }
/// <summary>平台履约类型。</summary>
public string? FulfillmentType { get; set; }
public string? CustomTags { get; set; }
public bool IsSuspended { get; set; }
@ -364,60 +366,6 @@ public class PlatformStoreOrderMatchInventoryOutput
public string? ClientNo { get; set; }
}
/// <summary>
/// 店铺订单创建运单时填写的运单基本信息(对齐下单页基本信息)。
/// </summary>
public class PlatformStoreOrderParcelBasicInput
{
/// <summary>客户单号;空则沿用平台订单号。</summary>
public string? ClientNo { get; set; }
/// <summary>货物属性:general / electronics / sensitive。</summary>
public string? CargoAttribute { get; set; }
/// <summary>实重(kg)。</summary>
public decimal? ActualWeight { get; set; }
/// <summary>包裹数量。</summary>
public int? Quantity { get; set; }
/// <summary>长(cm)。</summary>
public decimal? Length { get; set; }
/// <summary>宽(cm)。</summary>
public decimal? Width { get; set; }
/// <summary>高(cm)。</summary>
public decimal? Height { get; set; }
/// <summary>出口报关方式。</summary>
public string? ExportCustomsMode { get; set; }
/// <summary>出口资质备案 Id(9610Summary 时必填)。</summary>
public int? ExportQualificationId { get; set; }
/// <summary>电商平台编码(9610Summary 时必填)。</summary>
public string? PlatformCode { get; set; }
/// <summary>到仓方式:selfShip / doorPickup。</summary>
public string? InboundMethod { get; set; }
/// <summary>上门揽收发件地址簿 Id。</summary>
public int? SenderAddressId { get; set; }
/// <summary>期望揽收时间。</summary>
public DateTime? ExpectPickupTime { get; set; }
/// <summary>揽收方向。</summary>
public string? PickupDirection { get; set; }
/// <summary>承运商编码。</summary>
public string? CarrierCode { get; set; }
/// <summary>承运服务。</summary>
public string? CarrierService { get; set; }
}
public class PlatformStoreOrderBatchCreateLogisticsInput
{
[Required]
@ -427,9 +375,6 @@ public class PlatformStoreOrderBatchCreateLogisticsInput
public int? CustomerId { get; set; }
/// <summary>运单基本信息(创建运单/代发运单必填)。</summary>
public PlatformStoreOrderParcelBasicInput? ParcelBasic { get; set; }
[Required]
public List<PlatformStoreOrderBatchCreateItemInput> Items { get; set; } = [];
}
@ -444,9 +389,6 @@ public class PlatformStoreOrderBatchCreateFulfillInput
public int? CustomerId { get; set; }
/// <summary>运单基本信息(创建运单/代发运单必填)。</summary>
public PlatformStoreOrderParcelBasicInput? ParcelBasic { get; set; }
[Required]
public List<PlatformStoreOrderBatchCreateItemInput> Items { get; set; } = [];
}
@ -456,9 +398,6 @@ public class PlatformStoreOrderBatchCreateItemInput
[Required]
public int StoreOrderId { get; set; }
/// <summary>覆盖本单客户单号;空则用 ParcelBasic.ClientNo 或平台订单号。</summary>
public string? ClientNo { get; set; }
public OrderParcelReceiverInput? Receiver { get; set; }
public List<OrderStockBindSaveItemInput> BindItems { get; set; } = [];

View File

@ -26,11 +26,6 @@ public class InternalUserListItemDto
public DateTime? CreatedAt { get; set; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; } = true;
public List<InternalUserRoleDto> Roles { get; set; } = [];
}

View File

@ -16,11 +16,6 @@ public class UserProfileData
/// 企业微信客服接入链接(微信外接入)。
/// </summary>
public string? WecomKfUrl { get; set; }
/// <summary>
/// 是否启用;缺省按启用处理。
/// </summary>
public bool IsEnabled { get; set; } = true;
}
public class OmsUserCreateInput : UserCreateDto
@ -34,11 +29,6 @@ public class OmsUserCreateInput : UserCreateDto
public string? WechatQrCode { get; set; }
public string? WecomKfUrl { get; set; }
/// <summary>
/// 是否启用,默认 true。
/// </summary>
public bool? IsEnabled { get; set; }
}
public class OmsUserUpdateInput : UserUpdateDto
@ -50,11 +40,6 @@ public class OmsUserUpdateInput : UserUpdateDto
public string? WechatQrCode { get; set; }
public string? WecomKfUrl { get; set; }
/// <summary>
/// 是否启用;不传则不改。
/// </summary>
public bool? IsEnabled { get; set; }
}
public class OmsUserDetailDto : UserDto
@ -68,11 +53,6 @@ public class OmsUserDetailDto : UserDto
public string? WechatQrCode { get; set; }
public string? WecomKfUrl { get; set; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; } = true;
}
/// <summary>
@ -85,14 +65,3 @@ public class ResetUserPasswordInput
/// </summary>
public string NewPassword { get; set; } = string.Empty;
}
/// <summary>
/// 设置用户启用状态入参。
/// </summary>
public class SetUserEnabledInput
{
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; }
}

View File

@ -1,25 +0,0 @@
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

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

View File

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

View File

@ -1,33 +0,0 @@
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

@ -47,21 +47,6 @@ public class WarehouseSkuOutput
/// </summary>
public string? DeclareElement { get; set; }
/// <summary>
/// 产品标识编码。
/// </summary>
public string? ProductId { get; set; }
/// <summary>
/// 非标产品标识编码。
/// </summary>
public string? NonStdProductId { get; set; }
/// <summary>
/// 标准产品标识编码(如 GTIN / UPC / EAN)。
/// </summary>
public string? StdProductId { get; set; }
public decimal? WeightG { get; set; }
public decimal? LengthCm { get; set; }
@ -106,21 +91,6 @@ public class WarehouseSkuSaveInput
/// </summary>
public string? DeclareElement { get; set; }
/// <summary>
/// 产品标识编码。
/// </summary>
public string? ProductId { get; set; }
/// <summary>
/// 非标产品标识编码。
/// </summary>
public string? NonStdProductId { get; set; }
/// <summary>
/// 标准产品标识编码(如 GTIN / UPC / EAN)。
/// </summary>
public string? StdProductId { get; set; }
public decimal? WeightG { get; set; }
public decimal? LengthCm { get; set; }
@ -288,7 +258,6 @@ public class WarehouseInboundSaveInput
[Required]
public int WarehouseId { get; set; }
/// <summary>唛头/快递单号,选填。</summary>
public string? MarkReference { get; set; }
public string InboundType { get; set; } = "normal";
@ -376,77 +345,6 @@ public class WarehouseInboundSummaryOutput
public int Completed { get; set; }
}
/// <summary>
/// 打印库内 SKU 标签入参。
/// </summary>
public class WarehouseSkuPrintLabelInput
{
/// <summary>
/// SKU ID。
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// 客户 ID(管理端可选)。
/// </summary>
public int? CustomerId { get; set; }
}
/// <summary>
/// 打印库内 SKU 标签出参。
/// </summary>
public class WarehouseSkuPrintLabelOutput
{
/// <summary>
/// 可打印的 PDF 公网地址。
/// </summary>
public string PrintUrl { get; set; } = string.Empty;
/// <summary>
/// SKU 编码。
/// </summary>
public string SkuCode { get; set; } = string.Empty;
}
/// <summary>
/// 打印入库箱唛标签入参。
/// </summary>
public class WarehouseInboundPrintLabelsInput
{
/// <summary>
/// 入库预报 ID。
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// 客户 ID(管理端可选)。
/// </summary>
public int? CustomerId { get; set; }
}
/// <summary>
/// 打印入库箱唛标签出参(多箱合并为一个多页 PDF)。
/// </summary>
public class WarehouseInboundPrintLabelsOutput
{
/// <summary>
/// 可打印的 PDF 公网地址。
/// </summary>
public string PrintUrl { get; set; } = string.Empty;
/// <summary>
/// 预报单号。
/// </summary>
public string NoticeNo { get; set; } = string.Empty;
/// <summary>
/// PDF 页数(通常等于箱数)。
/// </summary>
public int PageCount { get; set; }
}
public class WarehouseParcelSummaryOutput
{
public int InTransit { get; set; }

View File

@ -1,12 +0,0 @@
namespace Oms.Application;
public static class FakeTables
{
public static string User => "fk_user";
public static string Role => "fk_role";
public static string Menu => "fk_menu";
public static string Organization => "fk_organization";
public static string UserRole => "fk_user_role";
public static string RolePermission => "fk_role_permission";
}

View File

@ -1,5 +0,0 @@
# 面单 PDF 中文字体
`OmsLabelCjk.ttf`:随程序嵌入,供 QuestPDF 渲染箱唛/SKU 中文(避免默认字体缺字形显示 `?`/`□`)。
部署镜像无需再依赖系统 Noto;仍可通过环境变量 `OMS_LABEL_FONT` 覆盖为其它 ttf/otf。

View File

@ -1,171 +0,0 @@
using System.Text.Json;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Integrations.Itfx;
/// <summary>
/// 解析 itfx getFile 成功响应中的 Data,并回写订单单号。
/// </summary>
internal static class ItfxGetFileDataParser
{
public const string StandardCode = "getFile";
public const int TypeDownstreamLabel = 21;
public const int TypeLastMileLabel = 31;
public const int TypePod = 180;
public sealed class GetFileData
{
public string? ServiceNo { get; init; }
public string? TrackNo { get; init; }
public string? ClientNo { get; init; }
public string? Url { get; init; }
public int? Type { get; init; }
}
public static bool TryResolveFileType(int type, out string fileType)
{
fileType = type switch
{
TypeDownstreamLabel => OrderFileTypeCodes.DownstreamLabel,
TypeLastMileLabel => OrderFileTypeCodes.LastMileLabel,
TypePod => OrderFileTypeCodes.Pod,
_ => string.Empty
};
return !string.IsNullOrEmpty(fileType);
}
public static bool TryParse(string? responseBody, out GetFileData? data)
{
data = null;
if (string.IsNullOrWhiteSpace(responseBody))
{
return false;
}
try
{
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return false;
}
if (!TryGetPropertyIgnoreCase(root, out var dataElement, "data", "Data")
|| dataElement.ValueKind != JsonValueKind.Object)
{
return false;
}
data = new GetFileData
{
ServiceNo = ReadString(dataElement, "serviceNo", "ServiceNo"),
TrackNo = ReadString(dataElement, "trackNo", "TrackNo"),
ClientNo = ReadString(dataElement, "clientNo", "ClientNo"),
Url = ReadString(dataElement, "url", "Url"),
Type = ReadInt(dataElement, "type", "Type"),
};
return !string.IsNullOrWhiteSpace(data.ServiceNo)
|| !string.IsNullOrWhiteSpace(data.TrackNo)
|| !string.IsNullOrWhiteSpace(data.ClientNo)
|| !string.IsNullOrWhiteSpace(data.Url);
}
catch
{
return false;
}
}
/// <summary>
/// 服务商单号 ← Data.serviceNo,跟踪号 ← Data.trackNo;空值不覆盖。
/// </summary>
public static bool ApplyToOrder(Order order, GetFileData data)
{
var changed = false;
if (!string.IsNullOrWhiteSpace(data.ServiceNo))
{
var value = data.ServiceNo.Trim();
if (!string.Equals(order.DownstreamServiceNo, value, StringComparison.Ordinal))
{
order.DownstreamServiceNo = value;
changed = true;
}
}
if (!string.IsNullOrWhiteSpace(data.TrackNo))
{
var value = data.TrackNo.Trim();
if (!string.Equals(order.TrackNo, value, StringComparison.Ordinal))
{
order.TrackNo = value;
changed = true;
}
}
return changed;
}
private static bool TryGetPropertyIgnoreCase(JsonElement root, out JsonElement value, params string[] names)
{
foreach (var name in names)
{
if (root.TryGetProperty(name, out value))
{
return true;
}
}
foreach (var prop in root.EnumerateObject())
{
foreach (var name in names)
{
if (prop.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
}
value = default;
return false;
}
private static string? ReadString(JsonElement root, params string[] names)
{
if (!TryGetPropertyIgnoreCase(root, out var prop, names))
{
return null;
}
return prop.ValueKind switch
{
JsonValueKind.String => prop.GetString()?.Trim(),
JsonValueKind.Number => prop.GetRawText(),
JsonValueKind.Null => null,
_ => null
};
}
private static int? ReadInt(JsonElement root, params string[] names)
{
if (!TryGetPropertyIgnoreCase(root, out var prop, names))
{
return null;
}
return prop.ValueKind switch
{
JsonValueKind.Number when prop.TryGetInt32(out var n) => n,
JsonValueKind.String when int.TryParse(prop.GetString(), out var n) => n,
_ => null
};
}
}

View File

@ -20,29 +20,6 @@ internal static class PlatformShopCrypto
}
public static long UnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
/// <summary>
/// TikTok Shop 202309 签名,对齐 WMS <c>TiktokApi.makeTiktokSign</c>:
/// 查询参数按 key 排序后拼接 key+value,有请求体则原样接在后面,两侧包 appSecret,HMAC-SHA256 十六进制。
/// </summary>
public static string TiktokSign(string path, IReadOnlyDictionary<string, string> query, string? body, string appSecret)
{
var input = new StringBuilder(path);
foreach (var key in query.Keys
.Where(k => k is not ("sign" or "access_token"))
.OrderBy(k => k, StringComparer.Ordinal))
{
input.Append(key).Append(query[key]);
}
if (!string.IsNullOrEmpty(body))
{
input.Append(body);
}
var payload = appSecret + input + appSecret;
return HmacSha256Hex(payload, appSecret);
}
}
/// <summary>

View File

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

View File

@ -1,6 +1,5 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Oms.Application.Messaging.TrackChange;
namespace Oms.Application.Integrations.SmallBagTrack;
@ -9,16 +8,12 @@ public static class SmallBagTrackServiceExtensions
public static IServiceCollection AddOmsSmallBagTrack(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<SmallBagTrackOptions>(configuration.GetSection(SmallBagTrackOptions.SectionName));
services.Configure<TrackChangeSubscribeOptions>(
configuration.GetSection(TrackChangeSubscribeOptions.SectionName));
services.AddHttpClient(SmallBagTrackOrderClient.HttpClientName);
services.AddScoped<ISmallBagTrackConfigProvider, SmallBagTrackConfigProvider>();
services.AddScoped<ISmallBagTrackUpsertRequestBuilder, SmallBagTrackUpsertRequestBuilder>();
services.AddScoped<ISmallBagTrackOrderClient, SmallBagTrackOrderClient>();
services.AddScoped<IPickupBigBagTrackSyncer, PickupBigBagTrackSyncer>();
services.AddSingleton<ISmallBagTrackOrderNotifier, SmallBagTrackOrderNotifier>();
services.AddScoped<ITrackChangeOrderStatusApplier, TrackChangeOrderStatusApplier>();
services.AddHostedService<TrackChangeSubscriberHostedService>();
return services;
}
}

View File

@ -67,18 +67,15 @@ internal sealed class SmallBagTrackUpsertRequestBuilder(
};
}
/// <summary>
/// 轨迹侧 TrackNo 必填。有真实跟踪号用跟踪号;尚未下发时先用客户单号占位,后续有 TrackNo 再 Upsert 更新。
/// </summary>
internal static string ResolveTrackNo(Order order)
{
var trackNo = order.TrackNo?.Trim() ?? string.Empty;
var trackNo = order.TrackNo?.Trim();
if (!string.IsNullOrWhiteSpace(trackNo))
{
return trackNo;
}
return order.ClientNo?.Trim() ?? string.Empty;
return order.ServiceNo?.Trim() ?? string.Empty;
}
internal static string? PickSupplierCodeFromProviders(

View File

@ -63,10 +63,6 @@ public sealed class HiprintElementOptions
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>设计器「测试数据」;无 field 的纯文本有时只把文案写在这里。</summary>
[JsonPropertyName("testData")]
public string? TestData { get; set; }
[JsonPropertyName("field")]
public string? Field { get; set; }

View File

@ -105,15 +105,14 @@ public sealed class HiprintTemplateParser
Src = ResolveValue(opt.Field, opt.Src ?? opt.Title, printData)
};
case "longtext":
return BuildTextOp(
opt, left, top, width, height, printData, wrap: true, element.PrintElementType?.Title);
return BuildTextOp(opt, left, top, width, height, printData, wrap: true);
case "text":
default:
{
var textType = opt.TextType?.Trim().ToLowerInvariant();
if (textType is "barcode" or "qrcode")
{
var content = ResolveValue(opt.Field, FirstNonEmpty(opt.Title, opt.TestData), printData);
var content = ResolveValue(opt.Field, opt.Title, printData);
if (string.IsNullOrWhiteSpace(content))
{
return null;
@ -130,8 +129,7 @@ public sealed class HiprintTemplateParser
};
}
return BuildTextOp(
opt, left, top, width, height, printData, wrap: false, element.PrintElementType?.Title);
return BuildTextOp(opt, left, top, width, height, printData, wrap: false);
}
}
}
@ -143,38 +141,25 @@ public sealed class HiprintTemplateParser
float width,
float height,
IReadOnlyDictionary<string, object?> printData,
bool wrap,
string? elementTypeTitle = null)
bool wrap)
{
var value = ResolveValue(opt.Field, null, printData);
// 与 vg-print 一致:无 field 时打印 title;有 field 时按 hideTitle 决定是否带标题
var title = FirstNonEmpty(opt.Title, elementTypeTitle);
string text;
if (!string.IsNullOrWhiteSpace(opt.Field))
{
var hideTitle = opt.HideTitle != false; // null/true → 隐藏标题(OMS 字段默认 hideTitle:true)
if (!hideTitle && !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(value))
// 有 field:默认只打字段值;hideTitle=false 且有 title 时拼「标题:值」
if (opt.HideTitle == false && !string.IsNullOrWhiteSpace(opt.Title) && !string.IsNullOrWhiteSpace(value))
{
text = $"{title}:{value}";
}
else if (!string.IsNullOrWhiteSpace(value))
{
text = value!;
}
else if (!hideTitle && !string.IsNullOrWhiteSpace(title))
{
// 仅标题(值为空时仍保留自定义标题文案)
text = title!;
text = $"{opt.Title}:{value}";
}
else
{
text = string.Empty;
text = value ?? string.Empty;
}
}
else
{
// 纯自定义文本:title → testData → 元素类型名
text = FirstNonEmpty(opt.Title, opt.TestData, elementTypeTitle) ?? string.Empty;
text = opt.Title ?? string.Empty;
}
if (string.IsNullOrWhiteSpace(text))
@ -184,18 +169,15 @@ public sealed class HiprintTemplateParser
var bold = string.Equals(opt.FontWeight, "bold", StringComparison.OrdinalIgnoreCase)
|| opt.FontWeight is "700" or "600";
var fontSize = opt.FontSize is > 0 ? opt.FontSize.Value : 10f;
// 设计器文本框常只有十余 pt 高,行高略大于字号时会被裁成「空白」
var boxHeight = Math.Max(height, fontSize * 1.4f);
return new LabelTextOp
{
LeftPt = left,
TopPt = top,
WidthPt = width,
HeightPt = boxHeight,
HeightPt = height,
Text = text,
FontSizePt = fontSize,
FontSizePt = opt.FontSize is > 0 ? opt.FontSize.Value : 10f,
Bold = bold,
ColorHex = opt.Color,
Align = (opt.TextAlign ?? "left").ToLowerInvariant(),
@ -203,9 +185,6 @@ public sealed class HiprintTemplateParser
};
}
private static string? FirstNonEmpty(params string?[] values) =>
values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
private static string? ResolveValue(
string? field,
string? fallback,

View File

@ -1,120 +0,0 @@
using System.Reflection;
using QuestPDF.Drawing;
namespace Oms.Application.Labeling.Hiprint;
/// <summary>
/// 面单 PDF 中文字体:QuestPDF 默认字体无 CJK;优先嵌入字体,避免依赖容器内 TTC。
/// </summary>
public static class LabelPdfFontRegistrar
{
public const string FamilyName = "OmsLabelSans";
private const string EmbeddedName = "Oms.Application.Fonts.OmsLabelCjk.ttf";
private static int _state; // 0=未试 1=成功 2=失败
private static string? _source;
private static string? _lastError;
/// <summary>
/// 确保已注册可用中文字体;成功返回 <see cref="FamilyName"/>,否则 null。
/// </summary>
public static string? EnsureRegistered()
{
var state = Volatile.Read(ref _state);
if (state == 1)
{
return FamilyName;
}
if (state == 2)
{
return null;
}
foreach (var attempt in EnumerateAttempts())
{
try
{
using var stream = attempt.Open();
if (stream == null || stream.Length == 0)
{
continue;
}
// 读入内存:Register 后不可依赖外部流生命周期;并规避部分 TTC 流问题
using var ms = new MemoryStream();
stream.CopyTo(ms);
ms.Position = 0;
FontManager.RegisterFontWithCustomName(FamilyName, ms);
_source = attempt.Description;
Interlocked.Exchange(ref _state, 1);
return FamilyName;
}
catch (Exception ex)
{
_lastError = $"{attempt.Description}: {ex.Message}";
}
}
Interlocked.Exchange(ref _state, 2);
return null;
}
public static string? RegisteredSource => _source;
public static string? LastError => _lastError;
private static IEnumerable<FontAttempt> EnumerateAttempts()
{
yield return new FontAttempt(
"embedded:" + EmbeddedName,
() => Assembly.GetExecutingAssembly().GetManifestResourceStream(EmbeddedName));
var fromEnv = Environment.GetEnvironmentVariable("OMS_LABEL_FONT");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
var path = fromEnv.Trim();
yield return new FontAttempt("env:" + path, () => File.Exists(path) ? File.OpenRead(path) : null);
}
var baseDir = AppContext.BaseDirectory;
foreach (var name in new[]
{
"OmsLabelCjk.ttf",
"NotoSansSC-Regular.otf",
"NotoSansSC-Regular.ttf",
"NotoSansCJKsc-Regular.otf",
"simhei.ttf"
})
{
var path = Path.Combine(baseDir, "Fonts", name);
yield return new FontAttempt("file:" + path, () => File.Exists(path) ? File.OpenRead(path) : null);
}
var winFonts = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
if (!string.IsNullOrWhiteSpace(winFonts))
{
// 单文件 TTF 优先于 TTC(集合字体在部分 Skia 版本上注册失败/缺简体)
foreach (var name in new[] { "simhei.ttf", "msyh.ttc", "msyhbd.ttc", "simsun.ttc" })
{
var path = Path.Combine(winFonts, name);
yield return new FontAttempt("win:" + path, () => File.Exists(path) ? File.OpenRead(path) : null);
}
}
foreach (var path in new[]
{
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/truetype/arphic/uming.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"
})
{
yield return new FontAttempt("linux:" + path, () => File.Exists(path) ? File.OpenRead(path) : null);
}
}
private readonly record struct FontAttempt(string Description, Func<Stream?> Open);
}

View File

@ -14,62 +14,37 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
public Task<byte[]> RenderAsync(
string templateJson,
IReadOnlyDictionary<string, object?> printData,
CancellationToken cancellationToken = default) =>
RenderManyAsync(templateJson, [printData], cancellationToken);
public Task<byte[]> RenderManyAsync(
string templateJson,
IReadOnlyList<IReadOnlyDictionary<string, object?>> printDataPages,
CancellationToken cancellationToken = default)
{
EnsureLicense();
var fontFamily = LabelPdfFontRegistrar.EnsureRegistered()
?? throw new InvalidOperationException(
"面单中文字体未就绪:" + (LabelPdfFontRegistrar.LastError ?? "未找到可用 CJK 字体"));
cancellationToken.ThrowIfCancellationRequested();
if (printDataPages == null || printDataPages.Count == 0)
{
throw new ArgumentException("printDataPages 不能为空", nameof(printDataPages));
}
if (string.IsNullOrWhiteSpace(templateJson))
{
throw new ArgumentException("templateJson 不能为空", nameof(templateJson));
}
var pageIr = parser.Parse(templateJson, printData);
var pageWidthPt = MmToPt(pageIr.WidthMm);
var pageHeightPt = MmToPt(pageIr.HeightMm);
var bytes = Document.Create(container =>
{
for (var i = 0; i < printDataPages.Count; i++)
container.Page(page =>
{
cancellationToken.ThrowIfCancellationRequested();
var pageIr = parser.Parse(templateJson, printDataPages[i]);
var pageWidthPt = MmToPt(pageIr.WidthMm);
var pageHeightPt = MmToPt(pageIr.HeightMm);
container.Page(page =>
page.Size(pageWidthPt, pageHeightPt);
page.Margin(0);
page.Content().Layers(layers =>
{
page.Size(pageWidthPt, pageHeightPt);
page.Margin(0);
// 页级默认中文字体;单元素再设字号/颜色。勿对嵌入 Regular 调 Bold(会回退缺字西文字体 → ???)
page.DefaultTextStyle(x => x.FontFamily(fontFamily).FontColor(Colors.Black));
page.Content().Layers(layers =>
{
layers.PrimaryLayer().Background(Colors.White);
layers.PrimaryLayer().Background(Colors.White);
foreach (var op in pageIr.Ops)
{
DrawOp(layers, op, fontFamily);
}
});
foreach (var op in pageIr.Ops)
{
DrawOp(layers, op);
}
});
}
});
}).GeneratePdf();
return Task.FromResult(bytes);
}
private static void DrawOp(LayersDescriptor layers, LabelDrawOp op, string fontFamily)
private static void DrawOp(LayersDescriptor layers, LabelDrawOp op)
{
switch (op)
{
@ -89,16 +64,16 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
_ => box.AlignLeft()
};
aligned.AlignTop().Text(t =>
var span = aligned
.AlignTop()
.Text(text.Text)
.FontSize(text.FontSizePt)
.FontColor(ParseColor(text.ColorHex));
if (text.Bold)
{
// 勿对嵌入 Regular 调用 Bold():会回退缺字西文字体,中文变成 ???
var size = text.Bold ? text.FontSizePt * 1.02f : text.FontSizePt;
t.DefaultTextStyle(style => style
.FontFamily(fontFamily)
.FontSize(size)
.FontColor(ParseColor(text.ColorHex)));
t.Span(text.Text ?? string.Empty);
});
span.Bold();
}
break;
}
@ -165,8 +140,6 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
}
QuestPDF.Settings.License = LicenseType.Community;
// 缺字形时不抛,但仍应依赖嵌入 CJK 字体
QuestPDF.Settings.CheckIfAllTextGlyphsAreAvailable = false;
}
private static float MmToPt(float mm) => mm * 72f / 25.4f;

View File

@ -5,19 +5,8 @@ namespace Oms.Application.Labeling;
/// </summary>
public interface ILabelPdfRenderer
{
/// <summary>
/// 渲染单页 PDF。
/// </summary>
Task<byte[]> RenderAsync(
string templateJson,
IReadOnlyDictionary<string, object?> printData,
CancellationToken cancellationToken = default);
/// <summary>
/// 同一模板按多组 PrintData 渲染多页,合并为单个 PDF。
/// </summary>
Task<byte[]> RenderManyAsync(
string templateJson,
IReadOnlyList<IReadOnlyDictionary<string, object?>> printDataPages,
CancellationToken cancellationToken = default);
}

View File

@ -1,159 +0,0 @@
using System.Globalization;
using Oms.Domain.WarehouseAggregate.Entities;
namespace Oms.Application.Labeling;
/// <summary>
/// 入库箱唛 PrintData(camelCase,与 hiprint 字段对齐;箱唛为主、SKU 为辅)。
/// </summary>
public static class InboundBoxLabelPrintDataBuilder
{
public static IReadOnlyDictionary<string, object?> Build(
CInboundNotice notice,
CInboundBox box,
IReadOnlyList<CInboundBoxLine> lines,
IReadOnlyDictionary<int, CSku> skuMap,
string? warehouseName = null,
string? customerName = null,
string? customerCode = null,
string? operatorName = null,
DateTime? printTime = null,
int pageIndex = 1,
int pageCount = 1)
{
var barcode = ResolveBarcode(notice.NoticeNo, box);
var skuLines = lines
.OrderBy(x => x.Id)
.Select(line =>
{
skuMap.TryGetValue(line.SkuId, out var sku);
var skuCode = FirstNonEmpty(line.SkuCode, sku?.SkuCode) ?? string.Empty;
var skuName = sku?.SkuName ?? string.Empty;
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skuCode"] = skuCode,
["skuName"] = skuName,
["qty"] = line.PlannedQty
};
})
.ToList();
var first = skuLines.FirstOrDefault();
var skuSummary = string.Join(
"\n",
skuLines.Select(x =>
{
var code = Convert.ToString(x.GetValueOrDefault("skuCode")) ?? string.Empty;
var name = Convert.ToString(x.GetValueOrDefault("skuName")) ?? string.Empty;
var qty = Convert.ToString(x.GetValueOrDefault("qty")) ?? "0";
return string.IsNullOrWhiteSpace(name)
? $"{code}×{qty}"
: $"{code} {name}×{qty}";
}));
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["noticeNo"] = notice.NoticeNo,
["markReference"] = notice.MarkReference,
["inboundType"] = notice.InboundType,
["receiptStatus"] = notice.ReceiptStatus,
["remark"] = notice.Remark,
["boxId"] = box.Id,
["boxSeqNo"] = box.BoxSeqNo,
["boxCount"] = pageCount,
["markBoxNo"] = box.MarkBoxNo,
["barcode"] = barcode,
["warehouseName"] = warehouseName,
["customerCode"] = customerCode,
["customerName"] = customerName,
["weightG"] = box.WeightG,
["lengthCm"] = box.LengthCm,
["widthCm"] = box.WidthCm,
["heightCm"] = box.HeightCm,
["weightUnit"] = "G",
["dimUnit"] = "CM",
["skuSummary"] = skuSummary,
["skuCode"] = first?.GetValueOrDefault("skuCode"),
["skuName"] = first?.GetValueOrDefault("skuName"),
["skuQty"] = first?.GetValueOrDefault("qty"),
["items"] = skuLines,
["pageIndex"] = pageIndex,
["pageCount"] = pageCount,
["boxIndexText"] = $"{pageIndex}/{pageCount}",
["printTime"] = (printTime ?? DateTime.Now).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
["operatorName"] = operatorName
};
}
/// <summary>
/// 设计预览用 Mock PrintData。
/// </summary>
public static IReadOnlyDictionary<string, object?> BuildMock() =>
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["noticeNo"] = "IN202610220001",
["markReference"] = "SF1234567890",
["inboundType"] = "normal",
["receiptStatus"] = "forecasted",
["remark"] = null,
["boxId"] = 1,
["boxSeqNo"] = "1",
["boxCount"] = 2,
["markBoxNo"] = "BOX-001",
["barcode"] = "BOX-001",
["warehouseName"] = "SZ-01",
["customerCode"] = "AF0A1",
["customerName"] = "Demo Customer",
["weightG"] = 1200m,
["lengthCm"] = 30m,
["widthCm"] = 20m,
["heightCm"] = 15m,
["weightUnit"] = "G",
["dimUnit"] = "CM",
["skuSummary"] = "SKU001 T-Shirt×2\nSKU002 Pants×1",
["skuCode"] = "SKU001",
["skuName"] = "T-Shirt",
["skuQty"] = 2,
["items"] = new List<Dictionary<string, object?>>
{
new()
{
["skuCode"] = "SKU001",
["skuName"] = "T-Shirt",
["qty"] = 2
},
new()
{
["skuCode"] = "SKU002",
["skuName"] = "Pants",
["qty"] = 1
}
},
["pageIndex"] = 1,
["pageCount"] = 2,
["boxIndexText"] = "1/2",
["printTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
["operatorName"] = "preview"
};
public static string ResolveBarcode(string noticeNo, CInboundBox box)
{
if (!string.IsNullOrWhiteSpace(box.MarkBoxNo))
{
return box.MarkBoxNo.Trim();
}
var seq = string.IsNullOrWhiteSpace(box.BoxSeqNo) ? "1" : box.BoxSeqNo.Trim();
var no = string.IsNullOrWhiteSpace(noticeNo) ? "IN" : noticeNo.Trim();
return $"{no}-{seq}";
}
private static string? FirstNonEmpty(params string?[] values) =>
values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
}

View File

@ -76,60 +76,13 @@ public static class LabelFieldDictionary
]
},
new LabelFieldGroup
{
Code = "inboundBox",
Title = "入库箱唛",
Fields =
[
Field("noticeNo", "预报单号"),
Field("markReference", "唛头参考"),
Field("markBoxNo", "箱唛号"),
Field("boxSeqNo", "箱序号"),
Field("boxCount", "箱子总数"),
Field("barcode", "条码内容"),
Field("boxIndexText", "箱序页码"),
Field("skuSummary", "SKU 摘要"),
Field("skuCode", "首 SKU 编码"),
Field("skuName", "首 SKU 名称"),
Field("skuQty", "首 SKU 数量"),
Field("weightG", "箱重量(g)"),
Field("lengthCm", "箱长(cm)"),
Field("widthCm", "箱宽(cm)"),
Field("heightCm", "箱高(cm)")
]
},
new LabelFieldGroup
{
Code = "warehouseSku",
Title = "库内 SKU",
Fields =
[
Field("skuCode", "SKU 编码"),
Field("skuName", "SKU 名称"),
Field("skuNameEn", "SKU 英文名"),
Field("barcode", "条码内容"),
Field("unitPrice", "单价"),
Field("exHsCode", "出口海关编码"),
Field("imHsCode", "进口海关编码"),
Field("declareElement", "申报要素"),
Field("weightG", "重量(g)"),
Field("lengthCm", "长(cm)"),
Field("widthCm", "宽(cm)"),
Field("heightCm", "高(cm)"),
Field("dimensions", "尺寸摘要"),
Field("remark", "备注")
]
},
new LabelFieldGroup
{
Code = "system",
Title = "系统",
Fields =
[
Field("printTime", "打印时间"),
Field("operatorName", "操作人"),
Field("pageIndex", "页码"),
Field("pageCount", "总页数")
Field("operatorName", "操作人")
]
}
];

View File

@ -53,7 +53,8 @@ public sealed class LabelPdfGenerationService(
var relativeUrl = await SavePdfAsync(order.ServiceNo, pdfBytes, cancellationToken);
order.LabelTemplateId = templateId;
await orderRepository.UpdateLabelTemplateIdAsync(order.Id, templateId.Value, cancellationToken);
order.UpdateTime = DateTime.Now;
await orderRepository.UpdateAsync(order);
await orderFileManager.ReplaceSystemLabelAsync(
order.Id,

View File

@ -1,94 +0,0 @@
using System.Globalization;
using Oms.Domain.WarehouseAggregate.Entities;
namespace Oms.Application.Labeling;
/// <summary>
/// 库内 SKU 标签 PrintData(camelCase,与 hiprint 字段对齐)。
/// </summary>
public static class SkuLabelPrintDataBuilder
{
public static IReadOnlyDictionary<string, object?> Build(
CSku sku,
string? customerName = null,
string? customerCode = null,
string? warehouseName = null,
string? operatorName = null,
DateTime? printTime = null)
{
var barcode = string.IsNullOrWhiteSpace(sku.SkuCode) ? $"SKU-{sku.Id}" : sku.SkuCode.Trim();
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skuId"] = sku.Id,
["skuCode"] = sku.SkuCode,
["skuName"] = sku.SkuName,
["skuNameEn"] = sku.SkuNameEn,
["barcode"] = barcode,
["skuImageUrl"] = sku.SkuImageUrl,
["unitPrice"] = sku.UnitPrice,
["exHsCode"] = sku.ExHsCode,
["imHsCode"] = sku.ImHsCode,
["declareElement"] = sku.DeclareElement,
["productId"] = sku.ProductId,
["nonStdProductId"] = sku.NonStdProductId,
["stdProductId"] = sku.StdProductId,
["weightG"] = sku.WeightG,
["lengthCm"] = sku.LengthCm,
["widthCm"] = sku.WidthCm,
["heightCm"] = sku.HeightCm,
["dimensions"] = FormatDimensions(sku.LengthCm, sku.WidthCm, sku.HeightCm),
["remark"] = sku.Remark,
["weightUnit"] = "G",
["dimUnit"] = "CM",
["customerCode"] = customerCode,
["customerName"] = customerName,
["warehouseName"] = warehouseName,
["printTime"] = (printTime ?? DateTime.Now).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
["operatorName"] = operatorName
};
}
/// <summary>
/// 设计预览用 Mock PrintData。
/// </summary>
public static IReadOnlyDictionary<string, object?> BuildMock() =>
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skuId"] = 1,
["skuCode"] = "SKU001",
["skuName"] = "T-Shirt",
["skuNameEn"] = "T-Shirt",
["barcode"] = "SKU001",
["skuImageUrl"] = null,
["unitPrice"] = 9.9m,
["exHsCode"] = "610910",
["imHsCode"] = "610910",
["declareElement"] = "棉质 T 恤",
["productId"] = null,
["nonStdProductId"] = null,
["stdProductId"] = "06901234567890",
["weightG"] = 200m,
["lengthCm"] = 30m,
["widthCm"] = 20m,
["heightCm"] = 2m,
["dimensions"] = "30×20×2",
["remark"] = null,
["weightUnit"] = "G",
["dimUnit"] = "CM",
["customerCode"] = "AF0A1",
["customerName"] = "Demo Customer",
["warehouseName"] = "SZ-01",
["printTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
["operatorName"] = "preview"
};
private static string? FormatDimensions(decimal? length, decimal? width, decimal? height)
{
if (length is null && width is null && height is null)
{
return null;
}
return $"{length ?? 0}×{width ?? 0}×{height ?? 0}";
}
}

View File

@ -40,16 +40,20 @@ public static class McpToolCatalog
new(
ChannelGuide,
"查渠道对客说明",
"客户问某品名能不能寄、走什么渠道时调用。必填 cargoName;productCode/productName 可选(已知渠道时传入)。仅品名时按对客说明检索可走渠道。",
"按销售产品代码或名称查询对客说明,判断客户品名是否可承运(禁运/可寄)。",
"""
{
"type": "object",
"properties": {
"productCode": { "type": "string", "description": "销售产品代码,可选;客户已指定渠道时优先" },
"productName": { "type": "string", "description": "销售产品/渠道名称关键词,可选" },
"cargoName": { "type": "string", "description": "客户品名,如 lipstick、化妆品;必填" }
"productCode": { "type": "string", "description": "销售产品代码,优先使用" },
"productName": { "type": "string", "description": "销售产品名称关键词(无代码时用)" },
"cargoName": { "type": "string", "description": "客户品名,用于对照说明判断可否承运" }
},
"required": ["cargoName"]
"required": ["cargoName"],
"anyOf": [
{ "required": ["productCode"] },
{ "required": ["productName"] }
]
}
""",
20),

View File

@ -153,13 +153,6 @@ public class McpToolExecutor(
var productName = GetString(root, "productName", "ProductName");
var cargoName = GetString(root, "cargoName", "CargoName");
if (string.IsNullOrWhiteSpace(cargoName)
&& string.IsNullOrWhiteSpace(productCode)
&& string.IsNullOrWhiteSpace(productName))
{
return (false, "请至少提供 cargoName(客户品名),或 productCode / productName");
}
var products = new List<Oms.Domain.ChannelsAggregate.Entities.SalesProduct>();
if (!string.IsNullOrWhiteSpace(productCode))
{
@ -173,51 +166,14 @@ public class McpToolExecutor(
var list = await salesProductRepository.GetListAsync(
x => x.IsActive == EnableStatus.Enabled
&& (x.ProductName.Contains(keyword) || x.ProductCode.Contains(keyword)));
products.AddRange(list.Take(8));
}
// 仅品名:在已启用且配置了对客说明的产品中检索可走渠道
if (products.Count == 0 && !string.IsNullOrWhiteSpace(cargoName))
{
var keyword = cargoName.Trim();
var withGuide = await salesProductRepository.GetListAsync(
x => x.IsActive == EnableStatus.Enabled
&& x.CustomerGuideMarkdown != null
&& x.CustomerGuideMarkdown != "");
static bool ContainsIgnoreCase(string? haystack, string needle) =>
!string.IsNullOrEmpty(haystack)
&& haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
var matched = withGuide
.Where(p =>
ContainsIgnoreCase(p.CustomerGuideMarkdown, keyword)
|| ContainsIgnoreCase(p.ProductName, keyword)
|| ContainsIgnoreCase(p.ProductCode, keyword))
.Take(10)
.ToList();
if (matched.Count > 0)
{
products.AddRange(matched);
}
else
{
return (true, JsonSerializer.Serialize(new
{
items = Array.Empty<object>(),
cargoName = keyword,
hint = "未在已配置对客说明的渠道中命中该品名。请换关键词,或补充销售产品代码/名称后再查;不要擅自判定可寄。"
}, JsonOptions));
}
products.AddRange(list.Take(5));
}
if (products.Count == 0)
{
return (false, "未找到销售产品,请提供 productCode、productName 或 cargoName");
return (false, "未找到销售产品,请提供 productCode 或 productName");
}
var cargo = string.IsNullOrWhiteSpace(cargoName) ? null : cargoName.Trim();
var items = products.Select(p =>
{
var guide = p.CustomerGuideMarkdown?.Trim() ?? string.Empty;
@ -228,19 +184,14 @@ public class McpToolExecutor(
productName = p.ProductName,
hasGuide = !string.IsNullOrWhiteSpace(guide),
customerGuideMarkdown = string.IsNullOrWhiteSpace(guide) ? null : guide,
cargoNameToCheck = cargo,
cargoNameToCheck = string.IsNullOrWhiteSpace(cargoName) ? null : cargoName.Trim(),
hint = string.IsNullOrWhiteSpace(guide)
? "该产品尚未配置对客说明,无法据此判断品名可否承运。"
: "请根据对客说明判断 cargoName 是否可寄、是否适合该渠道;说明未提及的品名不要擅自判定可寄。"
: "请根据对客说明判断 cargoName 是否可寄;说明未提及的品名不要擅自判定可寄。"
};
}).ToList();
return (true, JsonSerializer.Serialize(new
{
items,
cargoName = cargo,
hint = "用一两句中文告诉客户:哪些渠道对客说明支持该品名、哪些不支持;可列出产品代码/名称。"
}, JsonOptions));
return (true, JsonSerializer.Serialize(new { items }, JsonOptions));
}
private async Task<(bool, string)> TrackQueryAsync(string argumentsJson, CancellationToken cancellationToken)

View File

@ -1,14 +0,0 @@
namespace Oms.Application.Messaging.Agents;
/// <summary>代理返佣异步任务动作。</summary>
public static class AgentCommissionJobActionCodes
{
/// <summary>下单/计费后写或刷新预计返佣。</summary>
public const string Estimate = "estimate";
/// <summary>派送终态锁定为待结算。</summary>
public const string Finalize = "finalize";
/// <summary>删单/全额退作废。</summary>
public const string Void = "void";
}

View File

@ -1,75 +0,0 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Oms.Application.Shared;
namespace Oms.Application.Messaging.Agents;
/// <summary>消费代理返佣异步任务。</summary>
public sealed class AgentCommissionJobEventHandler(
IServiceScopeFactory scopeFactory,
IUnitOfWorkManager unitOfWorkManager,
ILogger<AgentCommissionJobEventHandler> logger)
: IEventHandler<AgentCommissionJobIntegrationEvent>, ITransientDependency
{
public async Task HandleAsync(
AgentCommissionJobIntegrationEvent @event,
CancellationToken cancellationToken)
{
if (@event.OrderId <= 0 || string.IsNullOrWhiteSpace(@event.Action))
{
logger.LogWarning(
"AgentCommission job skipped invalid payload OrderId={OrderId} Action={Action}",
@event.OrderId,
@event.Action);
return;
}
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
using var scope = scopeFactory.CreateScope();
var manager = scope.ServiceProvider.GetRequiredService<AgentCommissionManager>();
try
{
var action = @event.Action.Trim();
if (string.Equals(action, AgentCommissionJobActionCodes.Estimate, StringComparison.OrdinalIgnoreCase))
{
await manager.TryEstimateOnOrderCreatedAsync(@event.OrderId, cancellationToken);
}
else if (string.Equals(action, AgentCommissionJobActionCodes.Finalize, StringComparison.OrdinalIgnoreCase))
{
await manager.TryAccrueOnDeliveryTerminalAsync(
@event.OrderId,
@event.TriggerOrderStatus ?? string.Empty,
cancellationToken);
}
else if (string.Equals(action, AgentCommissionJobActionCodes.Void, StringComparison.OrdinalIgnoreCase))
{
await manager.TryVoidOnOrderCancelledAsync(
@event.OrderId,
@event.Reason ?? "异步作废返佣",
cancellationToken);
}
else
{
logger.LogWarning(
"AgentCommission job unknown Action={Action} OrderId={OrderId}",
@event.Action,
@event.OrderId);
}
}
catch (Exception ex)
{
// 失败 Ack,避免打满队列;明细可后续补偿
logger.LogWarning(
ex,
"AgentCommission job failed OrderId={OrderId} Action={Action}",
@event.OrderId,
@event.Action);
}
await unitOfWork.CompleteAsync(cancellationToken);
}
}

View File

@ -1,21 +0,0 @@
using Fake.EventBus.Distributed;
namespace Oms.Application.Messaging.Agents;
/// <summary>
/// 代理返佣异步任务(预计 / 锁定 / 作废),走 Fake.EventBus Outbox。
/// </summary>
[Serializable]
public class AgentCommissionJobIntegrationEvent : IntegrationEvent
{
public int OrderId { get; set; }
/// <summary><see cref="AgentCommissionJobActionCodes"/>。</summary>
public string Action { get; set; } = AgentCommissionJobActionCodes.Estimate;
/// <summary>finalize 时的触发运单状态。</summary>
public string? TriggerOrderStatus { get; set; }
/// <summary>void 时的原因备注。</summary>
public string? Reason { get; set; }
}

View File

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

View File

@ -0,0 +1,68 @@
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 投递)。
/// </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))
{
await eventBus.PublishAsync(@event, cancellationToken);
logger.LogDebug(
"Outbox enqueued {EventName} Id={EventId} (ambient TX)",
@event.GetType().Name,
@event.Id);
return;
}
// 无环境事务时开启短事务只写 Outbox,并告警:业务落库与事件非原子。
logger.LogWarning(
"Publish {EventName} Id={EventId} without ambient transactional UoW; opening short outbox TX",
@event.GetType().Name,
@event.Id);
using var uow = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
// 先打开 Outbox DbContext 以产生可征用的 DbTransaction
var dbProvider = uow.ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>();
await dbProvider.GetDbContextAsync();
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;
}
}
}

View File

@ -17,16 +17,6 @@ public static class OmsOutboxUnitOfWork
IsolationLevel = IsolationLevel.ReadCommitted
};
/// <summary>
/// 与当前业务事务脱开的短事务,用于创单/预报成功之后的独立事件。
/// </summary>
public static UnitOfWorkAttribute Independent() => new()
{
IsTransactional = true,
RequiresNew = true,
IsolationLevel = IsolationLevel.ReadCommitted
};
public static bool IsAmbientTransactional(IUnitOfWorkManager unitOfWorkManager) =>
unitOfWorkManager.Current?.Context.IsTransactional == true;
}

View File

@ -29,7 +29,7 @@ public sealed class OrderBatchJobEventHandler(
return;
}
// 业务结果在本事务提交;轨迹/面单/自动触发由处理过程登记到提交后单独执行。
// ForecastBilling 成功后继续写轨迹/面单/自动触发 Outbox,须事务性 UoW
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
await processor.ProcessAsync(@event.ToMessage(), cancellationToken);
await unitOfWork.CompleteAsync(cancellationToken);

View File

@ -0,0 +1,36 @@
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

@ -0,0 +1,22 @@
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,7 +1,6 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Oms.Application.Messaging;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders.Issues;
@ -27,7 +26,7 @@ public sealed class OrderAutoTriggerFireEventHandler(
return;
}
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
using var unitOfWork = unitOfWorkManager.Begin();
try
{
await autoTriggerRuntimeService.FireByOrderIdAsync(

View File

@ -1,5 +1,6 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging;
using Oms.Application.Integrations.SmallBagTrack;
@ -7,6 +8,7 @@ namespace Oms.Application.Messaging.OrderSideEffects;
public sealed class OrderLifecycleTrackAppendEventHandler(
ISmallBagTrackOrderClient trackClient,
IUnitOfWorkManager unitOfWorkManager,
ILogger<OrderLifecycleTrackAppendEventHandler> logger)
: IEventHandler<OrderLifecycleTrackAppendIntegrationEvent>, ITransientDependency
{
@ -22,7 +24,7 @@ public sealed class OrderLifecycleTrackAppendEventHandler(
return;
}
// 纯外部 HTTP,无需事务;占 TX 只会拖长连接占用。
using var unitOfWork = unitOfWorkManager.Begin();
try
{
await trackClient.AppendLifecycleTrackAsync(
@ -41,5 +43,7 @@ public sealed class OrderLifecycleTrackAppendEventHandler(
@event.OrderNo,
@event.TrackCode);
}
await unitOfWork.CompleteAsync(cancellationToken);
}
}

View File

@ -0,0 +1,61 @@
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

@ -0,0 +1,46 @@
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

@ -0,0 +1,143 @@
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

@ -1,10 +1,8 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Oms.Application.Messaging;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Messaging.ParcelCreate;
@ -23,13 +21,11 @@ public sealed class OrderParcelCustoms9610UpsertEventHandler(
OrderParcelCustoms9610UpsertIntegrationEvent @event,
CancellationToken cancellationToken)
{
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
using var unitOfWork = unitOfWorkManager.Begin();
var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken);
if (order == null
|| string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
if (order == null)
{
logger.LogWarning("9610 upsert skipped, order missing/deleted OrderId={OrderId}", @event.OrderId);
await unitOfWork.CompleteAsync(cancellationToken);
logger.LogWarning("9610 upsert skipped, order missing OrderId={OrderId}", @event.OrderId);
return;
}

View File

@ -1,7 +1,6 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Oms.Application.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders;
@ -21,7 +20,7 @@ public sealed class OrderParcelLabelGenerateEventHandler(
OrderParcelLabelGenerateIntegrationEvent @event,
CancellationToken cancellationToken)
{
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
using var unitOfWork = unitOfWorkManager.Begin();
using var scope = scopeFactory.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
var result = await orderService.GenerateLabelPdfForBatchAsync(@event.OrderId, cancellationToken);

View File

@ -1,7 +1,6 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Oms.Application.Messaging;
using Microsoft.Extensions.Logging;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders;
@ -22,7 +21,7 @@ public sealed class OrderParcelPickupAttachEventHandler(
OrderParcelPickupAttachIntegrationEvent @event,
CancellationToken cancellationToken)
{
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
using var unitOfWork = unitOfWorkManager.Begin();
try
{
await pickupOrderService.CreateForParcelInternalAsync(new PickupOrderCreateInput

View File

@ -24,8 +24,7 @@ public sealed class OrderParcelTrackUpsertEventHandler(
OrderParcelTrackUpsertIntegrationEvent @event,
CancellationToken cancellationToken)
{
// 仅读库 + 外部 HTTP,不要占事务连接;失败抛出让总线重试。
using var unitOfWork = unitOfWorkManager.Begin(new UnitOfWorkAttribute { IsTransactional = false });
using var unitOfWork = unitOfWorkManager.Begin();
var request = await ResolveRequestAsync(@event, cancellationToken);
request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess;

View File

@ -1,12 +0,0 @@
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 根据轨迹变更事件同步 OMS 出库后订单状态。
/// </summary>
public interface ITrackChangeOrderStatusApplier
{
/// <summary>
/// 处理一条 TrackChange 消息;业务跳过不抛异常。
/// </summary>
Task ApplyAsync(TrackChangeEto eto, CancellationToken cancellationToken = default);
}

View File

@ -1,65 +0,0 @@
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 轨迹中心轨迹变更广播(RabbitMQ topic:track.on_track_change_ex / TrackChange.{AppKey})。
/// 字段与 TrackCenter.Application.Events.TrackChangeEto 对齐(Newtonsoft 默认 PascalCase)。
/// </summary>
public sealed class TrackChangeEto
{
/// <summary>轨迹唯一 id;操作失败时可能为 null。</summary>
public string? TrackEsId { get; set; }
/// <summary>操作类型:1 新增,2 删除,3 修改。</summary>
public int Option { get; set; }
/// <summary>操作是否成功。</summary>
public bool IsSuccess { get; set; }
/// <summary>失败原因。</summary>
public string? FailedReason { get; set; }
/// <summary>应用 AppKey。</summary>
public string? AppKey { get; set; }
/// <summary>单号(OMS 侧对应运单号 ServiceNo)。</summary>
public string? OrderNo { get; set; }
/// <summary>客户标识。</summary>
public string? Customer { get; set; }
/// <summary>操作人。</summary>
public string? OperationMan { get; set; }
/// <summary>轨迹内容代码。</summary>
public string? TrackCode { get; set; }
/// <summary>轨迹内容。</summary>
public string? Content { get; set; }
/// <summary>轨迹内容英文。</summary>
public string? EnStatus { get; set; }
/// <summary>轨迹状态代码(如 D03 / D09 / D11)。</summary>
public string? TrackStatusCode { get; set; }
/// <summary>轨迹发生地。</summary>
public string? TrackPlace { get; set; }
/// <summary>轨迹发生时间。</summary>
public DateTime TrackTime { get; set; }
/// <summary>轨迹发生地时区。</summary>
public float TrackTimeZone { get; set; }
/// <summary>是否对外。</summary>
public bool IsExternal { get; set; }
/// <summary>数据来源:1 外部服务商,2 内部系统,3 人工。</summary>
public int Source { get; set; }
/// <summary>轨迹备注。</summary>
public string? Remarks { get; set; }
/// <summary>扩展信息。</summary>
public string? Extra { get; set; }
}

View File

@ -1,186 +0,0 @@
using Fake.DependencyInjection;
using Fake.EventBus.Distributed;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging;
using Oms.Application.Messaging.Agents;
using Oms.Application.Services.Orders;
using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 将轨迹状态 D03/D09/D11 同步到运单订单状态(运输中 / 派送失败 / 派送成功)。
/// 规则对齐手动「出库后状态校正」:仅出库后四态可改;派送成功只进不出。
/// </summary>
public sealed class TrackChangeOrderStatusApplier(
IOrderRepository orderRepository,
OrderOperationLogWriter operationLogWriter,
ReferralCommissionManager referralCommissionManager,
IDistributedEventBus eventBus,
MarketingCouponManager marketingCouponManager,
IUnitOfWorkManager unitOfWorkManager,
ILogger<TrackChangeOrderStatusApplier> logger) : ITrackChangeOrderStatusApplier, IScopedDependency
{
private static readonly HashSet<string> PostShipStatuses = new(StringComparer.OrdinalIgnoreCase)
{
OrderStatusCodes.PendingOnline,
OrderStatusCodes.Shipping,
OrderStatusCodes.DeliverySuccess,
OrderStatusCodes.DeliveryFailed
};
public async Task ApplyAsync(TrackChangeEto eto, CancellationToken cancellationToken = default)
{
if (eto == null)
{
return;
}
if (!eto.IsSuccess)
{
logger.LogDebug(
"TrackChange skip failed event OrderNo={OrderNo} Reason={Reason}",
eto.OrderNo,
eto.FailedReason);
return;
}
// 删除轨迹不回退 OMS 状态
if (eto.Option == 2)
{
logger.LogDebug("TrackChange skip delete Option=2 OrderNo={OrderNo}", eto.OrderNo);
return;
}
var targetStatus = TrackStatusOrderStatusMapper.TryMapToOrderStatus(eto.TrackStatusCode);
if (targetStatus == null)
{
logger.LogDebug(
"TrackChange skip unmapped status OrderNo={OrderNo} TrackStatusCode={TrackStatusCode}",
eto.OrderNo,
eto.TrackStatusCode);
return;
}
var orderNo = eto.OrderNo?.Trim();
if (string.IsNullOrWhiteSpace(orderNo))
{
logger.LogWarning("TrackChange skip empty OrderNo TrackStatusCode={TrackStatusCode}", eto.TrackStatusCode);
return;
}
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
var order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken);
if (order == null)
{
logger.LogWarning(
"TrackChange order not found ServiceNo={OrderNo} TrackStatusCode={TrackStatusCode}",
orderNo,
eto.TrackStatusCode);
await unitOfWork.CompleteAsync(cancellationToken);
return;
}
var fromStatus = order.OrderStatus?.Trim() ?? string.Empty;
if (!IsPostShipStatus(fromStatus))
{
logger.LogInformation(
"TrackChange skip non post-ship status OrderId={OrderId} ServiceNo={ServiceNo} From={From} TrackStatusCode={TrackStatusCode}",
order.Id,
order.ServiceNo,
fromStatus,
eto.TrackStatusCode);
await unitOfWork.CompleteAsync(cancellationToken);
return;
}
if (string.Equals(fromStatus, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation(
"TrackChange skip deliverySuccess terminal OrderId={OrderId} ServiceNo={ServiceNo} TrackStatusCode={TrackStatusCode}",
order.Id,
order.ServiceNo,
eto.TrackStatusCode);
await unitOfWork.CompleteAsync(cancellationToken);
return;
}
if (string.Equals(fromStatus, targetStatus, StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug(
"TrackChange idempotent skip OrderId={OrderId} ServiceNo={ServiceNo} Status={Status}",
order.Id,
order.ServiceNo,
targetStatus);
await unitOfWork.CompleteAsync(cancellationToken);
return;
}
var beforeSnap = OrderOperationLogSnapshot.CaptureOrder(order);
order.OrderStatus = targetStatus;
order.UpdateTime = DateTime.Now;
if (string.Equals(targetStatus, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase))
{
order.SignTime = DateTime.Now;
}
await orderRepository.UpdateAsync(order, cancellationToken: cancellationToken);
if (string.Equals(targetStatus, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase))
{
await referralCommissionManager.OnOrderDeliverySuccessAsync(order.Id, cancellationToken);
// 重新加载,避免营销侧依赖最新字段
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken);
await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent
{
OrderId = order.Id,
Action = AgentCommissionJobActionCodes.Finalize,
TriggerOrderStatus = OrderStatusCodes.DeliverySuccess
});
}
else if (string.Equals(targetStatus, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase))
{
await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent
{
OrderId = order.Id,
Action = AgentCommissionJobActionCodes.Finalize,
TriggerOrderStatus = OrderStatusCodes.DeliveryFailed
});
}
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
var afterSnap = OrderOperationLogSnapshot.CaptureOrder(order);
var fromName = OmsDocHelper.GetOrderStatusName(fromStatus) ?? fromStatus;
var toName = OmsDocHelper.GetOrderStatusName(targetStatus) ?? targetStatus;
var trackHint = string.IsNullOrWhiteSpace(eto.Content)
? eto.TrackStatusCode
: $"{eto.TrackStatusCode}/{eto.Content}";
await operationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.TrackStatusSync,
beforeSnap,
afterSnap,
$"{fromName} → {toName};轨迹同步 {trackHint}",
operatorId: 0,
operatorName: string.IsNullOrWhiteSpace(eto.OperationMan) ? "轨迹中心" : eto.OperationMan.Trim(),
operatorType: "system",
source: "trackCenter");
await unitOfWork.CompleteAsync(cancellationToken);
logger.LogInformation(
"TrackChange applied OrderId={OrderId} ServiceNo={ServiceNo} {From} → {To} TrackStatusCode={TrackStatusCode}",
order.Id,
order.ServiceNo,
fromStatus,
targetStatus,
eto.TrackStatusCode);
}
private static bool IsPostShipStatus(string? status) =>
!string.IsNullOrWhiteSpace(status) && PostShipStatuses.Contains(status.Trim());
}

View File

@ -1,43 +0,0 @@
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 订阅轨迹中心 TrackChange 广播的配置(Consul / 配置:Oms:TrackChangeSubscribe)。
/// </summary>
public class TrackChangeSubscribeOptions
{
public const string SectionName = "Oms:TrackChangeSubscribe";
/// <summary>是否启用订阅;默认 true。</summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// 轨迹中心广播交换器;须与 TrackCenter GlobalConst.TrackChangeExchange 一致。
/// </summary>
public string Exchange { get; set; } = "track.on_track_change_ex";
/// <summary>
/// 绑定路由键模板,{AppKey} 会替换为轨迹服务 AppKey。
/// </summary>
public string RoutingKeyTemplate { get; set; } = "TrackChange.{AppKey}";
/// <summary>
/// 本地队列名模板;{AppKey} 会替换。为空时使用 oms.track_change.{AppKey}。
/// </summary>
public string? QueueNameTemplate { get; set; } = "oms.track_change.{AppKey}";
/// <summary>
/// Fake.RabbitMQ 连接名;为空则使用 RabbitMqEventBusOptions.ConnectionName。
/// </summary>
public string? ConnectionName { get; set; }
/// <summary>消费预取数量。</summary>
public ushort PrefetchCount { get; set; } = 10;
/// <summary>
/// 覆盖轨迹 AppKey(一般留空,走 Oms:TrackService / 系统其他配置 track_service)。
/// </summary>
public string? AppKey { get; set; }
/// <summary>处理失败时是否重新入队。</summary>
public bool FailedRequeue { get; set; } = true;
}

View File

@ -1,202 +0,0 @@
using System.Text;
using Fake.EventBus.RabbitMQ;
using Fake.RabbitMQ;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Oms.Application.Integrations.SmallBagTrack;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 订阅轨迹中心 TrackChange 广播,驱动 OMS 出库后订单状态变更。
/// </summary>
public sealed class TrackChangeSubscriberHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IRabbitMqConnectionPool _connectionPool;
private readonly IOptions<TrackChangeSubscribeOptions> _subscribeOptions;
private readonly IOptions<RabbitMqEventBusOptions> _eventBusOptions;
private readonly ILogger<TrackChangeSubscriberHostedService> _logger;
private IModel? _channel;
public TrackChangeSubscriberHostedService(
IServiceScopeFactory scopeFactory,
IRabbitMqConnectionPool connectionPool,
IOptions<TrackChangeSubscribeOptions> subscribeOptions,
IOptions<RabbitMqEventBusOptions> eventBusOptions,
ILogger<TrackChangeSubscriberHostedService> logger)
{
_scopeFactory = scopeFactory;
_connectionPool = connectionPool;
_subscribeOptions = subscribeOptions;
_eventBusOptions = eventBusOptions;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var options = _subscribeOptions.Value;
if (!options.Enabled)
{
_logger.LogInformation("TrackChange subscribe disabled");
return;
}
string? appKey;
try
{
appKey = await ResolveAppKeyAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "TrackChange subscribe failed to resolve AppKey; consumer not started");
return;
}
if (string.IsNullOrWhiteSpace(appKey))
{
_logger.LogWarning("TrackChange subscribe skipped: AppKey empty (configure Oms:TrackService or Oms:TrackChangeSubscribe:AppKey)");
return;
}
var exchange = string.IsNullOrWhiteSpace(options.Exchange)
? "track.on_track_change_ex"
: options.Exchange.Trim();
var routingKey = (options.RoutingKeyTemplate ?? "TrackChange.{AppKey}")
.Replace("{AppKey}", appKey, StringComparison.OrdinalIgnoreCase);
var queueName = string.IsNullOrWhiteSpace(options.QueueNameTemplate)
? $"oms.track_change.{appKey}"
: options.QueueNameTemplate.Replace("{AppKey}", appKey, StringComparison.OrdinalIgnoreCase);
var connectionName = string.IsNullOrWhiteSpace(options.ConnectionName)
? _eventBusOptions.Value.ConnectionName
: options.ConnectionName;
try
{
// Connection 由 Fake 连接池托管,禁止 Dispose
var connection = _connectionPool.Get(connectionName);
_channel = connection.CreateModel();
_channel.ExchangeDeclare(exchange, ExchangeType.Topic, durable: true, autoDelete: false);
_channel.QueueDeclare(queueName, durable: true, exclusive: false, autoDelete: false);
_channel.QueueBind(queueName, exchange, routingKey);
if (options.PrefetchCount > 0)
{
_channel.BasicQos(0, options.PrefetchCount, false);
}
var consumer = new AsyncEventingBasicConsumer(_channel);
consumer.Received += OnReceivedAsync;
_channel.BasicConsume(queue: queueName, autoAck: false, consumer: consumer);
_logger.LogInformation(
"TrackChange subscribe started Exchange={Exchange} RoutingKey={RoutingKey} Queue={Queue}",
exchange,
routingKey,
queueName);
}
catch (Exception ex)
{
_logger.LogError(ex, "TrackChange subscribe failed to start");
DisposeChannel();
return;
}
try
{
await Task.Delay(Timeout.Infinite, stoppingToken);
}
catch (OperationCanceledException)
{
// shutdown
}
finally
{
DisposeChannel();
_logger.LogInformation("TrackChange subscribe stopped");
}
}
private async Task OnReceivedAsync(object sender, BasicDeliverEventArgs ea)
{
var channel = _channel;
if (channel == null || !channel.IsOpen)
{
return;
}
var options = _subscribeOptions.Value;
try
{
var json = Encoding.UTF8.GetString(ea.Body.Span);
var eto = JsonConvert.DeserializeObject<TrackChangeEto>(json);
if (eto == null)
{
_logger.LogWarning("TrackChange deserialize null body");
channel.BasicAck(ea.DeliveryTag, false);
return;
}
await using var scope = _scopeFactory.CreateAsyncScope();
var applier = scope.ServiceProvider.GetRequiredService<ITrackChangeOrderStatusApplier>();
await applier.ApplyAsync(eto);
channel.BasicAck(ea.DeliveryTag, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "TrackChange handle failed DeliveryTag={DeliveryTag}", ea.DeliveryTag);
try
{
channel.BasicNack(ea.DeliveryTag, false, options.FailedRequeue);
}
catch (Exception nackEx)
{
_logger.LogError(nackEx, "TrackChange Nack failed DeliveryTag={DeliveryTag}", ea.DeliveryTag);
}
}
}
private async Task<string?> ResolveAppKeyAsync(CancellationToken cancellationToken)
{
var overrideKey = _subscribeOptions.Value.AppKey?.Trim();
if (!string.IsNullOrWhiteSpace(overrideKey))
{
return overrideKey;
}
await using var scope = _scopeFactory.CreateAsyncScope();
var trackConfig = scope.ServiceProvider.GetRequiredService<ISmallBagTrackConfigProvider>();
var settings = await trackConfig.GetAsync(cancellationToken);
return string.IsNullOrWhiteSpace(settings.AppKey) ? null : settings.AppKey.Trim();
}
private void DisposeChannel()
{
try
{
_channel?.Close();
_channel?.Dispose();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "TrackChange channel dispose ignored");
}
finally
{
_channel = null;
}
}
public override void Dispose()
{
DisposeChannel();
base.Dispose();
}
}

View File

@ -1,38 +0,0 @@
using Oms.Domain.OrdersAggregate.Consts;
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 轨迹状态码 → OMS 出库后订单状态。
/// </summary>
public static class TrackStatusOrderStatusMapper
{
/// <summary>运输中。</summary>
public const string InTransit = "D03";
/// <summary>派送失败。</summary>
public const string DeliveryFailed = "D09";
/// <summary>派送成功。</summary>
public const string DeliverySuccess = "D11";
/// <summary>
/// 将轨迹状态码映射为 OMS OrderStatus;未映射返回 null。
/// </summary>
public static string? TryMapToOrderStatus(string? trackStatusCode)
{
var code = trackStatusCode?.Trim();
if (string.IsNullOrWhiteSpace(code))
{
return null;
}
return code.ToUpperInvariant() switch
{
InTransit => OrderStatusCodes.Shipping,
DeliveryFailed => OrderStatusCodes.DeliveryFailed,
DeliverySuccess => OrderStatusCodes.DeliverySuccess,
_ => null
};
}
}

View File

@ -18,10 +18,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fake.ObjectMapping.AutoMapper" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.ObjectStorage" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.EventBus.RabbitMQ" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.ObjectMapping.AutoMapper" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.ObjectStorage" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.RabbitMQ" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.Rbac.Infrastructure" Version="$(FakePackageVersion)" />
@ -41,13 +41,6 @@
<ItemGroup>
<EmbeddedResource Include="Services\Ai\AiSkillSeedContent.json" />
<!-- 面单 QuestPDF 中文:嵌入单文件 TTF,避免容器仅有 TTC/未装字体时出 ??? -->
<EmbeddedResource Include="Fonts\OmsLabelCjk.ttf" LogicalName="Oms.Application.Fonts.OmsLabelCjk.ttf" />
</ItemGroup>
<ItemGroup>
<Content Include="Fonts\OmsLabelCjk.ttf" CopyToOutputDirectory="PreserveNewest" Link="Fonts\OmsLabelCjk.ttf" />
<None Include="Fonts\README.md" CopyToOutputDirectory="Never" />
</ItemGroup>
</Project>

View File

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

View File

@ -1,6 +1,5 @@
using Fake.DependencyInjection;
using Fake.Rbac.Infrastructure;
using Fake.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Oms.Application.Dtos.Customers;
@ -42,17 +41,18 @@ public class CustomerServiceAdvisorResolver(FakeRbacDbContext dbContext) : IScop
return null;
}
var sql = $@"
const string sql = """
SELECT
u.`Name` AS `Name`,
u.`WechatId` AS `WechatId`,
u.`WechatQrCode` AS `WechatQrCode`,
u.`WecomKfUrl` AS `WecomKfUrl`
FROM `{FakeTables.User}` u
FROM `user` u
WHERE u.`IsDeleted` = 0
AND u.`userid` = {{0}}
AND u.`userid` = {0}
LIMIT 1
";
""";
return await dbContext.Database
.SqlQueryRaw<AdvisorRow>(sql, userId)
.FirstOrDefaultAsync(cancellationToken);
@ -60,18 +60,18 @@ public class CustomerServiceAdvisorResolver(FakeRbacDbContext dbContext) : IScop
private async Task<AdvisorRow?> FindByNameOrAccountAsync(string key, CancellationToken cancellationToken)
{
var sql = $@"
const string sql = """
SELECT
u.`Name` AS `Name`,
u.`WechatId` AS `WechatId`,
u.`WechatQrCode` AS `WechatQrCode`,
u.`WecomKfUrl` AS `WecomKfUrl`
FROM `{FakeTables.User}` u
FROM `user` u
WHERE u.`IsDeleted` = 0
AND (u.`Name` = {{0}} OR u.`Account` = {{0}})
AND (u.`Name` = {0} OR u.`Account` = {0})
ORDER BY u.`CreateTime` ASC
LIMIT 1
";
""";
return await dbContext.Database
.SqlQueryRaw<AdvisorRow>(sql, key)

Some files were not shown because too many files have changed in this diff Show More