diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 9d38b61..e2f0a9a 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -1,10 +1,10 @@
-# 复制到 admin .gitea/workflows/deploy.yml
+# 复制到 admin .gitea/workflows/deploy.yml
+# push master → prod;push test → test
name: Build and Deploy
on:
push:
- branches: [master, main]
- workflow_dispatch:
+ branches: [master, test]
env:
CSPROJ: src/SimpleAdmin.Api/SimpleAdmin.Api.csproj
@@ -21,14 +21,47 @@ jobs:
- name: Build and deploy
run: |
set -euxo pipefail
- export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
- TAG="${{ gitea.sha }}"
- IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}"
+ REF="${{ gitea.ref_name }}"
+ case "$REF" in
+ test) TARGET=test ;;
+ master) TARGET=prod ;;
+ *) echo "unsupported branch: $REF"; exit 1 ;;
+ esac
+
+ SHA="${{ gitea.sha }}"
REPO="${{ gitea.repository }}"
test -n "$REPO" && [[ "$REPO" == */* ]]
- echo "REPO=$REPO TAG=$TAG host=$(hostname)"
+
+ case "$TARGET" in
+ test) TAG="test-${SHA}" ;;
+ *) TAG="${SHA}" ;;
+ esac
+
+ IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}"
+ echo "TARGET=$TARGET REF=$REF REPO=$REPO TAG=$TAG host=$(hostname)"
+
+ export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
+ unset NAMESPACE
cd /data/k3s
./scripts/ci-publish-container.sh "$REPO" "${{ env.CSPROJ }}" \
- "${{ env.IMAGE_NAME }}" "$TAG" "$TAG"
+ "${{ env.IMAGE_NAME }}" "$TAG" "$SHA"
+
+ case "$TARGET" in
+ test)
+ export KUBECONFIG=/etc/rancher/k3s/k3s-test.yaml
+ export NAMESPACE=test
+ export MANIFEST_ROOT=/data/k3s-test
+ ;;
+ *)
+ export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
+ export NAMESPACE=prod
+ export MANIFEST_ROOT=/data/k3s
+ ;;
+ esac
+ if [[ ! -f "$KUBECONFIG" ]]; then
+ echo "FAIL: missing KUBECONFIG=$KUBECONFIG host=$(hostname) uid=$(id -u)"
+ ls -la /etc/rancher/k3s/ /root/.kube/ 2>/dev/null || true
+ exit 1
+ fi
./scripts/ci-deploy-api.sh "${{ env.DEPLOY_NAME }}" \
"${{ env.CONTAINER_NAME }}" "$IMG"
diff --git a/Directory.Build.props b/Directory.Build.props
index e7da47d..7d6f04d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,6 @@
- 8.0.5.16
+ 8.0.5.17
diff --git a/db.sql b/db.sql
index 72e747b..b2c744a 100644
--- a/db.sql
+++ b/db.sql
@@ -124,6 +124,7 @@ CREATE TABLE `t_order` (
`ConsigneeCountryName` VARCHAR(64) NULL COMMENT '国家中文名称',
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
`ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式',
+ `ExportQualificationId` INT NULL COMMENT '出口资质备案 Id(t_customer_export_qualification.Id)',
`PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因',
`AllocationPendingReason` VARCHAR(512) NULL COMMENT '待排货原因(排货或渠道买价未匹配)',
`AllocationScheme` VARCHAR(32) NULL COMMENT '最近一次排货方案 costLowest/timeFastest/overallBest/designated',
@@ -204,6 +205,7 @@ CREATE TABLE `t_order` (
KEY `idx_ord_source_ctime` (`OrderSource`, `CreateTime`),
KEY `idx_order_pickup` (`PickupOrderId`),
KEY `idx_order_inbound_method` (`InboundMethod`),
+ KEY `idx_order_export_qualification` (`ExportQualificationId`),
KEY `idx_ord_fulfillment_customer` (`FulfillmentType`, `CustomerId`, `OrderStatus`)
) COMMENT='订单表';
@@ -212,7 +214,7 @@ CREATE TABLE `t_order` (
CREATE TABLE `t_order_file` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
`OrderId` INT NOT NULL COMMENT '订单Id',
- `FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片',
+ `FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片/pod签收证明',
`FileName` VARCHAR(255) NULL COMMENT '原始文件名',
`FileUrl` VARCHAR(512) NOT NULL COMMENT '文件地址(相对路径或外链)',
`ContentType` VARCHAR(128) NULL COMMENT '文件MIME类型',
@@ -791,6 +793,9 @@ CREATE TABLE `t_kpi_area_info` (
`City` VARCHAR(128) NULL COMMENT '城市',
`LeftPostcode` VARCHAR(32) NULL COMMENT '左侧邮编',
`RightPostcode` VARCHAR(32) NULL COMMENT '右侧邮编',
+ `MatchDim` VARCHAR(16) NOT NULL DEFAULT 'country' COMMENT '匹配维度:zip/state/city/country',
+ `MatchMode` VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT '匹配方式:prefix/exact/range/all',
+ `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT '邮编前缀长度(zip+prefix)',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -798,7 +803,8 @@ CREATE TABLE `t_kpi_area_info` (
`UpdateTime` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`Id`),
KEY `idx_kpi_info_area` (`AreaId`),
- KEY `idx_kpi_info_area_country` (`AreaId`, `CountryCode`)
+ KEY `idx_kpi_info_area_country` (`AreaId`, `CountryCode`),
+ KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`)
) COMMENT='分区明细表';
CREATE TABLE `t_weight_rule` (
@@ -1905,6 +1911,15 @@ CREATE TABLE `t_pickup_order` (
`ParcelCount` INT NOT NULL DEFAULT 0 COMMENT '关联票数',
`TotalActualWeight` DECIMAL(18,3) NULL COMMENT '汇总实重',
`ChargeableWeight` DECIMAL(18,3) NULL COMMENT '计费重',
+ `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重',
+ `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON',
+ `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)',
+ `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)',
+ `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)',
+ `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文',
+ `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON',
+ `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON',
+ `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间',
`Currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '币种',
`FreightAmount` DECIMAL(18,2) NULL COMMENT '对客揽收运费',
`OtherFeeAmount` DECIMAL(18,2) NULL COMMENT '对客其他费',
diff --git a/sql/README.md b/sql/README.md
index ddb4def..9d84b35 100644
--- a/sql/README.md
+++ b/sql/README.md
@@ -3,5 +3,5 @@
OMS / 共用库的建表脚本已统一到仓库根目录 `db.sql`。
- 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行)
-- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录下单个表脚本(如 `c_parcel_arrival.sql`、`c_parcel_item_AttributeText.sql`、`c_parcel_item_ImageUrl.sql`)可直接在已有库执行增量建表;若仍为旧名 `C_*`,先执行 `migrate_rename_warehouse_C_to_c.sql`
+- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过)
- 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落
diff --git a/sql/admin.sql b/sql/admin.sql
new file mode 100644
index 0000000..07f3ba7
--- /dev/null
+++ b/sql/admin.sql
@@ -0,0 +1,361 @@
+-- =============================================================================
+-- 存量库增量迁移(合并自 sql/migrate_*.sql)
+-- 可按整文件执行;多数段可重复执行(information_schema 判断)。
+-- 注意:揽收 Direction / FeePush 两段为裸 ALTER,已执行过的库请跳过或手工注释。
+-- =============================================================================
+
+SET @db := DATABASE();
+
+-- -----------------------------------------------------------------------------
+-- 1) 排货规则服务商表:MatchCondition
+-- 原:migrate_allocation_match_condition.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_sales_product_allocation_provider'
+ AND COLUMN_NAME = 'MatchCondition'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_sales_product_allocation_provider` ADD COLUMN `MatchCondition` TEXT NULL COMMENT ''排货判断条件(中文公式),空表示全部订单可用'' AFTER `CustomerIds`',
+ 'SELECT ''MatchCondition already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 2) 客户出口资质备案表(含 IsDefault / Remark)
+-- 原:migrate_customer_export_qualification.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'CREATE TABLE `t_customer_export_qualification` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+ `CustomerId` INT NOT NULL COMMENT ''归属客户 t_customer.Id'',
+ `CompanyName` VARCHAR(200) NOT NULL COMMENT ''企业名称'',
+ `SocialCreditCode` VARCHAR(64) NOT NULL COMMENT ''企业社会信用代码'',
+ `DomesticConsignor` VARCHAR(200) NOT NULL COMMENT ''境内发货人'',
+ `DomesticConsignorCustomsCode` VARCHAR(64) NULL COMMENT ''境内发货人海关代码'',
+ `ProducerName` VARCHAR(200) NOT NULL COMMENT ''生产销售单位'',
+ `ProducerCustomsCode` VARCHAR(64) NULL COMMENT ''生产销售单位海关代码'',
+ `CustomsPort` VARCHAR(100) NULL COMMENT ''备案海关港口地'',
+ `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'',
+ `Remark` VARCHAR(512) NULL COMMENT ''备注'',
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
+ `UpdateUserId` INT NULL COMMENT ''更新人'',
+ `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
+ PRIMARY KEY (`Id`),
+ KEY `idx_ceq_customer` (`CustomerId`, `IsDeleted`, `Id`),
+ KEY `idx_ceq_credit` (`SocialCreditCode`, `IsDeleted`),
+ KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)
+) COMMENT=''客户出口资质备案''',
+ 'SELECT ''t_customer_export_qualification already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+ AND COLUMN_NAME = 'IsDefault'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_customer_export_qualification`
+ ADD COLUMN `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'' AFTER `CustomsPort`',
+ 'SELECT ''IsDefault already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+ AND COLUMN_NAME = 'Remark'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_customer_export_qualification`
+ ADD COLUMN `Remark` VARCHAR(512) NULL COMMENT ''备注'' AFTER `IsDefault`',
+ 'SELECT ''Remark already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @idx := (
+ SELECT COUNT(1)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+ AND INDEX_NAME = 'idx_ceq_customer_default'
+);
+SET @sql := IF(
+ @idx = 0,
+ 'ALTER TABLE `t_customer_export_qualification` ADD KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)',
+ 'SELECT ''idx_ceq_customer_default already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 3) t_order:出口资质备案 Id
+-- 原:migrate_order_export_qualification_id.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order'
+ AND COLUMN_NAME = 'ExportQualificationId'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order`
+ ADD COLUMN `ExportQualificationId` INT NULL COMMENT ''出口资质备案 Id(t_customer_export_qualification.Id)'' AFTER `ExportCustomsMode`,
+ ADD KEY `idx_order_export_qualification` (`ExportQualificationId`)',
+ 'SELECT ''t_order.ExportQualificationId already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 4) SKU / 订单申报:DeclareElement
+-- 原:migrate_declare_element.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 'c_sku'
+ AND COLUMN_NAME = 'DeclareElement'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `c_sku` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ImHsCode`',
+ 'SELECT ''c_sku.DeclareElement already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order_declaration'
+ AND COLUMN_NAME = 'DeclareElement'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order_declaration` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ExHsCode`',
+ 'SELECT ''t_order_declaration.DeclareElement already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 5) 问题模板:处理人字段
+-- 原:migrate_issue_template_handler.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_issue_template'
+ AND COLUMN_NAME = 'HandlerType'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerType` VARCHAR(16) NULL COMMENT ''处理人类型:customer=订单所属客户,user=指定用户'' AFTER `NotifyMethod`',
+ 'SELECT ''HandlerType already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_issue_template'
+ AND COLUMN_NAME = 'HandlerUserId'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerUserId` INT NULL COMMENT ''指定处理人用户Id(HandlerType=user 时必填)'' AFTER `HandlerType`',
+ 'SELECT ''HandlerUserId already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_issue_template`
+SET `HandlerType` = 'user'
+WHERE (`HandlerType` IS NULL OR `HandlerType` = '')
+ AND `IsDeleted` = 0;
+
+-- -----------------------------------------------------------------------------
+-- 6) 揽收方向 + 客户地址类型
+-- 原:migrate_pickup_direction_and_address_type.sql
+-- 注意:裸 ALTER,已执行过勿再跑本段
+-- -----------------------------------------------------------------------------
+ALTER TABLE `t_pickup_order`
+ ADD COLUMN `Direction` VARCHAR(16) NOT NULL DEFAULT 'forward'
+ COMMENT 'forward正向(客户→仓)/reverse逆向(仓→客户)'
+ AFTER `SenderAddressId`;
+
+ALTER TABLE `t_pickup_order`
+ ADD KEY `idx_pickup_direction_status` (`Direction`, `Status`, `CreateTime`);
+
+ALTER TABLE `t_customer_sender_address`
+ MODIFY COLUMN `AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender'
+ COMMENT 'sender国内发件/return国内退件/pickup国内揽收/receiver海外收件';
+
+-- -----------------------------------------------------------------------------
+-- 7) 揽收单:承运推送件重尺 + 费用快照
+-- 原:migrate_pickup_fee_push.sql
+-- 注意:裸 ALTER,已执行过勿再跑本段
+-- -----------------------------------------------------------------------------
+ALTER TABLE `t_pickup_order`
+ ADD COLUMN `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重' AFTER `ChargeableWeight`,
+ ADD COLUMN `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON' AFTER `VolumetricWeight`,
+ ADD COLUMN `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)' AFTER `PackageSizesJson`,
+ ADD COLUMN `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)' AFTER `CarrierFreightWeight`,
+ ADD COLUMN `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)' AFTER `CarrierQuotedFreightAmount`,
+ ADD COLUMN `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文' AFTER `CarrierQuotedReceivableAmount`,
+ ADD COLUMN `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON' AFTER `CarrierQuoteJson`,
+ ADD COLUMN `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON' AFTER `ReceivableFeeJson`,
+ ADD COLUMN `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间' AFTER `PayableFeeJson`;
+
+-- -----------------------------------------------------------------------------
+-- 8) 客户 OpenAPI 机机凭证
+-- 原:migrate_customer_openapi_credential.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_openapi_credential'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'CREATE TABLE `t_customer_openapi_credential` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+ `CustomerId` INT NOT NULL COMMENT ''归属主客户 t_customer.Id'',
+ `AppKey` VARCHAR(64) NOT NULL COMMENT ''开放接口 AppKey'',
+ `AppSecret` VARCHAR(128) NOT NULL COMMENT ''开放接口 AppSecret(明文,创建/重置时返回)'',
+ `Enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:0-否,1-是'',
+ `LastUsedAt` DATETIME NULL COMMENT ''最近鉴权成功时间'',
+ `LastUsedIp` VARCHAR(64) NULL COMMENT ''最近鉴权成功 IP'',
+ `Remark` VARCHAR(512) NULL COMMENT ''备注'',
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
+ `UpdateUserId` INT NULL COMMENT ''更新人'',
+ `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
+ PRIMARY KEY (`Id`),
+ UNIQUE KEY `uk_coc_customer` (`CustomerId`),
+ UNIQUE KEY `uk_coc_app_key` (`AppKey`),
+ KEY `idx_coc_enabled` (`Enabled`, `IsDeleted`)
+) COMMENT=''客户 OpenAPI 机机凭证''',
+ 'SELECT ''t_customer_openapi_credential already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 9) 分区明细:匹配维度 / 方式 / 前缀长度
+-- -----------------------------------------------------------------------------
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND COLUMN_NAME = 'MatchDim'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_kpi_area_info`
+ ADD COLUMN `MatchDim` VARCHAR(16) NOT NULL DEFAULT ''country'' COMMENT ''匹配维度:zip/state/city/country'' AFTER `RightPostcode`,
+ ADD COLUMN `MatchMode` VARCHAR(16) NOT NULL DEFAULT ''all'' COMMENT ''匹配方式:prefix/exact/range/all'' AFTER `MatchDim`,
+ ADD COLUMN `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT ''邮编前缀长度(zip+prefix)'' AFTER `MatchMode`',
+ 'SELECT ''kpi area match columns already exist'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @idx := (
+ SELECT COUNT(1)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND INDEX_NAME = 'idx_kpi_info_area_dim'
+);
+SET @sql := IF(
+ @idx = 0,
+ 'ALTER TABLE `t_kpi_area_info` ADD KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`)',
+ 'SELECT ''idx_kpi_info_area_dim already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_kpi_area_info`
+SET
+ `MatchDim` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' OR IFNULL(`RightPostcode`, '') <> '' THEN 'zip'
+ WHEN IFNULL(`City`, '') <> '' THEN 'city'
+ WHEN IFNULL(`Province`, '') <> '' THEN 'state'
+ ELSE 'country'
+ END,
+ `MatchMode` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN 'prefix'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN 'exact'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN 'range'
+ WHEN IFNULL(`City`, '') <> '' OR IFNULL(`Province`, '') <> '' THEN 'exact'
+ ELSE 'all'
+ END,
+ `PrefixLen` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN LEAST(CHAR_LENGTH(`LeftPostcode`), CHAR_LENGTH(`RightPostcode`))
+ ELSE 0
+ END
+WHERE IFNULL(`MatchDim`, '') IN ('', 'country')
+ AND (
+ IFNULL(`LeftPostcode`, '') <> ''
+ OR IFNULL(`RightPostcode`, '') <> ''
+ OR IFNULL(`Province`, '') <> ''
+ OR IFNULL(`City`, '') <> ''
+ );
diff --git a/sql/migrate_allocation_match_condition.sql b/sql/migrate_allocation_match_condition.sql
deleted file mode 100644
index 274f2e0..0000000
--- a/sql/migrate_allocation_match_condition.sql
+++ /dev/null
@@ -1,21 +0,0 @@
--- 存量库:排货规则服务商表增加判断条件 MatchCondition
--- 可重复执行;仅在列不存在时 ADD。
--- 目标表:t_sales_product_allocation_provider
-
-SET @db := DATABASE();
-
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_sales_product_allocation_provider'
- AND COLUMN_NAME = 'MatchCondition'
-);
-SET @sql := IF(
- @exists = 0,
- 'ALTER TABLE `t_sales_product_allocation_provider` ADD COLUMN `MatchCondition` TEXT NULL COMMENT ''排货判断条件(中文公式),空表示全部订单可用'' AFTER `CustomerIds`',
- 'SELECT ''MatchCondition already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_coupon_grant_system.sql b/sql/migrate_coupon_grant_system.sql
new file mode 100644
index 0000000..f5b5419
--- /dev/null
+++ b/sql/migrate_coupon_grant_system.sql
@@ -0,0 +1,109 @@
+-- 优惠券发放体系:未解锁状态、到期提醒标记、新人/裂变券模板(可重复执行)
+SET @db := DATABASE();
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_coupon_instance'
+ AND COLUMN_NAME = 'ExpiryNoticeSent'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_coupon_instance`
+ ADD COLUMN `ExpiryNoticeSent` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''到期前3天已提醒'' AFTER `LockExpireTime`',
+ 'SELECT ''ExpiryNoticeSent already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 新人首单券 15 元,领取后 15 天
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
+)
+SELECT
+ 'NEWCOMER_FIRST', '新人首单券', 'fixed_off', 15.00, NULL, NULL,
+ 0, 0, 0, 1,
+ 2, NULL, NULL, 15,
+ NULL, 'newcomer', 1, '注册自动下发,仅抵基础运费,退款不退回',
+ 0, 0, NOW(), 0, NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_FIRST'
+);
+
+-- 新人第二单券 15 元,满 150 基础运费,解锁后 30 天
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
+)
+SELECT
+ 'NEWCOMER_SECOND', '新人第二单券', 'fixed_off', 15.00, NULL, NULL,
+ 150, 0, 0, 1,
+ 3, NULL, NULL, 30,
+ NULL, 'newcomer', 1, '首单派送成功后解锁,实付基础运费满 150',
+ 0, 0, NOW(), 0, NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_SECOND'
+);
+
+-- 新人备货券 20 元,满 200,注册后 60 天
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
+)
+SELECT
+ 'NEWCOMER_STOCK', '新人备货券', 'fixed_off', 20.00, NULL, NULL,
+ 200, 0, 0, 1,
+ 2, NULL, NULL, 60,
+ NULL, 'newcomer', 1, '注册自动下发,实付基础运费满 200',
+ 0, 0, NOW(), 0, NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_STOCK'
+);
+
+-- 裂变:老客 20 元无门槛
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
+)
+SELECT
+ 'REFERRAL_INVITER', '邀请有礼券', 'fixed_off', 20.00, NULL, NULL,
+ 0, 0, 0, 1,
+ 2, NULL, NULL, 30,
+ NULL, 'referral', 1, '被邀请人首单派送成功后发给邀请人',
+ 0, 0, NOW(), 0, NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'REFERRAL_INVITER'
+);
+
+-- 裂变:新客额外 5 元
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
+)
+SELECT
+ 'REFERRAL_INVITEE', '邀请注册加赠券', 'fixed_off', 5.00, NULL, NULL,
+ 0, 0, 0, 1,
+ 2, NULL, NULL, 30,
+ NULL, 'referral', 1, '被邀请人注册即发 5 元券',
+ 0, 0, NOW(), 0, NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'REFERRAL_INVITEE'
+);
diff --git a/sql/migrate_customer_export_qualification.sql b/sql/migrate_customer_export_qualification.sql
deleted file mode 100644
index efd88fe..0000000
--- a/sql/migrate_customer_export_qualification.sql
+++ /dev/null
@@ -1,98 +0,0 @@
--- 存量库:客户出口资质备案表(含 IsDefault / Remark)
--- 可重复执行。
--- 目标表:t_customer_export_qualification
-
-SET @db := DATABASE();
-
--- 1) 表不存在则整表创建
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.TABLES
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_customer_export_qualification'
-);
-
-SET @sql := IF(
- @exists = 0,
- 'CREATE TABLE `t_customer_export_qualification` (
- `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
- `CustomerId` INT NOT NULL COMMENT ''归属客户 t_customer.Id'',
- `CompanyName` VARCHAR(200) NOT NULL COMMENT ''企业名称'',
- `SocialCreditCode` VARCHAR(64) NOT NULL COMMENT ''企业社会信用代码'',
- `DomesticConsignor` VARCHAR(200) NOT NULL COMMENT ''境内发货人'',
- `DomesticConsignorCustomsCode` VARCHAR(64) NULL COMMENT ''境内发货人海关代码'',
- `ProducerName` VARCHAR(200) NOT NULL COMMENT ''生产销售单位'',
- `ProducerCustomsCode` VARCHAR(64) NULL COMMENT ''生产销售单位海关代码'',
- `CustomsPort` VARCHAR(100) NULL COMMENT ''备案海关港口地'',
- `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'',
- `Remark` VARCHAR(512) NULL COMMENT ''备注'',
- `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
- `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
- `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
- `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
- `UpdateUserId` INT NULL COMMENT ''更新人'',
- `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
- PRIMARY KEY (`Id`),
- KEY `idx_ceq_customer` (`CustomerId`, `IsDeleted`, `Id`),
- KEY `idx_ceq_credit` (`SocialCreditCode`, `IsDeleted`),
- KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)
-) COMMENT=''客户出口资质备案''',
- 'SELECT ''t_customer_export_qualification already exists'' AS info'
-);
-
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- 2) 已建表但缺 IsDefault 时补列
-SET @col := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_customer_export_qualification'
- AND COLUMN_NAME = 'IsDefault'
-);
-SET @sql := IF(
- @col = 0,
- 'ALTER TABLE `t_customer_export_qualification`
- ADD COLUMN `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'' AFTER `CustomsPort`',
- 'SELECT ''IsDefault already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- 3) 已建表但缺 Remark 时补列
-SET @col := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_customer_export_qualification'
- AND COLUMN_NAME = 'Remark'
-);
-SET @sql := IF(
- @col = 0,
- 'ALTER TABLE `t_customer_export_qualification`
- ADD COLUMN `Remark` VARCHAR(512) NULL COMMENT ''备注'' AFTER `IsDefault`',
- 'SELECT ''Remark already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- 4) 默认索引
-SET @idx := (
- SELECT COUNT(1)
- FROM information_schema.STATISTICS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_customer_export_qualification'
- AND INDEX_NAME = 'idx_ceq_customer_default'
-);
-SET @sql := IF(
- @idx = 0,
- 'ALTER TABLE `t_customer_export_qualification` ADD KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)',
- 'SELECT ''idx_ceq_customer_default already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_declare_element.sql b/sql/migrate_declare_element.sql
deleted file mode 100644
index 62f59fd..0000000
--- a/sql/migrate_declare_element.sql
+++ /dev/null
@@ -1,38 +0,0 @@
--- 存量库:SKU / 订单申报增加「出口申报要素」DeclareElement
--- 可重复执行;仅在列不存在时 ADD。
-
-SET @db := DATABASE();
-
--- c_sku.DeclareElement
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 'c_sku'
- AND COLUMN_NAME = 'DeclareElement'
-);
-SET @sql := IF(
- @exists = 0,
- 'ALTER TABLE `c_sku` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ImHsCode`',
- 'SELECT ''c_sku.DeclareElement already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- t_order_declaration.DeclareElement
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_order_declaration'
- AND COLUMN_NAME = 'DeclareElement'
-);
-SET @sql := IF(
- @exists = 0,
- 'ALTER TABLE `t_order_declaration` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ExHsCode`',
- 'SELECT ''t_order_declaration.DeclareElement already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_issue_template_handler.sql b/sql/migrate_issue_template_handler.sql
deleted file mode 100644
index 409cdb7..0000000
--- a/sql/migrate_issue_template_handler.sql
+++ /dev/null
@@ -1,45 +0,0 @@
--- 存量库:为问题模板补充处理人字段(与 db.sql / IssueTemplate 实体对齐)
--- 可重复执行;仅在列不存在时 ADD。
--- 目标表:t_issue_template
-
-SET @db := DATABASE();
-
--- HandlerType
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_issue_template'
- AND COLUMN_NAME = 'HandlerType'
-);
-SET @sql := IF(
- @exists = 0,
- 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerType` VARCHAR(16) NULL COMMENT ''处理人类型:customer=订单所属客户,user=指定用户'' AFTER `NotifyMethod`',
- 'SELECT ''HandlerType already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- HandlerUserId
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_issue_template'
- AND COLUMN_NAME = 'HandlerUserId'
-);
-SET @sql := IF(
- @exists = 0,
- 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerUserId` INT NULL COMMENT ''指定处理人用户Id(HandlerType=user 时必填)'' AFTER `HandlerType`',
- 'SELECT ''HandlerUserId already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- 历史数据默认按「指定用户」语义;无处理人时可在页面再编辑
-UPDATE `t_issue_template`
-SET `HandlerType` = 'user'
-WHERE (`HandlerType` IS NULL OR `HandlerType` = '')
- AND `IsDeleted` = 0;
diff --git a/sql/migrate_kpi_area_info_area_id_index.sql b/sql/migrate_kpi_area_info_area_id_index.sql
new file mode 100644
index 0000000..da86588
--- /dev/null
+++ b/sql/migrate_kpi_area_info_area_id_index.sql
@@ -0,0 +1,18 @@
+-- 分区明细按 AreaId 全量/分页查询时避免 filesort 打满 sort_buffer(查价 LIVE 路径易踩)
+SET @db := DATABASE();
+
+SET @idx := (
+ SELECT COUNT(1)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND INDEX_NAME = 'idx_kpi_info_area_id'
+);
+SET @sql := IF(
+ @idx = 0,
+ 'ALTER TABLE `t_kpi_area_info` ADD KEY `idx_kpi_info_area_id` (`AreaId`, `Id`)',
+ 'SELECT ''idx_kpi_info_area_id already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_kpi_area_info_match.sql b/sql/migrate_kpi_area_info_match.sql
new file mode 100644
index 0000000..1096fae
--- /dev/null
+++ b/sql/migrate_kpi_area_info_match.sql
@@ -0,0 +1,66 @@
+-- 存量库:分区明细增加匹配维度/方式/前缀长度(可重复执行)
+SET @db := DATABASE();
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND COLUMN_NAME = 'MatchDim'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_kpi_area_info`
+ ADD COLUMN `MatchDim` VARCHAR(16) NOT NULL DEFAULT ''country'' COMMENT ''匹配维度:zip/state/city/country'' AFTER `RightPostcode`,
+ ADD COLUMN `MatchMode` VARCHAR(16) NOT NULL DEFAULT ''all'' COMMENT ''匹配方式:prefix/exact/range/all'' AFTER `MatchDim`,
+ ADD COLUMN `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT ''邮编前缀长度(zip+prefix)'' AFTER `MatchMode`',
+ 'SELECT ''kpi area match columns already exist'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @idx := (
+ SELECT COUNT(1)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND INDEX_NAME = 'idx_kpi_info_area_dim'
+);
+SET @sql := IF(
+ @idx = 0,
+ 'ALTER TABLE `t_kpi_area_info` ADD KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`)',
+ 'SELECT ''idx_kpi_info_area_dim already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_kpi_area_info`
+SET
+ `MatchDim` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' OR IFNULL(`RightPostcode`, '') <> '' THEN 'zip'
+ WHEN IFNULL(`City`, '') <> '' THEN 'city'
+ WHEN IFNULL(`Province`, '') <> '' THEN 'state'
+ ELSE 'country'
+ END,
+ `MatchMode` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN 'prefix'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN 'exact'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN 'range'
+ WHEN IFNULL(`City`, '') <> '' OR IFNULL(`Province`, '') <> '' THEN 'exact'
+ ELSE 'all'
+ END,
+ `PrefixLen` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN LEAST(CHAR_LENGTH(`LeftPostcode`), CHAR_LENGTH(`RightPostcode`))
+ ELSE 0
+ END
+WHERE IFNULL(`MatchDim`, '') IN ('', 'country')
+ AND (
+ IFNULL(`LeftPostcode`, '') <> ''
+ OR IFNULL(`RightPostcode`, '') <> ''
+ OR IFNULL(`Province`, '') <> ''
+ OR IFNULL(`City`, '') <> ''
+ );
diff --git a/sql/migrate_kpi_area_publish_record.sql b/sql/migrate_kpi_area_publish_record.sql
new file mode 100644
index 0000000..aa8e3c2
--- /dev/null
+++ b/sql/migrate_kpi_area_publish_record.sql
@@ -0,0 +1,56 @@
+-- 分区包发布表(价区 / 黑名单 / 偏远):卖价/规则发布钉包,计费按 version 加载
+SET @db := DATABASE();
+
+SET @tbl := (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_publish_record'
+);
+SET @sql := IF(
+ @tbl = 0,
+ 'CREATE TABLE `t_kpi_area_publish_record` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+ `AreaId` INT NOT NULL COMMENT ''所属分区表 t_kpi_area.Id'',
+ `AreaType` VARCHAR(32) NOT NULL COMMENT ''分区类型:price=价格分区,blacklist=黑名单,remote=偏远'',
+ `Version` VARCHAR(32) NOT NULL COMMENT ''包版本号(yyyyMMddHHmmssfff)'',
+ `Status` TINYINT NOT NULL DEFAULT 1 COMMENT ''状态:1=生效 Active,2=失效 Inactive'',
+ `ContentHash` VARCHAR(64) NOT NULL COMMENT ''规范化分区明细 Content 的 SHA256(hex),同 AreaId+Hash 去重复用'',
+ `Content` JSON NULL COMMENT ''分区明细快照 JSON(KpiAreaSnapshotRuleDto 数组)'',
+ `PublishUser` VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''发布人'',
+ `PublishTime` DATETIME NOT NULL COMMENT ''发布时间'',
+ `Description` VARCHAR(512) NULL COMMENT ''发布说明'',
+ `IsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''软删除:0=否,1=是'',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
+ `UpdateUserId` INT NULL COMMENT ''更新人'',
+ `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
+ PRIMARY KEY (`Id`),
+ UNIQUE KEY `uk_kpi_pack_area_hash` (`AreaId`, `ContentHash`),
+ KEY `idx_kpi_pack_area_ver` (`AreaId`, `Version`),
+ KEY `idx_kpi_pack_area_type_time` (`AreaId`, `AreaType`, `PublishTime`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''KPI分区发布包(价区/黑名单/偏远明细不可变快照)''',
+ 'SELECT ''t_kpi_area_publish_record already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 表已存在时回填表/字段注释(可重复执行;不包进 PREPARE)
+ALTER TABLE `t_kpi_area_publish_record`
+ COMMENT = 'KPI分区发布包(价区/黑名单/偏远明细不可变快照)',
+ MODIFY COLUMN `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
+ MODIFY COLUMN `AreaId` INT NOT NULL COMMENT '所属分区表 t_kpi_area.Id',
+ MODIFY COLUMN `AreaType` VARCHAR(32) NOT NULL COMMENT '分区类型:price=价格分区,blacklist=黑名单,remote=偏远',
+ MODIFY COLUMN `Version` VARCHAR(32) NOT NULL COMMENT '包版本号(yyyyMMddHHmmssfff)',
+ MODIFY COLUMN `Status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=生效 Active,2=失效 Inactive',
+ MODIFY COLUMN `ContentHash` VARCHAR(64) NOT NULL COMMENT '规范化分区明细 Content 的 SHA256(hex),同 AreaId+Hash 去重复用',
+ MODIFY COLUMN `Content` JSON NULL COMMENT '分区明细快照 JSON(KpiAreaSnapshotRuleDto 数组)',
+ MODIFY COLUMN `PublishUser` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '发布人',
+ MODIFY COLUMN `PublishTime` DATETIME NOT NULL COMMENT '发布时间',
+ MODIFY COLUMN `Description` VARCHAR(512) NULL COMMENT '发布说明',
+ MODIFY COLUMN `IsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除:0=否,1=是',
+ MODIFY COLUMN `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
+ MODIFY COLUMN `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ MODIFY COLUMN `UpdateUserId` INT NULL COMMENT '更新人',
+ MODIFY COLUMN `UpdateTime` DATETIME NULL COMMENT '更新时间';
diff --git a/sql/migrate_member_level_rolling_points.sql b/sql/migrate_member_level_rolling_points.sql
new file mode 100644
index 0000000..ee8c2cf
--- /dev/null
+++ b/sql/migrate_member_level_rolling_points.sql
@@ -0,0 +1,216 @@
+-- 旧会员体系 → V0~V5(可重复执行)
+-- old: normal/silver/gold/diamond
+-- new: V0 普通 / V1 铜卡(新增) / V2 银卡 / V3 金卡 / V4 钻石 / V5 皇冠(新增)
+SET @db := DATABASE();
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_member_level'
+ AND COLUMN_NAME = 'CanDowngrade'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_member_level`
+ ADD COLUMN `CanDowngrade` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''V4/V5 允许逐级降级'' AFTER `ExtraDiscountRate`',
+ 'SELECT ''CanDowngrade already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ---------- 把旧行升级成新编码(仅当目标 V* 还不存在)----------
+UPDATE `t_member_level`
+SET
+ `LevelCode` = 'V0',
+ `LevelName` = '普通会员',
+ `MinPoints` = 0,
+ `ExtraDiscountRate` = 0.00,
+ `CanDowngrade` = 0,
+ `Sort` = 0,
+ `BenefitDesc` = '新注册 / 小单试单卖家',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0
+ AND LOWER(`LevelCode`) = 'normal'
+ AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V0') t);
+
+UPDATE `t_member_level`
+SET
+ `LevelCode` = 'V2',
+ `LevelName` = '银卡会员',
+ `MinPoints` = 60000,
+ `ExtraDiscountRate` = 0.02,
+ `CanDowngrade` = 0,
+ `Sort` = 2,
+ `BenefitDesc` = '主力中小卖家,基础运费 98 折',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0
+ AND LOWER(`LevelCode`) = 'silver'
+ AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V2') t);
+
+UPDATE `t_member_level`
+SET
+ `LevelCode` = 'V3',
+ `LevelName` = '金卡会员',
+ `MinPoints` = 120000,
+ `ExtraDiscountRate` = 0.03,
+ `CanDowngrade` = 0,
+ `Sort` = 3,
+ `BenefitDesc` = '优质中型卖家,基础运费 97 折;此级及以下只升不降',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0
+ AND LOWER(`LevelCode`) = 'gold'
+ AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V3') t);
+
+UPDATE `t_member_level`
+SET
+ `LevelCode` = 'V4',
+ `LevelName` = '钻石会员',
+ `MinPoints` = 240000,
+ `ExtraDiscountRate` = 0.04,
+ `CanDowngrade` = 1,
+ `Sort` = 4,
+ `BenefitDesc` = '中大卖家,基础运费 96 折;滚动积分不足可降到金卡',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0
+ AND LOWER(`LevelCode`) = 'diamond'
+ AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V4') t);
+
+-- ---------- 缺档则插入(铜卡、皇冠,以及未被旧行带过来的档)----------
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V0', '普通会员', 0, 0.00, 0, 0, '新注册 / 小单试单卖家', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V0' AND `IsDeleted` = 0);
+
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V1', '铜卡会员', 24000, 0.01, 0, 1, '起步稳定发货卖家,基础运费 99 折', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V1' AND `IsDeleted` = 0);
+
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V2', '银卡会员', 60000, 0.02, 0, 2, '主力中小卖家,基础运费 98 折', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V2' AND `IsDeleted` = 0);
+
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V3', '金卡会员', 120000, 0.03, 0, 3, '优质中型卖家,基础运费 97 折;此级及以下只升不降', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V3' AND `IsDeleted` = 0);
+
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V4', '钻石会员', 240000, 0.04, 1, 4, '中大卖家,基础运费 96 折;滚动积分不足可降到金卡', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V4' AND `IsDeleted` = 0);
+
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V5', '皇冠会员', 600000, 0.05, 1, 5, '顶级头部卖家,基础运费 95 折封顶;滚动积分不足可降到钻石', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V5' AND `IsDeleted` = 0);
+
+-- ---------- 旧编码与新档并存时:客户挂到新档,旧行停用 ----------
+UPDATE `t_customer` c
+INNER JOIN `t_member_level` old ON old.`Id` = c.`MemberLevelId` AND old.`IsDeleted` = 0
+INNER JOIN `t_member_level` canon ON canon.`IsDeleted` = 0 AND canon.`LevelCode` = CASE LOWER(old.`LevelCode`)
+ WHEN 'normal' THEN 'V0'
+ WHEN 'silver' THEN 'V2'
+ WHEN 'gold' THEN 'V3'
+ WHEN 'diamond' THEN 'V4'
+ ELSE NULL
+END
+SET c.`MemberLevelId` = canon.`Id`, c.`UpdateTime` = NOW()
+WHERE LOWER(old.`LevelCode`) IN ('normal', 'silver', 'gold', 'diamond')
+ AND old.`Id` <> canon.`Id`;
+
+UPDATE `t_member_level` old
+INNER JOIN `t_member_level` canon ON canon.`IsDeleted` = 0 AND canon.`LevelCode` = CASE LOWER(old.`LevelCode`)
+ WHEN 'normal' THEN 'V0'
+ WHEN 'silver' THEN 'V2'
+ WHEN 'gold' THEN 'V3'
+ WHEN 'diamond' THEN 'V4'
+ ELSE NULL
+END
+SET
+ old.`IsDeleted` = 1,
+ old.`Status` = 1,
+ old.`UpdateTime` = NOW()
+WHERE old.`IsDeleted` = 0
+ AND LOWER(old.`LevelCode`) IN ('normal', 'silver', 'gold', 'diamond')
+ AND old.`Id` <> canon.`Id`;
+
+-- ---------- 校正 V0~V5 门槛/折扣(已占用客户的行 Id 不变)----------
+UPDATE `t_member_level`
+SET
+ `LevelName` = '普通会员',
+ `MinPoints` = 0,
+ `ExtraDiscountRate` = 0.00,
+ `CanDowngrade` = 0,
+ `Sort` = 0,
+ `BenefitDesc` = '新注册 / 小单试单卖家',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V0';
+
+UPDATE `t_member_level`
+SET
+ `LevelName` = '铜卡会员',
+ `MinPoints` = 24000,
+ `ExtraDiscountRate` = 0.01,
+ `CanDowngrade` = 0,
+ `Sort` = 1,
+ `BenefitDesc` = '起步稳定发货卖家,基础运费 99 折',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V1';
+
+UPDATE `t_member_level`
+SET
+ `LevelName` = '银卡会员',
+ `MinPoints` = 60000,
+ `ExtraDiscountRate` = 0.02,
+ `CanDowngrade` = 0,
+ `Sort` = 2,
+ `BenefitDesc` = '主力中小卖家,基础运费 98 折',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V2';
+
+UPDATE `t_member_level`
+SET
+ `LevelName` = '金卡会员',
+ `MinPoints` = 120000,
+ `ExtraDiscountRate` = 0.03,
+ `CanDowngrade` = 0,
+ `Sort` = 3,
+ `BenefitDesc` = '优质中型卖家,基础运费 97 折;此级及以下只升不降',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V3';
+
+UPDATE `t_member_level`
+SET
+ `LevelName` = '钻石会员',
+ `MinPoints` = 240000,
+ `ExtraDiscountRate` = 0.04,
+ `CanDowngrade` = 1,
+ `Sort` = 4,
+ `BenefitDesc` = '中大卖家,基础运费 96 折;滚动积分不足可降到金卡',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V4';
+
+UPDATE `t_member_level`
+SET
+ `LevelName` = '皇冠会员',
+ `MinPoints` = 600000,
+ `ExtraDiscountRate` = 0.05,
+ `CanDowngrade` = 1,
+ `Sort` = 5,
+ `BenefitDesc` = '顶级头部卖家,基础运费 95 折封顶;滚动积分不足可降到钻石',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V5';
diff --git a/sql/migrate_pickup_direction_and_address_type.sql b/sql/migrate_pickup_direction_and_address_type.sql
deleted file mode 100644
index 8ebfac4..0000000
--- a/sql/migrate_pickup_direction_and_address_type.sql
+++ /dev/null
@@ -1,16 +0,0 @@
--- 揽收方向 + 客户地址类型扩展(国内揽收)
--- 正向 forward:客户揽收地址 → 仓库
--- 逆向 reverse:仓库 → 客户揽收地址
-
-ALTER TABLE `t_pickup_order`
- ADD COLUMN `Direction` VARCHAR(16) NOT NULL DEFAULT 'forward'
- COMMENT 'forward正向(客户→仓)/reverse逆向(仓→客户)'
- AFTER `SenderAddressId`;
-
-ALTER TABLE `t_pickup_order`
- ADD KEY `idx_pickup_direction_status` (`Direction`, `Status`, `CreateTime`);
-
--- 地址类型注释更新(列已存在,仅说明)
-ALTER TABLE `t_customer_sender_address`
- MODIFY COLUMN `AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender'
- COMMENT 'sender国内发件/return国内退件/pickup国内揽收/receiver海外收件';
diff --git a/sql/migrate_referral_invite_reward.sql b/sql/migrate_referral_invite_reward.sql
new file mode 100644
index 0000000..3d23f2c
--- /dev/null
+++ b/sql/migrate_referral_invite_reward.sql
@@ -0,0 +1,97 @@
+-- 邀请奖励:按实付基础运费百分比、T+N 工作日入运费余额、退款标记(可重复执行)
+SET @db := DATABASE();
+
+-- ---------- t_order.IsRefunded ----------
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order'
+ AND COLUMN_NAME = 'IsRefunded'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_order`
+ ADD COLUMN `IsRefunded` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否已退款(分成计次排除)'' AFTER `IsPaid`',
+ 'SELECT ''t_order.IsRefunded already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ---------- t_referral_commission_rule 新字段 ----------
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_referral_commission_rule'
+ AND COLUMN_NAME = 'MinInviteLevelCode'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_referral_commission_rule`
+ ADD COLUMN `MinInviteLevelCode` VARCHAR(16) NOT NULL DEFAULT ''V1'' COMMENT ''邀请资格最低等级'' AFTER `MonthlyCap`,
+ ADD COLUMN `MinPaidBaseFreight` DECIMAL(18,2) NOT NULL DEFAULT 50.00 COMMENT ''计奖最低实付基础运费'' AFTER `MinInviteLevelCode`,
+ ADD COLUMN `FirstOrderRate` DECIMAL(8,4) NOT NULL DEFAULT 0.0500 COMMENT ''首单奖励比例'' AFTER `MinPaidBaseFreight`,
+ ADD COLUMN `FirstOrderCap` DECIMAL(18,2) NOT NULL DEFAULT 50.00 COMMENT ''首单奖励封顶'' AFTER `FirstOrderRate`,
+ ADD COLUMN `FollowOrderRate` DECIMAL(8,4) NOT NULL DEFAULT 0.0300 COMMENT ''后续单奖励比例'' AFTER `FirstOrderCap`,
+ ADD COLUMN `FollowOrderCap` DECIMAL(18,2) NOT NULL DEFAULT 30.00 COMMENT ''后续单奖励封顶'' AFTER `FollowOrderRate`,
+ ADD COLUMN `FollowWindowDays` INT NOT NULL DEFAULT 90 COMMENT ''后续单注册后天数窗口'' AFTER `FollowOrderCap`,
+ ADD COLUMN `MaxRewardOrders` INT NOT NULL DEFAULT 6 COMMENT ''最多计奖订单数'' AFTER `FollowWindowDays`,
+ ADD COLUMN `SettleBusinessDays` INT NOT NULL DEFAULT 3 COMMENT ''派送成功后结算工作日'' AFTER `MaxRewardOrders`',
+ 'SELECT ''t_referral_commission_rule reward columns already exist'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_referral_commission_rule`
+SET
+ `CommissionMode` = 'percent_of_paid_base_freight',
+ `SettleTriggerOrderStatus` = 'deliverySuccess',
+ `SettleBusinessDays` = CASE WHEN `SettleBusinessDays` > 0 THEN `SettleBusinessDays` ELSE 3 END,
+ `FreezeDays` = CASE WHEN `SettleBusinessDays` > 0 THEN `SettleBusinessDays` ELSE 3 END,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0;
+
+-- ---------- t_referral_commission_detail ----------
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_referral_commission_detail'
+ AND COLUMN_NAME = 'SettleDueTime'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_referral_commission_detail`
+ ADD COLUMN `SettleDueTime` DATETIME NULL COMMENT ''计划结算时间'' AFTER `EligibleTime`',
+ 'SELECT ''SettleDueTime already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_referral_commission_detail'
+ AND COLUMN_NAME = 'RewardOrderIndex'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_referral_commission_detail`
+ ADD COLUMN `RewardOrderIndex` INT NOT NULL DEFAULT 0 COMMENT ''被邀请人第几单'' AFTER `SettleDueTime`',
+ 'SELECT ''RewardOrderIndex already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_coupon_template`
+SET
+ `Remark` = '被邀请人注册即发 5 元券',
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0
+ AND `TemplateCode` = 'REFERRAL_INVITEE';
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs
index a2e5388..a65be92 100644
--- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs
@@ -12,8 +12,19 @@ public class KpiAreaInfoImportInput
///
public int AreaId { get; set; }
+ ///
+ /// 导入模式:incremental=增量(跳过重复);full=全量(先清空本分区明细再导入)。
+ ///
+ public string ImportMode { get; set; } = ImportModes.Incremental;
+
///
/// Excel 文件。
///
public IFormFile File { get; set; } = null!;
+
+ public static class ImportModes
+ {
+ public const string Incremental = "incremental";
+ public const string Full = "full";
+ }
}
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs
index 5927568..db2cd35 100644
--- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs
@@ -7,6 +7,8 @@ public class KpiAreaInfoImportOutput
{
public int SuccessCount { get; set; }
+ public int SkipCount { get; set; }
+
public int FailCount { get; set; }
public List Failures { get; set; } = [];
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs
index 2536231..efea04f 100644
--- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs
@@ -45,6 +45,21 @@ public class KpiAreaInfoOutput
///
public string? RightPostcode { get; set; }
+ ///
+ /// 匹配维度:zip / state / city / country。
+ ///
+ public string? MatchDim { get; set; }
+
+ ///
+ /// 匹配方式:prefix / exact / range / all。
+ ///
+ public string? MatchMode { get; set; }
+
+ ///
+ /// 邮编前缀长度(zip+prefix)。
+ ///
+ public int PrefixLen { get; set; }
+
///
/// 创建时间。
///
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs
index aa8caf8..e754747 100644
--- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs
@@ -44,4 +44,19 @@ public class KpiAreaInfoSaveInput
/// 结束邮编。
///
public string? RightPostcode { get; set; }
+
+ ///
+ /// 匹配维度:zip / state / city / country。
+ ///
+ public string? MatchDim { get; set; }
+
+ ///
+ /// 匹配方式:prefix / exact / range / all。
+ ///
+ public string? MatchMode { get; set; }
+
+ ///
+ /// 邮编前缀长度。
+ ///
+ public int? PrefixLen { get; set; }
}
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaPublishRecordOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaPublishRecordOutput.cs
new file mode 100644
index 0000000..bf41312
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaPublishRecordOutput.cs
@@ -0,0 +1,28 @@
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// KPI 分区包版本头/详情。
+///
+public class KpiAreaPublishRecordOutput
+{
+ public int Id { get; set; }
+
+ public int AreaId { get; set; }
+
+ public string AreaType { get; set; } = string.Empty;
+
+ public string Version { get; set; } = string.Empty;
+
+ public string Status { get; set; } = string.Empty;
+
+ public string ContentHash { get; set; } = string.Empty;
+
+ public string PublishUser { get; set; } = string.Empty;
+
+ public DateTime PublishTime { get; set; }
+
+ public string? Description { get; set; }
+
+ /// 仅 get 接口返回正文。
+ public object? Content { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
index 3743ba9..9719866 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
@@ -100,8 +100,18 @@ public class SalesProductListOutput
///
public DateTime CreateTime { get; set; }
+ ///
+ /// 创建人展示名。
+ ///
+ public string? CreateBy { get; set; }
+
///
/// 更新时间。
///
public DateTime? UpdateTime { get; set; }
+
+ ///
+ /// 修改人展示名。
+ ///
+ public string? UpdateBy { get; set; }
}
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs
index fff802a..840ca84 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs
@@ -6,10 +6,15 @@ namespace Oms.Application.Dtos.Channels;
public class SalesProductPortalCatalogOutput
{
///
- /// 分组列表(已排除国内揽收,且仅含有产品的分组)。
+ /// 分组列表(字典 sales_product_group,已排除国内揽收,且仅含有产品的分组)。
///
public List Groups { get; set; } = [];
+ ///
+ /// 货型筛选项(字典 cargo_attribute)。
+ ///
+ public List Cargos { get; set; } = [];
+
///
/// 启用中的销售产品(已排除国内揽收)。
///
@@ -22,12 +27,28 @@ public class SalesProductPortalCatalogOutput
public class SalesProductPortalGroupOutput
{
///
- /// 分组编码(字典 code)。
+ /// 分组编码(字典 sales_product_group.code)。
///
public string Code { get; set; } = string.Empty;
///
- /// 分组名称。
+ /// 分组名称(字典 sales_product_group.name)。
+ ///
+ public string Name { get; set; } = string.Empty;
+}
+
+///
+/// 官网货型筛选项。
+///
+public class SalesProductPortalCargoOutput
+{
+ ///
+ /// 货型编码(字典 cargo_attribute.code)。
+ ///
+ public string Code { get; set; } = string.Empty;
+
+ ///
+ /// 货型名称(字典 cargo_attribute.name)。
///
public string Name { get; set; } = string.Empty;
}
@@ -81,4 +102,20 @@ public class SalesProductPortalItemOutput
/// 产品说明。
///
public string? ProductDescription { get; set; }
+
+ ///
+ /// 官网目的地区域编码:us / eu / sea / ca_mx / au / global。
+ /// 优先由价格分区国家推导;国际快递跨区时含 global。
+ ///
+ public List DestRegions { get; set; } = [];
+
+ ///
+ /// 覆盖国家二字码(来自价格分区明细,去重后截断)。
+ ///
+ public List CoverageCountries { get; set; } = [];
+
+ ///
+ /// 货型标签(字典 cargo_attribute.code,如 general / electronics / sensitive)。
+ ///
+ public List CargoTags { get; set; } = [];
}
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductPublishListInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductPublishListInput.cs
index a13648b..f50dc74 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductPublishListInput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductPublishListInput.cs
@@ -19,4 +19,9 @@ public class SalesProductPublishListInput
/// 每页条数。
///
public int PageSize { get; set; }
+
+ ///
+ /// 是否返回发布快照正文。版本头只展示版本号和时间时传 false,避免传输并反序列化整份价格矩阵。
+ ///
+ public bool IncludeContent { get; set; } = true;
}
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductPublishSyncStatusOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductPublishSyncStatusOutput.cs
new file mode 100644
index 0000000..ae388e3
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/SalesProductPublishSyncStatusOutput.cs
@@ -0,0 +1,22 @@
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 销售产品发布同步态(轻量,不含快照正文)。
+///
+public class SalesProductPublishSyncStatusOutput
+{
+ /// 相对最新生效版本是否有待发布变更。
+ public bool Pending { get; set; }
+
+ /// 最新生效版本号;无则 null。
+ public string? OnlineVersion { get; set; }
+
+ /// 最新生效发布时间。
+ public DateTime? OnlinePublishTime { get; set; }
+
+ /// 待发布快照内容哈希(规范化后)。
+ public string? PendingContentHash { get; set; }
+
+ /// 线上快照内容哈希。
+ public string? OnlineContentHash { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs
new file mode 100644
index 0000000..ec05ce5
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs
@@ -0,0 +1,21 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 单独修改销售产品代码入参。
+///
+public class SalesProductUpdateCodeInput
+{
+ ///
+ /// 销售产品主键。
+ ///
+ [Required]
+ public int Id { get; set; }
+
+ ///
+ /// 新的产品代码,仅大写字母、数字、-。
+ ///
+ [Required]
+ public string ProductCode { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs
index 546f298..e491347 100644
--- a/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs
@@ -70,8 +70,18 @@ public class ServiceChannelListOutput
///
public DateTime CreateTime { get; set; }
+ ///
+ /// 创建人展示名。
+ ///
+ public string? CreateBy { get; set; }
+
///
/// 更新时间。
///
public DateTime? UpdateTime { get; set; }
+
+ ///
+ /// 修改人展示名。
+ ///
+ public string? UpdateBy { get; set; }
}
diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelOptionOutput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelOptionOutput.cs
new file mode 100644
index 0000000..1bb6205
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/ServiceChannelOptionOutput.cs
@@ -0,0 +1,22 @@
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 服务渠道下拉项。只含展示所需字段,不分页。
+///
+public class ServiceChannelOptionOutput
+{
+ ///
+ /// 主键。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 分组名称。
+ ///
+ public string? GroupName { get; set; }
+
+ ///
+ /// 渠道名称。
+ ///
+ public string ChannelName { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelPublishSyncStatusOutput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelPublishSyncStatusOutput.cs
new file mode 100644
index 0000000..cd94690
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/ServiceChannelPublishSyncStatusOutput.cs
@@ -0,0 +1,22 @@
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 服务渠道发布同步态(轻量,不含快照正文)。
+///
+public class ServiceChannelPublishSyncStatusOutput
+{
+ /// 相对最新生效版本是否有待发布变更。
+ public bool Pending { get; set; }
+
+ /// 最新生效版本号;无则 null。
+ public string? OnlineVersion { get; set; }
+
+ /// 最新生效发布时间。
+ public DateTime? OnlinePublishTime { get; set; }
+
+ /// 待发布快照内容哈希(规范化后)。
+ public string? PendingContentHash { get; set; }
+
+ /// 线上快照内容哈希。
+ public string? OnlineContentHash { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelRulesPublishSnapshotDto.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelRulesPublishSnapshotDto.cs
index 553f95f..e192c5e 100644
--- a/src/Oms.Application/Dtos/Channels/ServiceChannelRulesPublishSnapshotDto.cs
+++ b/src/Oms.Application/Dtos/Channels/ServiceChannelRulesPublishSnapshotDto.cs
@@ -1,3 +1,6 @@
+using System.Text.Json.Serialization;
+using Oms.Application.Dtos.Orders;
+
namespace Oms.Application.Dtos.Channels;
///
@@ -8,4 +11,17 @@ public class ServiceChannelRulesPublishSnapshotDto
public ServiceChannelSaveInput? Channel { get; set; }
public ServiceChannelDownstreamSnapshotDto? Downstream { get; set; }
+
+ /// 偏远分区 ID。
+ public int? RemoteAreaId { get; set; }
+
+ /// 偏远分区名称。
+ public string? RemoteAreaName { get; set; }
+
+ /// 偏远分区包引用(规则发布时钉包)。
+ public KpiAreaPackRefDto? RemoteAreaRef { get; set; }
+
+ /// 运行时 hydrate / LIVE 用,不落发布 JSON。
+ [JsonIgnore]
+ public List RemoteAreaInfos { get; set; } = [];
}
diff --git a/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs b/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs
index d068309..a0268b8 100644
--- a/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs
+++ b/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs
@@ -48,18 +48,21 @@ public class CreateCustomerExportQualificationInput
[StringLength(200)]
public string DomesticConsignor { get; set; } = string.Empty;
+ [Required]
[StringLength(64)]
- public string? DomesticConsignorCustomsCode { get; set; }
+ public string DomesticConsignorCustomsCode { get; set; } = string.Empty;
[Required]
[StringLength(200)]
public string ProducerName { get; set; } = string.Empty;
+ [Required]
[StringLength(64)]
- public string? ProducerCustomsCode { get; set; }
+ public string ProducerCustomsCode { get; set; } = string.Empty;
+ [Required]
[StringLength(100)]
- public string? CustomsPort { get; set; }
+ public string CustomsPort { get; set; } = string.Empty;
public bool IsDefault { get; set; }
diff --git a/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs
new file mode 100644
index 0000000..e5b11c3
--- /dev/null
+++ b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs
@@ -0,0 +1,43 @@
+namespace Oms.Application.Dtos.Customers;
+
+public class CustomerOpenApiCredentialOutput
+{
+ public int Id { get; set; }
+
+ public int CustomerId { get; set; }
+
+ public string AppKey { get; set; } = string.Empty;
+
+ /// 默认掩码;仅 ensure(新建)/ reset-secret 返回完整明文。
+ public string AppSecret { get; set; } = string.Empty;
+
+ public bool Enabled { get; set; }
+
+ public bool HasCredential { get; set; }
+
+ /// 本次响应是否包含完整 AppSecret(前端可提示用户复制保存)。
+ public bool SecretRevealed { get; set; }
+
+ public DateTime? LastUsedAt { get; set; }
+
+ public string? LastUsedIp { get; set; }
+
+ public DateTime? CreateTime { get; set; }
+
+ public DateTime? UpdateTime { get; set; }
+}
+
+public class CustomerOpenApiCredentialAdminInput
+{
+ public int CustomerId { get; set; }
+}
+
+public class CustomerOpenApiCredentialSetEnabledInput
+{
+ public bool Enabled { get; set; }
+}
+
+public class CustomerOpenApiCredentialAdminSetEnabledInput : CustomerOpenApiCredentialAdminInput
+{
+ public bool Enabled { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs b/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
index e60808d..4fb56f0 100644
--- a/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
@@ -22,6 +22,24 @@ public class ReferralCommissionRuleOutput
public decimal? MonthlyCap { get; set; }
+ public string MinInviteLevelCode { get; set; } = "V1";
+
+ public decimal MinPaidBaseFreight { get; set; }
+
+ public decimal FirstOrderRate { get; set; }
+
+ public decimal FirstOrderCap { get; set; }
+
+ public decimal FollowOrderRate { get; set; }
+
+ public decimal FollowOrderCap { get; set; }
+
+ public int FollowWindowDays { get; set; }
+
+ public int MaxRewardOrders { get; set; }
+
+ public int SettleBusinessDays { get; set; }
+
public EnableStatus Status { get; set; }
public string StatusName { get; set; } = string.Empty;
@@ -50,6 +68,24 @@ public class ReferralCommissionRuleSaveInput
public decimal? MonthlyCap { get; set; }
+ public string MinInviteLevelCode { get; set; } = "V1";
+
+ public decimal MinPaidBaseFreight { get; set; } = 50M;
+
+ public decimal FirstOrderRate { get; set; } = 0.05M;
+
+ public decimal FirstOrderCap { get; set; } = 50M;
+
+ public decimal FollowOrderRate { get; set; } = 0.03M;
+
+ public decimal FollowOrderCap { get; set; } = 30M;
+
+ public int FollowWindowDays { get; set; } = 90;
+
+ public int MaxRewardOrders { get; set; } = 6;
+
+ public int SettleBusinessDays { get; set; } = 3;
+
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
}
@@ -92,6 +128,10 @@ public class CommissionDetailOutput
public decimal CommissionAmount { get; set; }
+ public int RewardOrderIndex { get; set; }
+
+ public DateTime? SettleDueTime { get; set; }
+
public CommissionStatus Status { get; set; }
public string StatusName { get; set; } = string.Empty;
diff --git a/src/Oms.Application/Dtos/Marketing/CouponDtos.cs b/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
index 42d15c3..826c143 100644
--- a/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
@@ -387,6 +387,10 @@ public class CouponInstanceOutput
public DateTime? LockExpireTime { get; set; }
+ public string? TemplateCode { get; set; }
+
+ public string? UnlockHint { get; set; }
+
}
@@ -415,6 +419,10 @@ public class CouponAvailableInput
public decimal? AmountAfterLevel { get; set; }
+ public decimal? BaseFreightAmount { get; set; }
+
+ public string? DestinationCountry { get; set; }
+
}
diff --git a/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs b/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs
index 4ee00a8..ba4651b 100644
--- a/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs
+++ b/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs
@@ -4,6 +4,8 @@ public class MarketingPriceBreakdown
{
public decimal ReceivableSubtotal { get; set; }
+ public decimal BaseFreightAmount { get; set; }
+
public decimal LevelDiscountAmount { get; set; }
public decimal AmountAfterLevel { get; set; }
@@ -12,6 +14,8 @@ public class MarketingPriceBreakdown
public decimal PayableAmount { get; set; }
+ public decimal PaidBaseFreightAmount { get; set; }
+
public int? MemberLevelId { get; set; }
public string? MemberLevelName { get; set; }
diff --git a/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs b/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs
index 773eed9..84ba9ff 100644
--- a/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs
+++ b/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs
@@ -17,6 +17,9 @@ public class MemberLevelOutput
public decimal ExtraDiscountRate { get; set; }
+ /// 是否允许降级。V4 钻石、V5 皇冠为 true,且只能逐级降。
+ public bool CanDowngrade { get; set; }
+
public int Sort { get; set; }
public string? IconUrl { get; set; }
diff --git a/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs b/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs
index f2f35bd..6aa2cc7 100644
--- a/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs
+++ b/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs
@@ -20,6 +20,8 @@ public class MemberLevelSaveInput
public decimal ExtraDiscountRate { get; set; }
+ public bool CanDowngrade { get; set; }
+
public int Sort { get; set; }
public string? IconUrl { get; set; }
diff --git a/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs b/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs
index 3c8a01e..90798de 100644
--- a/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs
+++ b/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs
@@ -1,25 +1,31 @@
-namespace Oms.Application.Dtos.Marketing;
-
-public class MemberOverviewOutput
-{
- public int? MemberLevelId { get; set; }
-
- public string? MemberLevelName { get; set; }
-
- public string? PriceTierKey { get; set; }
-
- public int TotalPoints { get; set; }
-
- public int AvailablePoints { get; set; }
-
- public int? NextLevelId { get; set; }
-
- public string? NextLevelName { get; set; }
-
- public int? PointsToNextLevel { get; set; }
-
- public decimal ExtraDiscountRate { get; set; }
-
- public string? BenefitDesc { get; set; }
-}
+namespace Oms.Application.Dtos.Marketing;
+
+public class MemberOverviewOutput
+{
+ public int? MemberLevelId { get; set; }
+
+ public string? MemberLevelCode { get; set; }
+
+ public string? MemberLevelName { get; set; }
+
+ public string? IconUrl { get; set; }
+
+ public string? PriceTierKey { get; set; }
+
+ public int TotalPoints { get; set; }
+
+ public int RollingPoints { get; set; }
+
+ public int AvailablePoints { get; set; }
+
+ public int? NextLevelId { get; set; }
+
+ public string? NextLevelName { get; set; }
+
+ public int? PointsToNextLevel { get; set; }
+
+ public decimal ExtraDiscountRate { get; set; }
+
+ public string? BenefitDesc { get; set; }
+}
\ No newline at end of file
diff --git a/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs b/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs
index 7c3a326..f8cc66e 100644
--- a/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs
@@ -1,123 +1,137 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace Oms.Application.Dtos.Marketing;
-
-public class ReferralMyInviteOutput
-{
- public string InviteCode { get; set; } = string.Empty;
-
- public byte Status { get; set; }
-
- public string StatusName { get; set; } = string.Empty;
-
- public int RefereeCount { get; set; }
-}
-
-public class ReferralRefereeListInput
-{
- public int Page { get; set; }
-
- public int PageSize { get; set; }
-}
-
-public class ReferralRefereeOutput
-{
- public int CustomerId { get; set; }
-
- public string CustomerCode { get; set; } = string.Empty;
-
- public string? CustomerNameMasked { get; set; }
-
- public string? PhoneMasked { get; set; }
-
- public DateTime CreateTime { get; set; }
-
- public bool HasPaidOrder { get; set; }
-
- public bool HasDeliverySuccessOrder { get; set; }
-}
-
-public class ReferralValidateCodeOutput
-{
- public bool Valid { get; set; }
-
- public string? InviterCustomerCode { get; set; }
-
- public string? InviterNameMasked { get; set; }
-
- public string? Message { get; set; }
-}
-
-public class ReferralTreeInput
-{
- public int? CustomerId { get; set; }
-
- public string? CustomerCode { get; set; }
-
- public int Page { get; set; }
-
- public int PageSize { get; set; }
-}
-
-public class ReferralReferrerListInput
-{
- public string? Keyword { get; set; }
-
- public int Page { get; set; }
-
- public int PageSize { get; set; }
-}
-
-public class ReferralReferrerOutput
-{
- public int CustomerId { get; set; }
-
- public string CustomerCode { get; set; } = string.Empty;
-
- public string CustomerName { get; set; } = string.Empty;
-
- public string? Phone { get; set; }
-
- public int RefereeCount { get; set; }
-
- public DateTime? LatestBindTime { get; set; }
-}
-
-public class ReferralTreeNodeOutput
-{
- public int CustomerId { get; set; }
-
- public string CustomerCode { get; set; } = string.Empty;
-
- public string CustomerName { get; set; } = string.Empty;
-
- public string? Phone { get; set; }
-
- public int? ReferrerCustomerId { get; set; }
-
- public string? ReferrerCustomerCode { get; set; }
-
- public DateTime CreateTime { get; set; }
-
- public DateTime? ReferralBindTime { get; set; }
-
- public string? ReferralChannel { get; set; }
-}
-
-public class ReferralRebindInput
-{
- public int? CustomerId { get; set; }
-
- public string? CustomerCode { get; set; }
-
- public int? NewReferrerCustomerId { get; set; }
-
- public string? NewReferrerCustomerCode { get; set; }
-
- public string? NewInviteCode { get; set; }
-
- [Required]
- [StringLength(512, MinimumLength = 20)]
- public string Reason { get; set; } = string.Empty;
-}
-
\ No newline at end of file
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Marketing;
+
+public class ReferralMyInviteOutput
+{
+ public string InviteCode { get; set; } = string.Empty;
+
+ public byte Status { get; set; }
+
+ public string StatusName { get; set; } = string.Empty;
+
+ public int RefereeCount { get; set; }
+
+ public bool CanInvite { get; set; }
+
+ public string MinInviteLevelCode { get; set; } = "V1";
+
+ public decimal PendingReward { get; set; }
+
+ public decimal SettledReward { get; set; }
+}
+
+public class ReferralRefereeListInput
+{
+ public int Page { get; set; }
+
+ public int PageSize { get; set; }
+}
+
+public class ReferralRefereeOutput
+{
+ public int CustomerId { get; set; }
+
+ public string CustomerCode { get; set; } = string.Empty;
+
+ public string? CustomerNameMasked { get; set; }
+
+ public string? PhoneMasked { get; set; }
+
+ public DateTime CreateTime { get; set; }
+
+ public bool HasPaidOrder { get; set; }
+
+ public bool HasDeliverySuccessOrder { get; set; }
+
+ public int RewardOrderCount { get; set; }
+
+ public decimal PendingReward { get; set; }
+
+ public decimal SettledReward { get; set; }
+}
+
+public class ReferralValidateCodeOutput
+{
+ public bool Valid { get; set; }
+
+ public string? InviterCustomerCode { get; set; }
+
+ public string? InviterNameMasked { get; set; }
+
+ public string? Message { get; set; }
+}
+
+public class ReferralTreeInput
+{
+ public int? CustomerId { get; set; }
+
+ public string? CustomerCode { get; set; }
+
+ public int Page { get; set; }
+
+ public int PageSize { get; set; }
+}
+
+public class ReferralReferrerListInput
+{
+ public string? Keyword { get; set; }
+
+ public int Page { get; set; }
+
+ public int PageSize { get; set; }
+}
+
+public class ReferralReferrerOutput
+{
+ public int CustomerId { get; set; }
+
+ public string CustomerCode { get; set; } = string.Empty;
+
+ public string CustomerName { get; set; } = string.Empty;
+
+ public string? Phone { get; set; }
+
+ public int RefereeCount { get; set; }
+
+ public DateTime? LatestBindTime { get; set; }
+}
+
+public class ReferralTreeNodeOutput
+{
+ public int CustomerId { get; set; }
+
+ public string CustomerCode { get; set; } = string.Empty;
+
+ public string CustomerName { get; set; } = string.Empty;
+
+ public string? Phone { get; set; }
+
+ public int? ReferrerCustomerId { get; set; }
+
+ public string? ReferrerCustomerCode { get; set; }
+
+ public DateTime CreateTime { get; set; }
+
+ public DateTime? ReferralBindTime { get; set; }
+
+ public string? ReferralChannel { get; set; }
+}
+
+public class ReferralRebindInput
+{
+ public int? CustomerId { get; set; }
+
+ public string? CustomerCode { get; set; }
+
+ public int? NewReferrerCustomerId { get; set; }
+
+ public string? NewReferrerCustomerCode { get; set; }
+
+ public string? NewInviteCode { get; set; }
+
+ [Required]
+ [StringLength(512, MinimumLength = 20)]
+ public string Reason { get; set; } = string.Empty;
+}
+
diff --git a/src/Oms.Application/Dtos/OpenApi/OpenApiEnvelope.cs b/src/Oms.Application/Dtos/OpenApi/OpenApiEnvelope.cs
new file mode 100644
index 0000000..4d724f0
--- /dev/null
+++ b/src/Oms.Application/Dtos/OpenApi/OpenApiEnvelope.cs
@@ -0,0 +1,29 @@
+namespace Oms.Application.Dtos.OpenApi;
+
+///
+/// 对外开放接口统一响应。
+///
+public class OpenApiEnvelope
+{
+ public string Code { get; set; } = "200";
+
+ public string Msg { get; set; } = "success";
+
+ public T? Data { get; set; }
+
+ public static OpenApiEnvelope Ok(T data) =>
+ new()
+ {
+ Code = "200",
+ Msg = "success",
+ Data = data
+ };
+
+ public static OpenApiEnvelope Fail(string msg, string code = "400") =>
+ new()
+ {
+ Code = code,
+ Msg = msg,
+ Data = default
+ };
+}
diff --git a/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs b/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs
new file mode 100644
index 0000000..90b6e26
--- /dev/null
+++ b/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs
@@ -0,0 +1,97 @@
+namespace Oms.Application.Dtos.OpenApi;
+
+///
+/// 按客户单号 / 服务单号 / 跟踪号定位订单(至少填一项)。
+///
+public class OpenApiOrderLookupInput
+{
+ /// 客户单号。与 serviceNo / trackNo 至少填一项;同时传时优先 clientNo。
+ public string? ClientNo { get; set; }
+
+ /// 服务单号(OMS 运单号)。
+ public string? ServiceNo { get; set; }
+
+ /// 跟踪号。
+ public string? TrackNo { get; set; }
+}
+
+/// 查询轨迹入参。
+public class OpenApiTrackQueryInput : OpenApiOrderLookupInput
+{
+}
+
+/// 按附件类型获取文件入参。
+public class OpenApiFilesGetInput : OpenApiOrderLookupInput
+{
+ ///
+ /// 附件类型:tradeProof / systemLabel / lastMileLabel / infraredImage / pod。不含 downstreamLabel。
+ ///
+ public string? FileType { get; set; }
+}
+
+public class OpenApiTrackQueryOutput
+{
+ public int OrderId { get; set; }
+
+ public string? ClientNo { get; set; }
+
+ public string? ServiceNo { get; set; }
+
+ public string? TrackNo { get; set; }
+
+ public List Events { get; set; } = [];
+}
+
+public class OpenApiTrackEventDto
+{
+ public string? TrackCode { get; set; }
+
+ public string? Status { get; set; }
+
+ public string? Content { get; set; }
+
+ public string? EnContent { get; set; }
+
+ public string? Location { get; set; }
+
+ public DateTime? Time { get; set; }
+
+ public List Files { get; set; } = [];
+}
+
+public class OpenApiTrackFileDto
+{
+ public string? FileName { get; set; }
+
+ public string? Url { get; set; }
+}
+
+public class OpenApiFilesGetOutput
+{
+ public int OrderId { get; set; }
+
+ public string? ClientNo { get; set; }
+
+ public string? ServiceNo { get; set; }
+
+ public string FileType { get; set; } = string.Empty;
+
+ public List Files { get; set; } = [];
+}
+
+public class OpenApiFileItemDto
+{
+ public int Id { get; set; }
+
+ public string? FileName { get; set; }
+
+ public string FileUrl { get; set; } = string.Empty;
+
+ public string? ContentType { get; set; }
+
+ public long? FileSize { get; set; }
+
+ public string? Source { get; set; }
+
+ public DateTime CreateTime { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Orders/KpiAreaPackRefDto.cs b/src/Oms.Application/Dtos/Orders/KpiAreaPackRefDto.cs
new file mode 100644
index 0000000..788a1f6
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/KpiAreaPackRefDto.cs
@@ -0,0 +1,17 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 卖价/规则快照内对分区包的引用(明细在 t_kpi_area_publish_record)。
+///
+public class KpiAreaPackRefDto
+{
+ public int AreaId { get; set; }
+
+ public string? AreaName { get; set; }
+
+ /// 分区包 Version(yyyyMMddHHmmss)。
+ public string PackVersion { get; set; } = string.Empty;
+
+ /// 包 ContentHash,便于同步态对比。
+ public string? ContentHash { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs
new file mode 100644
index 0000000..dcfb695
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs
@@ -0,0 +1,146 @@
+using Oms.Domain.OrdersAggregate.Consts;
+
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 开放接口创建订单包裹入参:与单票字段对齐(不含上门揽收)。
+/// 鉴权凭证放请求头 appKey / appSecret,勿放入 Body。
+///
+public class OpenApiParcelCreateInput
+{
+ /// 客户单号。
+ public string? ClientNo { get; set; }
+
+ /// 跟踪号(可选,系统通常忽略由下游回写)。
+ public string? TrackNo { get; set; }
+
+ /// 服务单号(可选,不传时系统自动生成)。
+ public string? ServiceNo { get; set; }
+
+ /// 产品编码(运输方式)。
+ public string? ProductCode { get; set; }
+
+ /// 渠道编码(可选)。
+ public string? ChannelCode { get; set; }
+
+ /// 货物属性(字典 cargo_attribute:general / electronics / sensitive)。
+ public string? CargoAttribute { get; set; }
+
+ /// 出口报关方式(字典 export_customs_mode)。
+ public string? ExportCustomsMode { get; set; }
+
+ /// 出口资质备案 Id(9610 汇总申报时)。
+ public int? ExportQualificationId { get; set; }
+
+ /// 电商平台(9610 汇总申报时)。
+ public string? PlatformCode { get; set; }
+
+ /// 仓库主键。
+ public int? WarehouseId { get; set; }
+
+ /// 长(cm)。
+ public decimal? Length { get; set; }
+
+ /// 宽(cm)。
+ public decimal? Width { get; set; }
+
+ /// 高(cm)。
+ public decimal? Height { get; set; }
+
+ /// 实重(kg)。
+ public decimal? ActualWeight { get; set; }
+
+ /// 体积重(kg,可选)。
+ public decimal? VolumetricWeight { get; set; }
+
+ /// 计费重(kg,可选)。
+ public decimal? ChargeableWeight { get; set; }
+
+ /// 体积(cm³,可选)。
+ public decimal? Cubic { get; set; }
+
+ /// 是否 COD。
+ public bool IsCod { get; set; }
+
+ /// COD 币种。
+ public string? CodCurrency { get; set; }
+
+ /// COD 金额。
+ public decimal? CodSum { get; set; }
+
+ /// 申报币种(字典 currency)。
+ public string? DeclaredCurrency { get; set; }
+
+ /// 申报总金额(可选,不填可由明细汇总)。
+ public decimal? DeclaredSum { get; set; }
+
+ /// 附加服务字典 code 列表。
+ public List? AdditionalServices { get; set; }
+
+ /// 是否偏远(可选预传)。
+ public byte? IsRemote { get; set; }
+
+ /// 包裹数量。
+ public int? Quantity { get; set; }
+
+ /// 收件人。
+ public OrderParcelReceiverInput? Receiver { get; set; }
+
+ /// 寄件人。
+ public OrderParcelSenderInput? Sender { get; set; }
+
+ /// 申报明细。
+ public List Items { get; set; } = [];
+
+ /// 优惠券实例 Id(可选)。
+ public int? CouponInstanceId { get; set; }
+
+ /// 仅保存草稿,不预报计费。
+ public bool SaveAsDraft { get; set; }
+
+ ///
+ /// 映射为内部单票创建入参。开放接口固定自发货(selfShip),客户 Id 由凭证解析后写入。
+ ///
+ public OrderParcelCreateInput ToOrderParcelCreateInput(int customerId) =>
+ new()
+ {
+ RequestId = null,
+ ClientNo = ClientNo,
+ TrackNo = TrackNo,
+ ServiceNo = ServiceNo,
+ ProductCode = ProductCode,
+ ChannelCode = ChannelCode,
+ CargoAttribute = CargoAttribute,
+ ExportCustomsMode = ExportCustomsMode,
+ ExportQualificationId = ExportQualificationId,
+ PlatformCode = PlatformCode,
+ WarehouseId = WarehouseId,
+ CustomerId = customerId,
+ Length = Length,
+ Width = Width,
+ Height = Height,
+ ActualWeight = ActualWeight,
+ VolumetricWeight = VolumetricWeight,
+ ChargeableWeight = ChargeableWeight,
+ Cubic = Cubic,
+ IsCod = IsCod,
+ CodCurrency = CodCurrency,
+ CodSum = CodSum,
+ DeclaredCurrency = DeclaredCurrency,
+ DeclaredSum = DeclaredSum,
+ AdditionalServices = AdditionalServices,
+ IsRemote = IsRemote,
+ Quantity = Quantity,
+ Receiver = Receiver,
+ Sender = Sender,
+ Items = Items ?? [],
+ CouponInstanceId = CouponInstanceId,
+ SaveAsDraft = SaveAsDraft,
+ InboundMethod = OrderInboundMethodCodes.SelfShip,
+ SenderAddressId = null,
+ ExpectPickupTime = null,
+ PickupDirection = null,
+ CarrierCode = null,
+ CarrierService = null
+ };
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
index 47b72f4..a82aea9 100644
--- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
@@ -205,6 +205,11 @@ public class OrderOutput
///
public string? ExportCustomsMode { get; set; }
+ ///
+ /// 出口资质备案 Id。
+ ///
+ public int? ExportQualificationId { get; set; }
+
///
/// 预报失败原因(草稿订单)。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs
index f3048f6..653924a 100644
--- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs
@@ -45,6 +45,16 @@ public class OrderParcelCreateInput
///
public string? ExportCustomsMode { get; set; }
+ ///
+ /// 出口资质备案 Id(ExportCustomsMode=9610Summary 时必填)。
+ ///
+ public int? ExportQualificationId { get; set; }
+
+ ///
+ /// 电商平台(ExportCustomsMode=9610Summary 时必填,写入 t_9610_order.PlatformCode)。
+ ///
+ public string? PlatformCode { get; set; }
+
///
/// 仓库主键。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs
index c45bee6..7c6b0a5 100644
--- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs
@@ -55,6 +55,11 @@ public class OrderPriceCheckInput
///
public string? DestinationProvince { get; set; }
+ ///
+ /// 目的城市。
+ ///
+ public string? DestinationCity { get; set; }
+
///
/// 邮编。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderReceiverOutput.cs b/src/Oms.Application/Dtos/Orders/OrderReceiverOutput.cs
index f5eb79a..c2c75fa 100644
--- a/src/Oms.Application/Dtos/Orders/OrderReceiverOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderReceiverOutput.cs
@@ -31,9 +31,9 @@ public class OrderReceiverOutput
public string? Email { get; set; }
///
- /// 国家/地区代码。
+ /// 国家/地区二字码。
///
- public int? CountryCode { get; set; }
+ public string? CountryCode { get; set; }
///
/// 省份。
diff --git a/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs b/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs
new file mode 100644
index 0000000..ac425f8
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Orders;
+
+public class OrderRefundInput
+{
+ [Required]
+ public int Id { get; set; }
+
+ ///
+ /// true=全额退款(可退券);false=部分退款(券不退回)。
+ ///
+ public bool IsFullRefund { get; set; } = true;
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderSenderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderSenderOutput.cs
index fe7fbc9..e9793d1 100644
--- a/src/Oms.Application/Dtos/Orders/OrderSenderOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderSenderOutput.cs
@@ -31,9 +31,9 @@ public class OrderSenderOutput
public string? Email { get; set; }
///
- /// 国家/地区代码。
+ /// 国家/地区二字码。
///
- public int? CountryCode { get; set; }
+ public string? CountryCode { get; set; }
///
/// 省份。
diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
index 0706a9f..d9fd2fd 100644
--- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
@@ -273,6 +273,24 @@ public class PickupOrderOutput
public decimal? ChargeableWeight { get; set; }
+ public decimal? VolumetricWeight { get; set; }
+
+ public List PackageSizes { get; set; } = [];
+
+ public decimal? CarrierFreightWeight { get; set; }
+
+ public decimal? CarrierQuotedFreightAmount { get; set; }
+
+ public decimal? CarrierQuotedReceivableAmount { get; set; }
+
+ public PickupCarrierQuoteOutput? CarrierQuote { get; set; }
+
+ public List ReceivableFees { get; set; } = [];
+
+ public List PayableFees { get; set; } = [];
+
+ public DateTime? FeePushedAt { get; set; }
+
public string Currency { get; set; } = "CNY";
public decimal? FreightAmount { get; set; }
@@ -303,3 +321,81 @@ public class PickupOrderOutput
public List Rejected { get; set; } = [];
}
+
+public class PickupPackageSizeOutput
+{
+ public decimal Length { get; set; }
+
+ public decimal Width { get; set; }
+
+ public decimal Height { get; set; }
+
+ public int Count { get; set; }
+}
+
+public class PickupFeeLineOutput
+{
+ public string CostItem { get; set; } = string.Empty;
+
+ public decimal Amount { get; set; }
+
+ public string Currency { get; set; } = string.Empty;
+}
+
+public class PickupCarrierQuoteOutput
+{
+ public List ValueInfoList { get; set; } = [];
+}
+
+public class PickupCarrierValueFeeOutput
+{
+ public string? FeeCode { get; set; }
+
+ public string? FeeName { get; set; }
+
+ public decimal? FeeAmount { get; set; }
+}
+
+public class PickupOpenApiPushFeeResult
+{
+ public string PickupNo { get; set; } = string.Empty;
+
+ public decimal TotalActualWeight { get; set; }
+
+ public decimal ChargeableWeight { get; set; }
+
+ public decimal? VolumetricWeight { get; set; }
+
+ public decimal ReceivableAmount { get; set; }
+
+ public decimal? CarrierPayableAmount { get; set; }
+
+ public decimal? CarrierQuotedFreightAmount { get; set; }
+}
+
+///
+/// 承运推送件重尺入参(字段名对齐 t_pickup_order)。
+///
+public class PickupOpenApiPushFeeInput
+{
+ /// 承运运单号。
+ public string? CarrierWaybillNo { get; set; }
+
+ /// 实重合计。
+ public decimal TotalActualWeight { get; set; }
+
+ /// 承运计费重(对照)。
+ public decimal? CarrierFreightWeight { get; set; }
+
+ /// 承运报价运费合计(对照)。
+ public decimal? CarrierQuotedFreightAmount { get; set; }
+
+ /// 承运报文应收(对照)。
+ public decimal? CarrierQuotedReceivableAmount { get; set; }
+
+ /// 承运附加费明细(写入 CarrierQuoteJson)。
+ public List? ValueInfoList { get; set; }
+
+ /// 尺寸明细(写入 PackageSizesJson)。
+ public List? PackageSizes { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
index feb4d90..0486a17 100644
--- a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
@@ -1,9 +1,10 @@
+using System.Text.Json.Serialization;
using Oms.Application.Dtos.Channels;
namespace Oms.Application.Dtos.Orders;
///
-/// 销售产品价格发布快照(与渠道发布 JSON 同构)。
+/// 销售产品价格发布快照。
///
public class SalesProductPricePublishSnapshotDto
{
@@ -13,7 +14,7 @@ public class SalesProductPricePublishSnapshotDto
public int SalesProductId { get; set; }
///
- /// 价格分区 ID(发布时固化,查价/计费优先用快照内分区规则)。
+ /// 价格分区 ID(单表时冗余展示;以 PriceAreaRefs 为准)。
///
public int? PriceAreaId { get; set; }
@@ -23,8 +24,14 @@ public class SalesProductPricePublishSnapshotDto
public string? PriceAreaName { get; set; }
///
- /// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
+ /// 价格分区包引用(发布时钉包;明细在 t_kpi_area_publish_record)。
///
+ public List PriceAreaRefs { get; set; } = [];
+
+ ///
+ /// 运行时 hydrate / LIVE 用分区明细,不落发布 JSON。
+ ///
+ [JsonIgnore]
public List PriceAreaInfos { get; set; } = [];
///
@@ -34,7 +41,7 @@ public class SalesProductPricePublishSnapshotDto
}
///
-/// 销售产品规则发布快照(与渠道发布 JSON 同构)。
+/// 销售产品规则发布快照。
///
public class SalesProductRulesPublishSnapshotDto
{
@@ -49,7 +56,7 @@ public class SalesProductRulesPublishSnapshotDto
public List Providers { get; set; } = [];
///
- /// 黑名单分区 ID(发布时固化)。
+ /// 黑名单分区 ID。
///
public int? BlacklistAreaId { get; set; }
@@ -59,12 +66,18 @@ public class SalesProductRulesPublishSnapshotDto
public string? BlacklistAreaName { get; set; }
///
- /// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
+ /// 黑名单分区包引用。
///
+ public KpiAreaPackRefDto? BlacklistAreaRef { get; set; }
+
+ ///
+ /// 运行时 hydrate / LIVE 用,不落发布 JSON。
+ ///
+ [JsonIgnore]
public List BlacklistAreaInfos { get; set; } = [];
///
- /// 偏远分区 ID(发布时固化)。
+ /// 偏远分区 ID。
///
public int? RemoteAreaId { get; set; }
@@ -74,8 +87,14 @@ public class SalesProductRulesPublishSnapshotDto
public string? RemoteAreaName { get; set; }
///
- /// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
+ /// 偏远分区包引用。
///
+ public KpiAreaPackRefDto? RemoteAreaRef { get; set; }
+
+ ///
+ /// 运行时 hydrate / LIVE 用,不落发布 JSON。
+ ///
+ [JsonIgnore]
public List RemoteAreaInfos { get; set; } = [];
///
@@ -110,12 +129,25 @@ public class SalesProductRulesPublishSnapshotDto
}
///
-/// 服务渠道买价发布快照。
+/// 服务渠道买价发布快照(渠道自有价区,与产品卖价分区独立)。
///
public class ServiceChannelPricePublishSnapshotDto
{
public int ServiceChannelId { get; set; }
+ /// 价格分区 ID(单表时冗余展示;以 PriceAreaRefs 为准)。
+ public int? PriceAreaId { get; set; }
+
+ /// 价格分区名称。
+ public string? PriceAreaName { get; set; }
+
+ /// 渠道价区包引用(买价发布时钉包)。
+ public List PriceAreaRefs { get; set; } = [];
+
+ /// 运行时 hydrate / LIVE 用,不落发布 JSON。
+ [JsonIgnore]
+ public List PriceAreaInfos { get; set; } = [];
+
public List Prices { get; set; } = [];
}
diff --git a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
index bd9ba46..b02e6bc 100644
--- a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
@@ -11,14 +11,28 @@ public class KpiAreaSnapshotRuleDto
{
public int Id { get; set; }
+ /// 所属 KPI 分区表主键(多价格分区表合并快照时用于生成 zone 键)。
+ public int AreaId { get; set; }
+
+ /// 所属分区表名称(可选,便于展示与 zone 键兼容)。
+ public string? AreaName { get; set; }
+
public string? CountryCode { get; set; }
public string? Province { get; set; }
public string? City { get; set; }
+ /// zip / state / city / country,发布时固化。
+ public string? MatchDim { get; set; }
+
+ /// prefix / exact / range / all,发布时固化。
+ public string? MatchMode { get; set; }
+
+ public int PrefixLen { get; set; }
+
///
- /// 邮编区间,左开右闭,如 (50000,90000]。
+ /// 邮编匹配编码:prefix:900 / exact:90001 / [90000,90199];旧快照 (left,right] 按闭区间解析。
///
public string? PostcodeRange { get; set; }
diff --git a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs
index 903a8f2..415831c 100644
--- a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs
+++ b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs
@@ -54,3 +54,14 @@ public class OmsUserDetailDto : UserDto
public string? WecomKfUrl { get; set; }
}
+
+///
+/// 管理员重置用户密码入参。
+///
+public class ResetUserPasswordInput
+{
+ ///
+ /// 新密码。
+ ///
+ public string NewPassword { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs b/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs
new file mode 100644
index 0000000..2e38732
--- /dev/null
+++ b/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs
@@ -0,0 +1,49 @@
+namespace Oms.Application.Dtos.Storage;
+
+public class FileStorageNormalizeKeyInput
+{
+ public string? PathOrUrl { get; set; }
+}
+
+public class FileStorageKeyOutput
+{
+ public string Key { get; set; } = string.Empty;
+}
+
+public class FileStorageCapabilitiesOutput
+{
+ public bool SupportsClientDirectUpload { get; set; }
+
+ public string? Provider { get; set; }
+}
+
+public class FileStorageAccessUrlOutput
+{
+ public string Key { get; set; } = string.Empty;
+
+ public string Url { get; set; } = string.Empty;
+}
+
+public class FileStoragePresignInput
+{
+ public string? Folder { get; set; }
+
+ public string? FileName { get; set; }
+
+ public string? ContentType { get; set; }
+}
+
+public class FileStoragePresignOutput
+{
+ public string Key { get; set; } = string.Empty;
+
+ public string UploadUrl { get; set; } = string.Empty;
+
+ public string Method { get; set; } = "PUT";
+
+ public string? ContentType { get; set; }
+
+ public Dictionary? Headers { get; set; }
+
+ public string? ExpireAt { get; set; }
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs
index 9714178..15b8a0b 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs
@@ -1,3 +1,5 @@
+using Oms.Application.Dtos.OpenApi;
+
namespace Oms.Application.Integrations.SmallBagTrack;
public interface ISmallBagTrackOrderClient
@@ -5,7 +7,7 @@ public interface ISmallBagTrackOrderClient
Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
///
- /// 追加一条生命周期轨迹节点(POST ProviderService/Track/Upsert)。
+ /// 追加一条生命周期轨迹节点(POST ProviderService/Track/Upsert),默认小包 orderType=1。
///
Task AppendLifecycleTrackAsync(
string orderNo,
@@ -13,8 +15,43 @@ public interface ISmallBagTrackOrderClient
string? authorization = null,
CancellationToken cancellationToken = default);
+ ///
+ /// 追加生命周期轨迹节点,返回调用结果(用于按序编排)。
+ ///
+ Task AppendLifecycleTrackResultAsync(
+ string orderNo,
+ string trackCode,
+ int orderType,
+ string? authorization = null,
+ CancellationToken cancellationToken = default);
+
///
/// 同步调用并返回完整请求/响应(仅调试)。
///
Task UpsertDebugAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// 创建/更新大包轨迹订单(POST ProviderService/Order/UpsertBigBag)。
+ ///
+ Task UpsertBigBagAsync(
+ SmallBagTrackUpsertBigBagRequest request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 关联大包与小包(POST ProviderService/Order/Link)。
+ ///
+ Task LinkOrdersAsync(
+ string parentOrderNo,
+ int parentOrderType,
+ IReadOnlyList linkOrderNos,
+ int linkOrderType,
+ string? authorization = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询小包轨迹(POST ProviderService/Track/Query)。
+ ///
+ Task> QuerySmallBagTracksAsync(
+ string orderNo,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
new file mode 100644
index 0000000..edffb5b
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
@@ -0,0 +1,302 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Oms.Application.Integrations.Itfx;
+using Oms.Domain.ChannelsAggregate.Entities;
+using Oms.Domain.ChannelsAggregate.Repositories;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+///
+/// 揽收大包轨迹同步结果(UpsertBigBag → Link → Track A0201)。
+///
+public class PickupBigBagTrackSyncResult
+{
+ public string PickupNo { get; set; } = string.Empty;
+
+ public bool Success { get; set; }
+
+ public string? FailStep { get; set; }
+
+ public string? Message { get; set; }
+
+ public SmallBagTrackUpsertResult? UpsertBigBag { get; set; }
+
+ public SmallBagTrackUpsertResult? Link { get; set; }
+
+ public SmallBagTrackUpsertResult? TrackUpsert { get; set; }
+}
+
+///
+/// 揽收单预报下游成功后 / 手动:按序 UpsertBigBag → Link → Track/Upsert(A0201)。
+///
+public interface IPickupBigBagTrackSyncer
+{
+ Task SyncAfterDownstreamForwardAsync(PickupOrder pickup, CancellationToken cancellationToken = default);
+
+ ///
+ /// 同步执行三步并返回逐步请求/响应(手动「创建轨迹订单」联调)。
+ ///
+ Task SyncDebugAsync(PickupOrder pickup, CancellationToken cancellationToken = default);
+}
+
+internal sealed class PickupBigBagTrackSyncer(
+ ISmallBagTrackOrderClient trackClient,
+ IPickupOrderLineRepository pickupOrderLineRepository,
+ ISalesProductRepository salesProductRepository,
+ IServiceChannelRepository serviceChannelRepository,
+ IServiceChannelGroupRepository serviceChannelGroupRepository,
+ IItfxMetadataClient itfxMetadataClient,
+ IHttpContextAccessor httpContextAccessor,
+ ILogger logger) : IPickupBigBagTrackSyncer
+{
+ public async Task SyncAfterDownstreamForwardAsync(
+ PickupOrder pickup,
+ CancellationToken cancellationToken = default)
+ {
+ await SyncDebugAsync(pickup, cancellationToken);
+ }
+
+ public async Task SyncDebugAsync(
+ PickupOrder pickup,
+ CancellationToken cancellationToken = default)
+ {
+ var result = new PickupBigBagTrackSyncResult
+ {
+ PickupNo = pickup?.PickupNo?.Trim() ?? string.Empty
+ };
+
+ if (pickup == null || string.IsNullOrWhiteSpace(pickup.PickupNo))
+ {
+ result.Success = false;
+ result.FailStep = "validate";
+ result.Message = "pickup or PickupNo empty";
+ logger.LogWarning("Skip big-bag track sync: pickup or PickupNo empty");
+ return result;
+ }
+
+ var bagNo = pickup.PickupNo.Trim();
+ result.PickupNo = bagNo;
+ var authorization = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
+ if (string.IsNullOrWhiteSpace(authorization))
+ {
+ authorization = null;
+ }
+
+ try
+ {
+ var (channel, product, apiCode) = await ResolveChannelProductAsync(pickup.CarrierService, cancellationToken);
+ var country = ResolveCountry(pickup);
+
+ // 1) UpsertBigBag:client 取 product(销售产品编码)
+ var upsertResult = await trackClient.UpsertBigBagAsync(
+ new SmallBagTrackUpsertBigBagRequest
+ {
+ Client = product ?? string.Empty,
+ BagNo = bagNo,
+ OtherNo = pickup.CarrierWaybillNo,
+ Channel = channel,
+ Product = product,
+ Country = country,
+ ApiCode = apiCode,
+ Authorization = authorization
+ },
+ cancellationToken);
+ result.UpsertBigBag = upsertResult;
+
+ if (upsertResult.Skipped)
+ {
+ result.Success = false;
+ result.FailStep = "UpsertBigBag";
+ result.Message = upsertResult.SkipReason ?? "skipped";
+ logger.LogDebug(
+ "UpsertBigBag skipped for pickup {PickupNo}: {Reason}",
+ bagNo,
+ upsertResult.SkipReason);
+ return result;
+ }
+
+ if (!upsertResult.Success)
+ {
+ result.Success = false;
+ result.FailStep = "UpsertBigBag";
+ result.Message = $"HTTP {upsertResult.StatusCode}";
+ logger.LogWarning(
+ "Stop big-bag track sync after UpsertBigBag failure for pickup {PickupNo}",
+ bagNo);
+ return result;
+ }
+
+ // 2) Link(无明细单号则跳过,继续写轨迹)
+ var lines = await pickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id, cancellationToken);
+ var serviceNos = lines
+ .Select(x => x.ServiceNo)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x!.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (serviceNos.Count > 0)
+ {
+ var linkResult = await trackClient.LinkOrdersAsync(
+ bagNo,
+ SmallBagTrackLifecycleEvents.BigBagOrderType,
+ serviceNos,
+ SmallBagTrackLifecycleEvents.OrderType,
+ authorization,
+ cancellationToken);
+ result.Link = linkResult;
+
+ if (linkResult.Skipped)
+ {
+ logger.LogDebug(
+ "Link skipped for pickup {PickupNo}: {Reason}",
+ bagNo,
+ linkResult.SkipReason);
+ }
+ else if (!linkResult.Success)
+ {
+ result.Success = false;
+ result.FailStep = "Link";
+ result.Message = $"HTTP {linkResult.StatusCode}";
+ logger.LogWarning(
+ "Stop big-bag track sync after Link failure for pickup {PickupNo}",
+ bagNo);
+ return result;
+ }
+ }
+ else
+ {
+ logger.LogDebug("Skip Link for pickup {PickupNo}: no bound ServiceNo", bagNo);
+ }
+
+ // 3) Track Upsert A0201
+ var trackResult = await trackClient.AppendLifecycleTrackResultAsync(
+ bagNo,
+ SmallBagTrackLifecycleEvents.PickupCreateTrackCode,
+ SmallBagTrackLifecycleEvents.BigBagOrderType,
+ authorization,
+ cancellationToken);
+ result.TrackUpsert = trackResult;
+
+ if (trackResult.Skipped)
+ {
+ result.Success = false;
+ result.FailStep = "TrackUpsert";
+ result.Message = trackResult.SkipReason ?? "skipped";
+ logger.LogDebug(
+ "Track A0201 skipped for pickup {PickupNo}: {Reason}",
+ bagNo,
+ trackResult.SkipReason);
+ return result;
+ }
+
+ if (!trackResult.Success)
+ {
+ result.Success = false;
+ result.FailStep = "TrackUpsert";
+ result.Message = $"HTTP {trackResult.StatusCode}";
+ logger.LogWarning(
+ "Track A0201 failed for pickup {PickupNo}: {StatusCode} {Body}",
+ bagNo,
+ trackResult.StatusCode,
+ trackResult.ResponseBody);
+ return result;
+ }
+
+ result.Success = true;
+ result.Message = "ok";
+ return result;
+ }
+ catch (Exception ex)
+ {
+ result.Success = false;
+ result.FailStep ??= "exception";
+ result.Message = ex.Message;
+ logger.LogWarning(ex, "Big-bag track sync failed for pickup {PickupNo}", bagNo);
+ return result;
+ }
+ }
+
+ private async Task<(string? Channel, string? Product, string? ApiCode)> ResolveChannelProductAsync(
+ string? carrierService,
+ CancellationToken cancellationToken)
+ {
+ var salesProductCode = carrierService?.Trim();
+ if (string.IsNullOrWhiteSpace(salesProductCode))
+ {
+ return (null, null, null);
+ }
+
+ var product = await salesProductRepository.GetByProductCodeAsync(salesProductCode, cancellationToken);
+ if (product == null)
+ {
+ return (null, salesProductCode, null);
+ }
+
+ var providers = await salesProductRepository.GetAllocationProvidersAsync(product.Id, cancellationToken);
+ var channelId = providers
+ .Where(x => x.ServiceChannelId is > 0)
+ .OrderBy(x => x.SerialNumber)
+ .Select(x => x.ServiceChannelId!.Value)
+ .FirstOrDefault();
+ if (channelId <= 0)
+ {
+ return (null, product.ProductCode, null);
+ }
+
+ var channel = await serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId, cancellationToken);
+ var channelCode = channel?.ChannelCode?.Trim();
+ var apiCode = channel == null
+ ? null
+ : await ResolveApiCodeFromServiceChannelAsync(channel, cancellationToken);
+ return (
+ string.IsNullOrWhiteSpace(channelCode) ? null : channelCode,
+ product.ProductCode,
+ apiCode);
+ }
+
+ private async Task ResolveApiCodeFromServiceChannelAsync(
+ ServiceChannel channel,
+ CancellationToken cancellationToken)
+ {
+ if (!channel.GroupId.HasValue || channel.GroupId.Value <= 0)
+ {
+ return null;
+ }
+
+ var group = await serviceChannelGroupRepository.FirstOrDefaultAsync(
+ x => x.Id == channel.GroupId.Value,
+ cancellationToken);
+ if (group == null || group.ApiServiceProviderId <= 0)
+ {
+ return null;
+ }
+
+ var serviceProvider = await itfxMetadataClient.GetServiceProviderAsync(
+ group.ApiServiceProviderId,
+ cancellationToken);
+ var code = serviceProvider?.Code?.Trim();
+ return string.IsNullOrWhiteSpace(code) ? null : code;
+ }
+
+ private static string ResolveCountry(PickupOrder pickup)
+ {
+ var isReverse = string.Equals(
+ PickupDirectionCodes.NormalizeOrDefault(pickup.Direction),
+ PickupDirectionCodes.Reverse,
+ StringComparison.OrdinalIgnoreCase);
+
+ if (isReverse)
+ {
+ return string.IsNullOrWhiteSpace(pickup.SenderCountryCode)
+ ? "CN"
+ : pickup.SenderCountryCode.Trim().ToUpperInvariant();
+ }
+
+ // 正向:目的仓,仓库无国别字段,默认 CN
+ return "CN";
+ }
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs
index fceed43..15d6ca8 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs
@@ -1,12 +1,15 @@
namespace Oms.Application.Integrations.SmallBagTrack;
///
-/// 小包订单生命周期默认轨迹节点(POST ProviderService/Track/Upsert)。
+/// 订单生命周期默认轨迹节点(POST ProviderService/Track/Upsert)。
///
internal static class SmallBagTrackLifecycleEvents
{
public const string CreateTrackCode = "A0101";
+ /// 揽收大包创单轨迹。
+ public const string PickupCreateTrackCode = "A0201";
+
public const string InboundTrackCode = "A0301";
public const string OutboundTrackCode = "A0701";
@@ -18,6 +21,9 @@ internal static class SmallBagTrackLifecycleEvents
/// 轨迹订单类型:小包。
public const int OrderType = 1;
+ /// 轨迹订单类型:大包。
+ public const int BigBagOrderType = 2;
+
/// 数据来源:内部系统。
public const int Source = 2;
}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLinkOrderApiRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLinkOrderApiRequest.cs
new file mode 100644
index 0000000..9a32edd
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLinkOrderApiRequest.cs
@@ -0,0 +1,24 @@
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+///
+/// POST /ProviderService/Order/Link 业务请求体。
+///
+internal sealed class SmallBagTrackLinkOrderApiRequest
+{
+ public string AppKey { get; set; } = string.Empty;
+
+ public bool IsLink { get; set; } = true;
+
+ public int OrderType { get; set; }
+
+ public string OrderNo { get; set; } = string.Empty;
+
+ public string LinkAppKey { get; set; } = string.Empty;
+
+ public int LinkOrderType { get; set; }
+
+ public List LinkOrderNos { get; set; } = [];
+
+ public static string BuildEndpointUrl(string baseUrl) =>
+ $"{baseUrl.Trim().TrimEnd('/')}/ProviderService/Order/Link";
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
index b97d847..1d1189f 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
@@ -1,8 +1,10 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
+using Fake.Application;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
+using Oms.Application.Dtos.OpenApi;
namespace Oms.Application.Integrations.SmallBagTrack;
@@ -51,7 +53,114 @@ internal sealed class SmallBagTrackOrderClient(
string trackCode,
string? authorization = null,
CancellationToken cancellationToken = default) =>
- AppendLifecycleTrackCoreAsync(orderNo, trackCode, authorization, cancellationToken);
+ AppendLifecycleTrackResultAsync(
+ orderNo,
+ trackCode,
+ SmallBagTrackLifecycleEvents.OrderType,
+ authorization,
+ cancellationToken);
+
+ public async Task AppendLifecycleTrackResultAsync(
+ string orderNo,
+ string trackCode,
+ int orderType,
+ string? authorization = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(orderNo))
+ {
+ logger.LogWarning("Skip lifecycle track append: orderNo empty");
+ return Skipped("orderNo empty");
+ }
+
+ if (string.IsNullOrWhiteSpace(trackCode))
+ {
+ logger.LogWarning("Skip lifecycle track append for order {OrderNo}: trackCode empty", orderNo);
+ return Skipped("trackCode empty");
+ }
+
+ var settings = await configProvider.GetAsync(cancellationToken);
+ if (!settings.Enabled)
+ {
+ return Skipped("TrackService disabled");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.BaseUrl))
+ {
+ return Skipped("BaseUrl empty");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
+ {
+ return Skipped("AppKey or AppSecret not configured");
+ }
+
+ var appKey = settings.AppKey.Trim();
+ var auth = ResolveAuthorization(authorization);
+ var trackTime = DateTime.Now;
+ var body = SmallBagTrackUpsertTrackApiRequest.CreateLifecycleEvent(
+ appKey,
+ orderNo.Trim(),
+ trackCode.Trim(),
+ trackTime,
+ orderType);
+ var url = SmallBagTrackUpsertTrackApiRequest.BuildEndpointUrl(settings.BaseUrl);
+ var requestJson = JsonSerializer.Serialize(body, JsonOptions);
+
+ try
+ {
+ var response = await SendSignedPostAsync(
+ settings,
+ appKey,
+ url,
+ body,
+ auth,
+ cancellationToken);
+
+ var result = new SmallBagTrackUpsertResult
+ {
+ Skipped = false,
+ Success = response.Response.IsSuccessStatusCode,
+ Url = url,
+ RequestJson = requestJson,
+ StatusCode = (int)response.Response.StatusCode,
+ ResponseBody = response.ResponseBody
+ };
+
+ if (result.Success)
+ {
+ logger.LogInformation(
+ "Lifecycle track {TrackCode} appended for order {OrderNo} (orderType={OrderType}) at {TrackPlace}",
+ trackCode,
+ orderNo,
+ orderType,
+ SmallBagTrackLifecycleEvents.TrackPlace);
+ }
+ else
+ {
+ logger.LogWarning(
+ "Lifecycle track {TrackCode} append failed for order {OrderNo}: {StatusCode} {Body}",
+ trackCode,
+ orderNo,
+ result.StatusCode,
+ result.ResponseBody);
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Lifecycle track {TrackCode} append failed for order {OrderNo}", trackCode, orderNo);
+ return new SmallBagTrackUpsertResult
+ {
+ Skipped = false,
+ Success = false,
+ Url = url,
+ RequestJson = requestJson,
+ ResponseBody = ex.Message
+ };
+ }
+ }
public async Task UpsertDebugAsync(
SmallBagTrackUpsertRequest request,
@@ -76,7 +185,7 @@ internal sealed class SmallBagTrackOrderClient(
var appKey = settings.AppKey.Trim();
var body = SmallBagTrackUpsertApiRequest.FromBusinessRequest(request);
var url = SmallBagTrackUpsertApiRequest.BuildEndpointUrl(settings.BaseUrl);
- var authorization = ResolveAuthorization(request);
+ var authorization = ResolveAuthorization(request.Authorization);
var requestJson = JsonSerializer.Serialize(body, JsonOptions);
var response = await SendSignedPostAsync(
@@ -120,41 +229,45 @@ internal sealed class SmallBagTrackOrderClient(
return result;
}
- private async Task AppendLifecycleTrackCoreAsync(
- string orderNo,
- string trackCode,
- string? authorization,
- CancellationToken cancellationToken)
+ public async Task UpsertBigBagAsync(
+ SmallBagTrackUpsertBigBagRequest request,
+ CancellationToken cancellationToken = default)
{
- if (string.IsNullOrWhiteSpace(orderNo))
+ if (string.IsNullOrWhiteSpace(request.BagNo))
{
- logger.LogWarning("Skip lifecycle track append: orderNo empty");
- return;
- }
-
- if (string.IsNullOrWhiteSpace(trackCode))
- {
- logger.LogWarning("Skip lifecycle track append for order {OrderNo}: trackCode empty", orderNo);
- return;
+ return Skipped("bagNo empty");
}
var settings = await configProvider.GetAsync(cancellationToken);
- if (!settings.Enabled
- || string.IsNullOrWhiteSpace(settings.BaseUrl)
- || string.IsNullOrWhiteSpace(settings.AppKey)
- || string.IsNullOrWhiteSpace(settings.AppSecret))
+ if (!settings.Enabled)
{
- return;
+ return Skipped("TrackService disabled");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.BaseUrl))
+ {
+ return Skipped("BaseUrl empty");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
+ {
+ return Skipped("AppKey or AppSecret not configured");
}
var appKey = settings.AppKey.Trim();
- var trackTime = DateTime.Now;
- var body = SmallBagTrackUpsertTrackApiRequest.CreateLifecycleEvent(
- appKey,
- orderNo.Trim(),
- trackCode.Trim(),
- trackTime);
- var url = SmallBagTrackUpsertTrackApiRequest.BuildEndpointUrl(settings.BaseUrl);
+ var body = new SmallBagTrackUpsertBigBagApiRequest
+ {
+ Client = request.Client?.Trim() ?? string.Empty,
+ BagNo = request.BagNo.Trim(),
+ OtherNo = request.OtherNo?.Trim() ?? string.Empty,
+ Channel = request.Channel?.Trim() ?? string.Empty,
+ Product = request.Product?.Trim() ?? string.Empty,
+ Country = request.Country?.Trim() ?? string.Empty,
+ ApiCode = request.ApiCode?.Trim() ?? string.Empty
+ };
+ var url = SmallBagTrackUpsertBigBagApiRequest.BuildEndpointUrl(settings.BaseUrl);
+ var authorization = ResolveAuthorization(request.Authorization);
+ var requestJson = JsonSerializer.Serialize(body, JsonOptions);
try
{
@@ -166,29 +279,325 @@ internal sealed class SmallBagTrackOrderClient(
authorization,
cancellationToken);
- if (response.Response.IsSuccessStatusCode)
+ var result = new SmallBagTrackUpsertResult
{
- logger.LogInformation(
- "Lifecycle track {TrackCode} appended for order {OrderNo} at {TrackPlace}",
- trackCode,
- orderNo,
- SmallBagTrackLifecycleEvents.TrackPlace);
- return;
+ Skipped = false,
+ Success = response.Response.IsSuccessStatusCode,
+ Url = url,
+ RequestJson = requestJson,
+ StatusCode = (int)response.Response.StatusCode,
+ ResponseBody = response.ResponseBody
+ };
+
+ if (result.Success)
+ {
+ logger.LogInformation("UpsertBigBag succeeded for bag {BagNo}", body.BagNo);
+ }
+ else
+ {
+ logger.LogWarning(
+ "UpsertBigBag failed for bag {BagNo}: {StatusCode} {Body}",
+ body.BagNo,
+ result.StatusCode,
+ result.ResponseBody);
}
- logger.LogWarning(
- "Lifecycle track {TrackCode} append failed for order {OrderNo}: {StatusCode} {Body}",
- trackCode,
- orderNo,
- (int)response.Response.StatusCode,
- response.ResponseBody);
+ return result;
}
catch (Exception ex)
{
- logger.LogWarning(ex, "Lifecycle track {TrackCode} append failed for order {OrderNo}", trackCode, orderNo);
+ logger.LogWarning(ex, "UpsertBigBag failed for bag {BagNo}", request.BagNo);
+ return new SmallBagTrackUpsertResult
+ {
+ Skipped = false,
+ Success = false,
+ Url = url,
+ RequestJson = requestJson,
+ ResponseBody = ex.Message
+ };
}
}
+ public async Task LinkOrdersAsync(
+ string parentOrderNo,
+ int parentOrderType,
+ IReadOnlyList linkOrderNos,
+ int linkOrderType,
+ string? authorization = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(parentOrderNo))
+ {
+ return Skipped("parentOrderNo empty");
+ }
+
+ var nos = (linkOrderNos ?? [])
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x.Trim())
+ .Where(x => !string.Equals(x, parentOrderNo.Trim(), StringComparison.OrdinalIgnoreCase))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (nos.Count == 0)
+ {
+ return Skipped("linkOrderNos empty");
+ }
+
+ var settings = await configProvider.GetAsync(cancellationToken);
+ if (!settings.Enabled)
+ {
+ return Skipped("TrackService disabled");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.BaseUrl))
+ {
+ return Skipped("BaseUrl empty");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
+ {
+ return Skipped("AppKey or AppSecret not configured");
+ }
+
+ var appKey = settings.AppKey.Trim();
+ var body = new SmallBagTrackLinkOrderApiRequest
+ {
+ AppKey = appKey,
+ IsLink = true,
+ OrderType = parentOrderType,
+ OrderNo = parentOrderNo.Trim(),
+ LinkAppKey = appKey,
+ LinkOrderType = linkOrderType,
+ LinkOrderNos = nos
+ };
+ var url = SmallBagTrackLinkOrderApiRequest.BuildEndpointUrl(settings.BaseUrl);
+ var auth = ResolveAuthorization(authorization);
+ var requestJson = JsonSerializer.Serialize(body, JsonOptions);
+
+ try
+ {
+ var response = await SendSignedPostAsync(
+ settings,
+ appKey,
+ url,
+ body,
+ auth,
+ cancellationToken);
+
+ var result = new SmallBagTrackUpsertResult
+ {
+ Skipped = false,
+ Success = response.Response.IsSuccessStatusCode,
+ Url = url,
+ RequestJson = requestJson,
+ StatusCode = (int)response.Response.StatusCode,
+ ResponseBody = response.ResponseBody
+ };
+
+ if (result.Success)
+ {
+ logger.LogInformation(
+ "LinkOrders succeeded for parent {OrderNo} -> {Count} children",
+ body.OrderNo,
+ nos.Count);
+ }
+ else
+ {
+ logger.LogWarning(
+ "LinkOrders failed for parent {OrderNo}: {StatusCode} {Body}",
+ body.OrderNo,
+ result.StatusCode,
+ result.ResponseBody);
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "LinkOrders failed for parent {OrderNo}", parentOrderNo);
+ return new SmallBagTrackUpsertResult
+ {
+ Skipped = false,
+ Success = false,
+ Url = url,
+ RequestJson = requestJson,
+ ResponseBody = ex.Message
+ };
+ }
+ }
+
+ public async Task> QuerySmallBagTracksAsync(
+ string orderNo,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(orderNo))
+ {
+ throw new BusinessException("服务单号为空,无法查询轨迹");
+ }
+
+ var settings = await configProvider.GetAsync(cancellationToken);
+ if (!settings.Enabled)
+ {
+ throw new BusinessException("轨迹服务未启用");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.BaseUrl))
+ {
+ throw new BusinessException("轨迹服务未配置");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
+ {
+ throw new BusinessException("轨迹服务未配置");
+ }
+
+ var appKey = settings.AppKey.Trim();
+ var url = $"{settings.BaseUrl.Trim().TrimEnd('/')}/ProviderService/Track/Query";
+ var body = new
+ {
+ appKey,
+ orderType = SmallBagTrackLifecycleEvents.OrderType,
+ orderNos = new[] { orderNo.Trim() }
+ };
+
+ var response = await SendSignedPostAsync(
+ settings,
+ appKey,
+ url,
+ body,
+ authorization: null,
+ cancellationToken);
+
+ if (!response.Response.IsSuccessStatusCode)
+ {
+ throw new BusinessException("轨迹查询失败");
+ }
+
+ return ParseTrackEvents(response.ResponseBody);
+ }
+
+ private static List ParseTrackEvents(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return [];
+ }
+
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ var arr = root;
+ if (root.ValueKind == JsonValueKind.Object
+ && (root.TryGetProperty("data", out var data) || root.TryGetProperty("Data", out data)))
+ {
+ arr = data;
+ }
+
+ if (arr.ValueKind != JsonValueKind.Array)
+ {
+ return [];
+ }
+
+ var events = new List();
+ foreach (var orderEl in arr.EnumerateArray())
+ {
+ if (orderEl.ValueKind != JsonValueKind.Object)
+ {
+ continue;
+ }
+
+ if (!orderEl.TryGetProperty("trackDetails", out var details)
+ && !orderEl.TryGetProperty("TrackDetails", out details))
+ {
+ continue;
+ }
+
+ if (details.ValueKind != JsonValueKind.Array)
+ {
+ continue;
+ }
+
+ foreach (var el in details.EnumerateArray())
+ {
+ var content = ReadJsonString(el, "content", "Content");
+ if (string.IsNullOrWhiteSpace(content) || content == "--")
+ {
+ continue;
+ }
+
+ events.Add(new OpenApiTrackEventDto
+ {
+ TrackCode = ReadJsonString(el, "code", "Code"),
+ Status = ReadJsonString(el, "status", "Status"),
+ Content = content,
+ EnContent = ReadJsonString(el, "enContent", "EnContent"),
+ Location = ReadJsonString(el, "location", "Location"),
+ Time = ReadJsonTime(el),
+ Files = ReadJsonFiles(el)
+ });
+ }
+ }
+
+ return events;
+ }
+
+ private static List ReadJsonFiles(JsonElement el)
+ {
+ if (!el.TryGetProperty("files", out var files) && !el.TryGetProperty("Files", out files))
+ {
+ return [];
+ }
+
+ if (files.ValueKind != JsonValueKind.Array)
+ {
+ return [];
+ }
+
+ var list = new List();
+ foreach (var f in files.EnumerateArray())
+ {
+ list.Add(new OpenApiTrackFileDto
+ {
+ FileName = ReadJsonString(f, "fileName", "FileName"),
+ Url = ReadJsonString(f, "downloadUrl", "DownloadUrl", "url", "Url")
+ });
+ }
+
+ return list;
+ }
+
+ private static DateTime? ReadJsonTime(JsonElement el)
+ {
+ if (!el.TryGetProperty("time", out var time) && !el.TryGetProperty("Time", out time))
+ {
+ return null;
+ }
+
+ if (time.ValueKind == JsonValueKind.String && time.TryGetDateTime(out var dt))
+ {
+ return dt;
+ }
+
+ if (time.ValueKind == JsonValueKind.Number && time.TryGetInt64(out var unix))
+ {
+ return DateTimeOffset.FromUnixTimeSeconds(unix > 10_000_000_000 ? unix / 1000 : unix).UtcDateTime;
+ }
+
+ return null;
+ }
+
+ private static string? ReadJsonString(JsonElement el, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String)
+ {
+ return p.GetString();
+ }
+ }
+
+ return null;
+ }
+
private async Task SendSignedPostAsync(
SmallBagTrackOptions settings,
string appKey,
@@ -215,11 +624,11 @@ internal sealed class SmallBagTrackOrderClient(
return new SignedTrackHttpResponse(response, responseBody, timestamp, sign);
}
- private string? ResolveAuthorization(SmallBagTrackUpsertRequest request)
+ private string? ResolveAuthorization(string? authorization)
{
- if (!string.IsNullOrWhiteSpace(request.Authorization))
+ if (!string.IsNullOrWhiteSpace(authorization))
{
- return request.Authorization.Trim();
+ return authorization.Trim();
}
return httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs
index d8d0bb3..2427d8e 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs
@@ -12,6 +12,7 @@ public static class SmallBagTrackServiceExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddSingleton();
return services;
}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs
new file mode 100644
index 0000000..a694b0c
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs
@@ -0,0 +1,24 @@
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+///
+/// POST /ProviderService/Order/UpsertBigBag 业务请求体。
+///
+internal sealed class SmallBagTrackUpsertBigBagApiRequest
+{
+ public string Client { get; set; } = string.Empty;
+
+ public string BagNo { get; set; } = string.Empty;
+
+ public string OtherNo { get; set; } = string.Empty;
+
+ public string Channel { get; set; } = string.Empty;
+
+ public string Product { get; set; } = string.Empty;
+
+ public string Country { get; set; } = string.Empty;
+
+ public string ApiCode { get; set; } = string.Empty;
+
+ public static string BuildEndpointUrl(string baseUrl) =>
+ $"{baseUrl.Trim().TrimEnd('/')}/ProviderService/Order/UpsertBigBag";
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs
new file mode 100644
index 0000000..9ac9c1a
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs
@@ -0,0 +1,26 @@
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+///
+/// 大包轨迹订单业务请求(UpsertBigBag)。
+///
+public class SmallBagTrackUpsertBigBagRequest
+{
+ public string Client { get; set; } = string.Empty;
+
+ public string BagNo { get; set; } = string.Empty;
+
+ public string? OtherNo { get; set; }
+
+ public string? Channel { get; set; }
+
+ public string? Product { get; set; }
+
+ public string? Country { get; set; }
+
+ ///
+ /// API 服务商 code(轨迹拉取配置 ServiceProviderCode)。
+ ///
+ public string? ApiCode { get; set; }
+
+ public string? Authorization { get; set; }
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs
index 24f2c05..261462f 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs
@@ -24,11 +24,12 @@ internal sealed class SmallBagTrackUpsertTrackApiRequest
string appKey,
string orderNo,
string trackCode,
- DateTime trackTime) =>
+ DateTime trackTime,
+ int orderType = SmallBagTrackLifecycleEvents.OrderType) =>
new()
{
AppKey = appKey,
- OrderType = SmallBagTrackLifecycleEvents.OrderType,
+ OrderType = orderType,
OrderNo = orderNo,
IsAdd = true,
TrackCode = trackCode,
diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs
index 8b3516b..a6da67b 100644
--- a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs
+++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs
@@ -1,4 +1,5 @@
using Fake.Application;
+using Fake.ObjectStorage;
using Microsoft.Extensions.Logging;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders;
@@ -22,7 +23,7 @@ public sealed class LabelPdfGenerationService(
ISystemOtherConfigRepository systemOtherConfigRepository,
IOrderRepository orderRepository,
OrderFileManager orderFileManager,
- IFileStorage fileStorage,
+ IObjectStorage fileStorage,
ILogger logger)
{
public async Task GenerateAndPersistAsync(
@@ -167,7 +168,7 @@ public sealed class LabelPdfGenerationService(
var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf";
var relativeKey = $"uploads/prints/orders/{fileName}";
await using var stream = new MemoryStream(pdfBytes);
- return await fileStorage.SaveAsync(stream, relativeKey, "application/pdf", cancellationToken);
+ return await fileStorage.SaveObjectAsync(stream, relativeKey, "application/pdf", cancellationToken);
}
}
diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj
index 8783a7d..73214d1 100644
--- a/src/Oms.Application/Oms.Application.csproj
+++ b/src/Oms.Application/Oms.Application.csproj
@@ -20,6 +20,7 @@
+
@@ -29,8 +30,6 @@
-
-
diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs
index 7603018..c24e699 100644
--- a/src/Oms.Application/OmsApplicationModule.cs
+++ b/src/Oms.Application/OmsApplicationModule.cs
@@ -12,8 +12,8 @@ using Oms.Application.Integrations.Itfx;
using Oms.Application.Integrations.PlatformShop;
using Oms.Application.Labeling;
using Oms.Application.Labeling.Hiprint;
+using Oms.Application.Services.Storage;
using Oms.Application.Shared;
-using Oms.Application.Storage;
using Oms.Application.Workers;
using Oms.Domain;
@@ -21,6 +21,7 @@ namespace Oms.Application;
///
/// OMS 应用层模块注册。
+/// 对象存储由 Api 宿主注册 Fake.ObjectStorage(Local / TencentCos);文件元数据走 Fake.FileManagement。
///
[DependsOn(
typeof(OmsDomainModule),
@@ -52,7 +53,11 @@ public class OmsApplicationModule : FakeModule
context.Services.AddTransient();
context.Services.AddTransient();
- context.Services.AddOmsFileStorage(configuration);
+ context.Services.AddHttpClient(FileStorageService.UrlFetchClientName, client =>
+ {
+ client.Timeout = TimeSpan.FromSeconds(60);
+ });
+
context.Services.AddOmsSmallBagTrack(configuration);
context.Services.AddOmsItfxIntegration(configuration);
context.Services.AddOmsPlatformShopIntegration(configuration);
diff --git a/src/Oms.Application/Services/Ai/AiSkillService.cs b/src/Oms.Application/Services/Ai/AiSkillService.cs
index 775d83f..ce85056 100644
--- a/src/Oms.Application/Services/Ai/AiSkillService.cs
+++ b/src/Oms.Application/Services/Ai/AiSkillService.cs
@@ -93,6 +93,7 @@ public class AiSkillService : ApplicationService
}
entity ??= await AiSkillRepository.GetBySkillKeyAsync(skillKey);
+ entity ??= await AiSkillRepository.GetBySkillKeyIncludingDeletedAsync(skillKey);
if (entity == null)
{
@@ -106,9 +107,17 @@ public class AiSkillService : ApplicationService
throw new BusinessException($"SkillKey 已存在:{skillKey}");
}
+ var softDeleted = await AiSkillRepository.GetBySkillKeyIncludingDeletedAsync(skillKey);
+ if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id)
+ {
+ softDeleted.SkillKey = $"{skillKey}__deleted_{softDeleted.Id}";
+ await AiSkillRepository.UpdateAsync(softDeleted);
+ }
+
entity.SkillKey = skillKey;
}
+ entity.IsDeleted = false;
entity.Scene = scene;
entity.Name = input.Name.Trim();
entity.Description = NormalizeOptional(input.Description);
diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs
index 8313e88..95f6061 100644
--- a/src/Oms.Application/Services/Channels/KpiAreaService.cs
+++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs
@@ -7,6 +7,7 @@ using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
+using Oms.Domain.Shared;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.Channels;
@@ -90,6 +91,7 @@ public class KpiAreaService : ApplicationService
public async Task DeleteAsync(IdInput input)
{
var entity = await GetAreaOrThrowAsync(input.Id);
+ await KpiAreaRepository.DeleteInfosByAreaIdAsync(entity.Id);
await KpiAreaRepository.DeleteAsync(entity);
}
@@ -122,7 +124,7 @@ public class KpiAreaService : ApplicationService
public async Task AddInfoAsync(KpiAreaInfoSaveInput input)
{
await GetAreaOrThrowAsync(input.AreaId);
- await KpiAreaRepository.InsertInfoAsync(new KpiAreaInfo
+ var entity = new KpiAreaInfo
{
AreaId = input.AreaId,
CountryCode = input.CountryCode,
@@ -130,8 +132,14 @@ public class KpiAreaService : ApplicationService
Province = input.Province,
City = input.City,
LeftPostcode = input.LeftPostcode,
- RightPostcode = input.RightPostcode
- });
+ RightPostcode = input.RightPostcode,
+ MatchDim = input.MatchDim,
+ MatchMode = input.MatchMode,
+ PrefixLen = input.PrefixLen ?? 0
+ };
+ PrepareAreaInfo(entity);
+ await EnsureInfoNotDuplicateAsync(entity, excludeId: null);
+ await KpiAreaRepository.InsertInfoAsync(entity);
}
///
@@ -151,6 +159,11 @@ public class KpiAreaService : ApplicationService
entity.City = input.City;
entity.LeftPostcode = input.LeftPostcode;
entity.RightPostcode = input.RightPostcode;
+ entity.MatchDim = input.MatchDim;
+ entity.MatchMode = input.MatchMode;
+ entity.PrefixLen = input.PrefixLen ?? 0;
+ PrepareAreaInfo(entity);
+ await EnsureInfoNotDuplicateAsync(entity, excludeId: entity.Id);
await KpiAreaRepository.UpdateInfoAsync(entity);
}
@@ -191,6 +204,7 @@ public class KpiAreaService : ApplicationService
}
await GetAreaOrThrowAsync(input.AreaId);
+ var importMode = NormalizeImportMode(input.ImportMode);
await using var stream = input.File.OpenReadStream();
var parsedRows = await KpiAreaInfoImportHelper.ParseImportRowsAsync(stream);
@@ -205,6 +219,19 @@ public class KpiAreaService : ApplicationService
.GroupBy(x => x.CountryCode.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
+ var existingKeys = new HashSet(StringComparer.Ordinal);
+ if (importMode == KpiAreaInfoImportInput.ImportModes.Full)
+ {
+ await KpiAreaRepository.DeleteInfosByAreaIdAsync(input.AreaId);
+ }
+ else
+ {
+ foreach (var item in await KpiAreaRepository.GetAllInfosByAreaIdAsync(input.AreaId))
+ {
+ TryAddDuplicateKey(existingKeys, item);
+ }
+ }
+
var output = new KpiAreaInfoImportOutput();
foreach (var (rowNumber, entity) in parsedRows)
{
@@ -212,6 +239,19 @@ public class KpiAreaService : ApplicationService
{
KpiAreaInfoImportHelper.PrepareEntityForImport(entity, countriesByCode);
entity.AreaId = input.AreaId;
+ if (!TryAddDuplicateKey(existingKeys, entity))
+ {
+ output.SkipCount++;
+ output.Failures.Add(new KpiAreaInfoImportFailRow
+ {
+ RowNumber = rowNumber,
+ Reason = importMode == KpiAreaInfoImportInput.ImportModes.Full
+ ? "文件内规则重复,已跳过"
+ : "与已有规则重复,已跳过"
+ });
+ continue;
+ }
+
await KpiAreaRepository.InsertInfoAsync(entity);
output.SuccessCount++;
}
@@ -229,6 +269,18 @@ public class KpiAreaService : ApplicationService
return output;
}
+ private static string NormalizeImportMode(string? mode)
+ {
+ if (string.Equals(mode, KpiAreaInfoImportInput.ImportModes.Full, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(mode, "全量", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(mode, "replace", StringComparison.OrdinalIgnoreCase))
+ {
+ return KpiAreaInfoImportInput.ImportModes.Full;
+ }
+
+ return KpiAreaInfoImportInput.ImportModes.Incremental;
+ }
+
private static Expression> BuildAreaPredicate(KpiAreaListInput input)
{
var appKey = input.AppKey?.Trim();
@@ -293,8 +345,58 @@ public class KpiAreaService : ApplicationService
};
}
+ private static void PrepareAreaInfo(KpiAreaInfo entity)
+ {
+ try
+ {
+ GeoPartitionMatcher.PrepareEntity(entity);
+ }
+ catch (InvalidOperationException ex)
+ {
+ throw new BusinessException(ex.Message);
+ }
+ }
+
+ private async Task EnsureInfoNotDuplicateAsync(KpiAreaInfo entity, int? excludeId)
+ {
+ var key = GeoPartitionMatcher.BuildDuplicateKey(entity);
+ var existing = await KpiAreaRepository.GetAllInfosByAreaIdAsync(entity.AreaId);
+ foreach (var item in existing)
+ {
+ if (excludeId.HasValue && item.Id == excludeId.Value)
+ {
+ continue;
+ }
+
+ if (TryBuildDuplicateKey(item, out var existingKey)
+ && string.Equals(existingKey, key, StringComparison.Ordinal))
+ {
+ throw new BusinessException("分区明细已存在相同规则");
+ }
+ }
+ }
+
+ private static bool TryAddDuplicateKey(HashSet keys, KpiAreaInfo entity) =>
+ TryBuildDuplicateKey(entity, out var key) && keys.Add(key);
+
+ private static bool TryBuildDuplicateKey(KpiAreaInfo entity, out string key)
+ {
+ try
+ {
+ GeoPartitionMatcher.PrepareEntity(entity);
+ key = GeoPartitionMatcher.BuildDuplicateKey(entity);
+ return true;
+ }
+ catch (InvalidOperationException)
+ {
+ key = string.Empty;
+ return false;
+ }
+ }
+
private static KpiAreaInfoOutput MapInfoOutput(KpiAreaInfo entity)
{
+ var (matchDim, matchMode, prefixLen) = GeoPartitionMatcher.Describe(entity);
return new KpiAreaInfoOutput
{
Id = entity.Id,
@@ -305,8 +407,70 @@ public class KpiAreaService : ApplicationService
City = entity.City,
LeftPostcode = entity.LeftPostcode,
RightPostcode = entity.RightPostcode,
+ MatchDim = matchDim,
+ MatchMode = matchMode,
+ PrefixLen = prefixLen,
CreateTime = entity.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
}
+
+ ///
+ /// 分区包版本列表(不含正文)。
+ ///
+ [HttpGet("/channel/kpi-area/publish/list")]
+ public async Task> GetPublishListAsync([FromQuery] int areaId)
+ {
+ if (areaId <= 0)
+ {
+ throw new Fake.Application.BusinessException("分区表 Id 无效");
+ }
+
+ await GetAreaOrThrowAsync(areaId);
+ var headers = await KpiAreaRepository.GetPublishHeadersByAreaIdAsync(areaId);
+ return headers.Select(MapPublishHeader).ToList();
+ }
+
+ ///
+ /// 单条分区包(含正文)。
+ ///
+ [HttpGet("/channel/kpi-area/publish/get")]
+ public async Task GetPublishRecordAsync([FromQuery] int id)
+ {
+ var entity = await KpiAreaRepository.GetPublishByIdAsync(id)
+ ?? throw new Fake.Application.BusinessException("分区包不存在");
+ return MapPublishDetail(entity);
+ }
+
+ private async Task GetAreaOrThrowAsync(int areaId)
+ {
+ var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId);
+ if (area == null)
+ {
+ throw new Fake.Application.BusinessException("分区表不存在");
+ }
+
+ return area;
+ }
+
+ private static KpiAreaPublishRecordOutput MapPublishHeader(KpiAreaPublishRecord entity) =>
+ new()
+ {
+ Id = entity.Id,
+ AreaId = entity.AreaId,
+ AreaType = entity.AreaType,
+ Version = entity.Version,
+ Status = EnumDisplayNames.Of(entity.Status),
+ ContentHash = entity.ContentHash,
+ PublishUser = entity.PublishUser,
+ PublishTime = entity.PublishTime,
+ Description = entity.Description
+ };
+
+ private static KpiAreaPublishRecordOutput MapPublishDetail(KpiAreaPublishRecord entity)
+ {
+ var output = MapPublishHeader(entity);
+ output.Content = OmsDocHelper.DeserializeJsonObject(entity.Content);
+ return output;
+ }
}
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
index 28ca2c5..3c6d5f9 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
@@ -38,8 +38,28 @@ public partial class SalesProductService
}
var warnings = new List();
- var priceSnapshot = await BuildPricePublishSnapshotAsync(product.Id);
- var rulesSnapshot = await BuildRulesPublishSnapshotAsync(product.Id);
+ // 模拟用当前草稿:LIVE Infos,不强制走已发布包
+ var priceSnapshot = new SalesProductPricePublishSnapshotDto
+ {
+ SalesProductId = product.Id,
+ Prices = (await GetPriceOutputsAsync(product.Id)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
+ };
+ await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync(
+ priceSnapshot,
+ product,
+ KpiAreaRepository,
+ KpiAreaPackService);
+ var rulesSnapshot = new SalesProductRulesPublishSnapshotDto
+ {
+ Product = MapProductToRulesSaveInput(product)
+ };
+ await RulesSnapshotHelper.ApplyLiveRulesToSnapshotAsync(
+ rulesSnapshot,
+ product,
+ KpiAreaRepository,
+ WeightRuleRepository,
+ WeightRoundingRepository,
+ KpiAreaPackService);
var weight = new OrderWeight
{
@@ -165,13 +185,16 @@ public partial class SalesProductService
payableFees = await TrySimulatePayableFeesAsync(
product,
rulesSnapshot,
- zoneKeys,
chargeableWeight,
cargoAttribute,
input.WarehouseId,
warehouseName,
isRemote,
remoteZoneKeys,
+ countryCode,
+ input.Province,
+ input.City,
+ input.ZipCode,
input.DeclaredSum,
input.DeclaredCurrency,
priceVersion,
@@ -261,7 +284,7 @@ public partial class SalesProductService
return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
}
- private async Task IsSimulateRemoteHitAsync(
+ private Task IsSimulateRemoteHitAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto rulesSnapshot,
string countryCode,
@@ -269,29 +292,17 @@ public partial class SalesProductService
string? city,
string? zipCode)
{
- var areaId = product.RemoteAreaId is > 0 ? product.RemoteAreaId : rulesSnapshot.RemoteAreaId;
- if (areaId is not > 0)
- {
- return false;
- }
-
if (rulesSnapshot.RemoteAreaInfos is { Count: > 0 })
{
- return RulesSnapshotHelper.IsAreaHitFromSnapshot(
+ return Task.FromResult(GeoPartitionMatcher.IsHit(
rulesSnapshot.RemoteAreaInfos,
countryCode,
province,
city,
- zipCode);
+ zipCode));
}
- var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return Task.FromResult(false);
}
private async Task ResolveSimulateWarehouseNameAsync(int? warehouseId)
@@ -308,13 +319,16 @@ public partial class SalesProductService
private async Task> TrySimulatePayableFeesAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto rulesSnapshot,
- IReadOnlyList zoneKeys,
decimal chargeableWeight,
string cargoAttribute,
int? warehouseId,
string? warehouseName,
- bool isRemote,
- IReadOnlyList remoteZoneKeys,
+ bool productIsRemote,
+ IReadOnlyList productRemoteZoneKeys,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode,
decimal? declaredSum,
string? declaredCurrency,
string priceVersion,
@@ -336,7 +350,7 @@ public partial class SalesProductService
WarehouseId = warehouseId,
DeclaredSum = declaredSum,
DeclaredCurrency = declaredCurrency,
- IsRemote = isRemote ? (byte)1 : (byte)0
+ IsRemote = productIsRemote ? (byte)1 : (byte)0
})).Passed;
var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0);
if (provider?.ServiceChannelId is not > 0)
@@ -345,33 +359,81 @@ public partial class SalesProductService
return [];
}
- var channelPrices = await BuildChannelPriceOutputsAsync(provider.ServiceChannelId.Value);
- if (channelPrices.Count == 0)
+ var channelId = provider.ServiceChannelId.Value;
+ var channelPriceSnapshot = new ServiceChannelPricePublishSnapshotDto
{
- warnings.Add($"服务渠道 {provider.ServiceChannelId} 无买价,跳过应付估算");
+ ServiceChannelId = channelId,
+ Prices = (await BuildChannelPriceOutputsAsync(channelId))
+ .Select(PublishSnapshotSlimmer.SlimChannelPrice)
+ .ToList()
+ };
+ if (channelPriceSnapshot.Prices.Count == 0)
+ {
+ warnings.Add($"服务渠道 {channelId} 无买价,跳过应付估算");
return [];
}
- var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == provider.ServiceChannelId.Value);
- var payableRemoteZoneKeys = isRemote
- ? ParcelBillingCalculator.BuildRemoteZoneKeys(
- channel?.RemoteAreaId is > 0 ? channel.RemoteAreaId : null)
- : Array.Empty();
- // 渠道未配偏远分区时回落产品偏远键,便于模拟偏远费行
- if (isRemote && payableRemoteZoneKeys.Count == 0)
+ await PriceAreaSnapshotHelper.ApplyLiveChannelPriceAreaInfosAsync(
+ channelPriceSnapshot,
+ KpiAreaRepository,
+ KpiAreaPackService);
+ var channelPrices = channelPriceSnapshot.Prices
+ .Select(PublishSnapshotSlimmer.ToChannelPriceListOutput)
+ .ToList();
+
+ IReadOnlyList payableZoneKeys = Array.Empty();
+ if (channelPriceSnapshot.PriceAreaInfos is { Count: > 0 })
{
- payableRemoteZoneKeys = remoteZoneKeys;
+ var resolved = ParcelBillingCalculator.TryResolveZoneKeysFromChannelPriceSnapshot(
+ channelPriceSnapshot,
+ countryCode,
+ province,
+ city,
+ zipCode);
+ if (resolved == null || resolved.Count == 0)
+ {
+ warnings.Add($"服务渠道 {channelId} 买价分区未匹配,跳过应付估算");
+ return [];
+ }
+
+ payableZoneKeys = resolved;
+ }
+
+ var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId);
+ var channelRemoteAreaId = channel?.RemoteAreaId;
+ var channelIsRemote = productIsRemote;
+ if (channelRemoteAreaId is > 0)
+ {
+ var channelRules = new ServiceChannelRulesPublishSnapshotDto();
+ await KpiAreaPackService.LoadLiveRemoteIntoChannelRulesSnapshotAsync(
+ channelRules,
+ channelRemoteAreaId);
+ channelIsRemote = GeoPartitionMatcher.IsHit(
+ channelRules.RemoteAreaInfos,
+ countryCode,
+ province,
+ city,
+ zipCode);
+ }
+
+ var payableRemoteZoneKeys = channelIsRemote
+ ? ParcelBillingCalculator.BuildRemoteZoneKeys(
+ channelRemoteAreaId is > 0 ? channelRemoteAreaId : null)
+ : Array.Empty();
+ if (channelIsRemote && payableRemoteZoneKeys.Count == 0)
+ {
+ payableRemoteZoneKeys = productRemoteZoneKeys;
}
var payableContext = OrderRuleEvaluationContext.FromOrderOnly(new Order
{
ProductCode = product.ProductCode,
- ConsigneeCountry = null,
+ ConsigneeCountry = countryCode,
CargoAttribute = cargoAttribute,
WarehouseId = warehouseId,
DeclaredSum = declaredSum,
DeclaredCurrency = declaredCurrency,
- IsRemote = isRemote ? (byte)1 : (byte)0
+ IsRemote = channelIsRemote ? (byte)1 : (byte)0
});
var payable = ParcelBillingCalculator.CalculatePayableFees(
@@ -379,7 +441,7 @@ public partial class SalesProductService
priceVersion,
priceEffectiveTime,
chargeableWeight,
- zoneKeys,
+ payableZoneKeys,
cargoAttribute,
OrderFeeChargingStageCodes.Forecast,
warehouseId,
@@ -391,11 +453,11 @@ public partial class SalesProductService
if (payable.Count == 0)
{
- warnings.Add($"服务渠道 {provider.ServiceChannelId} 买价未匹配,跳过应付估算");
+ warnings.Add($"服务渠道 {channelId} 买价未匹配,跳过应付估算");
return [];
}
- warnings.Add($"应付按排货服务渠道 {provider.ServiceChannelId} 估算(首个可用渠道)");
+ warnings.Add($"应付按排货服务渠道 {channelId} 估算(首个可用渠道,渠道价区)");
return payable.Select(x => new SalesProductBillingSimulateFeeOutput
{
CostItem = x.CostItem,
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
index 344f1ab..d7bc155 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
@@ -11,6 +11,29 @@ namespace Oms.Application.Services.Channels;
public partial class SalesProductService
{
+ ///
+ /// 规范化并校验产品代码格式。
+ ///
+ private static string NormalizeProductCode(string? productCode)
+ {
+ var code = (productCode ?? string.Empty).Trim().ToUpperInvariant();
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ throw new Fake.Application.BusinessException("产品代码不能为空");
+ }
+
+ for (var i = 0; i < code.Length; i++)
+ {
+ var ch = code[i];
+ if (ch is < 'A' or > 'Z' && ch is < '0' or > '9' && ch != '-')
+ {
+ throw new Fake.Application.BusinessException("产品代码仅支持大写字母、数字和-");
+ }
+ }
+
+ return code;
+ }
+
///
/// 校验产品编码唯一性。
///
@@ -23,6 +46,21 @@ public partial class SalesProductService
}
}
+ ///
+ /// 更新时若目标编码被软删记录占用,改名释放 uk_sp_code。
+ ///
+ private async Task FreeSoftDeletedProductCodeIfNeededAsync(string productCode, int currentId)
+ {
+ var conflict = await SalesProductRepository.GetByProductCodeIncludingDeletedAsync(productCode);
+ if (conflict == null || !conflict.IsDeleted || conflict.Id == currentId)
+ {
+ return;
+ }
+
+ conflict.ProductCode = $"{productCode}__deleted_{conflict.Id}";
+ await SalesProductRepository.UpdateAsync(conflict);
+ }
+
///
/// 获取销售产品,不存在时直接抛异常。
///
@@ -63,17 +101,20 @@ public partial class SalesProductService
///
/// 一次性装配价格项、因子和明细,避免接口层 N+1 查询。
+ /// includeDetails=false 时跳过明细(列表页首屏),显著减小载荷。
///
- private async Task> GetPriceOutputsAsync(int salesProductId)
+ private async Task> GetPriceOutputsAsync(int salesProductId, bool includeDetails = true)
{
var prices = await SalesProductRepository.GetPricesAsync(salesProductId);
var priceIds = prices.Select(x => x.Id).ToArray();
var factorMap = (await SalesProductRepository.GetPriceFactorsAsync(priceIds))
.GroupBy(x => x.PriceId)
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList());
- var detailMap = (await SalesProductRepository.GetPriceDetailsAsync(priceIds))
- .GroupBy(x => x.PriceId)
- .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
+ var detailMap = includeDetails
+ ? (await SalesProductRepository.GetPriceDetailsAsync(priceIds))
+ .GroupBy(x => x.PriceId)
+ .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList())
+ : new Dictionary>();
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
prices.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId }));
@@ -90,7 +131,9 @@ public partial class SalesProductService
Currency = price.Currency,
ChargingCondition = price.ChargingCondition,
Factors = factorMap.TryGetValue(price.Id, out var factors) ? factors.Select(MapPriceFactorOutput).ToList() : [],
- Details = detailMap.TryGetValue(price.Id, out var details) ? details.Select(MapPriceDetailOutput).ToList() : [],
+ Details = includeDetails && detailMap.TryGetValue(price.Id, out var details)
+ ? details.Select(MapPriceDetailOutput).ToList()
+ : [],
CreateTime = price.CreateTime,
CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(price.CreateUserId, auditUserNameMap),
UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime),
@@ -98,6 +141,38 @@ public partial class SalesProductService
}).ToList();
}
+ ///
+ /// 单条价格项(含因子+明细),供编辑弹窗按需加载。
+ ///
+ private async Task GetPriceOutputByIdAsync(int priceId)
+ {
+ var price = await GetPriceOrThrowAsync(priceId);
+ var priceIds = new[] { price.Id };
+ var factors = await SalesProductRepository.GetPriceFactorsAsync(priceIds);
+ var details = await SalesProductRepository.GetPriceDetailsAsync(priceIds);
+ var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
+ [price.CreateUserId, price.UpdateUserId]);
+
+ return new SalesProductPriceListOutput
+ {
+ Id = price.Id,
+ SalesProductId = price.SalesProductId,
+ SerialNumber = price.SerialNumber,
+ Status = (byte)price.Status,
+ PriceName = price.PriceName,
+ CostItem = price.CostItem,
+ CostUnit = price.CostUnit,
+ Currency = price.Currency,
+ ChargingCondition = price.ChargingCondition,
+ Factors = factors.OrderBy(item => item.SerialNumber).Select(MapPriceFactorOutput).ToList(),
+ Details = details.OrderBy(item => item.Id).Select(MapPriceDetailOutput).ToList(),
+ CreateTime = price.CreateTime,
+ CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(price.CreateUserId, auditUserNameMap),
+ UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime),
+ UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(price.UpdateUserId, auditUserNameMap)
+ };
+ }
+
///
/// 获取排货方案,并补充服务渠道名称。
///
@@ -138,32 +213,65 @@ public partial class SalesProductService
private async Task> GetPublishListAsync(SalesProductPublishListInput input, string operatorType)
{
await GetProductOrThrowAsync(input.SalesProductId);
+ // 版本列表不传正文时一次返回全部版本头,页面没有分页。
+ if (!input.IncludeContent)
+ {
+ var headers = await SalesProductRepository.GetAllPublishRecordHeadersAsync(input.SalesProductId, operatorType);
+ return new OmsPageResult
+ {
+ Items = headers.Select(entity => MapPublishRecordOutput(entity, includeContent: false)).ToList(),
+ Total = headers.Count
+ };
+ }
+
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await SalesProductRepository.GetPublishRecordsAsync(input.SalesProductId, operatorType, pageIndex, pageSize);
var total = await SalesProductRepository.CountPublishRecordsAsync(input.SalesProductId, operatorType);
return new OmsPageResult
{
- Items = entities.Select(entity => new SalesProductPublishRecordOutput
- {
- Id = entity.Id,
- SalesProductId = entity.SalesProductId,
- Version = entity.Version,
- OperatorType = entity.OperatorType,
- Status = EnumDisplayNames.Of(entity.Status),
- PublishUser = entity.PublishUser,
- PublishTime = entity.PublishTime,
- Description = entity.Description,
- AiAnalysisReport = entity.AiAnalysisReport,
- Content = operatorType == PriceOperatorType
- ? OmsDocHelper.ParseSalesProductPricePublishContentForApi(entity.Content)
- : OmsDocHelper.ToSnapshotApiContent(OmsDocHelper.DeserializeRulesSnapshot(entity.Content)),
- CreateTime = entity.CreateTime
- }).ToList(),
+ Items = entities.Select(entity => MapPublishRecordOutput(entity, includeContent: true)).ToList(),
Total = total
};
}
+ private SalesProductPublishRecordOutput MapPublishRecordOutput(SalesProductPublishRecord entity, bool includeContent)
+ {
+ var operatorType = entity.OperatorType;
+ return new SalesProductPublishRecordOutput
+ {
+ Id = entity.Id,
+ SalesProductId = entity.SalesProductId,
+ Version = entity.Version,
+ OperatorType = operatorType,
+ Status = EnumDisplayNames.Of(entity.Status),
+ PublishUser = entity.PublishUser,
+ PublishTime = entity.PublishTime,
+ Description = entity.Description,
+ AiAnalysisReport = includeContent ? entity.AiAnalysisReport : null,
+ Content = !includeContent
+ ? null
+ : string.Equals(operatorType, PriceOperatorType, StringComparison.OrdinalIgnoreCase)
+ ? OmsDocHelper.ParseSalesProductPricePublishContentForApi(entity.Content)
+ : OmsDocHelper.ToSnapshotApiContent(OmsDocHelper.DeserializeRulesSnapshot(entity.Content)),
+ CreateTime = entity.CreateTime
+ };
+ }
+
+ ///
+ /// 单条发布记录(含快照正文),供版本详情按需加载。
+ ///
+ private async Task GetPublishRecordCoreAsync(int id)
+ {
+ var entity = await SalesProductRepository.GetPublishRecordByIdAsync(id);
+ if (entity == null)
+ {
+ throw new Fake.Application.BusinessException("发布记录不存在");
+ }
+
+ return MapPublishRecordOutput(entity, includeContent: true);
+ }
+
///
/// 组装规则发布快照(不落库),含黑名单/偏远/计重/进位明细。
///
@@ -182,13 +290,38 @@ public partial class SalesProductService
product,
KpiAreaRepository,
WeightRuleRepository,
- WeightRoundingRepository);
+ WeightRoundingRepository,
+ KpiAreaPackService,
+ ResolvePublishUser());
await AllocationProviderSnapshotEnricher.EnrichAsync(rulesSnapshot.Providers);
return rulesSnapshot;
}
///
- /// 组装价格发布快照(不落库)。
+ /// 同步态规则快照:分区不 Ensure,其余与发布组装一致。
+ ///
+ private async Task BuildRulesPublishSnapshotForSyncAsync(int salesProductId)
+ {
+ var product = await GetProductOrThrowAsync(salesProductId);
+ var rulesSnapshot = new SalesProductRulesPublishSnapshot
+ {
+ Product = MapProductToRulesSaveInput(product),
+ Providers = (await GetAllocationCoreAsync(salesProductId)).Providers
+ .Select(PublishSnapshotSlimmer.SlimAllocationProvider)
+ .ToList()
+ };
+ await RulesSnapshotHelper.ApplyRulesToSnapshotForSyncAsync(
+ rulesSnapshot,
+ product,
+ WeightRuleRepository,
+ WeightRoundingRepository,
+ KpiAreaPackService);
+ await AllocationProviderSnapshotEnricher.EnrichAsync(rulesSnapshot.Providers);
+ return rulesSnapshot;
+ }
+
+ ///
+ /// 组装价格发布快照(不落库);价区钉分区包 Refs。
///
private async Task BuildPricePublishSnapshotAsync(int salesProductId)
{
@@ -198,10 +331,39 @@ public partial class SalesProductService
SalesProductId = salesProductId,
Prices = (await GetPriceOutputsAsync(salesProductId)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
};
- await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(priceSnapshot, product, KpiAreaRepository);
+ await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(
+ priceSnapshot,
+ product,
+ KpiAreaRepository,
+ KpiAreaPackService,
+ ResolvePublishUser());
return priceSnapshot;
}
+ ///
+ /// 同步态待发布快照:价区只解析哈希/已有包,不 Ensure 写库。
+ ///
+ private async Task BuildPricePublishSnapshotForSyncAsync(int salesProductId)
+ {
+ var product = await GetProductOrThrowAsync(salesProductId);
+ var priceSnapshot = new SalesProductPricePublishSnapshot
+ {
+ SalesProductId = salesProductId,
+ Prices = (await GetPriceOutputsAsync(salesProductId)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
+ };
+ await PriceAreaSnapshotHelper.ApplyPriceAreaRefsForSyncAsync(
+ priceSnapshot,
+ product,
+ KpiAreaRepository,
+ KpiAreaPackService);
+ return priceSnapshot;
+ }
+
+ private string ResolvePublishUser() =>
+ string.IsNullOrWhiteSpace(CurrentUser.UserName)
+ ? CurrentUser.GetId().ToString()
+ : CurrentUser.UserName!;
+
private static SalesProductSaveInput MapProductToRulesSaveInput(SalesProduct product) =>
new()
{
@@ -244,7 +406,6 @@ public partial class SalesProductService
var priceSnapshot = await BuildPricePublishSnapshotAsync(salesProductId);
ValidateBillingPriceItemRules(
priceSnapshot.Prices.Select(p => MapPriceSaveInput(p, salesProductId)).ToList());
- await PriceAreaSnapshotHelper.EnsureSingleResolvedPriceAreaAsync(priceSnapshot, KpiAreaRepository);
snapshot = priceSnapshot;
}
else
@@ -252,6 +413,11 @@ public partial class SalesProductService
snapshot = await BuildRulesPublishSnapshotAsync(salesProductId);
}
+ // Content 存 canonical 形态,与 sync-status 对比口径一致
+ var content = operatorType == PriceOperatorType
+ ? OmsDocHelper.CanonicalizeSalesProductPriceSnapshotJson(snapshot)
+ : OmsDocHelper.CanonicalizeSalesProductRulesSnapshotJson(snapshot);
+
// Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。
await SalesProductRepository.InsertPublishRecordAsync(new SalesProductPublishRecord
{
@@ -263,7 +429,7 @@ public partial class SalesProductService
PublishTime = DateTime.Now,
Description = description,
AiAnalysisReport = string.IsNullOrWhiteSpace(aiAnalysisReport) ? null : aiAnalysisReport.Trim(),
- Content = OmsDocHelper.SerializeSnapshotJson(snapshot)
+ Content = content
});
}
@@ -329,9 +495,12 @@ public partial class SalesProductService
///
/// 将保存入参映射到销售产品实体。
///
- private static void ApplyProductInput(SalesProduct entity, SalesProductSaveInput input)
+ private static void ApplyProductInput(SalesProduct entity, SalesProductSaveInput input, bool updateProductCode = true)
{
- entity.ProductCode = input.ProductCode;
+ if (updateProductCode)
+ {
+ entity.ProductCode = NormalizeProductCode(input.ProductCode);
+ }
entity.ProductName = input.ProductName;
entity.ProductGroup = input.ProductGroup;
entity.MinTimeliness = input.MinTimeliness;
@@ -388,10 +557,11 @@ public partial class SalesProductService
};
///
- /// 保险/偏远费等附加费用项的计费条件校验。
+ /// 保险/偏远费等附加费用项的计费条件校验;同一提交批次内费用项不可重复。
///
private static void ValidateBillingPriceItemRules(IReadOnlyList prices)
{
+ EnsureUniqueCostItemsInBatch(prices.Select(x => x.CostItem));
foreach (var price in prices)
{
var costItem = price.CostItem?.Trim() ?? string.Empty;
@@ -417,6 +587,44 @@ public partial class SalesProductService
}
}
+ ///
+ /// 同一销售产品下每种费用项只能有一条卖价配置。
+ ///
+ private async Task EnsureCostItemUniqueAsync(int salesProductId, string? costItem, int? excludePriceId)
+ {
+ var trimmed = costItem?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(trimmed))
+ {
+ throw new Fake.Application.BusinessException("费用项不能为空");
+ }
+
+ var existing = await SalesProductRepository.GetPricesAsync(salesProductId);
+ var duplicated = existing.Any(p =>
+ (!excludePriceId.HasValue || p.Id != excludePriceId.Value)
+ && string.Equals(p.CostItem?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase));
+ if (duplicated)
+ {
+ throw new Fake.Application.BusinessException(
+ $"该产品已存在费用项「{trimmed}」,同一产品下每种费用项只能有一条");
+ }
+ }
+
+ private static void EnsureUniqueCostItemsInBatch(IEnumerable costItems)
+ {
+ var duplicates = costItems
+ .Select(x => x?.Trim() ?? string.Empty)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .GroupBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .Where(g => g.Count() > 1)
+ .Select(g => g.Key)
+ .ToList();
+ if (duplicates.Count > 0)
+ {
+ throw new Fake.Application.BusinessException(
+ $"同一产品下费用项不能重复:{string.Join("、", duplicates)}");
+ }
+ }
+
private static string? NormalizeAdditionalServices(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
@@ -439,7 +647,9 @@ public partial class SalesProductService
return normalized.Count == 0 ? null : OmsDocHelper.SerializeJson(normalized);
}
- private static SalesProductListOutput MapListOutput(SalesProduct entity)
+ private static SalesProductListOutput MapListOutput(
+ SalesProduct entity,
+ IReadOnlyDictionary auditUserNameMap)
{
return new SalesProductListOutput
{
@@ -462,7 +672,9 @@ public partial class SalesProductService
IsActive = (byte)entity.IsActive,
Scheme = entity.Scheme,
CreateTime = entity.CreateTime,
- UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
+ CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.CreateUserId, auditUserNameMap),
+ UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime),
+ UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.UpdateUserId, auditUserNameMap)
};
}
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs b/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs
index 12c0ed9..5599706 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Channels;
+using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.Shared.Enums;
using Oms.Domain.SystemAggregate.Repositories;
@@ -9,8 +10,22 @@ namespace Oms.Application.Services.Channels;
public partial class SalesProductService
{
private const string SalesProductGroupDictType = "sales_product_group";
+ private const string CargoAttributeDictType = "cargo_attribute";
private const string UngroupedProductGroupCode = "_ungrouped";
private const string DomesticPickupGroupNameKeyword = "国内揽收";
+ private const string ExpressProductGroupCode = "express";
+ private const int PortalCoverageCountryLimit = 40;
+
+ private static readonly HashSet PortalEuCountryCodes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT",
+ "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "UK"
+ };
+
+ private static readonly HashSet PortalSeaCountryCodes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "MY", "SG", "PH", "TH", "VN", "ID"
+ };
private IDictRepository DictRepository =>
LazyServiceProvider.GetRequiredService();
@@ -24,6 +39,7 @@ public partial class SalesProductService
{
var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled);
var dictRows = await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType);
+ var cargoDictRows = await DictRepository.GetListAsync(x => x.DictType == CargoAttributeDictType);
var domesticPickupGroupCodes = dictRows
.Where(x => !string.IsNullOrWhiteSpace(x.DictName)
@@ -59,6 +75,26 @@ public partial class SalesProductService
.ThenBy(p => p.Id)
.ToList();
+ var areaIds = filtered
+ .Select(p => p.PriceAreaId)
+ .Where(id => id is > 0)
+ .Select(id => id!.Value)
+ .Distinct()
+ .ToList();
+
+ var countriesByAreaId = new Dictionary>();
+ foreach (var areaId in areaIds)
+ {
+ var infos = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId);
+ var codes = infos
+ .Select(x => (x.CountryCode ?? string.Empty).Trim().ToUpperInvariant())
+ .Where(code => code.Length == 2)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(code => code, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ countriesByAreaId[areaId] = codes;
+ }
+
var usedGroupCodes = new HashSet(StringComparer.OrdinalIgnoreCase);
var items = filtered.Select(p =>
{
@@ -67,6 +103,11 @@ public partial class SalesProductService
: p.ProductGroup.Trim();
usedGroupCodes.Add(groupCode);
groupNameMap.TryGetValue(groupCode, out var groupName);
+
+ var coverageCountries = ResolvePortalCoverageCountries(p, countriesByAreaId);
+ var destRegions = ResolvePortalDestRegions(p, groupCode, coverageCountries);
+ var cargoTags = ResolvePortalCargoTags(p);
+
return new SalesProductPortalItemOutput
{
Id = p.Id,
@@ -77,7 +118,10 @@ public partial class SalesProductService
MaxTimeliness = p.MaxTimeliness,
TradeTerms = string.IsNullOrWhiteSpace(p.TradeTerms) ? null : p.TradeTerms.Trim().ToUpperInvariant(),
RouteSort = p.RouteSort,
- ProductDescription = p.ProductDescription
+ ProductDescription = p.ProductDescription,
+ DestRegions = destRegions,
+ CoverageCountries = coverageCountries.Take(PortalCoverageCountryLimit).ToList(),
+ CargoTags = cargoTags
};
}).ToList();
@@ -121,10 +165,156 @@ public partial class SalesProductService
});
}
+ var cargos = cargoDictRows
+ .Where(x => !string.IsNullOrWhiteSpace(x.DictCode))
+ .GroupBy(x => x.DictCode!.Trim(), StringComparer.OrdinalIgnoreCase)
+ .Select(g => g.OrderBy(x => x.DictSort).ThenBy(x => x.DictCode).First())
+ .OrderBy(x => x.DictSort)
+ .ThenBy(x => x.DictCode)
+ .Select(x => new SalesProductPortalCargoOutput
+ {
+ Code = x.DictCode!.Trim(),
+ Name = string.IsNullOrWhiteSpace(x.DictName) ? x.DictCode!.Trim() : x.DictName.Trim()
+ })
+ .ToList();
+
return new SalesProductPortalCatalogOutput
{
Groups = groups,
+ Cargos = cargos,
Items = items
};
}
+
+ private static List ResolvePortalCoverageCountries(
+ SalesProduct product,
+ IReadOnlyDictionary> countriesByAreaId)
+ {
+ if (product.PriceAreaId is not > 0)
+ {
+ return [];
+ }
+
+ return countriesByAreaId.TryGetValue(product.PriceAreaId.Value, out var codes)
+ ? codes
+ : [];
+ }
+
+ private static List ResolvePortalDestRegions(
+ SalesProduct product,
+ string groupCode,
+ IReadOnlyCollection coverageCountries)
+ {
+ var regions = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var code in coverageCountries)
+ {
+ if (string.Equals(code, "US", StringComparison.OrdinalIgnoreCase))
+ {
+ regions.Add("us");
+ continue;
+ }
+
+ if (string.Equals(code, "CA", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(code, "MX", StringComparison.OrdinalIgnoreCase))
+ {
+ regions.Add("ca_mx");
+ continue;
+ }
+
+ if (string.Equals(code, "AU", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(code, "NZ", StringComparison.OrdinalIgnoreCase))
+ {
+ regions.Add("au");
+ continue;
+ }
+
+ if (PortalSeaCountryCodes.Contains(code))
+ {
+ regions.Add("sea");
+ continue;
+ }
+
+ if (PortalEuCountryCodes.Contains(code))
+ {
+ regions.Add("eu");
+ }
+ }
+
+ // 国际快递:价格分区跨多区,或分区为空时仍作为全球快递备选
+ var isExpress = string.Equals(groupCode, ExpressProductGroupCode, StringComparison.OrdinalIgnoreCase)
+ || ContainsAnyIgnoreCase(product.ProductName, "DHL", "UPS", "FedEx", "FEDEX", "3PE");
+ if (isExpress && (regions.Count >= 2 || coverageCountries.Count == 0 || coverageCountries.Count >= 12))
+ {
+ regions.Add("global");
+ }
+
+ // 无价格分区时,用产品名做轻量兜底(与官网前端关键词对齐)
+ if (regions.Count == 0)
+ {
+ var blob = $"{product.ProductName} {product.ProductDescription}";
+ if (ContainsAnyIgnoreCase(blob, "美国", "USA", "United States", "美东", "美西")) regions.Add("us");
+ if (ContainsAnyIgnoreCase(blob, "东南", "越南", "马来", "新加坡", "泰国", "菲律宾", "印尼")) regions.Add("sea");
+ if (ContainsAnyIgnoreCase(blob, "欧盟", "欧洲", "英国", "德国", "法国")) regions.Add("eu");
+ if (ContainsAnyIgnoreCase(blob, "加拿大", "墨西哥")) regions.Add("ca_mx");
+ if (ContainsAnyIgnoreCase(blob, "澳洲", "澳大利亚", "新西兰")) regions.Add("au");
+ if (isExpress) regions.Add("global");
+ }
+
+ return regions
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ ///
+ /// 将产品说明映射到字典 cargo_attribute 编码(general / electronics / sensitive)。
+ /// 销售产品本身暂无货型字段,官网先按说明关键词归类,便于筛选项与字典一致。
+ ///
+ private static List ResolvePortalCargoTags(SalesProduct product)
+ {
+ var blob = $"{product.ProductName} {product.ProductDescription}";
+ var tags = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ if (ContainsAnyIgnoreCase(blob, "带电", "电池", "锂电", "3C", "lithium", "battery", "electronics"))
+ {
+ tags.Add("electronics");
+ }
+
+ if (ContainsAnyIgnoreCase(
+ blob,
+ "化妆品",
+ "cosmetic",
+ "敏感",
+ "特殊",
+ "疑难",
+ "超长",
+ "超重",
+ "超大",
+ "oversize",
+ "overweight",
+ "special",
+ "sensitive"))
+ {
+ tags.Add("sensitive");
+ }
+
+ if (ContainsAnyIgnoreCase(blob, "普货", "general cargo", "general"))
+ {
+ tags.Add("general");
+ }
+
+ return tags
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private static bool ContainsAnyIgnoreCase(string? text, params string[] needles)
+ {
+ if (string.IsNullOrWhiteSpace(text) || needles.Length == 0)
+ {
+ return false;
+ }
+
+ return needles.Any(n => text.Contains(n, StringComparison.OrdinalIgnoreCase));
+ }
}
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs
index 3b86f15..bba0e20 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.cs
@@ -23,6 +23,7 @@ public partial class SalesProductService : ApplicationService
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService();
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService();
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService();
+ private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
@@ -45,9 +46,11 @@ public partial class SalesProductService : ApplicationService
pageSize,
new Dictionary { [nameof(SalesProduct.UpdateTime)] = false, [nameof(SalesProduct.Id)] = false });
var total = await SalesProductRepository.CountAsync(predicate);
+ var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
+ entities.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId }));
return new OmsPageResult
{
- Items = entities.Select(MapListOutput).ToList(),
+ Items = entities.Select(entity => MapListOutput(entity, auditUserNameMap)).ToList(),
Total = total
};
}
@@ -74,7 +77,17 @@ public partial class SalesProductService : ApplicationService
[HttpPost("/channel/sales-product/create")]
public async Task CreateAsync(SalesProductSaveInput input)
{
- await ValidateProductCodeAsync(input.ProductCode, null);
+ var code = NormalizeProductCode(input.ProductCode);
+ await ValidateProductCodeAsync(code, null);
+ var deleted = await SalesProductRepository.GetByProductCodeIncludingDeletedAsync(code);
+ if (deleted != null && deleted.IsDeleted)
+ {
+ deleted.IsDeleted = false;
+ ApplyProductInput(deleted, input);
+ await SalesProductRepository.UpdateAsync(deleted);
+ return;
+ }
+
var entity = new SalesProduct();
ApplyProductInput(entity, input);
await SalesProductRepository.InsertAsync(entity);
@@ -89,8 +102,28 @@ public partial class SalesProductService : ApplicationService
public async Task UpdateAsync(SalesProductSaveInput input)
{
var entity = await GetProductOrThrowAsync(input.Id);
- await ValidateProductCodeAsync(input.ProductCode, entity.Id);
- ApplyProductInput(entity, input);
+ ApplyProductInput(entity, input, updateProductCode: false);
+ await SalesProductRepository.UpdateAsync(entity);
+ }
+
+ ///
+ /// 单独修改销售产品代码。
+ ///
+ /// 入参:产品主键与新代码。
+ /// 无返回体,仅完成产品代码更新。
+ [HttpPost("/channel/sales-product/product-code/update")]
+ public async Task UpdateProductCodeAsync(SalesProductUpdateCodeInput input)
+ {
+ var entity = await GetProductOrThrowAsync(input.Id);
+ var code = NormalizeProductCode(input.ProductCode);
+ if (string.Equals(entity.ProductCode, code, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ await ValidateProductCodeAsync(code, entity.Id);
+ await FreeSoftDeletedProductCodeIfNeededAsync(code, entity.Id);
+ entity.ProductCode = code;
await SalesProductRepository.UpdateAsync(entity);
}
@@ -116,8 +149,7 @@ public partial class SalesProductService : ApplicationService
public async Task SaveConfigAsync(SalesProductSaveInput input)
{
var entity = await GetProductOrThrowAsync(input.Id);
- await ValidateProductCodeAsync(input.ProductCode, entity.Id);
- ApplyProductInput(entity, input);
+ ApplyProductInput(entity, input, updateProductCode: false);
await SalesProductRepository.UpdateAsync(entity);
}
@@ -138,17 +170,29 @@ public partial class SalesProductService : ApplicationService
/// 查询销售产品价格项列表。
///
/// 入参:销售产品主键。
+ /// 是否返回价格明细矩阵;列表页建议 false。
/// 销售产品价格项列表结果。
[HttpGet("/channel/sales-product/price/list")]
- public async Task> GetPriceListAsync([FromQuery] int salesProductId)
+ public async Task> GetPriceListAsync(
+ [FromQuery] int salesProductId,
+ [FromQuery] bool includeDetails = true)
{
await GetProductOrThrowAsync(salesProductId);
return new OmsItemsResult
{
- Items = await GetPriceOutputsAsync(salesProductId)
+ Items = await GetPriceOutputsAsync(salesProductId, includeDetails)
};
}
+ ///
+ /// 获取单条销售产品价格项(含计费因子与价格明细)。
+ ///
+ [HttpGet("/channel/sales-product/price/get")]
+ public async Task GetPriceAsync([FromQuery] int id)
+ {
+ return await GetPriceOutputByIdAsync(id);
+ }
+
///
/// 新增销售产品价格项。
///
@@ -159,6 +203,7 @@ public partial class SalesProductService : ApplicationService
{
await GetProductOrThrowAsync(input.SalesProductId);
ValidateBillingPriceItemRules([input]);
+ await EnsureCostItemUniqueAsync(input.SalesProductId, input.CostItem, excludePriceId: null);
await SalesProductRepository.InsertPriceAsync(
new SalesProductPrice
{
@@ -190,6 +235,7 @@ public partial class SalesProductService : ApplicationService
}
ValidateBillingPriceItemRules([input]);
+ await EnsureCostItemUniqueAsync(input.SalesProductId, input.CostItem, excludePriceId: price.Id);
price.SerialNumber = input.SerialNumber;
price.Status = input.Status;
price.PriceName = input.PriceName;
@@ -237,9 +283,6 @@ public partial class SalesProductService : ApplicationService
{
await GetProductOrThrowAsync(input.SalesProductId);
ValidateBillingPriceItemRules(input.Prices);
- await PriceAreaSnapshotHelper.EnsureSingleResolvedPriceAreaAsync(
- BuildPriceSnapshotFromSaveInputs(input.SalesProductId, input.Prices),
- KpiAreaRepository);
var prices = new List();
var factors = new List();
var details = new List();
@@ -344,6 +387,15 @@ public partial class SalesProductService : ApplicationService
return GetPublishListAsync(input, RulesOperatorType);
}
+ ///
+ /// 查询单条发布记录,含快照正文。
+ ///
+ [HttpGet("/channel/sales-product/publish/get")]
+ public Task GetPublishRecordAsync([FromQuery] int id)
+ {
+ return GetPublishRecordCoreAsync(id);
+ }
+
///
/// 预览即将发布的快照 JSON(不落库),用于核对黑名单/计重等是否已固化进 Content。
///
@@ -357,6 +409,67 @@ public partial class SalesProductService : ApplicationService
return OmsDocHelper.ToSnapshotApiContent(snapshot)!;
}
+ ///
+ /// 发布同步态:当前配置 vs 最新生效发布 Content。前端只展示 pending,不算差异。
+ ///
+ [HttpGet("/channel/sales-product/publish/sync-status")]
+ public async Task GetPublishSyncStatusAsync(
+ [FromQuery] int salesProductId,
+ [FromQuery] string operatorType)
+ {
+ var normalized = NormalizeOperatorType(operatorType);
+ await GetProductOrThrowAsync(salesProductId);
+
+ // 当前 = 与发布同源的快照组装(分区只解析哈希,不 Ensure 写库)
+ object currentSnapshot = normalized == PriceOperatorType
+ ? await BuildPricePublishSnapshotForSyncAsync(salesProductId)
+ : await BuildRulesPublishSnapshotForSyncAsync(salesProductId);
+ var currentHash = ComputeContentHash(
+ normalized == PriceOperatorType
+ ? OmsDocHelper.HashSalesProductPriceSnapshotForSync(currentSnapshot)
+ : OmsDocHelper.HashSalesProductRulesSnapshotForSync(currentSnapshot));
+
+ // 生效 = 最新 Active 发布记录 Content
+ var onlineRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(
+ salesProductId,
+ normalized,
+ DateTime.Now);
+ if (onlineRecord == null || string.IsNullOrWhiteSpace(onlineRecord.Content))
+ {
+ return new SalesProductPublishSyncStatusOutput
+ {
+ Pending = true,
+ OnlineVersion = null,
+ OnlinePublishTime = null,
+ PendingContentHash = currentHash,
+ OnlineContentHash = null
+ };
+ }
+
+ var onlineHash = ComputeContentHash(
+ normalized == PriceOperatorType
+ ? OmsDocHelper.HashSalesProductPriceSnapshotForSync(
+ OmsDocHelper.DeserializePriceSnapshot(onlineRecord.Content))
+ : OmsDocHelper.HashSalesProductRulesSnapshotForSync(
+ OmsDocHelper.DeserializeRulesSnapshot(onlineRecord.Content)));
+
+ return new SalesProductPublishSyncStatusOutput
+ {
+ Pending = !string.Equals(currentHash, onlineHash, StringComparison.Ordinal),
+ OnlineVersion = onlineRecord.Version,
+ OnlinePublishTime = onlineRecord.PublishTime,
+ PendingContentHash = currentHash,
+ OnlineContentHash = onlineHash
+ };
+ }
+
+ private static string ComputeContentHash(string content)
+ {
+ var hash = global::System.Security.Cryptography.SHA256.HashData(
+ global::System.Text.Encoding.UTF8.GetBytes(content));
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+
///
/// 执行销售产品发布。
///
diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
index 2a00598..b986819 100644
--- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
@@ -91,6 +91,21 @@ public partial class ServiceChannelService
}
}
+ ///
+ /// 更新时若目标编码被软删记录占用,改名释放 uk_sc_code。
+ ///
+ private async Task FreeSoftDeletedChannelCodeIfNeededAsync(string channelCode, int currentId)
+ {
+ var conflict = await ServiceChannelRepository.GetByChannelCodeIncludingDeletedAsync(channelCode);
+ if (conflict == null || !conflict.IsDeleted || conflict.Id == currentId)
+ {
+ return;
+ }
+
+ conflict.ChannelCode = $"{channelCode}__deleted_{conflict.Id}";
+ await ServiceChannelRepository.UpdateAsync(conflict);
+ }
+
///
/// 获取服务渠道,不存在时直接抛异常。
///
@@ -131,17 +146,22 @@ public partial class ServiceChannelService
///
/// 一次性装配买价项、因子和明细,避免接口层 N+1 查询。
+ /// includeDetails=false 时跳过明细(列表页首屏),显著减小载荷。
///
- private async Task> GetPriceOutputsAsync(int serviceChannelId)
+ private async Task> GetPriceOutputsAsync(
+ int serviceChannelId,
+ bool includeDetails = true)
{
var prices = await ServiceChannelRepository.GetPricesAsync(serviceChannelId);
var priceIds = prices.Select(x => x.Id).ToArray();
var factorMap = (await ServiceChannelRepository.GetPriceFactorsAsync(priceIds))
.GroupBy(x => x.PriceId)
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList());
- var detailMap = (await ServiceChannelRepository.GetPriceDetailsAsync(priceIds))
- .GroupBy(x => x.PriceId)
- .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
+ var detailMap = includeDetails
+ ? (await ServiceChannelRepository.GetPriceDetailsAsync(priceIds))
+ .GroupBy(x => x.PriceId)
+ .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList())
+ : new Dictionary>();
return prices.Select(price => new ServiceChannelPriceListOutput
{
@@ -155,12 +175,42 @@ public partial class ServiceChannelService
Currency = price.Currency,
ChargingCondition = price.ChargingCondition,
Factors = factorMap.TryGetValue(price.Id, out var factors) ? factors.Select(MapPriceFactorOutput).ToList() : [],
- Details = detailMap.TryGetValue(price.Id, out var details) ? details.Select(MapPriceDetailOutput).ToList() : [],
+ Details = includeDetails && detailMap.TryGetValue(price.Id, out var details)
+ ? details.Select(MapPriceDetailOutput).ToList()
+ : [],
CreateTime = price.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime)
}).ToList();
}
+ ///
+ /// 单条买价项(含因子+明细),供编辑弹窗按需加载。
+ ///
+ private async Task GetPriceOutputByIdAsync(int priceId)
+ {
+ var price = await GetPriceOrThrowAsync(priceId);
+ var priceIds = new[] { price.Id };
+ var factors = await ServiceChannelRepository.GetPriceFactorsAsync(priceIds);
+ var details = await ServiceChannelRepository.GetPriceDetailsAsync(priceIds);
+
+ return new ServiceChannelPriceListOutput
+ {
+ Id = price.Id,
+ ServiceChannelId = price.ServiceChannelId,
+ SerialNumber = price.SerialNumber,
+ Status = (byte)price.Status,
+ PriceName = price.PriceName,
+ CostItem = price.CostItem,
+ CostUnit = price.CostUnit,
+ Currency = price.Currency,
+ ChargingCondition = price.ChargingCondition,
+ Factors = factors.OrderBy(item => item.SerialNumber).Select(MapPriceFactorOutput).ToList(),
+ Details = details.OrderBy(item => item.Id).Select(MapPriceDetailOutput).ToList(),
+ CreateTime = price.CreateTime,
+ UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime)
+ };
+ }
+
///
/// 获取指定类型的发布记录。
///
@@ -193,23 +243,48 @@ public partial class ServiceChannelService
}
///
- /// 组装买价发布快照(不落库),与产品卖价 §5.3.2 对称。
+ /// 组装买价发布快照(不落库):钉渠道自有价区包。
///
- private async Task BuildPricePublishSnapshotAsync(int serviceChannelId)
+ private async Task BuildPricePublishSnapshotAsync(int serviceChannelId)
{
- return new ServiceChannelPricePublishSnapshot
+ var snapshot = new ServiceChannelPricePublishSnapshotDto
{
ServiceChannelId = serviceChannelId,
Prices = (await GetPriceOutputsAsync(serviceChannelId))
.Select(PublishSnapshotSlimmer.SlimChannelPrice)
.ToList()
};
+ await PriceAreaSnapshotHelper.ApplyChannelPriceAreaToSnapshotAsync(
+ snapshot,
+ KpiAreaRepository,
+ KpiAreaPackService,
+ ResolvePublishUser());
+ return snapshot;
}
///
- /// 组装渠道规则发布快照(不落库)。
+ /// 买价同步态:不 Ensure 新分区包。
///
- private async Task BuildRulesPublishSnapshotAsync(int serviceChannelId)
+ private async Task BuildPricePublishSnapshotForSyncAsync(int serviceChannelId)
+ {
+ var snapshot = new ServiceChannelPricePublishSnapshotDto
+ {
+ ServiceChannelId = serviceChannelId,
+ Prices = (await GetPriceOutputsAsync(serviceChannelId))
+ .Select(PublishSnapshotSlimmer.SlimChannelPrice)
+ .ToList()
+ };
+ await PriceAreaSnapshotHelper.ApplyChannelPriceAreaRefsForSyncAsync(
+ snapshot,
+ KpiAreaRepository,
+ KpiAreaPackService);
+ return snapshot;
+ }
+
+ ///
+ /// 组装渠道规则发布快照(不落库):钉偏远分区包。
+ ///
+ private async Task BuildRulesPublishSnapshotAsync(int serviceChannelId)
{
var entity = await GetEntityOrThrowAsync(serviceChannelId);
ServiceChannelDownstreamSnapshotDto? downstream = null;
@@ -222,13 +297,79 @@ public partial class ServiceChannelService
}
}
- return new ServiceChannelRulesPublishSnapshot
+ var snapshot = new ServiceChannelRulesPublishSnapshotDto
{
Channel = MapChannelToSaveInput(entity),
- Downstream = downstream
+ Downstream = downstream,
+ RemoteAreaInfos = []
};
+
+ if (entity.RemoteAreaId is > 0)
+ {
+ var packRef = await KpiAreaPackService.EnsurePackAsync(
+ entity.RemoteAreaId.Value,
+ KpiAreaPackService.AreaTypeRemote,
+ ResolvePublishUser(),
+ description: "service-channel-rules-publish");
+ snapshot.RemoteAreaId = entity.RemoteAreaId;
+ snapshot.RemoteAreaName = packRef.AreaName;
+ snapshot.RemoteAreaRef = packRef;
+ }
+ else
+ {
+ snapshot.RemoteAreaId = null;
+ snapshot.RemoteAreaName = null;
+ snapshot.RemoteAreaRef = null;
+ }
+
+ return snapshot;
}
+ ///
+ /// 渠道规则同步态:偏远分区不 Ensure。
+ ///
+ private async Task BuildRulesPublishSnapshotForSyncAsync(int serviceChannelId)
+ {
+ var entity = await GetEntityOrThrowAsync(serviceChannelId);
+ ServiceChannelDownstreamSnapshotDto? downstream = null;
+ if (entity.GroupId is > 0)
+ {
+ var group = await ServiceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == entity.GroupId.Value);
+ if (group != null)
+ {
+ downstream = await AllocationProviderSnapshotEnricher.BuildDownstreamFromGroupAsync(group);
+ }
+ }
+
+ var snapshot = new ServiceChannelRulesPublishSnapshotDto
+ {
+ Channel = MapChannelToSaveInput(entity),
+ Downstream = downstream,
+ RemoteAreaInfos = []
+ };
+
+ if (entity.RemoteAreaId is > 0)
+ {
+ var packRef = await KpiAreaPackService.ResolvePackRefForSyncAsync(entity.RemoteAreaId.Value);
+ snapshot.RemoteAreaId = entity.RemoteAreaId;
+ snapshot.RemoteAreaName = packRef.AreaName;
+ snapshot.RemoteAreaRef = packRef;
+ }
+ else
+ {
+ snapshot.RemoteAreaId = null;
+ snapshot.RemoteAreaName = null;
+ snapshot.RemoteAreaRef = null;
+ }
+
+ return snapshot;
+ }
+
+ private string ResolvePublishUser() =>
+ string.IsNullOrWhiteSpace(CurrentUser.UserName)
+ ? CurrentUser.GetId().ToString()
+ : CurrentUser.UserName!;
+
private static ServiceChannelSaveInput MapChannelToSaveInput(ServiceChannel entity) =>
new()
{
@@ -255,7 +396,11 @@ public partial class ServiceChannelService
? await BuildPricePublishSnapshotAsync(serviceChannelId)
: await BuildRulesPublishSnapshotAsync(serviceChannelId);
- // Content 中保存完整快照,回滚时直接从快照恢复。
+ var content = operatorType == PriceOperatorType
+ ? OmsDocHelper.CanonicalizeChannelPriceSnapshotJson(snapshot)
+ : OmsDocHelper.SerializeSnapshotJson(snapshot);
+
+ // Content 存 canonical 形态,与 sync-status 对比口径一致。
await ServiceChannelRepository.InsertPublishRecordAsync(new ServiceChannelPublishRecord
{
ServiceChannelId = serviceChannelId,
@@ -265,7 +410,7 @@ public partial class ServiceChannelService
PublishUser = string.IsNullOrWhiteSpace(CurrentUser.UserName) ? CurrentUser.GetId().ToString() : CurrentUser.UserName!,
PublishTime = DateTime.Now,
Description = description,
- Content = OmsDocHelper.SerializeSnapshotJson(snapshot)
+ Content = content
});
}
@@ -284,7 +429,7 @@ public partial class ServiceChannelService
if (operatorType == PriceOperatorType)
{
- var snapshot = OmsDocHelper.DeserializeJson(record.Content);
+ var snapshot = OmsDocHelper.DeserializeChannelPriceSnapshot(record.Content);
if (snapshot == null)
{
throw new Fake.Application.BusinessException("发布快照无效");
@@ -299,8 +444,8 @@ public partial class ServiceChannelService
return;
}
- var rulesSnapshot = OmsDocHelper.DeserializeJson(record.Content);
- if (rulesSnapshot == null)
+ var rulesSnapshot = OmsDocHelper.DeserializeJson(record.Content);
+ if (rulesSnapshot?.Channel == null)
{
throw new Fake.Application.BusinessException("发布快照无效");
}
@@ -329,10 +474,11 @@ public partial class ServiceChannelService
}
///
- /// 买价偏远费等附加费用项的计费条件校验。
+ /// 买价偏远费等附加费用项的计费条件校验;同一提交批次内费用项不可重复。
///
private static void ValidateBillingPriceItemRules(IReadOnlyList prices)
{
+ EnsureUniqueCostItemsInBatch(prices.Select(x => x.CostItem));
foreach (var price in prices)
{
var costItem = price.CostItem?.Trim() ?? string.Empty;
@@ -349,6 +495,44 @@ public partial class ServiceChannelService
}
}
+ ///
+ /// 同一服务渠道下每种费用项只能有一条买价配置。
+ ///
+ private async Task EnsureCostItemUniqueAsync(int serviceChannelId, string? costItem, int? excludePriceId)
+ {
+ var trimmed = costItem?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(trimmed))
+ {
+ throw new Fake.Application.BusinessException("费用项不能为空");
+ }
+
+ var existing = await ServiceChannelRepository.GetPricesAsync(serviceChannelId);
+ var duplicated = existing.Any(p =>
+ (!excludePriceId.HasValue || p.Id != excludePriceId.Value)
+ && string.Equals(p.CostItem?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase));
+ if (duplicated)
+ {
+ throw new Fake.Application.BusinessException(
+ $"该渠道已存在费用项「{trimmed}」,同一渠道下每种费用项只能有一条");
+ }
+ }
+
+ private static void EnsureUniqueCostItemsInBatch(IEnumerable costItems)
+ {
+ var duplicates = costItems
+ .Select(x => x?.Trim() ?? string.Empty)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .GroupBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .Where(g => g.Count() > 1)
+ .Select(g => g.Key)
+ .ToList();
+ if (duplicates.Count > 0)
+ {
+ throw new Fake.Application.BusinessException(
+ $"同一渠道下费用项不能重复:{string.Join("、", duplicates)}");
+ }
+ }
+
private static ServiceChannelGroupListOutput MapGroupListOutput(ServiceChannelGroup entity)
{
return new ServiceChannelGroupListOutput
@@ -362,7 +546,10 @@ public partial class ServiceChannelService
};
}
- private static ServiceChannelListOutput MapListOutput(ServiceChannel entity, IReadOnlyDictionary groupMap)
+ private static ServiceChannelListOutput MapListOutput(
+ ServiceChannel entity,
+ IReadOnlyDictionary groupMap,
+ IReadOnlyDictionary auditUserNameMap)
{
return new ServiceChannelListOutput
{
@@ -379,7 +566,9 @@ public partial class ServiceChannelService
RemoteAreaId = entity.RemoteAreaId,
IsActive = (byte)entity.IsActive,
CreateTime = entity.CreateTime,
- UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
+ CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.CreateUserId, auditUserNameMap),
+ UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime),
+ UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.UpdateUserId, auditUserNameMap)
};
}
@@ -564,17 +753,4 @@ public partial class ServiceChannelService
};
}
- private sealed class ServiceChannelPricePublishSnapshot
- {
- public int ServiceChannelId { get; set; }
-
- public List Prices { get; set; } = [];
- }
-
- private sealed class ServiceChannelRulesPublishSnapshot
- {
- public ServiceChannelSaveInput Channel { get; set; } = new();
-
- public ServiceChannelDownstreamSnapshotDto? Downstream { get; set; }
- }
}
diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs
index 7188468..4e8336d 100644
--- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs
+++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs
@@ -20,11 +20,29 @@ public partial class ServiceChannelService : ApplicationService
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService();
private IServiceChannelGroupRepository ServiceChannelGroupRepository => LazyServiceProvider.GetRequiredService();
+ private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService();
+ private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
LazyServiceProvider.GetRequiredService();
+ ///
+ /// 服务渠道下拉。返回全部渠道的名称,不分页。
+ ///
+ [HttpGet("/channel/service-channel/options")]
+ public async Task> GetOptionsAsync()
+ {
+ var channels = await ServiceChannelRepository.GetOptionRowsAsync();
+ var groupMap = await GetGroupMapAsync();
+ return channels.Select(channel => new ServiceChannelOptionOutput
+ {
+ Id = channel.Id,
+ ChannelName = channel.ChannelName,
+ GroupName = channel.GroupId.HasValue && groupMap.TryGetValue(channel.GroupId.Value, out var name) ? name : null
+ }).ToList();
+ }
+
///
/// 查询服务渠道列表。
///
@@ -43,9 +61,11 @@ public partial class ServiceChannelService : ApplicationService
new Dictionary { [nameof(ServiceChannel.UpdateTime)] = false, [nameof(ServiceChannel.Id)] = false });
var total = await ServiceChannelRepository.CountAsync(predicate);
var groupMap = await GetGroupMapAsync();
+ var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
+ entities.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId }));
return new OmsPageResult
{
- Items = entities.Select(entity => MapListOutput(entity, groupMap)).ToList(),
+ Items = entities.Select(entity => MapListOutput(entity, groupMap, auditUserNameMap)).ToList(),
Total = total
};
}
@@ -73,6 +93,16 @@ public partial class ServiceChannelService : ApplicationService
public async Task CreateAsync(ServiceChannelSaveInput input)
{
await ValidateChannelCodeAsync(input.ChannelCode, null);
+ var code = input.ChannelCode.Trim();
+ var deleted = await ServiceChannelRepository.GetByChannelCodeIncludingDeletedAsync(code);
+ if (deleted != null && deleted.IsDeleted)
+ {
+ deleted.IsDeleted = false;
+ ApplyInput(deleted, input);
+ await ServiceChannelRepository.UpdateAsync(deleted);
+ return;
+ }
+
var entity = new ServiceChannel();
ApplyInput(entity, input);
await ServiceChannelRepository.InsertAsync(entity);
@@ -88,6 +118,7 @@ public partial class ServiceChannelService : ApplicationService
{
var entity = await GetEntityOrThrowAsync(input.Id);
await ValidateChannelCodeAsync(input.ChannelCode, entity.Id);
+ await FreeSoftDeletedChannelCodeIfNeededAsync(input.ChannelCode.Trim(), entity.Id);
ApplyInput(entity, input);
await ServiceChannelRepository.UpdateAsync(entity);
}
@@ -202,17 +233,29 @@ public partial class ServiceChannelService : ApplicationService
/// 查询服务渠道价格项列表。
///
/// 入参:serviceChannelId。
+ /// 是否返回价格明细矩阵;列表页建议 false。
/// 服务渠道价格项列表结果。
[HttpGet("/channel/service-channel/price/list")]
- public async Task> GetPriceListAsync([FromQuery] int serviceChannelId)
+ public async Task> GetPriceListAsync(
+ [FromQuery] int serviceChannelId,
+ [FromQuery] bool includeDetails = true)
{
await GetEntityOrThrowAsync(serviceChannelId);
return new OmsItemsResult
{
- Items = await GetPriceOutputsAsync(serviceChannelId)
+ Items = await GetPriceOutputsAsync(serviceChannelId, includeDetails)
};
}
+ ///
+ /// 获取单条服务渠道价格项(含计费因子与价格明细)。
+ ///
+ [HttpGet("/channel/service-channel/price/get")]
+ public async Task GetPriceAsync([FromQuery] int id)
+ {
+ return await GetPriceOutputByIdAsync(id);
+ }
+
///
/// 新增服务渠道价格项。
///
@@ -223,6 +266,7 @@ public partial class ServiceChannelService : ApplicationService
{
await GetEntityOrThrowAsync(input.ServiceChannelId);
ValidateBillingPriceItemRules([input]);
+ await EnsureCostItemUniqueAsync(input.ServiceChannelId, input.CostItem, excludePriceId: null);
await ServiceChannelRepository.InsertPriceAsync(
new ServiceChannelPrice
{
@@ -254,6 +298,7 @@ public partial class ServiceChannelService : ApplicationService
}
ValidateBillingPriceItemRules([input]);
+ await EnsureCostItemUniqueAsync(input.ServiceChannelId, input.CostItem, excludePriceId: price.Id);
price.SerialNumber = input.SerialNumber;
price.Status = input.Status;
price.PriceName = input.PriceName;
@@ -357,6 +402,88 @@ public partial class ServiceChannelService : ApplicationService
return OmsDocHelper.ToSnapshotApiContent(snapshot)!;
}
+ ///
+ /// 发布同步态:当前配置 vs 最新生效发布 Content。前端只展示 pending。
+ ///
+ [HttpGet("/channel/service-channel/publish/sync-status")]
+ public async Task GetPublishSyncStatusAsync(
+ [FromQuery] int serviceChannelId,
+ [FromQuery] string operatorType)
+ {
+ var normalized = NormalizeOperatorType(operatorType);
+ await GetEntityOrThrowAsync(serviceChannelId);
+
+ string currentCanon;
+ if (normalized == PriceOperatorType)
+ {
+ var current = await BuildPricePublishSnapshotForSyncAsync(serviceChannelId);
+ currentCanon = OmsDocHelper.HashChannelPriceSnapshotForSync(current);
+ }
+ else
+ {
+ var current = await BuildRulesPublishSnapshotForSyncAsync(serviceChannelId);
+ currentCanon = HashChannelRulesSnapshotForSync(current);
+ }
+
+ var currentHash = ComputeContentHash(currentCanon);
+
+ var onlineRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync(
+ serviceChannelId,
+ normalized,
+ DateTime.Now);
+ if (onlineRecord == null || string.IsNullOrWhiteSpace(onlineRecord.Content))
+ {
+ return new ServiceChannelPublishSyncStatusOutput
+ {
+ Pending = true,
+ OnlineVersion = null,
+ OnlinePublishTime = null,
+ PendingContentHash = currentHash,
+ OnlineContentHash = null
+ };
+ }
+
+ var onlineCanon = normalized == PriceOperatorType
+ ? OmsDocHelper.HashChannelPriceSnapshotForSync(
+ OmsDocHelper.DeserializeChannelPriceSnapshot(onlineRecord.Content))
+ : HashChannelRulesSnapshotForSync(
+ OmsDocHelper.DeserializeJson(onlineRecord.Content));
+ var onlineHash = ComputeContentHash(onlineCanon);
+
+ return new ServiceChannelPublishSyncStatusOutput
+ {
+ Pending = !string.Equals(currentHash, onlineHash, StringComparison.Ordinal),
+ OnlineVersion = onlineRecord.Version,
+ OnlinePublishTime = onlineRecord.PublishTime,
+ PendingContentHash = currentHash,
+ OnlineContentHash = onlineHash
+ };
+ }
+
+ private static string HashChannelRulesSnapshotForSync(ServiceChannelRulesPublishSnapshotDto? snapshot)
+ {
+ if (snapshot == null)
+ {
+ return string.Empty;
+ }
+
+ snapshot.RemoteAreaName = null;
+ if (snapshot.RemoteAreaRef != null)
+ {
+ snapshot.RemoteAreaRef.PackVersion = string.Empty;
+ snapshot.RemoteAreaRef.AreaName = null;
+ }
+
+ return OmsDocHelper.SerializeSnapshotJson(snapshot) ?? string.Empty;
+ }
+
+ private static string ComputeContentHash(string content)
+ {
+ var hash = global::System.Security.Cryptography.SHA256.HashData(
+ global::System.Text.Encoding.UTF8.GetBytes(content));
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+
///
/// 查询服务渠道发布规则列表。
///
diff --git a/src/Oms.Application/Services/Channels/WeightRoundingService.cs b/src/Oms.Application/Services/Channels/WeightRoundingService.cs
index c919d5b..a24f4a7 100644
--- a/src/Oms.Application/Services/Channels/WeightRoundingService.cs
+++ b/src/Oms.Application/Services/Channels/WeightRoundingService.cs
@@ -113,6 +113,7 @@ public class WeightRoundingService : ApplicationService
public async Task AddInfoAsync(WeightRoundingInfoSaveInput input)
{
await GetEntityOrThrowAsync(input.RoundingId);
+ await ValidateInfoRangeAsync(input);
await WeightRoundingRepository.InsertInfoAsync(new WeightRoundingInfo
{
RoundingId = input.RoundingId,
@@ -134,6 +135,7 @@ public class WeightRoundingService : ApplicationService
{
var entity = await GetInfoOrThrowAsync(input.Id);
await GetEntityOrThrowAsync(input.RoundingId);
+ await ValidateInfoRangeAsync(input, entity.Id);
entity.RoundingId = input.RoundingId;
entity.StartValue = input.StartValue;
entity.EndValue = input.EndValue;
@@ -202,6 +204,16 @@ public class WeightRoundingService : ApplicationService
return entity;
}
+ private async Task ValidateInfoRangeAsync(WeightRoundingInfoSaveInput input, int? excludeId = null)
+ {
+ var existed = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(input.RoundingId);
+ WeightRoundingRangeGuard.EnsureContiguous(
+ input.StartValue,
+ input.EndValue,
+ existed,
+ excludeId ?? input.Id);
+ }
+
private static WeightRoundingOutput MapOutput(WeightRounding entity)
{
return new WeightRoundingOutput
diff --git a/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs b/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs
index 4818a7b..16e86e0 100644
--- a/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs
+++ b/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs
@@ -86,10 +86,10 @@ public class CustomerExportQualificationService : ApplicationService
CompanyName = input.CompanyName.Trim(),
SocialCreditCode = input.SocialCreditCode.Trim(),
DomesticConsignor = input.DomesticConsignor.Trim(),
- DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode),
+ DomesticConsignorCustomsCode = input.DomesticConsignorCustomsCode.Trim(),
ProducerName = input.ProducerName.Trim(),
- ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode),
- CustomsPort = TrimOrNull(input.CustomsPort),
+ ProducerCustomsCode = input.ProducerCustomsCode.Trim(),
+ CustomsPort = input.CustomsPort.Trim(),
IsDefault = input.IsDefault ? (byte)1 : (byte)0,
Remark = TrimOrNull(input.Remark)
};
@@ -115,10 +115,10 @@ public class CustomerExportQualificationService : ApplicationService
entity.CompanyName = input.CompanyName.Trim();
entity.SocialCreditCode = input.SocialCreditCode.Trim();
entity.DomesticConsignor = input.DomesticConsignor.Trim();
- entity.DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode);
+ entity.DomesticConsignorCustomsCode = input.DomesticConsignorCustomsCode.Trim();
entity.ProducerName = input.ProducerName.Trim();
- entity.ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode);
- entity.CustomsPort = TrimOrNull(input.CustomsPort);
+ entity.ProducerCustomsCode = input.ProducerCustomsCode.Trim();
+ entity.CustomsPort = input.CustomsPort.Trim();
entity.IsDefault = input.IsDefault ? (byte)1 : (byte)0;
entity.Remark = TrimOrNull(input.Remark);
@@ -183,10 +183,25 @@ public class CustomerExportQualificationService : ApplicationService
throw new BusinessException("境内发货人不能为空");
}
+ if (string.IsNullOrWhiteSpace(input.DomesticConsignorCustomsCode))
+ {
+ throw new BusinessException("境内发货人海关代码不能为空");
+ }
+
if (string.IsNullOrWhiteSpace(input.ProducerName))
{
throw new BusinessException("生产销售单位不能为空");
}
+
+ if (string.IsNullOrWhiteSpace(input.ProducerCustomsCode))
+ {
+ throw new BusinessException("生产销售单位海关代码不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(input.CustomsPort))
+ {
+ throw new BusinessException("备案海关港口地不能为空");
+ }
}
private static Expression> BuildPredicate(
diff --git a/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs
new file mode 100644
index 0000000..787e720
--- /dev/null
+++ b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs
@@ -0,0 +1,256 @@
+using System.Security.Cryptography;
+using Fake.Application;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Customers;
+using Oms.Application.Shared;
+using Oms.Domain.CustomersAggregate.Entities;
+using Oms.Domain.CustomersAggregate.Repositories;
+
+namespace Oms.Application.Services.Customers;
+
+///
+/// 客户 OpenAPI 凭证管理(门户当前用户 + 后台代管)。
+///
+[ApiExplorerSettings(GroupName = "OMS-Customer")]
+public class CustomerOpenApiCredentialService : ApplicationService
+{
+ private const string SecretMask = "********";
+
+ private ICustomerRepository CustomerRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICustomerOpenApiCredentialRepository CredentialRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ /// 当前登录主客户查看凭证(Secret 掩码)。
+ [HttpGet("/customer/openapi-credential")]
+ public async Task GetMineAsync()
+ {
+ var customer = await GetCurrentMainCustomerOrThrowAsync();
+ return await MapGetAsync(customer.Id);
+ }
+
+ /// 当前登录主客户:无则生成并返回完整 Secret;已有则掩码返回。
+ [HttpPost("/customer/openapi-credential/ensure")]
+ public async Task EnsureMineAsync()
+ {
+ var customer = await GetCurrentMainCustomerOrThrowAsync();
+ return await EnsureAsync(customer.Id);
+ }
+
+ /// 当前登录主客户重置 Secret。
+ [HttpPost("/customer/openapi-credential/reset-secret")]
+ public async Task ResetMineSecretAsync()
+ {
+ var customer = await GetCurrentMainCustomerOrThrowAsync();
+ return await ResetSecretAsync(customer.Id);
+ }
+
+ /// 当前登录主客户启停。
+ [HttpPost("/customer/openapi-credential/set-enabled")]
+ public async Task SetMineEnabledAsync(
+ [FromBody] CustomerOpenApiCredentialSetEnabledInput input)
+ {
+ var customer = await GetCurrentMainCustomerOrThrowAsync();
+ return await SetEnabledAsync(customer.Id, input.Enabled);
+ }
+
+ /// 后台按客户查看凭证。
+ [HttpGet("/customer/openapi-credential/admin/get")]
+ public async Task AdminGetAsync([FromQuery] int customerId)
+ {
+ EnsureAdminSession();
+ await EnsureMainCustomerAsync(customerId);
+ return await MapGetAsync(customerId);
+ }
+
+ /// 后台确保生成凭证。
+ [HttpPost("/customer/openapi-credential/admin/ensure")]
+ public async Task AdminEnsureAsync(
+ [FromBody] CustomerOpenApiCredentialAdminInput input)
+ {
+ EnsureAdminSession();
+ await EnsureMainCustomerAsync(input.CustomerId);
+ return await EnsureAsync(input.CustomerId);
+ }
+
+ /// 后台重置 Secret。
+ [HttpPost("/customer/openapi-credential/admin/reset-secret")]
+ public async Task AdminResetSecretAsync(
+ [FromBody] CustomerOpenApiCredentialAdminInput input)
+ {
+ EnsureAdminSession();
+ await EnsureMainCustomerAsync(input.CustomerId);
+ return await ResetSecretAsync(input.CustomerId);
+ }
+
+ /// 后台启停。
+ [HttpPost("/customer/openapi-credential/admin/set-enabled")]
+ public async Task AdminSetEnabledAsync(
+ [FromBody] CustomerOpenApiCredentialAdminSetEnabledInput input)
+ {
+ EnsureAdminSession();
+ await EnsureMainCustomerAsync(input.CustomerId);
+ return await SetEnabledAsync(input.CustomerId, input.Enabled);
+ }
+
+ private async Task MapGetAsync(int customerId)
+ {
+ var entity = await CredentialRepository.GetByCustomerIdAsync(customerId);
+ if (entity == null)
+ {
+ return new CustomerOpenApiCredentialOutput
+ {
+ CustomerId = customerId,
+ HasCredential = false,
+ Enabled = false,
+ AppKey = string.Empty,
+ AppSecret = string.Empty,
+ SecretRevealed = false
+ };
+ }
+
+ return MapOutput(entity, revealSecret: false);
+ }
+
+ private async Task EnsureAsync(int customerId)
+ {
+ var existing = await CredentialRepository.GetByCustomerIdAsync(customerId);
+ if (existing != null)
+ {
+ return MapOutput(existing, revealSecret: false);
+ }
+
+ var entity = new CustomerOpenApiCredential
+ {
+ CustomerId = customerId,
+ AppKey = await GenerateUniqueAppKeyAsync(),
+ AppSecret = GenerateAppSecret(),
+ Enabled = true
+ };
+ entity = await CredentialRepository.InsertAsync(entity);
+ return MapOutput(entity, revealSecret: true);
+ }
+
+ private async Task ResetSecretAsync(int customerId)
+ {
+ var entity = await CredentialRepository.GetByCustomerIdAsync(customerId);
+ if (entity == null)
+ {
+ throw new BusinessException("尚未生成开放接口凭证,请先生成");
+ }
+
+ entity.AppSecret = GenerateAppSecret();
+ await CredentialRepository.UpdateAsync(entity);
+ return MapOutput(entity, revealSecret: true);
+ }
+
+ private async Task SetEnabledAsync(int customerId, bool enabled)
+ {
+ var entity = await CredentialRepository.GetByCustomerIdAsync(customerId);
+ if (entity == null)
+ {
+ throw new BusinessException("尚未生成开放接口凭证,请先生成");
+ }
+
+ entity.Enabled = enabled;
+ await CredentialRepository.UpdateAsync(entity);
+ return MapOutput(entity, revealSecret: false);
+ }
+
+ private async Task EnsureMainCustomerAsync(int customerId)
+ {
+ if (customerId <= 0)
+ {
+ throw new BusinessException("客户ID不能为空");
+ }
+
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId);
+ if (customer == null)
+ {
+ throw new BusinessException("客户不存在");
+ }
+
+ if (customer.ParentCustomerId.HasValue)
+ {
+ throw new BusinessException("仅主客户可配置开放接口凭证");
+ }
+ }
+
+ private async Task GetCurrentMainCustomerOrThrowAsync()
+ {
+ if (!OmsSessionHelper.IsCustomerLogin(CurrentUser))
+ {
+ throw new BusinessException(
+ "当前是管理后台登录,不能调用客户门户接口。请到「客户」列表打开对应客户的开放接口凭证;客户请从客户门户登录后操作");
+ }
+
+ var mapId = OmsSessionHelper.TryGetCustomerMapId(CurrentUser);
+ if (!mapId.HasValue || mapId.Value <= 0)
+ {
+ throw new BusinessException("当前登录不是客户账号,无法读取开放接口凭证");
+ }
+
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == mapId.Value);
+ if (customer == null)
+ {
+ throw new BusinessException("客户不存在");
+ }
+
+ if (customer.ParentCustomerId.HasValue)
+ {
+ throw new BusinessException("仅主客户可管理开放接口凭证");
+ }
+
+ return customer;
+ }
+
+ private void EnsureAdminSession()
+ {
+ if (OmsSessionHelper.IsCustomerLogin(CurrentUser))
+ {
+ throw new BusinessException("无权限");
+ }
+ }
+
+ private async Task GenerateUniqueAppKeyAsync()
+ {
+ for (var i = 0; i < 8; i++)
+ {
+ var key = $"oms_{Guid.NewGuid():N}"[..20];
+ var existed = await CredentialRepository.GetByAppKeyAsync(key);
+ if (existed == null)
+ {
+ return key;
+ }
+ }
+
+ throw new BusinessException("生成 AppKey 失败,请重试");
+ }
+
+ private static string GenerateAppSecret()
+ {
+ var bytes = RandomNumberGenerator.GetBytes(24);
+ return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', 'x').Replace('/', 'y');
+ }
+
+ private static CustomerOpenApiCredentialOutput MapOutput(
+ CustomerOpenApiCredential entity,
+ bool revealSecret)
+ {
+ return new CustomerOpenApiCredentialOutput
+ {
+ Id = entity.Id,
+ CustomerId = entity.CustomerId,
+ AppKey = entity.AppKey,
+ AppSecret = revealSecret ? entity.AppSecret : SecretMask,
+ Enabled = entity.Enabled,
+ HasCredential = true,
+ SecretRevealed = revealSecret,
+ LastUsedAt = entity.LastUsedAt,
+ LastUsedIp = entity.LastUsedIp,
+ CreateTime = entity.CreateTime,
+ UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
+ };
+ }
+}
diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs
index 21550cd..dd14bec 100644
--- a/src/Oms.Application/Services/Customers/CustomerService.cs
+++ b/src/Oms.Application/Services/Customers/CustomerService.cs
@@ -178,6 +178,12 @@ public class CustomerService : ApplicationService
NormalizeRequiredText(input.Email, "邮箱"),
WeChatRegistrationFieldNormalizer.EmailMaxLength);
+ await FreeSoftDeletedCustomerIdentityIfNeededAsync(
+ customer.CustomerCode,
+ phone,
+ email,
+ excludeId: customer.Id);
+
var duplicatedPhone = await CustomerRepository.GetByPhoneAsync(phone, cancellationToken);
if (duplicatedPhone != null && duplicatedPhone.Id != customer.Id)
{
@@ -216,6 +222,10 @@ public class CustomerService : ApplicationService
{
var normalized = await NormalizeCreateCustomerInputAsync(input, "客户编号", "客户名称");
+ await FreeSoftDeletedCustomerIdentityIfNeededAsync(
+ normalized.CustomerCode,
+ normalized.Phone,
+ normalized.Email);
await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email);
var customerRoleId = await GetCustomerRoleIdOrThrowAsync();
@@ -232,7 +242,9 @@ public class CustomerService : ApplicationService
entity,
input.InviteCode,
ReferralCodeRepository,
- CustomerRepository);
+ CustomerRepository,
+ MemberLevelRepository,
+ LazyServiceProvider.GetRequiredService());
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
entity,
input.AdvisorCode,
@@ -241,6 +253,10 @@ public class CustomerService : ApplicationService
try
{
await CouponManager.GrantNewcomerCouponsAsync(entity.Id);
+ if (entity.ReferrerCustomerId.HasValue)
+ {
+ await CouponManager.GrantInviteeCouponAsync(entity.Id);
+ }
}
catch
{
@@ -268,6 +284,10 @@ public class CustomerService : ApplicationService
var normalized = await NormalizeCreateCustomerInputAsync(input, "子客户编号", "子客户名称");
var permissions = await NormalizeAndValidateSubCustomerPermissionsAsync(input.Permissions);
+ await FreeSoftDeletedCustomerIdentityIfNeededAsync(
+ normalized.CustomerCode,
+ normalized.Phone,
+ normalized.Email);
await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email);
var user = await CreateRbacUserAsync(input, normalized);
@@ -550,8 +570,46 @@ public class CustomerService : ApplicationService
}
}
+ ///
+ /// 客户创建会新建 RBAC 用户,不适合直接恢复软删行;释放软删占用的唯一键即可。
+ ///
+ private async Task FreeSoftDeletedCustomerIdentityIfNeededAsync(
+ string customerCode,
+ string phone,
+ string? email,
+ int? excludeId = null)
+ {
+ var byCode = await CustomerRepository.GetByCustomerCodeIncludingDeletedAsync(customerCode);
+ if (byCode != null && byCode.IsDeleted && byCode.Id != excludeId)
+ {
+ byCode.CustomerCode = $"{customerCode}__deleted_{byCode.Id}";
+ await CustomerRepository.UpdateAsync(byCode);
+ }
+
+ var byPhone = await CustomerRepository.GetByPhoneIncludingDeletedAsync(phone);
+ if (byPhone != null && byPhone.IsDeleted && byPhone.Id != excludeId)
+ {
+ byPhone.Phone = $"{phone}__deleted_{byPhone.Id}";
+ await CustomerRepository.UpdateAsync(byPhone);
+ }
+
+ if (string.IsNullOrWhiteSpace(email))
+ {
+ return;
+ }
+
+ var byEmail = await CustomerRepository.GetByEmailIncludingDeletedAsync(email);
+ if (byEmail != null && byEmail.IsDeleted && byEmail.Id != excludeId)
+ {
+ byEmail.Email = $"{email}__deleted_{byEmail.Id}";
+ await CustomerRepository.UpdateAsync(byEmail);
+ }
+ }
+
private async Task EnsureCustomerIdentityUniqueForUpdateAsync(int id, string customerCode, string phone, string? email)
{
+ await FreeSoftDeletedCustomerIdentityIfNeededAsync(customerCode, phone, email, excludeId: id);
+
var duplicatedCode = await CustomerRepository.GetByCustomerCodeAsync(customerCode);
if (duplicatedCode != null && duplicatedCode.Id != id)
{
diff --git a/src/Oms.Application/Services/Kb/KbCategoryService.cs b/src/Oms.Application/Services/Kb/KbCategoryService.cs
index a42186e..492845c 100644
--- a/src/Oms.Application/Services/Kb/KbCategoryService.cs
+++ b/src/Oms.Application/Services/Kb/KbCategoryService.cs
@@ -64,7 +64,22 @@ public class KbCategoryService : ApplicationService
throw new BusinessException($"分类编码已存在:{code}");
}
- entity ??= new KbCategory();
+ if (entity == null)
+ {
+ var deleted = await CategoryRepository.GetByCodeIncludingDeletedAsync(code);
+ entity = deleted is { IsDeleted: true } ? deleted : new KbCategory();
+ }
+ else
+ {
+ var softDeleted = await CategoryRepository.GetByCodeIncludingDeletedAsync(code);
+ if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id)
+ {
+ softDeleted.Code = $"{code}__deleted_{softDeleted.Id}";
+ await CategoryRepository.UpdateAsync(softDeleted);
+ }
+ }
+
+ entity.IsDeleted = false;
entity.Code = code;
entity.Name = name;
entity.SortOrder = input.SortOrder;
diff --git a/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs b/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
index 75f867e..fe8ffc3 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
@@ -246,6 +246,8 @@ public class MarketingCommissionAdminService : ApplicationService
Status = entity.Status,
StatusName = MarketingEnumNames.Of(entity.Status),
EligibleTime = entity.EligibleTime,
+ SettleDueTime = entity.SettleDueTime,
+ RewardOrderIndex = entity.RewardOrderIndex,
SettleTime = entity.SettleTime,
CancelReason = entity.CancelReason,
CreateTime = entity.CreateTime
diff --git a/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs b/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs
index dc79370..6f43d77 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs
@@ -47,15 +47,26 @@ public class MarketingCommissionRuleService : ApplicationService
entity.RuleName = input.RuleName.Trim();
entity.CommissionMode = string.IsNullOrWhiteSpace(input.CommissionMode)
- ? "per_amount"
+ ? "percent_of_paid_base_freight"
: input.CommissionMode.Trim();
- entity.BaseAmount = input.BaseAmount;
- entity.CommissionAmount = input.CommissionAmount;
- entity.FreezeDays = input.FreezeDays;
+ entity.BaseAmount = input.MinPaidBaseFreight;
+ entity.CommissionAmount = 0;
entity.SettleTriggerOrderStatus = string.IsNullOrWhiteSpace(input.SettleTriggerOrderStatus)
? "deliverySuccess"
: input.SettleTriggerOrderStatus.Trim();
entity.MonthlyCap = input.MonthlyCap;
+ entity.MinInviteLevelCode = string.IsNullOrWhiteSpace(input.MinInviteLevelCode) ? "V1" : input.MinInviteLevelCode.Trim();
+ entity.MinPaidBaseFreight = input.MinPaidBaseFreight;
+ entity.FirstOrderRate = input.FirstOrderRate;
+ entity.FirstOrderCap = input.FirstOrderCap;
+ entity.FollowOrderRate = input.FollowOrderRate;
+ entity.FollowOrderCap = input.FollowOrderCap;
+ entity.FollowWindowDays = input.FollowWindowDays;
+ entity.MaxRewardOrders = input.MaxRewardOrders;
+ entity.SettleBusinessDays = input.SettleBusinessDays >= 0
+ ? input.SettleBusinessDays
+ : Math.Max(input.FreezeDays, 0);
+ entity.FreezeDays = entity.SettleBusinessDays;
entity.Status = input.Status;
if (input.Id is > 0)
@@ -75,19 +86,20 @@ public class MarketingCommissionRuleService : ApplicationService
throw new BusinessException("Rule name is required");
}
- if (input.BaseAmount <= 0)
+ if (input.MinPaidBaseFreight < 0
+ || input.FirstOrderRate is < 0 or > 1
+ || input.FirstOrderCap < 0
+ || input.FollowOrderRate is < 0 or > 1
+ || input.FollowOrderCap < 0
+ || input.FollowWindowDays <= 0
+ || input.MaxRewardOrders <= 0)
{
- throw new BusinessException("Base amount must be positive");
+ throw new BusinessException("Invite reward parameters are invalid");
}
- if (input.CommissionAmount <= 0)
+ if (input.SettleBusinessDays < 0 && input.FreezeDays < 0)
{
- throw new BusinessException("Commission amount must be positive");
- }
-
- if (input.FreezeDays < 0)
- {
- throw new BusinessException("Freeze days cannot be negative");
+ throw new BusinessException("Settle business days cannot be negative");
}
}
@@ -101,6 +113,15 @@ public class MarketingCommissionRuleService : ApplicationService
FreezeDays = rule.FreezeDays,
SettleTriggerOrderStatus = rule.SettleTriggerOrderStatus,
MonthlyCap = rule.MonthlyCap,
+ MinInviteLevelCode = rule.MinInviteLevelCode,
+ MinPaidBaseFreight = rule.MinPaidBaseFreight,
+ FirstOrderRate = rule.FirstOrderRate,
+ FirstOrderCap = rule.FirstOrderCap,
+ FollowOrderRate = rule.FollowOrderRate,
+ FollowOrderCap = rule.FollowOrderCap,
+ FollowWindowDays = rule.FollowWindowDays,
+ MaxRewardOrders = rule.MaxRewardOrders,
+ SettleBusinessDays = rule.SettleBusinessDays > 0 ? rule.SettleBusinessDays : rule.FreezeDays,
Status = rule.Status,
StatusName = MarketingEnumNames.Of(rule.Status),
CreateTime = rule.CreateTime,
diff --git a/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs b/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
index 9899c3d..073e645 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
@@ -224,6 +224,10 @@ public class MarketingCouponGrantService : ApplicationService
ValidEndTime = entity.ValidEndTime,
UseTime = entity.UseTime,
OrderId = entity.OrderId,
- LockExpireTime = entity.LockExpireTime
+ LockExpireTime = entity.LockExpireTime,
+ TemplateCode = template?.TemplateCode,
+ UnlockHint = entity.Status == CouponInstanceStatus.PendingUnlock
+ ? "完成首单派送即可解锁"
+ : null
};
}
diff --git a/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs b/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs
index d45d478..5230798 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs
@@ -165,6 +165,12 @@ public class MarketingCouponTemplateService : ApplicationService
throw new BusinessException("Template code already exists");
}
+ if (input.ValidType == CouponValidType.DaysAfterUnlock &&
+ (!input.ValidDaysAfterReceive.HasValue || input.ValidDaysAfterReceive.Value <= 0))
+ {
+ throw new BusinessException("Unlock validity days is required");
+ }
+
ValidateCouponTypeFields(input);
}
diff --git a/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs b/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs
index ee89690..455dd90 100644
--- a/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs
@@ -132,9 +132,9 @@ public class MarketingMemberLevelService : ApplicationService
throw new BusinessException("等级名称不能为空");
}
- if (input.ExtraDiscountRate < 0 || input.ExtraDiscountRate >= 1)
+ if (input.ExtraDiscountRate < 0 || input.ExtraDiscountRate > MarketingBillingCalculator.MaxFreightOffRate)
{
- throw new BusinessException("额外折扣率须在 0~1 之间(不含 1)");
+ throw new BusinessException("基础运费折扣率须在 0~0.05 之间(0.05 即 95 折封顶)");
}
if (await MemberLevelRepository.ExistsLevelCodeAsync(levelCode, excludeId))
@@ -149,6 +149,7 @@ public class MarketingMemberLevelService : ApplicationService
entity.LevelName = input.LevelName.Trim();
entity.MinPoints = input.MinPoints;
entity.ExtraDiscountRate = input.ExtraDiscountRate;
+ entity.CanDowngrade = input.CanDowngrade;
entity.Sort = input.Sort;
entity.IconUrl = string.IsNullOrWhiteSpace(input.IconUrl) ? null : input.IconUrl.Trim();
entity.BenefitDesc = string.IsNullOrWhiteSpace(input.BenefitDesc) ? null : input.BenefitDesc.Trim();
@@ -166,6 +167,7 @@ public class MarketingMemberLevelService : ApplicationService
LevelName = entity.LevelName,
MinPoints = entity.MinPoints,
ExtraDiscountRate = entity.ExtraDiscountRate,
+ CanDowngrade = entity.CanDowngrade,
Sort = entity.Sort,
IconUrl = entity.IconUrl,
BenefitDesc = entity.BenefitDesc,
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs
index 54e1901..3c3e011 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs
@@ -116,6 +116,8 @@ public class MarketingPortalCommissionService : ApplicationService
Status = entity.Status,
StatusName = MarketingEnumNames.Of(entity.Status),
EligibleTime = entity.EligibleTime,
+ SettleDueTime = entity.SettleDueTime,
+ RewardOrderIndex = entity.RewardOrderIndex,
SettleTime = entity.SettleTime,
CancelReason = entity.CancelReason,
CreateTime = entity.CreateTime
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs
index 93f0c15..cdd8ab5 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs
@@ -84,7 +84,8 @@ public class MarketingPortalCouponService : ApplicationService
var mainCustomer = await MarketingPortalSessionHelper.ResolveMainCustomerAsync(
CustomerRepository,
CurrentUser);
- return await CouponManager.BuildAvailableCouponsAsync(mainCustomer.Id, input.AmountAfterLevel);
+ return await CouponManager.BuildAvailableCouponsAsync(
+ mainCustomer.Id, input.BaseFreightAmount ?? input.AmountAfterLevel, input.DestinationCountry);
}
///
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs
index 4c382be..8bdb9c7 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs
@@ -38,6 +38,7 @@ public class MarketingPortalMemberService : ApplicationService
CustomerRepository,
CurrentUser);
var account = await PointsManager.GetOrCreateAccountAsync(mainCustomer.Id);
+ var rollingPoints = await PointsManager.GetRollingPointsAsync(mainCustomer.Id);
var levels = await MemberLevelRepository.GetAllEnabledOrderedAsync();
MemberLevel? currentLevel = null;
@@ -48,7 +49,7 @@ public class MarketingPortalMemberService : ApplicationService
}
var nextLevel = levels
- .Where(x => x.MinPoints > account.TotalPoints)
+ .Where(x => x.MinPoints > rollingPoints)
.OrderBy(x => x.MinPoints)
.ThenBy(x => x.Sort)
.FirstOrDefault();
@@ -56,13 +57,16 @@ public class MarketingPortalMemberService : ApplicationService
return new MemberOverviewOutput
{
MemberLevelId = currentLevel?.Id,
+ MemberLevelCode = currentLevel?.LevelCode,
MemberLevelName = currentLevel?.LevelName,
+ IconUrl = currentLevel?.IconUrl,
PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey),
TotalPoints = account.TotalPoints,
+ RollingPoints = rollingPoints,
AvailablePoints = account.AvailablePoints,
NextLevelId = nextLevel?.Id,
NextLevelName = nextLevel?.LevelName,
- PointsToNextLevel = nextLevel == null ? null : nextLevel.MinPoints - account.TotalPoints,
+ PointsToNextLevel = nextLevel == null ? null : Math.Max(0, nextLevel.MinPoints - rollingPoints),
ExtraDiscountRate = currentLevel?.ExtraDiscountRate ?? 0M,
BenefitDesc = currentLevel?.BenefitDesc
};
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs
index 16cb88d..2fe0eca 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs
@@ -28,6 +28,15 @@ public class MarketingPortalReferralService : ApplicationService
private IOrderRepository OrderRepository =>
LazyServiceProvider.GetRequiredService();
+ private IMemberLevelRepository MemberLevelRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private IReferralCommissionRuleRepository CommissionRuleRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private IReferralCommissionDetailRepository CommissionDetailRepository =>
+ LazyServiceProvider.GetRequiredService();
+
private MarketingReferralCodeProvisioner ReferralCodeProvisioner =>
LazyServiceProvider.GetRequiredService();
@@ -48,13 +57,22 @@ public class MarketingPortalReferralService : ApplicationService
var referralCode = await ReferralCodeProvisioner.GetOrCreateAsync(mainCustomer.Id);
var refereeCount = await CustomerRepository.CountAsync(
x => x.ReferrerCustomerId == mainCustomer.Id && x.ParentCustomerId == null);
+ var (canInvite, minLevel) = await ResolveCanInviteAsync(mainCustomer);
+ var pendingReward = await CommissionDetailRepository.SumByReferrerAsync(
+ mainCustomer.Id, CommissionStatus.Pending);
+ var settledReward = await CommissionDetailRepository.SumByReferrerAsync(
+ mainCustomer.Id, CommissionStatus.Settled);
return new ReferralMyInviteOutput
{
InviteCode = referralCode.InviteCode,
Status = (byte)referralCode.Status,
StatusName = MarketingEnumNames.Of(referralCode.Status),
- RefereeCount = refereeCount
+ RefereeCount = refereeCount,
+ CanInvite = canInvite,
+ MinInviteLevelCode = minLevel,
+ PendingReward = pendingReward,
+ SettledReward = settledReward
};
}
@@ -82,6 +100,14 @@ public class MarketingPortalReferralService : ApplicationService
var total = await CustomerRepository.CountAsync(
x => x.ReferrerCustomerId == mainCustomer.Id && x.ParentCustomerId == null);
+ var refereeIds = entities.Select(x => x.Id).ToArray();
+ var details = await CommissionDetailRepository.ListByReferrerAndRefereesAsync(
+ mainCustomer.Id, refereeIds);
+ var detailsByReferee = details
+ .Where(x => x.Status != CommissionStatus.Cancelled)
+ .GroupBy(x => x.RefereeCustomerId)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
var items = new List();
foreach (var entity in entities)
{
@@ -90,6 +116,8 @@ public class MarketingPortalReferralService : ApplicationService
var hasDeliverySuccess = await OrderRepository.AnyAsync(
x => x.CustomerId == entity.Id
&& x.OrderStatus == OrderStatusCodes.DeliverySuccess);
+ detailsByReferee.TryGetValue(entity.Id, out var refereeDetails);
+ refereeDetails ??= [];
items.Add(new ReferralRefereeOutput
{
@@ -99,7 +127,14 @@ public class MarketingPortalReferralService : ApplicationService
PhoneMasked = MarketingPortalSessionHelper.MaskPhone(entity.Phone),
CreateTime = entity.CreateTime,
HasPaidOrder = hasPaidOrder,
- HasDeliverySuccessOrder = hasDeliverySuccess
+ HasDeliverySuccessOrder = hasDeliverySuccess,
+ RewardOrderCount = refereeDetails.Count,
+ PendingReward = refereeDetails
+ .Where(x => x.Status == CommissionStatus.Pending)
+ .Sum(x => x.CommissionAmount),
+ SettledReward = refereeDetails
+ .Where(x => x.Status == CommissionStatus.Settled)
+ .Sum(x => x.CommissionAmount)
});
}
@@ -143,6 +178,16 @@ public class MarketingPortalReferralService : ApplicationService
};
}
+ var (canInvite, _) = await ResolveCanInviteAsync(inviter);
+ if (!canInvite)
+ {
+ return new ReferralValidateCodeOutput
+ {
+ Valid = false,
+ Message = "邀请人暂无邀请资格"
+ };
+ }
+
return new ReferralValidateCodeOutput
{
Valid = true,
@@ -151,4 +196,18 @@ public class MarketingPortalReferralService : ApplicationService
Message = "邀请码有效"
};
}
+
+ private async Task<(bool CanInvite, string MinLevel)> ResolveCanInviteAsync(
+ Domain.CustomersAggregate.Entities.Customer customer)
+ {
+ var rule = await CommissionRuleRepository.GetActiveRuleAsync();
+ var minLevel = string.IsNullOrWhiteSpace(rule?.MinInviteLevelCode) ? "V1" : rule.MinInviteLevelCode;
+ Domain.MarketingAggregate.Entities.MemberLevel? level = null;
+ if (customer.MemberLevelId.HasValue)
+ {
+ level = await MemberLevelRepository.FirstOrDefaultAsync(x => x.Id == customer.MemberLevelId.Value);
+ }
+
+ return (MarketingInviteRewardCalculator.CanInvite(level?.LevelCode, minLevel), minLevel);
+ }
}
diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
new file mode 100644
index 0000000..bba414f
--- /dev/null
+++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
@@ -0,0 +1,272 @@
+using System.Text;
+using System.Text.Json;
+using Fake.Application;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.OpenApi;
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Integrations.SmallBagTrack;
+using Oms.Application.Services.Orders;
+using Oms.Application.Shared;
+using Oms.Domain.OrdersAggregate.Consts;
+
+namespace Oms.Application.Services.OpenApi;
+
+///
+/// 对外开放 API(Swagger 文档分组:openApi)。
+/// 统一响应:{ code, msg, data }。
+/// 客户下单鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。
+///
+[ApiExplorerSettings(GroupName = "openApi")]
+public class OpenApiService : ApplicationService
+{
+ private static readonly JsonSerializerOptions BodyJsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ private CustomerOpenApiAuthHelper AuthHelper =>
+ LazyServiceProvider.GetRequiredService();
+
+ private PickupOrderFeePushService FeePush =>
+ LazyServiceProvider.GetRequiredService();
+
+ private OrderService OrderService =>
+ LazyServiceProvider.GetRequiredService();
+
+ private OrderFileManager OrderFileManager =>
+ LazyServiceProvider.GetRequiredService();
+
+ private OpenApiCustomerOrderResolver OrderResolver =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ISmallBagTrackOrderClient TrackClient =>
+ LazyServiceProvider.GetRequiredService();
+
+ private IHttpContextAccessor HttpContextAccessor =>
+ LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需客户凭证)。
+ ///
+ [AllowAnonymous]
+ [HttpPost("/openapi/pickup-order/push-fee")]
+ public async Task> PushFeeAsync(
+ [FromBody] PickupOpenApiPushFeeInput input)
+ {
+ try
+ {
+ var data = await FeePush.PushFeeAsync(input);
+ return OpenApiEnvelope.Ok(data);
+ }
+ catch (BusinessException ex)
+ {
+ return OpenApiEnvelope.Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ return OpenApiEnvelope.Fail(
+ string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message,
+ "500");
+ }
+ }
+
+ ///
+ /// 创建订单包裹(仅自发货)。
+ /// Header:appKey、timestamp(Unix 秒)、sign。
+ /// sign = hex(HMAC-SHA256(appSecret, appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)))。
+ ///
+ [AllowAnonymous]
+ [HttpPost("/openapi/parcel/create")]
+ [Consumes("application/json")]
+ [Produces("application/json")]
+ public async Task> CreateParcelAsync(
+ [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign,
+ [FromBody] OpenApiParcelCreateInput? requestBody)
+ {
+ try
+ {
+ var rawBody = await ReadRawBodyAsync();
+ var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody);
+
+ var input = JsonSerializer.Deserialize(rawBody, BodyJsonOptions);
+ if (input == null)
+ {
+ throw new BusinessException("请求体不能为空");
+ }
+
+ var data = await OrderService.CreateParcelForCustomerOpenApiAsync(
+ input.ToOrderParcelCreateInput(customer.Id));
+ return OpenApiEnvelope.Ok(data);
+ }
+ catch (BusinessException ex)
+ {
+ return OpenApiEnvelope.Fail(ex.Message);
+ }
+ catch (JsonException)
+ {
+ return OpenApiEnvelope.Fail("请求体格式错误");
+ }
+ catch (Exception ex)
+ {
+ return OpenApiEnvelope.Fail(
+ string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message,
+ "500");
+ }
+ }
+
+ ///
+ /// 查询轨迹。Header 同创建包裹。按 clientNo / serviceNo / trackNo 定位本客户订单。
+ ///
+ [AllowAnonymous]
+ [HttpPost("/openapi/track/query")]
+ [Consumes("application/json")]
+ [Produces("application/json")]
+ public async Task> QueryTrackAsync(
+ [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign,
+ [FromBody] OpenApiTrackQueryInput? requestBody)
+ {
+ try
+ {
+ var rawBody = await ReadRawBodyAsync();
+ var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody);
+ var input = DeserializeBody(rawBody);
+ var order = await OrderResolver.ResolveAsync(customer.Id, input);
+ if (string.IsNullOrWhiteSpace(order.ServiceNo))
+ {
+ throw new BusinessException("订单无服务单号,无法查询轨迹");
+ }
+
+ var events = await TrackClient.QuerySmallBagTracksAsync(order.ServiceNo);
+ return OpenApiEnvelope.Ok(new OpenApiTrackQueryOutput
+ {
+ OrderId = order.Id,
+ ClientNo = order.ClientNo,
+ ServiceNo = order.ServiceNo,
+ TrackNo = order.TrackNo,
+ Events = events
+ });
+ }
+ catch (BusinessException ex)
+ {
+ return OpenApiEnvelope.Fail(ex.Message);
+ }
+ catch (JsonException)
+ {
+ return OpenApiEnvelope.Fail("请求体格式错误");
+ }
+ catch (Exception ex)
+ {
+ return OpenApiEnvelope.Fail(
+ string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message,
+ "500");
+ }
+ }
+
+ ///
+ /// 按附件类型获取文件。Header 同创建包裹。
+ /// fileType:tradeProof / systemLabel / lastMileLabel / infraredImage / pod。不含 downstreamLabel。
+ ///
+ [AllowAnonymous]
+ [HttpPost("/openapi/files/get")]
+ [Consumes("application/json")]
+ [Produces("application/json")]
+ public async Task> GetFilesAsync(
+ [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign,
+ [FromBody] OpenApiFilesGetInput? requestBody)
+ {
+ try
+ {
+ var rawBody = await ReadRawBodyAsync();
+ var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody);
+ var input = DeserializeBody(rawBody);
+ if (string.IsNullOrWhiteSpace(input.FileType))
+ {
+ throw new BusinessException("请传入 fileType");
+ }
+
+ if (!OrderFileTypeCodes.IsCustomerOpenApiAllowed(input.FileType))
+ {
+ throw new BusinessException("不支持的附件类型");
+ }
+
+ var order = await OrderResolver.ResolveAsync(customer.Id, input);
+ var files = await OrderFileManager.GetByTypeAsync(order.Id, input.FileType);
+ return OpenApiEnvelope.Ok(new OpenApiFilesGetOutput
+ {
+ OrderId = order.Id,
+ ClientNo = order.ClientNo,
+ ServiceNo = order.ServiceNo,
+ FileType = OrderFileTypeCodes.Normalize(input.FileType),
+ Files = files.Select(x => new OpenApiFileItemDto
+ {
+ Id = x.Id,
+ FileName = x.FileName,
+ FileUrl = x.FileUrl,
+ ContentType = x.ContentType,
+ FileSize = x.FileSize,
+ Source = x.Source,
+ CreateTime = x.CreateTime
+ }).ToList()
+ });
+ }
+ catch (BusinessException ex)
+ {
+ return OpenApiEnvelope.Fail(ex.Message);
+ }
+ catch (JsonException)
+ {
+ return OpenApiEnvelope.Fail("请求体格式错误");
+ }
+ catch (Exception ex)
+ {
+ return OpenApiEnvelope.Fail(
+ string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message,
+ "500");
+ }
+ }
+
+ private T DeserializeBody(string rawBody)
+ {
+ if (string.IsNullOrWhiteSpace(rawBody))
+ {
+ throw new BusinessException("请求体不能为空");
+ }
+
+ var input = JsonSerializer.Deserialize(rawBody, BodyJsonOptions);
+ if (input == null)
+ {
+ throw new BusinessException("请求体不能为空");
+ }
+
+ return input;
+ }
+
+ private async Task ReadRawBodyAsync()
+ {
+ var http = HttpContextAccessor.HttpContext
+ ?? throw new BusinessException("凭证无效");
+ var request = http.Request;
+ request.EnableBuffering();
+ if (request.Body.CanSeek)
+ {
+ request.Body.Position = 0;
+ }
+
+ using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
+ var raw = await reader.ReadToEndAsync();
+ if (request.Body.CanSeek)
+ {
+ request.Body.Position = 0;
+ }
+
+ return raw;
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
index a88f537..913cd7a 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
@@ -1,5 +1,6 @@
using Fake.Application;
using Fake.DependencyInjection;
+using Fake.ObjectStorage;
using Newtonsoft.Json;
using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.Itfx;
@@ -19,7 +20,7 @@ public sealed class OrderBatchActionExecutor(
IOrderRepository orderRepository,
ICountryRepository countryRepository,
IServiceChannelRepository serviceChannelRepository,
- IFileStorage fileStorage,
+ IObjectStorage fileStorage,
ISmallBagTrackOrderClient smallBagTrackOrderClient,
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
OrderDownstreamCredentialResolver downstreamCredentialResolver,
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
index fed05fe..b73fa91 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
@@ -7,6 +7,7 @@ using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Messaging;
+using Fake.ObjectStorage;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Repositories;
@@ -26,7 +27,7 @@ public sealed class OrderBatchJobCoordinator(
IOrderRepository orderRepository,
ICountryRepository countryRepository,
IServiceChannelRepository serviceChannelRepository,
- IFileStorage fileStorage,
+ IObjectStorage fileStorage,
IOrderBatchJobPublisher publisher,
OrderBatchJobProcessor batchJobProcessor,
IOptions batchOptions,
diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
index 57b3d84..2e45bea 100644
--- a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
+++ b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
@@ -242,7 +242,7 @@ public class Customs9610OrderService : ApplicationService
}
///
- /// 预报下游(写死 create9610Order / BH 网关地址)。
+ /// 预报下游(create9610Order / BH;网关地址取自其他配置 api_center)。
///
[HttpPost("/order/customs-9610/fetch-declaration-form-batch")]
public async Task FetchDeclarationFormBatchAsync([FromBody] Customs9610IdsInput input)
@@ -274,10 +274,10 @@ public class Customs9610OrderService : ApplicationService
}
///
- /// 写死的 9610 预报下游地址:create9610Order + supplierCode=BH。
+ /// 9610 预报下游标准码 / 供应商码(网关根地址由 api_center.BaseUrl 拼装)。
///
- private const string Create9610OrderForwardUrl =
- "https://api.ideship.com/itfx/InterfaceForward/ForwardWithSubscribe?standardCode=create9610Order&supplierCode=BH";
+ private const string Create9610OrderStandardCode = "create9610Order";
+ private const string Create9610OrderSupplierCode = "BH";
private async Task DownstreamForwardOneAsync(int id)
{
@@ -303,8 +303,10 @@ public class Customs9610OrderService : ApplicationService
ItfxForwardResult forward;
try
{
- forward = await ItfxForwardClient.ForwardToAbsoluteUrlAsync(
- Create9610OrderForwardUrl,
+ // BaseUrl 来自其他配置 api_center(ItfxForwardClient 内部解析)
+ forward = await ItfxForwardClient.ForwardWithSubscribeAsync(
+ Create9610OrderStandardCode,
+ Create9610OrderSupplierCode,
creds.AppKey,
creds.AppSecret,
body);
@@ -322,7 +324,6 @@ public class Customs9610OrderService : ApplicationService
Status = header.Status,
ServiceNo = header.ServiceNo,
ClientNo = header.ClientNo,
- Url = Create9610OrderForwardUrl,
};
}
@@ -981,12 +982,27 @@ public class Customs9610OrderService : ApplicationService
if (root.TryGetProperty("data", out var data) || root.TryGetProperty("Data", out data))
{
customsOrderNo = ReadString(data, "orderNo", "OrderNo", "customsOrderNo", "CustomsOrderNo");
- referenceNo = ReadString(data, "referenceNo", "ReferenceNo");
+ // 下游单号:优先 serviceNo;兼容 referenceNo;BH create9610Order 常回 orderNo
+ referenceNo = ReadString(
+ data,
+ "serviceNo",
+ "ServiceNo",
+ "referenceNo",
+ "ReferenceNo",
+ "orderNo",
+ "OrderNo");
}
else
{
customsOrderNo = ReadString(root, "orderNo", "OrderNo", "customsOrderNo", "CustomsOrderNo");
- referenceNo = ReadString(root, "referenceNo", "ReferenceNo");
+ referenceNo = ReadString(
+ root,
+ "serviceNo",
+ "ServiceNo",
+ "referenceNo",
+ "ReferenceNo",
+ "orderNo",
+ "OrderNo");
}
}
catch
diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs
index f0a4f1d..14da193 100644
--- a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs
+++ b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs
@@ -1,4 +1,6 @@
using Fake.DependencyInjection;
+using Oms.Domain.CustomersAggregate.Entities;
+using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
@@ -13,18 +15,24 @@ public class Customs9610OrderWriter : IScopedDependency
private readonly ICustoms9610OrderRepository _headerRepository;
private readonly ICustoms9610OrderItemRepository _itemRepository;
private readonly IOrderRepository _orderRepository;
+ private readonly ICustomerExportQualificationRepository _exportQualificationRepository;
public Customs9610OrderWriter(
ICustoms9610OrderRepository headerRepository,
ICustoms9610OrderItemRepository itemRepository,
- IOrderRepository orderRepository)
+ IOrderRepository orderRepository,
+ ICustomerExportQualificationRepository exportQualificationRepository)
{
_headerRepository = headerRepository;
_itemRepository = itemRepository;
_orderRepository = orderRepository;
+ _exportQualificationRepository = exportQualificationRepository;
}
- public async Task UpsertFromOrderAsync(Order order, CancellationToken cancellationToken = default)
+ public async Task UpsertFromOrderAsync(
+ Order order,
+ string? platformCode = null,
+ CancellationToken cancellationToken = default)
{
if (order.Id <= 0)
{
@@ -55,6 +63,7 @@ public class Customs9610OrderWriter : IScopedDependency
var grossWeight = ResolveGrossWeight(weights);
var serviceNo = order.ServiceNo?.Trim() ?? string.Empty;
var clientNo = FirstNonEmpty(order.ClientNo, order.ServiceNo) ?? string.Empty;
+ var qualification = await ResolveQualificationAsync(order, cancellationToken);
if (existing != null
&& string.Equals(existing.Status, Customs9610StatusCodes.Declared, StringComparison.OrdinalIgnoreCase))
@@ -98,6 +107,15 @@ public class Customs9610OrderWriter : IScopedDependency
header.PreRecordFlag = "1";
}
+ ApplyQualification(header, qualification);
+
+ // 下单传入的电商平台优先;刷新时若未传则保留已有值
+ var normalizedPlatform = Truncate(FirstNonEmpty(platformCode, header.PlatformCode), 16);
+ if (!string.IsNullOrWhiteSpace(normalizedPlatform))
+ {
+ header.PlatformCode = normalizedPlatform;
+ }
+
header.GrossWeight = grossWeight;
header.Currency = FirstNonEmpty(order.DeclaredCurrency, header.Currency, "CNY")!;
header.DestCountry = FirstNonEmpty(order.ConsigneeCountry, header.DestCountry) ?? string.Empty;
@@ -141,6 +159,50 @@ public class Customs9610OrderWriter : IScopedDependency
}
}
+ private async Task ResolveQualificationAsync(
+ Order order,
+ CancellationToken cancellationToken)
+ {
+ if (order.CustomerId is not > 0)
+ {
+ return null;
+ }
+
+ if (order.ExportQualificationId is > 0)
+ {
+ var owned = await _exportQualificationRepository.GetOwnedAsync(
+ order.ExportQualificationId.Value,
+ order.CustomerId.Value,
+ cancellationToken);
+ if (owned != null)
+ {
+ return owned;
+ }
+ }
+
+ return await _exportQualificationRepository.GetDefaultAsync(
+ order.CustomerId.Value,
+ cancellationToken);
+ }
+
+ private static void ApplyQualification(Customs9610Order header, CustomerExportQualification? qualification)
+ {
+ if (qualification == null)
+ {
+ return;
+ }
+
+ header.EcomCorpCode = Truncate(
+ FirstNonEmpty(qualification.DomesticConsignorCustomsCode, qualification.SocialCreditCode),
+ 18);
+ header.EcomCorpName = Truncate(
+ FirstNonEmpty(qualification.CompanyName, qualification.DomesticConsignor),
+ 100);
+ header.ProducerCode = Truncate(qualification.ProducerCustomsCode, 18);
+ header.ProducerName = Truncate(qualification.ProducerName, 100);
+ header.ActualSellerCode = Truncate(qualification.SocialCreditCode, 18);
+ }
+
private static decimal ResolveGrossWeight(IReadOnlyList weights)
{
var forecast = weights.FirstOrDefault(x =>
diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs
index 3515518..3cf553b 100644
--- a/src/Oms.Application/Services/Orders/OrderFileManager.cs
+++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs
@@ -1,5 +1,6 @@
using Fake.Application;
using Fake.DependencyInjection;
+using Fake.ObjectStorage;
using Microsoft.AspNetCore.Http;
using Oms.Application.Dtos.Orders;
using Oms.Application.Storage;
@@ -14,7 +15,7 @@ namespace Oms.Application.Services.Orders;
///
public sealed class OrderFileManager(
IOrderFileRepository orderFileRepository,
- IFileStorage fileStorage) : IScopedDependency
+ IObjectStorage fileStorage) : IScopedDependency
{
private static readonly HashSet ImageExt =
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
@@ -65,6 +66,25 @@ public sealed class OrderFileManager(
return result;
}
+ public async Task> GetByTypeAsync(
+ int orderId,
+ string fileType,
+ CancellationToken cancellationToken = default)
+ {
+ if (!OrderFileTypeCodes.IsValid(fileType))
+ {
+ throw new BusinessException("不支持的附件类型");
+ }
+
+ var type = OrderFileTypeCodes.Normalize(fileType);
+ var list = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
+ return list
+ .OrderBy(x => x.Sort)
+ .ThenBy(x => x.Id)
+ .Select(MapItem)
+ .ToList();
+ }
+
public async Task GetGroupedAsync(
int orderId,
bool customerView,
@@ -125,7 +145,7 @@ public sealed class OrderFileManager(
OrderId = orderId,
FileType = OrderFileTypeCodes.SystemLabel,
FileName = fileName,
- FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
+ FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
ContentType = contentType,
FileSize = fileSize,
Sort = 0,
@@ -174,7 +194,7 @@ public sealed class OrderFileManager(
OrderId = orderId,
FileType = OrderFileTypeCodes.DownstreamLabel,
FileName = TryFileNameFromUrl(fileUrl),
- FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
+ FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
ContentType = null,
Sort = existing.Count,
Source = OrderFileSourceCodes.Downstream,
@@ -301,7 +321,8 @@ public sealed class OrderFileManager(
or OrderFileTypeCodes.DownstreamLabel
or OrderFileTypeCodes.LastMileLabel;
var isInfraredOrProof = fileType is OrderFileTypeCodes.InfraredImage
- or OrderFileTypeCodes.TradeProof;
+ or OrderFileTypeCodes.TradeProof
+ or OrderFileTypeCodes.Pod;
if (isLabelType)
{
@@ -324,7 +345,7 @@ public sealed class OrderFileManager(
{
if (!ImageExt.Contains(ext) && !string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase))
{
- throw new BusinessException("交易证明/红外图片仅支持图片或 pdf");
+ throw new BusinessException("交易证明/红外图片/POD 仅支持图片或 pdf");
}
if (file.Length > MaxPdfBytes)
@@ -336,7 +357,7 @@ public sealed class OrderFileManager(
var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}";
await using var stream = file.OpenReadStream();
- var key = await fileStorage.SaveAsync(stream, relativeKey, file.ContentType);
+ var key = await fileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType);
return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length);
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index ab15879..c5d6e34 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -79,6 +79,20 @@ public partial class OrderService
: await CountryRepository.GetListAsync(x => countryCodes.Contains(x.CountryCode));
var countryMap = countries.ToDictionary(x => x.CountryCode, StringComparer.OrdinalIgnoreCase);
+ var addressCountryIds = senders
+ .Select(x => x.Country)
+ .Concat(recipients.Select(x => x.Country))
+ .Where(x => x.HasValue)
+ .Select(x => x!.Value)
+ .Distinct()
+ .ToArray();
+ var addressCountries = addressCountryIds.Length == 0
+ ? []
+ : await CountryRepository.GetListAsync(x => addressCountryIds.Contains(x.Id));
+ var countryIdCodeMap = addressCountries.ToDictionary(
+ x => x.Id,
+ x => x.CountryCode ?? string.Empty);
+
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
orders.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId }));
@@ -200,6 +214,7 @@ public partial class OrderService
: null,
RequestId = order.RequestId,
ExportCustomsMode = order.ExportCustomsMode,
+ ExportQualificationId = order.ExportQualificationId,
PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason)
? null
: order.PredeclareFailReason.Trim(),
@@ -251,8 +266,8 @@ public partial class OrderService
CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.CreateUserId, auditUserNameMap),
UpdateTime = OmsDocHelper.NullIfDefault(order.UpdateTime),
UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.UpdateUserId, auditUserNameMap),
- Sender = MapSender(sender),
- Receiver = MapReceiver(recipient),
+ Sender = MapSender(sender, countryIdCodeMap),
+ Receiver = MapReceiver(recipient, countryIdCodeMap),
Items = (items ?? []).Select(MapDeclaration).ToList(),
WeightList = (orderWeights ?? []).Select(MapWeight).ToList(),
FeeList = feeOutputs,
@@ -488,12 +503,12 @@ public partial class OrderService
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
}
- if (!await FileStorage.ExistsAsync(template.SourceFilePath))
+ if (!await FileStorage.ExistsAsync(FileStoragePath.NormalizeKey(template.SourceFilePath)))
{
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
}
- await using var stream = await FileStorage.OpenReadAsync(template.SourceFilePath);
+ await using var stream = await FileStorage.GetAsync(FileStoragePath.NormalizeKey(template.SourceFilePath));
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
@@ -628,7 +643,7 @@ public partial class OrderService
""";
var relativeKey = $"uploads/prints/orders/{fileName}";
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(html));
- var key = await FileStorage.SaveAsync(stream, relativeKey, "text/html; charset=utf-8");
+ var key = await FileStorage.SaveObjectAsync(stream, relativeKey, "text/html; charset=utf-8");
return FileStorage.GetPublicUrl(key);
}
@@ -640,14 +655,25 @@ public partial class OrderService
? Path.Combine(WebHostEnvironment.ContentRootPath, "wwwroot")
: WebHostEnvironment.WebRootPath;
// 只确保 uploads 子目录可写,避免在只读 ContentRoot 上强建 wwwroot 根目录。
- Directory.CreateDirectory(Path.Combine(webRoot, "uploads"));
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(webRoot, "uploads"));
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // 容器非 root + /app/wwwroot 未预建/权限错误时,导出清理等旁路不应炸主流程
+ }
+ catch (IOException)
+ {
+ }
+
return webRoot;
}
private async Task CleanupExpiredLocalExportFilesAsync()
{
// COS 侧建议用生命周期规则清理;本地仍清理 24h 前导出文件。
- if (FileStorage is not LocalFileStorage)
+ if (!FileStorage.IsLocalProvider())
{
return;
}
@@ -693,7 +719,9 @@ public partial class OrderService
: null;
}
- private static OrderSenderOutput? MapSender(OrderSender? sender)
+ private static OrderSenderOutput? MapSender(
+ OrderSender? sender,
+ IReadOnlyDictionary countryIdCodeMap)
{
return sender == null
? null
@@ -704,7 +732,7 @@ public partial class OrderService
Phone = sender.Phone,
TelePhone = sender.TelePhone,
Email = sender.Email,
- CountryCode = sender.Country,
+ CountryCode = ResolveCountryCode(sender.Country, countryIdCodeMap),
Province = sender.Province,
City = sender.City,
ZipCode = sender.ZipCode,
@@ -715,7 +743,9 @@ public partial class OrderService
};
}
- private static OrderReceiverOutput? MapReceiver(OrderRecipient? recipient)
+ private static OrderReceiverOutput? MapReceiver(
+ OrderRecipient? recipient,
+ IReadOnlyDictionary countryIdCodeMap)
{
return recipient == null
? null
@@ -726,7 +756,7 @@ public partial class OrderService
Phone = recipient.Phone,
TelePhone = recipient.TelePhone,
Email = recipient.Email,
- CountryCode = recipient.Country,
+ CountryCode = ResolveCountryCode(recipient.Country, countryIdCodeMap),
Province = recipient.Province,
City = recipient.City,
StreetNumber = recipient.StreetNumber,
@@ -743,6 +773,20 @@ public partial class OrderService
};
}
+ private static string? ResolveCountryCode(
+ int? countryId,
+ IReadOnlyDictionary countryIdCodeMap)
+ {
+ if (!countryId.HasValue)
+ {
+ return null;
+ }
+
+ return countryIdCodeMap.TryGetValue(countryId.Value, out var code) && !string.IsNullOrWhiteSpace(code)
+ ? code
+ : null;
+ }
+
private OrderItemOutput MapDeclaration(OrderDeclaration x)
{
return new OrderItemOutput
diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
index 57aec08..616c9f0 100644
--- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
@@ -5,6 +5,7 @@ using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
+using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
@@ -1129,7 +1130,7 @@ public partial class OrderService
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode ?? x.ImHsCode,
DeclareElement = x.DeclareElement,
- SkuImage = FileStorage.ToStorageKey(x.SkuImage),
+ SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage),
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 0ef8833..2fd317d 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -10,6 +10,7 @@ using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Labeling;
using Oms.Application.Messaging;
using Oms.Application.Shared;
+using Oms.Application.Storage;
using Oms.Application.Services.Orders.Batch;
using Oms.Application.Services.Orders.Issues;
using Oms.Domain.ChannelsAggregate.Consts;
@@ -39,6 +40,8 @@ public partial class OrderService
private static readonly ConcurrentDictionary InboundBillingInFlight = new();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService();
+ private ICustomerExportQualificationRepository ExportQualificationRepository =>
+ LazyServiceProvider.GetRequiredService();
private IMemberLevelRepository MemberLevelRepository => LazyServiceProvider.GetRequiredService();
@@ -48,6 +51,7 @@ public partial class OrderService
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService();
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService();
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService();
+ private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService();
@@ -139,7 +143,7 @@ public partial class OrderService
// 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。
if (input.SaveAsDraft)
{
- await TryUpsertCustoms9610Async(order);
+ await TryUpsertCustoms9610Async(order, input.PlatformCode);
return await BuildParcelCreateOutputAsync(order);
}
@@ -155,7 +159,7 @@ public partial class OrderService
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
SyncWeightRows(weights, forecast);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
- await TryUpsertCustoms9610Async(order);
+ await TryUpsertCustoms9610Async(order, input.PlatformCode);
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
var priceTierKey = await ResolvePriceTierKeyAsync(order.CustomerId);
@@ -258,12 +262,12 @@ public partial class OrderService
await OrderRepository.DeleteAsync(order);
}
- private async Task TryUpsertCustoms9610Async(Order order)
+ private async Task TryUpsertCustoms9610Async(Order order, string? platformCode = null)
{
try
{
await LazyServiceProvider.GetRequiredService()
- .UpsertFromOrderAsync(order);
+ .UpsertFromOrderAsync(order, platformCode);
}
catch
{
@@ -408,6 +412,51 @@ public partial class OrderService
throw new BusinessException("出口报关方式不合法");
}
+ var is9610Summary = string.Equals(
+ input.ExportCustomsMode,
+ ExportCustomsModeCodes.Mode9610Summary,
+ StringComparison.OrdinalIgnoreCase);
+ if (is9610Summary)
+ {
+ CustomerExportQualification? qualification = null;
+ if (input.ExportQualificationId is > 0)
+ {
+ qualification = await ExportQualificationRepository.GetOwnedAsync(
+ input.ExportQualificationId.Value,
+ input.CustomerId!.Value);
+ if (qualification == null)
+ {
+ throw new BusinessException("出口资质备案不存在或不属于当前客户");
+ }
+ }
+ else
+ {
+ qualification = await ExportQualificationRepository.GetDefaultAsync(input.CustomerId!.Value);
+ if (qualification == null)
+ {
+ throw new BusinessException("9610汇总申报必须选择出口资质备案(或先维护默认备案)");
+ }
+
+ input.ExportQualificationId = qualification.Id;
+ }
+
+ if (string.IsNullOrWhiteSpace(input.PlatformCode))
+ {
+ throw new BusinessException("9610汇总申报必须填写电商平台");
+ }
+
+ input.PlatformCode = input.PlatformCode.Trim();
+ if (input.PlatformCode.Length > 16)
+ {
+ throw new BusinessException("电商平台长度不能超过 16");
+ }
+ }
+ else
+ {
+ input.ExportQualificationId = null;
+ input.PlatformCode = null;
+ }
+
if (string.IsNullOrWhiteSpace(input.DeclaredCurrency))
{
throw new BusinessException("申报币种不能为空");
@@ -504,7 +553,7 @@ public partial class OrderService
throw new BusinessException("未命中规则快照版本(内存/Redis/DB)");
}
- return new ProductSnapshotBinding
+ var binding = new ProductSnapshotBinding
{
RulesVersion = rulesRecord?.Version ?? $"LIVE-{product.Id}",
PriceVersion = priceRecord?.Version ?? $"LIVE-{product.Id}",
@@ -513,6 +562,8 @@ public partial class OrderService
Rules = rulesSnapshot,
Price = priceSnapshot
};
+ await KpiAreaPackService.HydrateBindingAsync(binding);
+ return binding;
}
private async Task ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order)
@@ -584,7 +635,7 @@ public partial class OrderService
priceEffectiveTime = priceRecord.PublishTime;
}
- return new ProductSnapshotBinding
+ var binding = new ProductSnapshotBinding
{
RulesVersion = rulesVersion,
PriceVersion = priceVersion,
@@ -593,6 +644,8 @@ public partial class OrderService
Rules = rulesSnapshot,
Price = priceSnapshot
};
+ await KpiAreaPackService.HydrateBindingAsync(binding);
+ return binding;
}
private async Task ResolveWarehouseNameAsync(int? warehouseId)
@@ -1004,8 +1057,9 @@ public partial class OrderService
continue;
}
- var channelIsRemote = await IsChannelRemoteHitAsync(
- channel,
+ var channelRulesBinding = await ResolveChannelRulesBindingAsync(channel, bindTime);
+ var channelIsRemote = IsChannelRemoteHit(
+ channelRulesBinding,
order.ConsigneeCountry ?? string.Empty,
recipient?.Province,
recipient?.City,
@@ -1015,15 +1069,38 @@ public partial class OrderService
channelIsRemote ? (byte)1 : (byte)0);
var channelPriceBinding = await ResolveChannelPriceBindingAsync(channel.Id, bindTime);
+ var channelZoneKeys = ResolveChannelZoneKeysOrSkip(
+ channelPriceBinding,
+ order.ConsigneeCountry ?? string.Empty,
+ recipient?.Province,
+ recipient?.City,
+ recipient?.ZipCode,
+ out var channelZoneMissReason);
+ if (channelZoneKeys == null)
+ {
+ skipped.Add(new OrderAllocationSkippedOutput
+ {
+ ServiceChannelId = channel.Id,
+ ChannelCode = channel.ChannelCode,
+ ChannelName = channel.ChannelName,
+ Stage = AllocationResolver.SkipStageBuyPrice,
+ MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition),
+ Reason = channelZoneMissReason
+ ?? $"渠道买价分区未匹配(版本 {channelPriceBinding.PriceVersion})"
+ });
+ continue;
+ }
+
var channelRemoteZoneKeys = channelIsRemote
- ? ParcelBillingCalculator.BuildRemoteZoneKeys(channel.RemoteAreaId)
+ ? ParcelBillingCalculator.BuildRemoteZoneKeys(
+ channelRulesBinding.RemoteAreaId ?? channel.RemoteAreaId)
: Array.Empty();
var previewFees = ParcelBillingCalculator.CalculatePayableFees(
channelPriceBinding.Prices,
channelPriceBinding.PriceVersion,
channelPriceBinding.PriceEffectiveTime,
chargeableWeight,
- zoneKeys,
+ channelZoneKeys,
cargoAttribute,
chargingStage,
order.WarehouseId,
@@ -1037,7 +1114,7 @@ public partial class OrderService
var miss = ParcelBillingCalculator.ExplainPayablePriceMiss(
channelPriceBinding.Prices,
chargeableWeight,
- zoneKeys,
+ channelZoneKeys,
cargoAttribute,
order.WarehouseId,
warehouseName,
@@ -1316,6 +1393,15 @@ public partial class OrderService
public string PriceVersion { get; init; } = string.Empty;
public DateTime PriceEffectiveTime { get; init; }
+
+ public ServiceChannelPricePublishSnapshotDto Snapshot { get; init; } = new();
+ }
+
+ private sealed class ChannelRulesBinding
+ {
+ public int? RemoteAreaId { get; init; }
+
+ public ServiceChannelRulesPublishSnapshotDto Snapshot { get; init; } = new();
}
private async Task ResolveChannelPriceBindingAsync(int serviceChannelId, DateTime bindTime)
@@ -1329,23 +1415,157 @@ public partial class OrderService
var snapshot = OmsDocHelper.DeserializeChannelPriceSnapshot(priceRecord.Content);
if (snapshot?.Prices.Count > 0)
{
+ await KpiAreaPackService.HydrateChannelPriceSnapshotAsync(snapshot);
return new ChannelPriceBinding
{
Prices = snapshot.Prices.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput).ToList(),
PriceVersion = priceRecord.Version,
- PriceEffectiveTime = priceRecord.PublishTime
+ PriceEffectiveTime = priceRecord.PublishTime,
+ Snapshot = snapshot
};
}
}
+ var liveSnapshot = await BuildLiveChannelPriceSnapshotAsync(serviceChannelId);
return new ChannelPriceBinding
{
- Prices = await BuildChannelPriceOutputsAsync(serviceChannelId),
+ Prices = liveSnapshot.Prices.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput).ToList(),
PriceVersion = $"LIVE-{serviceChannelId}",
- PriceEffectiveTime = bindTime
+ PriceEffectiveTime = bindTime,
+ Snapshot = liveSnapshot
};
}
+ private async Task ResolveChannelRulesBindingAsync(
+ ServiceChannel channel,
+ DateTime bindTime)
+ {
+ var rulesRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync(
+ channel.Id,
+ RulesOperatorType,
+ bindTime);
+ if (rulesRecord?.Content != null)
+ {
+ var snapshot = OmsDocHelper.DeserializeJson(rulesRecord.Content);
+ if (snapshot != null)
+ {
+ await KpiAreaPackService.HydrateChannelRulesSnapshotAsync(snapshot);
+ return new ChannelRulesBinding
+ {
+ RemoteAreaId = snapshot.RemoteAreaRef?.AreaId
+ ?? snapshot.RemoteAreaId
+ ?? snapshot.Channel?.RemoteAreaId
+ ?? channel.RemoteAreaId,
+ Snapshot = snapshot
+ };
+ }
+ }
+
+ var live = new ServiceChannelRulesPublishSnapshotDto
+ {
+ Channel = new ServiceChannelSaveInput
+ {
+ Id = channel.Id,
+ RemoteAreaId = channel.RemoteAreaId
+ },
+ RemoteAreaInfos = []
+ };
+ await KpiAreaPackService.LoadLiveRemoteIntoChannelRulesSnapshotAsync(
+ live,
+ channel.RemoteAreaId);
+ return new ChannelRulesBinding
+ {
+ RemoteAreaId = channel.RemoteAreaId,
+ Snapshot = live
+ };
+ }
+
+ private async Task BuildLiveChannelPriceSnapshotAsync(
+ int serviceChannelId)
+ {
+ var snapshot = new ServiceChannelPricePublishSnapshotDto
+ {
+ ServiceChannelId = serviceChannelId,
+ Prices = (await BuildChannelPriceOutputsAsync(serviceChannelId))
+ .Select(PublishSnapshotSlimmer.SlimChannelPrice)
+ .ToList()
+ };
+ await PriceAreaSnapshotHelper.ApplyLiveChannelPriceAreaInfosAsync(
+ snapshot,
+ KpiAreaRepository,
+ KpiAreaPackService);
+ return snapshot;
+ }
+
+ ///
+ /// 按渠道买价价区包解析 zoneKeys;有价区配置但未命中时返回 null(跳过该渠道)。
+ /// 无价区配置时返回空列表(不回落产品卖价分区)。
+ ///
+ private static IReadOnlyList? ResolveChannelZoneKeysOrSkip(
+ ChannelPriceBinding binding,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode,
+ out string? missReason)
+ {
+ missReason = null;
+ var snapshot = binding.Snapshot;
+ if (snapshot.PriceAreaRefs is { Count: > 0 }
+ && snapshot.PriceAreaInfos is not { Count: > 0 })
+ {
+ missReason =
+ $"渠道价格分区包未加载: refs={snapshot.PriceAreaRefs.Count} version={binding.PriceVersion}";
+ return null;
+ }
+
+ if (snapshot.PriceAreaInfos is not { Count: > 0 })
+ {
+ return Array.Empty();
+ }
+
+ var keys = ParcelBillingCalculator.TryResolveZoneKeysFromChannelPriceSnapshot(
+ snapshot,
+ countryCode,
+ province,
+ city,
+ zipCode);
+ if (keys == null || keys.Count == 0)
+ {
+ missReason = $"渠道买价分区未匹配(版本 {binding.PriceVersion})";
+ return null;
+ }
+
+ return keys;
+ }
+
+ private static bool IsChannelRemoteHit(
+ ChannelRulesBinding binding,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode)
+ {
+ var snapshot = binding.Snapshot;
+ if (snapshot.RemoteAreaRef != null && snapshot.RemoteAreaInfos is not { Count: > 0 })
+ {
+ throw new BusinessException(
+ $"渠道偏远分区包未加载: areaId={snapshot.RemoteAreaRef.AreaId} version={snapshot.RemoteAreaRef.PackVersion}");
+ }
+
+ if (snapshot.RemoteAreaInfos is { Count: > 0 })
+ {
+ return GeoPartitionMatcher.IsHit(
+ snapshot.RemoteAreaInfos,
+ countryCode,
+ province,
+ city,
+ zipCode);
+ }
+
+ return false;
+ }
+
private async Task BuildLiveRulesSnapshotAsync(SalesProduct product)
{
var snapshot = new SalesProductRulesPublishSnapshotDto
@@ -1364,12 +1584,13 @@ public partial class OrderService
Scheme = product.Scheme
}
};
- await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
+ await RulesSnapshotHelper.ApplyLiveRulesToSnapshotAsync(
snapshot,
product,
KpiAreaRepository,
WeightRuleRepository,
- WeightRoundingRepository);
+ WeightRoundingRepository,
+ KpiAreaPackService);
snapshot.Providers = await ResolveAllocationProvidersAsync(product, snapshot);
return snapshot;
}
@@ -1407,7 +1628,11 @@ public partial class OrderService
: []
})).ToList()
};
- await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, KpiAreaRepository);
+ await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync(
+ snapshot,
+ product,
+ KpiAreaRepository,
+ KpiAreaPackService);
return snapshot;
}
@@ -1436,7 +1661,7 @@ public partial class OrderService
};
}
- private async Task IsBlacklistHitAsync(
+ private Task IsBlacklistHitAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
string countryCode,
@@ -1444,32 +1669,26 @@ public partial class OrderService
string? city,
string? zipCode)
{
- var areaId = ResolveEffectiveBlacklistAreaId(product, rulesSnapshot);
- if (!areaId.HasValue)
+ if (rulesSnapshot?.BlacklistAreaRef != null && rulesSnapshot.BlacklistAreaInfos is not { Count: > 0 })
{
- return false;
+ throw new BusinessException(
+ $"黑名单分区包未加载: areaId={rulesSnapshot.BlacklistAreaRef.AreaId} version={rulesSnapshot.BlacklistAreaRef.PackVersion}");
}
if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 })
{
- return RulesSnapshotHelper.IsAreaHitFromSnapshot(
+ return Task.FromResult(GeoPartitionMatcher.IsHit(
rulesSnapshot.BlacklistAreaInfos,
countryCode,
province,
city,
- zipCode);
+ zipCode));
}
- var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return Task.FromResult(false);
}
- private async Task IsRemoteHitAsync(
+ private Task IsRemoteHitAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
string countryCode,
@@ -1477,29 +1696,23 @@ public partial class OrderService
string? city,
string? zipCode)
{
- var areaId = ResolveEffectiveRemoteAreaId(product, rulesSnapshot);
- if (!areaId.HasValue)
+ if (rulesSnapshot?.RemoteAreaRef != null && rulesSnapshot.RemoteAreaInfos is not { Count: > 0 })
{
- return false;
+ throw new BusinessException(
+ $"偏远分区包未加载: areaId={rulesSnapshot.RemoteAreaRef.AreaId} version={rulesSnapshot.RemoteAreaRef.PackVersion}");
}
if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 })
{
- return RulesSnapshotHelper.IsAreaHitFromSnapshot(
+ return Task.FromResult(GeoPartitionMatcher.IsHit(
rulesSnapshot.RemoteAreaInfos,
countryCode,
province,
city,
- zipCode);
+ zipCode));
}
- var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return Task.FromResult(false);
}
private static int? ResolveEffectiveBlacklistAreaId(
@@ -1569,7 +1782,7 @@ public partial class OrderService
return null;
}
- private async Task?> ResolvePriceZoneKeysAsync(
+ private Task?> ResolvePriceZoneKeysAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
SalesProductPricePublishSnapshotDto? priceSnapshot,
@@ -1578,42 +1791,24 @@ public partial class OrderService
string? city,
string? zipCode)
{
+ if (priceSnapshot?.PriceAreaRefs is { Count: > 0 }
+ && priceSnapshot.PriceAreaInfos is not { Count: > 0 })
+ {
+ throw new BusinessException("价格分区包未加载,无法匹配分区");
+ }
+
if (priceSnapshot?.PriceAreaInfos is { Count: > 0 })
{
- return ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
+ return Task.FromResult(ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
priceSnapshot,
countryCode,
province,
city,
- zipCode);
+ zipCode));
}
- var priceAreaId = ResolveEffectivePriceAreaId(product, rulesSnapshot, priceSnapshot);
- if (!priceAreaId.HasValue)
- {
- return null;
- }
-
- var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value);
- if (area == null)
- {
- return null;
- }
-
- var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value);
- var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
- if (matched == null)
- {
- return null;
- }
-
- var keys = ParcelBillingCalculator.BuildPriceZoneKeys(matched, area);
- return keys.Count > 0 ? keys : null;
+ // 无分区配置:返回 null;禁止回读线上 KPI(正式路径须走包)
+ return Task.FromResult?>(null);
}
private async Task ApplyChargeableWeightAsync(
@@ -1905,6 +2100,7 @@ public partial class OrderService
ConsigneeCountryName = consigneeCountryName,
CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General,
ExportCustomsMode = input.ExportCustomsMode,
+ ExportQualificationId = input.ExportQualificationId,
WarehouseId = input.WarehouseId,
CustomerId = input.CustomerId,
OrderSource = orderSource,
@@ -2013,7 +2209,7 @@ public partial class OrderService
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode,
DeclareElement = x.DeclareElement,
- SkuImage = FileStorage.ToStorageKey(x.SkuImage),
+ SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage),
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,
@@ -2587,26 +2783,49 @@ public partial class OrderService
private async Task BuildParcelCreateOutputAsync(Order order)
{
- var mapped = (await MapOrderOutputsAsync([order])).First();
- var systemLabelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id);
- var labelUrl = systemLabelUrl ?? string.Empty;
- if (string.IsNullOrWhiteSpace(labelUrl) && order.OrderStatus == OrderStatusCodes.Predeclared)
+ // 预报已成功时,映射/面单/本地存储失败不得回滚订单(常见:容器 /app/wwwroot 不可写)。
+ var logger = LazyServiceProvider.GetRequiredService()
+ .CreateLogger("Oms.OrderParcelCreate");
+ var labelUrl = string.Empty;
+ try
{
- // 回落 HTML 面单仅用于返回 LabelUrl;写盘失败不得回滚已预报成功的订单。
+ var mapped = (await MapOrderOutputsAsync([order])).First();
try
{
- labelUrl = await CreateOrderPrintFileAsync(mapped);
+ labelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id) ?? string.Empty;
}
catch (Exception ex)
{
- var logger = LazyServiceProvider.GetRequiredService()
- .CreateLogger("Oms.OrderParcelCreate");
logger.LogWarning(
ex,
- "预报成功但回落面单落盘失败 OrderId={OrderId} ServiceNo={ServiceNo}",
+ "预报成功但读取系统面单 URL 失败 OrderId={OrderId} ServiceNo={ServiceNo}",
order.Id,
order.ServiceNo);
}
+
+ if (string.IsNullOrWhiteSpace(labelUrl) && order.OrderStatus == OrderStatusCodes.Predeclared)
+ {
+ try
+ {
+ labelUrl = await CreateOrderPrintFileAsync(mapped);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(
+ ex,
+ "预报成功但回落面单落盘失败 OrderId={OrderId} ServiceNo={ServiceNo}",
+ order.Id,
+ order.ServiceNo);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(
+ ex,
+ "预报成功但组装创单出参失败 OrderId={OrderId} ServiceNo={ServiceNo}",
+ order.Id,
+ order.ServiceNo);
}
return new OrderParcelCreateOutput
@@ -2787,11 +3006,15 @@ public partial class OrderService
private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId)
{
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
- var receivableSubtotal = fees
- .Where(x => x.Type == OrderFeeTypeCodes.Receivable)
- .Sum(x => x.Amount);
+ var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees);
+ var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees);
var marketingHelper = LazyServiceProvider.GetRequiredService();
- var breakdown = await marketingHelper.BuildBreakdownAsync(order.CustomerId, receivableSubtotal, couponInstanceId);
+ var breakdown = await marketingHelper.BuildBreakdownAsync(
+ order.CustomerId,
+ receivableSubtotal,
+ baseFreightAmount,
+ couponInstanceId,
+ order.ConsigneeCountry);
marketingHelper.ApplySnapshotToOrder(order, breakdown, couponInstanceId);
if (breakdown.CouponInstanceId.HasValue && order.CustomerId.HasValue)
@@ -2821,14 +3044,15 @@ public partial class OrderService
private async Task RefreshMarketingPayableAmountAsync(Order order)
{
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
- var receivableSubtotal = fees
- .Where(x => x.Type == OrderFeeTypeCodes.Receivable)
- .Sum(x => x.Amount);
+ var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees);
+ var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees);
var marketingHelper = LazyServiceProvider.GetRequiredService();
var breakdown = await marketingHelper.BuildBreakdownAsync(
order.CustomerId,
receivableSubtotal,
- order.CouponInstanceId);
+ baseFreightAmount,
+ order.CouponInstanceId,
+ order.ConsigneeCountry);
marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId);
}
@@ -2913,24 +3137,4 @@ public partial class OrderService
return OrderRuleEvaluationContext.FromOrderOnly(evalOrder);
}
- private async Task IsChannelRemoteHitAsync(
- ServiceChannel channel,
- string countryCode,
- string? province,
- string? city,
- string? zipCode)
- {
- if (channel.RemoteAreaId is not > 0)
- {
- return false;
- }
-
- var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(channel.RemoteAreaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
- }
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
index a2b290a..7ef6839 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
@@ -135,6 +135,7 @@ public partial class OrderService
{
CountryCode = countryCode,
Province = input.DestinationProvince,
+ City = input.DestinationCity,
PostalCode = input.PostalCode,
Length = dimensionLength,
Width = dimensionWidth,
@@ -229,6 +230,7 @@ public partial class OrderService
{
CountryCode = baseContext.CountryCode,
Province = baseContext.Province,
+ City = baseContext.City,
PostalCode = baseContext.PostalCode,
CargoAttribute = cargo.CargoAttribute,
CargoTypeCode = cargo.Code,
@@ -324,15 +326,18 @@ public partial class OrderService
foreach (var item in result.Items)
{
+ var originalTotal = item.TotalCost;
var breakdown = await marketingHelper.BuildBreakdownAsync(
input.CustomerId,
- item.TotalCost,
- input.CouponInstanceId);
+ originalTotal,
+ item.Freight,
+ input.CouponInstanceId,
+ input.DestinationCountry);
item.PriceBreakdown = breakdown;
item.TotalCost = breakdown.PayableAmount;
item.TotalCostNum = breakdown.PayableAmount;
firstBreakdown ??= breakdown;
- amountAfterLevel ??= breakdown.AmountAfterLevel;
+ amountAfterLevel ??= item.Freight;
}
result.PriceBreakdown = firstBreakdown;
@@ -340,7 +345,8 @@ public partial class OrderService
{
result.AvailableCoupons = await marketingHelper.BuildAvailableCouponsAsync(
input.CustomerId,
- amountAfterLevel);
+ amountAfterLevel,
+ input.DestinationCountry);
}
}
@@ -531,8 +537,7 @@ public partial class OrderService
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
}
- if (profile.HasPostalCode
- && await IsBlacklistHitAsync(
+ if (await IsBlacklistHitAsync(
product,
snapshotBinding.Rules,
context.CountryCode,
@@ -588,8 +593,7 @@ public partial class OrderService
ParcelQuoteResult? bestQuote = null;
decimal bestTotal = decimal.MaxValue;
- var isRemote = profile.HasPostalCode
- && await IsRemoteHitAsync(
+ var isRemote = await IsRemoteHitAsync(
product,
snapshotBinding.Rules,
context.CountryCode,
@@ -705,7 +709,7 @@ public partial class OrderService
attempts.Add(new QuoteZoneAttempt(keys, isFallback, keys[0]));
}
- if (profile.HasPostalCode || profile.HasProvince)
+ if (profile.HasPostalCode || profile.HasProvince || profile.HasCity)
{
await TryAddAsync(
profile.HasProvince ? context.Province : null,
@@ -733,7 +737,13 @@ public partial class OrderService
}
// 仅国家+重量:分区规则带邮编时无法精确命中,按「同国家」分区给出估算价;
- // 快照中完全没有该国家时不跨国家兜底。
+ // 须已 hydrate;禁止回读线上 KPI。
+ if (snapshotBinding.Price?.PriceAreaRefs is { Count: > 0 }
+ && snapshotBinding.Price.PriceAreaInfos is not { Count: > 0 })
+ {
+ throw new BusinessException("价格分区包未加载,无法估算分区");
+ }
+
if (snapshotBinding.Price?.PriceAreaInfos is { Count: > 0 })
{
foreach (var zoneKey in ParcelBillingCalculator.CollectCountryEstimateZoneKeys(
@@ -742,42 +752,6 @@ public partial class OrderService
{
attempts.Add(new QuoteZoneAttempt([zoneKey], true, zoneKey));
}
-
- return attempts;
- }
-
- // 无快照分区明细时,退回库表:同国家且不要求邮编的规则
- var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price);
- if (priceAreaId is > 0)
- {
- var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value);
- var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value);
- foreach (var rule in rules)
- {
- if (!string.IsNullOrWhiteSpace(rule.CountryCode)
- && !string.Equals(rule.CountryCode, context.CountryCode, StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.Province) || !string.IsNullOrWhiteSpace(rule.City))
- {
- continue;
- }
-
- var keys = ParcelBillingCalculator.BuildPriceZoneKeys(rule, area);
- if (keys.Count == 0)
- {
- continue;
- }
-
- if (attempts.Any(x => x.ZoneKeys.SequenceEqual(keys, StringComparer.OrdinalIgnoreCase)))
- {
- continue;
- }
-
- attempts.Add(new QuoteZoneAttempt(keys.ToList(), true, keys[0]));
- }
}
return attempts;
diff --git a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs
index 09ec87b..04b0668 100644
--- a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
+using Oms.Application.Storage;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index 131f975..9d33c6c 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -1,5 +1,6 @@
using Fake.Application;
using Fake.Domain.Exceptions;
+using Fake.ObjectStorage;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using MiniExcelLibs;
@@ -35,7 +36,7 @@ public partial class OrderService : ApplicationService
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService();
private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService();
- private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService();
+ private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService();
private LabelPdfGenerationService LabelPdfGenerationService =>
LazyServiceProvider.GetRequiredService();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
@@ -177,7 +178,11 @@ public partial class OrderService : ApplicationService
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer);
var pointsManager = LazyServiceProvider.GetRequiredService();
- await pointsManager.EarnOnOrderPaidAsync(mainCustomer.Id, order.Id, payAmount);
+ var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight(
+ MarketingFeeHelper.SumReceivableBaseFreight(fees),
+ order.LevelDiscountAmount,
+ order.CouponDiscountAmount);
+ await pointsManager.EarnOnOrderPaidAsync(mainCustomer.Id, order.Id, paidBaseFreight, order.ServiceNo);
order.PayTime = DateTime.Now;
order.IsPaid = 1;
@@ -198,6 +203,47 @@ public partial class OrderService : ApplicationService
var commissionManager = LazyServiceProvider.GetRequiredService();
await commissionManager.OnOrderDeliverySuccessAsync(order.Id);
+
+ var couponManager = LazyServiceProvider.GetRequiredService();
+ await couponManager.OnFirstDeliverySuccessAsync(order);
+ }
+
+ ///
+ /// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。
+ ///
+ [HttpPost("/order/refund")]
+ public async Task RefundAsync(OrderRefundInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.Id);
+ if (order.IsPaid == 0)
+ {
+ throw new BusinessException("订单未支付,无需退款");
+ }
+
+ var couponManager = LazyServiceProvider.GetRequiredService();
+ await couponManager.RestoreOnOrderRefundAsync(order, input.IsFullRefund);
+
+ order.IsRefunded = 1;
+ await UpdateOrderAsync(order);
+
+ var commissionManager = LazyServiceProvider.GetRequiredService();
+ await commissionManager.ClawbackOnOrderRefundAsync(order);
+
+ if (input.IsFullRefund && order.CustomerId.HasValue)
+ {
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
+ if (customer != null)
+ {
+ var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
+ CustomerRepository, customer);
+ var pointsManager = LazyServiceProvider.GetRequiredService();
+ await pointsManager.ClawbackOnOrderAsync(
+ mainCustomer.Id,
+ order.Id,
+ order.ServiceNo,
+ "订单退款扣回积分");
+ }
+ }
}
///
@@ -264,6 +310,23 @@ public partial class OrderService : ApplicationService
}
order.OrderStatus = OrderStatusCodes.Deleted;
+ if (order.IsPaid > 0 && order.CustomerId.HasValue)
+ {
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
+ if (customer != null)
+ {
+ var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
+ CustomerRepository,
+ customer);
+ var pointsManager = LazyServiceProvider.GetRequiredService();
+ await pointsManager.ClawbackOnOrderAsync(
+ mainCustomer.Id,
+ order.Id,
+ order.ServiceNo,
+ "订单删除扣回积分");
+ }
+ }
+
await OrderRepository.UpdateAsync(order);
await OrderRepository.DeleteAsync(order);
}
@@ -428,7 +491,7 @@ public partial class OrderService : ApplicationService
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders));
stream.Position = 0;
- var storageKey = await FileStorage.SaveAsync(
+ var storageKey = await FileStorage.SaveObjectAsync(
stream,
relativeKey,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
@@ -530,6 +593,46 @@ public partial class OrderService : ApplicationService
return output;
}
+ ///
+ /// 客户 OpenAPI 创单:同一客户相同 clientNo 视为幂等,返回已存在订单,不新建。
+ ///
+ public async Task CreateParcelForCustomerOpenApiAsync(OrderParcelCreateInput input)
+ {
+ NormalizeParcelCreateInput(input);
+ if (!string.IsNullOrWhiteSpace(input.ClientNo) && input.CustomerId is > 0)
+ {
+ var existing = await OrderRepository.GetByCustomerAndClientNoAsync(
+ input.CustomerId.Value,
+ input.ClientNo);
+ if (existing != null)
+ {
+ return await BuildParcelCreateOutputAsync(existing);
+ }
+ }
+
+ try
+ {
+ return await CreateParcelAsync(input);
+ }
+ catch (BusinessException ex) when (ex.Message == "客户单号已存在")
+ {
+ if (string.IsNullOrWhiteSpace(input.ClientNo) || input.CustomerId is not > 0)
+ {
+ throw;
+ }
+
+ var raced = await OrderRepository.GetByCustomerAndClientNoAsync(
+ input.CustomerId.Value,
+ input.ClientNo);
+ if (raced == null)
+ {
+ throw;
+ }
+
+ return await BuildParcelCreateOutputAsync(raced);
+ }
+ }
+
private async Task TryAttachPickupAfterParcelCreateAsync(
OrderParcelCreateInput input,
OrderParcelCreateOutput output)
diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
index 331c327..e73d78d 100644
--- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
+++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
@@ -66,8 +66,9 @@ internal static class ParcelBillingCalculator
public static decimal ApplyRounding(decimal rawWeight, IReadOnlyList ranges)
{
var hit = ranges
- .OrderBy(x => x.Id)
- .FirstOrDefault(x => rawWeight >= x.StartValue && rawWeight <= x.EndValue);
+ .OrderBy(x => x.StartValue)
+ .ThenBy(x => x.Id)
+ .FirstOrDefault(x => WeightInLeftOpenRightClosedInterval(rawWeight, x.StartValue, x.EndValue));
if (hit == null)
{
throw new InvalidOperationException("计重进位区间未匹配");
@@ -87,41 +88,8 @@ internal static class ParcelBillingCalculator
string countryCode,
string? province,
string? city,
- string? zipCode)
- {
- if (!string.IsNullOrWhiteSpace(rule.CountryCode)
- && !string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.Province)
- && !string.Equals(rule.Province, province, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.City)
- && !string.Equals(rule.City, city, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.LeftPostcode) || !string.IsNullOrWhiteSpace(rule.RightPostcode))
- {
- if (string.IsNullOrWhiteSpace(zipCode))
- {
- return false;
- }
-
- if (!IsPostcodeInRange(zipCode, rule.LeftPostcode, rule.RightPostcode))
- {
- return false;
- }
- }
-
- return true;
- }
+ string? zipCode) =>
+ GeoPartitionMatcher.IsHit([rule], countryCode, province, city, zipCode);
public static bool IsAreaMatched(
KpiAreaInfoOutput rule,
@@ -139,27 +107,73 @@ internal static class ParcelBillingCalculator
string countryCode,
string? province,
string? city,
+ string? zipCode) =>
+ TryResolveZoneKeysFromAreaInfos(
+ priceSnapshot?.PriceAreaInfos,
+ priceSnapshot?.PriceAreaId,
+ priceSnapshot?.PriceAreaName,
+ countryCode,
+ province,
+ city,
+ zipCode);
+
+ ///
+ /// 从渠道买价快照分区规则解析分区键(不查库;与产品卖价独立)。
+ ///
+ public static IReadOnlyList? TryResolveZoneKeysFromChannelPriceSnapshot(
+ ServiceChannelPricePublishSnapshotDto? priceSnapshot,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode) =>
+ TryResolveZoneKeysFromAreaInfos(
+ priceSnapshot?.PriceAreaInfos,
+ priceSnapshot?.PriceAreaId,
+ priceSnapshot?.PriceAreaName,
+ countryCode,
+ province,
+ city,
+ zipCode);
+
+ ///
+ /// 按分区规则列表匹配地址并生成 zone 键。
+ ///
+ public static IReadOnlyList? TryResolveZoneKeysFromAreaInfos(
+ IReadOnlyList? priceAreaInfos,
+ int? priceAreaId,
+ string? priceAreaName,
+ string countryCode,
+ string? province,
+ string? city,
string? zipCode)
{
- if (priceSnapshot?.PriceAreaInfos == null || priceSnapshot.PriceAreaInfos.Count == 0)
+ if (priceAreaInfos == null || priceAreaInfos.Count == 0)
{
return null;
}
- var matched = priceSnapshot.PriceAreaInfos.FirstOrDefault(rule =>
- KpiAreaSnapshotMapper.IsRuleMatched(rule, countryCode, province, city, zipCode));
+ var matched = GeoPartitionMatcher.MatchSnapshot(
+ priceAreaInfos,
+ countryCode,
+ province,
+ city,
+ zipCode);
if (matched == null)
{
return null;
}
- var keys = BuildPriceZoneKeys(matched, priceSnapshot.PriceAreaName, priceSnapshot.PriceAreaId);
+ var areaId = matched.AreaId > 0 ? matched.AreaId : priceAreaId;
+ var areaName = !string.IsNullOrWhiteSpace(matched.AreaName)
+ ? matched.AreaName
+ : priceAreaName;
+ var keys = BuildPriceZoneKeys(matched, areaName, areaId);
return keys.Count > 0 ? keys : null;
}
///
- /// 查价仅填国家时:若快照已配置该目的国分区(即使规则带邮编区间),
- /// 则返回卖价明细中的分区键供估算报价;未配置该国家时返回空(不跨国家兜底)。
+ /// 查价仅填国家时:只使用该国分区规则对应的 zone 键(及与这些键重合的卖价明细分区)。
+ /// 未配置该国家时返回空;不把别国明细分区拿来估算。
///
public static IReadOnlyList CollectCountryEstimateZoneKeys(
SalesProductPricePublishSnapshotDto? priceSnapshot,
@@ -172,15 +186,38 @@ internal static class ParcelBillingCalculator
return [];
}
- var countryConfigured = priceSnapshot.PriceAreaInfos.Any(rule =>
- string.IsNullOrWhiteSpace(rule.CountryCode)
- || string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase));
- if (!countryConfigured)
+ var countryRules = priceSnapshot.PriceAreaInfos
+ .Where(rule => string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ if (countryRules.Count == 0)
{
return [];
}
- return CollectDistinctDetailZoneKeys(priceSnapshot);
+ var allowed = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var rule in countryRules)
+ {
+ var areaId = rule.AreaId > 0 ? rule.AreaId : priceSnapshot.PriceAreaId;
+ var areaName = !string.IsNullOrWhiteSpace(rule.AreaName)
+ ? rule.AreaName
+ : priceSnapshot.PriceAreaName;
+ foreach (var key in BuildPriceZoneKeys(rule, areaName, areaId))
+ {
+ if (!string.IsNullOrWhiteSpace(key))
+ {
+ allowed.Add(key.Trim());
+ }
+ }
+ }
+
+ var detailZones = CollectDistinctDetailZoneKeys(priceSnapshot);
+ var mapped = detailZones.Where(zone => allowed.Contains(zone)).ToList();
+ if (mapped.Count > 0)
+ {
+ return mapped;
+ }
+
+ return allowed.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
}
public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area)
@@ -476,10 +513,22 @@ internal static class ParcelBillingCalculator
///
/// 收集卖价快照中所有启用价格项的明细分区键(去重)。
///
- public static IReadOnlyList CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot)
+ public static IReadOnlyList CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot) =>
+ CollectDistinctDetailZoneKeys(
+ priceSnapshot.Prices.Select(p => (p.Status, p.Details)));
+
+ ///
+ /// 收集渠道买价快照中所有启用价格项的明细分区键(去重)。
+ ///
+ public static IReadOnlyList CollectDistinctDetailZoneKeys(ServiceChannelPricePublishSnapshotDto priceSnapshot) =>
+ CollectDistinctDetailZoneKeys(
+ priceSnapshot.Prices.Select(p => (p.Status, p.Details)));
+
+ private static IReadOnlyList CollectDistinctDetailZoneKeys(
+ IEnumerable<(byte Status, List? Details)> prices)
{
var zones = new HashSet(StringComparer.OrdinalIgnoreCase);
- foreach (var price in priceSnapshot.Prices.Where(x => EnableStatusRules.IsEnabled(x.Status)))
+ foreach (var price in prices.Where(x => EnableStatusRules.IsEnabled(x.Status)))
{
foreach (var detail in price.Details ?? [])
{
@@ -1008,9 +1057,10 @@ internal static class ParcelBillingCalculator
};
}
- private static bool IsFreightCostItem(string costItem) =>
- costItem.Equals("freight", StringComparison.OrdinalIgnoreCase)
- || costItem.Contains("运费", StringComparison.OrdinalIgnoreCase);
+ public static bool IsFreightCostItem(string costItem) =>
+ !string.IsNullOrWhiteSpace(costItem)
+ && (costItem.Equals("freight", StringComparison.OrdinalIgnoreCase)
+ || costItem.Contains("运费", StringComparison.OrdinalIgnoreCase));
internal static string ResolveFeeDisplayName(string costItem, string? priceName)
{
@@ -2199,28 +2249,10 @@ internal static class ParcelBillingCalculator
}
catch
{
- return false;
+ throw new InvalidOperationException($"计重规则条件无效:{condition}");
}
}
- private static bool IsPostcodeInRange(string zipCode, string? left, string? right)
- {
- if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right))
- {
- return true;
- }
-
- if (long.TryParse(zipCode, out var zip)
- && long.TryParse(left, out var leftValue)
- && long.TryParse(right, out var rightValue))
- {
- return zip >= leftValue && zip <= rightValue;
- }
-
- return string.Compare(zipCode, left, StringComparison.OrdinalIgnoreCase) >= 0
- && string.Compare(zipCode, right, StringComparison.OrdinalIgnoreCase) <= 0;
- }
-
private sealed class ContinuePricingConfig
{
public decimal FirstWeight { get; set; }
diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
new file mode 100644
index 0000000..19eef43
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
@@ -0,0 +1,438 @@
+using Fake.Application;
+using Fake.DependencyInjection;
+using Oms.Application.Dtos.Channels;
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Services.Orders.Issues;
+using Oms.Application.Shared;
+using Oms.Domain.ChannelsAggregate.Entities;
+using Oms.Domain.ChannelsAggregate.Repositories;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+using Oms.Domain.SystemAggregate.Repositories;
+
+namespace Oms.Application.Services.Orders;
+
+///
+/// 承运推送揽收件重尺后,按当前销售产品卖价/买价出账。
+///
+public class PickupOrderFeePushService : IScopedDependency
+{
+ private readonly IPickupOrderRepository _pickupOrderRepository;
+ private readonly ISalesProductRepository _salesProductRepository;
+ private readonly IServiceChannelRepository _serviceChannelRepository;
+ private readonly IWeightRuleRepository _weightRuleRepository;
+ private readonly IWeightRoundingRepository _weightRoundingRepository;
+ private readonly IKpiAreaRepository _kpiAreaRepository;
+ private readonly IWarehouseAddressRepository _warehouseAddressRepository;
+ private readonly KpiAreaPackService _kpiAreaPackService;
+
+ public PickupOrderFeePushService(
+ IPickupOrderRepository pickupOrderRepository,
+ ISalesProductRepository salesProductRepository,
+ IServiceChannelRepository serviceChannelRepository,
+ IWeightRuleRepository weightRuleRepository,
+ IWeightRoundingRepository weightRoundingRepository,
+ IKpiAreaRepository kpiAreaRepository,
+ IWarehouseAddressRepository warehouseAddressRepository,
+ KpiAreaPackService kpiAreaPackService)
+ {
+ _pickupOrderRepository = pickupOrderRepository;
+ _salesProductRepository = salesProductRepository;
+ _serviceChannelRepository = serviceChannelRepository;
+ _weightRuleRepository = weightRuleRepository;
+ _weightRoundingRepository = weightRoundingRepository;
+ _kpiAreaRepository = kpiAreaRepository;
+ _warehouseAddressRepository = warehouseAddressRepository;
+ _kpiAreaPackService = kpiAreaPackService;
+ }
+
+ public async Task PushFeeAsync(PickupOpenApiPushFeeInput? input)
+ {
+ if (input == null)
+ {
+ throw new BusinessException("请求体不能为空");
+ }
+
+ var carrierWaybillNo = (input.CarrierWaybillNo ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(carrierWaybillNo))
+ {
+ throw new BusinessException("carrierWaybillNo(承运运单号)不能为空");
+ }
+
+ if (input.TotalActualWeight <= 0)
+ {
+ throw new BusinessException("totalActualWeight 必须大于 0");
+ }
+
+ var sizes = NormalizeSizes(input.PackageSizes);
+ if (sizes.Count == 0)
+ {
+ throw new BusinessException("packageSizes 不能为空");
+ }
+
+ var pickup = await _pickupOrderRepository.GetByCarrierWaybillNoAsync(carrierWaybillNo)
+ ?? throw new BusinessException($"揽收单不存在:承运运单号 {carrierWaybillNo}");
+
+ if (pickup.PayStatus == PickupPayStatusCodes.Paid)
+ {
+ throw new BusinessException("揽收单已支付,拒绝修改件重尺与费用");
+ }
+
+ if (PickupOrderStatusCodes.IsTerminal(pickup.Status)
+ && pickup.Status != PickupOrderStatusCodes.Inbound)
+ {
+ throw new BusinessException("当前揽收单状态不可接收件重尺");
+ }
+
+ if (string.IsNullOrWhiteSpace(pickup.CarrierService))
+ {
+ throw new BusinessException("揽收单未选择承运服务(销售产品),无法计费");
+ }
+
+ var product = await _salesProductRepository.GetByProductCodeAsync(pickup.CarrierService.Trim())
+ ?? throw new BusinessException($"未找到承运服务对应销售产品「{pickup.CarrierService}」");
+
+ var totalVolume = sizes.Sum(x => x.Length * x.Width * x.Height * x.Count);
+ var weightRow = new OrderWeight
+ {
+ Type = OrderWeightTypeCodes.Receive,
+ Length = totalVolume,
+ Width = 1,
+ Height = 1,
+ ActualWeight = input.TotalActualWeight,
+ Cubic = ParcelBillingCalculator.CalculateCubic(totalVolume, 1, 1)
+ };
+
+ decimal volumetricWeight;
+ decimal chargeableWeight;
+ try
+ {
+ (volumetricWeight, chargeableWeight) = await CalculateChargeableAsync(product, weightRow);
+ }
+ catch (InvalidOperationException ex)
+ {
+ throw new BusinessException($"计费重计算失败:{ex.Message}");
+ }
+
+ var countryCode = string.IsNullOrWhiteSpace(pickup.SenderCountryCode)
+ ? "CN"
+ : pickup.SenderCountryCode.Trim().ToUpperInvariant();
+ var priceSnapshot = await BuildSellPriceSnapshotAsync(product);
+ var zoneKeys = ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
+ priceSnapshot,
+ countryCode,
+ pickup.SenderProvince,
+ pickup.SenderCity,
+ pickup.SenderZipCode);
+ if (zoneKeys == null || zoneKeys.Count == 0)
+ {
+ throw new BusinessException("卖价分区未匹配,请检查揽收地址与产品价格分区");
+ }
+
+ var warehouse = await _warehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == pickup.WarehouseId);
+ var warehouseName = warehouse?.WarehouseName;
+ var priceVersion = DateTime.Now.ToString("yyyyMMddHHmmss");
+ var priceEffectiveTime = DateTime.Now;
+ var cargoAttribute = CargoAttributeCodes.General;
+ var conditionContext = OrderRuleEvaluationContext.FromOrderOnly(new Order
+ {
+ ProductCode = product.ProductCode,
+ ConsigneeCountry = countryCode,
+ CargoAttribute = cargoAttribute,
+ WarehouseId = pickup.WarehouseId,
+ CustomerId = pickup.CustomerId
+ });
+
+ var receivableLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
+ priceSnapshot,
+ priceVersion,
+ priceEffectiveTime,
+ chargeableWeight,
+ zoneKeys,
+ cargoAttribute,
+ priceTierKey: null,
+ pickup.WarehouseId,
+ warehouseName,
+ conditionContext);
+
+ if (receivableLines.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(priceSnapshot))
+ {
+ var detail = ParcelBillingCalculator.ExplainQuotePriceMiss(
+ priceSnapshot,
+ chargeableWeight,
+ zoneKeys,
+ cargoAttribute,
+ null,
+ pickup.WarehouseId,
+ warehouseName);
+ throw new BusinessException($"卖价未匹配到有效价格:{detail}");
+ }
+
+ var payable = await CalculatePayableAsync(
+ product,
+ pickup,
+ zoneKeys,
+ chargeableWeight,
+ cargoAttribute,
+ warehouseName,
+ priceVersion,
+ priceEffectiveTime,
+ conditionContext);
+
+ var receivableFees = receivableLines.Select(x => ToFeeLine(x.Fee)).ToList();
+ var payableFees = payable.Select(ToFeeLine).ToList();
+ var receivableTotal = receivableFees.Sum(x => x.Amount);
+ var payableTotal = payableFees.Sum(x => x.Amount);
+ var freight = receivableFees.FirstOrDefault()?.Amount ?? receivableTotal;
+ var other = Math.Max(0, receivableTotal - freight);
+
+ pickup.TotalActualWeight = input.TotalActualWeight;
+ pickup.VolumetricWeight = volumetricWeight;
+ pickup.ChargeableWeight = chargeableWeight;
+ pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes);
+ pickup.CarrierFreightWeight = input.CarrierFreightWeight;
+ pickup.CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount;
+ pickup.CarrierQuotedReceivableAmount = input.CarrierQuotedReceivableAmount;
+ pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput
+ {
+ ValueInfoList = input.ValueInfoList ?? []
+ });
+ pickup.ReceivableFeeJson = OmsDocHelper.SerializeJson(receivableFees);
+ pickup.PayableFeeJson = OmsDocHelper.SerializeJson(payableFees);
+ pickup.FreightAmount = freight;
+ pickup.OtherFeeAmount = other;
+ pickup.ReceivableAmount = receivableTotal;
+ pickup.CarrierPayableAmount = payableTotal;
+ pickup.FeePushedAt = DateTime.Now;
+ pickup.UpdateTime = DateTime.Now;
+ await _pickupOrderRepository.UpdateAsync(pickup);
+
+ return new PickupOpenApiPushFeeResult
+ {
+ PickupNo = pickup.PickupNo,
+ TotalActualWeight = input.TotalActualWeight,
+ ChargeableWeight = chargeableWeight,
+ VolumetricWeight = volumetricWeight,
+ ReceivableAmount = receivableTotal,
+ CarrierPayableAmount = payableTotal,
+ CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount
+ };
+ }
+
+ private async Task<(decimal VolumetricWeight, decimal ChargeableWeight)> CalculateChargeableAsync(
+ SalesProduct product,
+ OrderWeight weight)
+ {
+ if (product.WeightRuleId is not > 0)
+ {
+ throw new BusinessException("未配置计重规则");
+ }
+
+ var ruleInfos = await _weightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value);
+ var (volumetric, raw) = ParcelBillingCalculator.CalculateRawBillableWeight(weight, ruleInfos);
+ if (product.WeightRoundingId is not > 0)
+ {
+ throw new BusinessException("未配置计重进位");
+ }
+
+ var ranges = await _weightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value);
+ var chargeable = ParcelBillingCalculator.ApplyRounding(raw, ranges);
+ return (volumetric, chargeable);
+ }
+
+ private async Task BuildSellPriceSnapshotAsync(SalesProduct product)
+ {
+ var snapshot = new SalesProductPricePublishSnapshotDto
+ {
+ SalesProductId = product.Id,
+ Prices = (await GetSellPriceOutputsAsync(product.Id)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
+ };
+ await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync(
+ snapshot,
+ product,
+ _kpiAreaRepository,
+ _kpiAreaPackService);
+ return snapshot;
+ }
+
+ private async Task> GetSellPriceOutputsAsync(int salesProductId)
+ {
+ var prices = await _salesProductRepository.GetPricesAsync(salesProductId);
+ var priceIds = prices.Select(x => x.Id).ToArray();
+ var factorMap = (await _salesProductRepository.GetPriceFactorsAsync(priceIds))
+ .GroupBy(x => x.PriceId)
+ .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList());
+ var detailMap = (await _salesProductRepository.GetPriceDetailsAsync(priceIds))
+ .GroupBy(x => x.PriceId)
+ .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
+
+ return prices.Select(price => new SalesProductPriceListOutput
+ {
+ Id = price.Id,
+ SalesProductId = price.SalesProductId,
+ SerialNumber = price.SerialNumber,
+ Status = (byte)price.Status,
+ PriceName = price.PriceName,
+ CostItem = price.CostItem,
+ CostUnit = price.CostUnit,
+ Currency = price.Currency,
+ ChargingCondition = price.ChargingCondition,
+ Factors = factorMap.TryGetValue(price.Id, out var factors)
+ ? factors.Select(f => new ChannelPriceFactorOutput
+ {
+ Id = f.Id,
+ PriceId = f.PriceId,
+ SerialNumber = f.SerialNumber,
+ Factor = f.Factor,
+ FactorValues = OmsDocHelper.DeserializeJson>(f.FactorValues) ?? []
+ }).ToList()
+ : [],
+ Details = detailMap.TryGetValue(price.Id, out var details)
+ ? details.Select(d => new ChannelPriceDetailOutput
+ {
+ Id = d.Id,
+ PriceId = d.PriceId,
+ FactorValuesJson = OmsDocHelper.DeserializeJsonObject(d.FactorValuesJson),
+ CalculationFormula = d.CalculationFormula,
+ CalculatedValue = d.CalculatedValue,
+ CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(d.CustomerGroupValues)
+ }).ToList()
+ : []
+ }).ToList();
+ }
+
+ private async Task> CalculatePayableAsync(
+ SalesProduct product,
+ PickupOrder pickup,
+ IReadOnlyList zoneKeys,
+ decimal chargeableWeight,
+ string cargoAttribute,
+ string? warehouseName,
+ string priceVersion,
+ DateTime priceEffectiveTime,
+ OrderRuleEvaluationContext conditionContext)
+ {
+ var providers = await _salesProductRepository.GetAllocationProvidersAsync(product.Id);
+ var channelIds = providers.Where(x => x.ServiceChannelId is > 0).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray();
+ var channels = channelIds.Length == 0
+ ? []
+ : await _serviceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id));
+ var channelNameMap = channels.ToDictionary(x => x.Id, x => x.ChannelName);
+ var snapshots = providers.Select(provider => new SalesProductAllocationProviderSnapshotDto
+ {
+ Id = provider.Id,
+ SalesProductId = provider.SalesProductId,
+ SerialNumber = provider.SerialNumber,
+ ProviderName = provider.ProviderName,
+ ServiceChannelId = provider.ServiceChannelId,
+ ServiceChannelName = provider.ServiceChannelId is > 0
+ && channelNameMap.TryGetValue(provider.ServiceChannelId.Value, out var name)
+ ? name
+ : null,
+ Weight = provider.Weight,
+ CustomerIds = OmsDocHelper.DeserializeJson>(provider.CustomerIds) ?? [],
+ MatchCondition = provider.MatchCondition
+ }).ToList();
+
+ var filtered = AllocationResolver.FilterProvidersDetailed(snapshots, pickup.CustomerId, conditionContext).Passed;
+ var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0);
+ if (provider?.ServiceChannelId is not > 0)
+ {
+ throw new BusinessException("未配置可用排货服务渠道,无法计算应付");
+ }
+
+ var channelPrices = await GetChannelPriceOutputsAsync(provider.ServiceChannelId.Value);
+ if (channelPrices.Count == 0)
+ {
+ throw new BusinessException($"服务渠道 {provider.ServiceChannelId} 无买价");
+ }
+
+ var payable = ParcelBillingCalculator.CalculatePayableFees(
+ channelPrices,
+ priceVersion,
+ priceEffectiveTime,
+ chargeableWeight,
+ zoneKeys,
+ cargoAttribute,
+ OrderFeeChargingStageCodes.Forecast,
+ pickup.WarehouseId,
+ warehouseName,
+ conditionContext);
+
+ if (payable.Count == 0)
+ {
+ throw new BusinessException($"服务渠道 {provider.ServiceChannelId} 买价未匹配");
+ }
+
+ return payable;
+ }
+
+ private async Task> GetChannelPriceOutputsAsync(int serviceChannelId)
+ {
+ var prices = await _serviceChannelRepository.GetPricesAsync(serviceChannelId);
+ var priceIds = prices.Select(x => x.Id).ToArray();
+ var factorMap = (await _serviceChannelRepository.GetPriceFactorsAsync(priceIds))
+ .GroupBy(x => x.PriceId)
+ .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList());
+ var detailMap = (await _serviceChannelRepository.GetPriceDetailsAsync(priceIds))
+ .GroupBy(x => x.PriceId)
+ .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
+
+ return prices.Select(price => new ServiceChannelPriceListOutput
+ {
+ Id = price.Id,
+ ServiceChannelId = price.ServiceChannelId,
+ SerialNumber = price.SerialNumber,
+ Status = (byte)price.Status,
+ PriceName = price.PriceName,
+ CostItem = price.CostItem,
+ CostUnit = price.CostUnit,
+ Currency = price.Currency,
+ ChargingCondition = price.ChargingCondition,
+ Factors = factorMap.TryGetValue(price.Id, out var factors)
+ ? factors.Select(f => new ChannelPriceFactorOutput
+ {
+ Id = f.Id,
+ PriceId = f.PriceId,
+ SerialNumber = f.SerialNumber,
+ Factor = f.Factor,
+ FactorValues = OmsDocHelper.DeserializeJson>(f.FactorValues) ?? []
+ }).ToList()
+ : [],
+ Details = detailMap.TryGetValue(price.Id, out var details)
+ ? details.Select(d => new ChannelPriceDetailOutput
+ {
+ Id = d.Id,
+ PriceId = d.PriceId,
+ FactorValuesJson = OmsDocHelper.DeserializeJsonObject(d.FactorValuesJson),
+ CalculationFormula = d.CalculationFormula,
+ CalculatedValue = d.CalculatedValue,
+ CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(d.CustomerGroupValues)
+ }).ToList()
+ : []
+ }).ToList();
+ }
+
+ private static List NormalizeSizes(List? list)
+ {
+ return (list ?? [])
+ .Select(x => new PickupPackageSizeOutput
+ {
+ Length = x.Length,
+ Width = x.Width,
+ Height = x.Height,
+ Count = x.Count <= 0 ? 1 : x.Count
+ })
+ .Where(x => x.Length > 0 && x.Width > 0 && x.Height > 0)
+ .ToList();
+ }
+
+ private static PickupFeeLineOutput ToFeeLine(OrderFee fee) =>
+ new()
+ {
+ CostItem = fee.CostItem,
+ Amount = fee.Amount,
+ Currency = fee.Currency ?? string.Empty
+ };
+}
diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs
index fc10454..4ee5162 100644
--- a/src/Oms.Application/Services/Orders/PickupOrderService.cs
+++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Integrations.Itfx;
+using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Repositories;
@@ -65,6 +66,9 @@ public class PickupOrderService : ApplicationService
private IItfxMetadataClient ItfxMetadataClient =>
LazyServiceProvider.GetRequiredService();
+ private IPickupBigBagTrackSyncer PickupBigBagTrackSyncer =>
+ LazyServiceProvider.GetRequiredService();
+
///
/// 创建揽收单并挂载物流单(原子:全部校验通过才落库)。
///
@@ -983,6 +987,11 @@ public class PickupOrderService : ApplicationService
}
}
+ if (forward.Success)
+ {
+ await PickupBigBagTrackSyncer.SyncAfterDownstreamForwardAsync(pickup);
+ }
+
return new OrderDownstreamForwardResult
{
Success = forward.Success,
@@ -996,6 +1005,16 @@ public class PickupOrderService : ApplicationService
};
}
+ ///
+ /// 手动创建大包轨迹订单:按序 UpsertBigBag → Link → Track/Upsert(A0201),返回逐步请求/响应。
+ ///
+ [HttpPost("/order/pickup-order/track-upsert-big-bag")]
+ public async Task TrackUpsertBigBagAsync([FromBody] PickupOrderIdInput input)
+ {
+ var pickup = await GetOwnedPickupOrThrowAsync(input.Id);
+ return await PickupBigBagTrackSyncer.SyncDebugAsync(pickup);
+ }
+
///
/// 凭证:优先揽收单 CarrierCode 作为 supplierCode;AppKey/Secret 仍按承运服务→渠道分组解析。
///
@@ -1137,6 +1156,15 @@ public class PickupOrderService : ApplicationService
ParcelCount = entity.ParcelCount,
TotalActualWeight = entity.TotalActualWeight,
ChargeableWeight = entity.ChargeableWeight,
+ VolumetricWeight = entity.VolumetricWeight,
+ PackageSizes = OmsDocHelper.DeserializeJson>(entity.PackageSizesJson) ?? [],
+ CarrierFreightWeight = entity.CarrierFreightWeight,
+ CarrierQuotedFreightAmount = entity.CarrierQuotedFreightAmount,
+ CarrierQuotedReceivableAmount = entity.CarrierQuotedReceivableAmount,
+ CarrierQuote = OmsDocHelper.DeserializeJson(entity.CarrierQuoteJson),
+ ReceivableFees = OmsDocHelper.DeserializeJson>(entity.ReceivableFeeJson) ?? [],
+ PayableFees = OmsDocHelper.DeserializeJson>(entity.PayableFeeJson) ?? [],
+ FeePushedAt = entity.FeePushedAt,
Currency = entity.Currency,
FreightAmount = entity.FreightAmount,
OtherFeeAmount = entity.OtherFeeAmount,
diff --git a/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs b/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
index 8d489db..1cc1540 100644
--- a/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
+++ b/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
@@ -56,9 +56,11 @@ internal static class PriceCheckHintBuilder
PriceCheckMissingFieldCodes.Dimensions =>
"未填写包裹尺寸:计费重按实重估算,入库实测尺寸后价格可能变化。",
PriceCheckMissingFieldCodes.PostalCode =>
- "未填写邮编:未校验黑名单/偏远/精确分区,报价仅供参考。",
+ "未填写邮编:优先按州/市或国家级分区匹配,精确邮编可能调整价格。",
PriceCheckMissingFieldCodes.Province =>
- "未填写省州:分区按国家级匹配,精确地址可能调整价格。",
+ "未填写省州:无邮编时无法按州/市降级匹配,分区可能按国家级兜底。",
+ PriceCheckMissingFieldCodes.City =>
+ "未填写城市:无邮编且州未命中时无法按城市匹配。",
PriceCheckMissingFieldCodes.CargoType =>
"未选择货物属性:已分别试算普货/带电/敏感货等,下单时以所选属性为准。",
PriceCheckMissingFieldCodes.Warehouse =>
diff --git a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
index fd72af7..c83a0c1 100644
--- a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
+++ b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
@@ -13,6 +13,8 @@ internal sealed class PriceCheckInputProfile
public bool HasProvince { get; init; }
+ public bool HasCity { get; init; }
+
public bool HasCargoTypeFilter { get; init; }
public bool HasWarehouseFilter { get; init; }
@@ -26,6 +28,7 @@ internal sealed class PriceCheckInputProfile
var hasDimensions = input.Length is > 0 && input.Width is > 0 && input.Height is > 0;
var hasPostal = !string.IsNullOrWhiteSpace(input.PostalCode);
var hasProvince = !string.IsNullOrWhiteSpace(input.DestinationProvince);
+ var hasCity = !string.IsNullOrWhiteSpace(input.DestinationCity);
var cargoCodes = (input.CargoTypes ?? [])
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
@@ -49,6 +52,11 @@ internal sealed class PriceCheckInputProfile
missing.Add(PriceCheckMissingFieldCodes.PostalCode);
}
+ if (!hasPostal && !hasCity)
+ {
+ missing.Add(PriceCheckMissingFieldCodes.City);
+ }
+
if (!hasProvince)
{
missing.Add(PriceCheckMissingFieldCodes.Province);
@@ -69,6 +77,7 @@ internal sealed class PriceCheckInputProfile
HasDimensions = hasDimensions,
HasPostalCode = hasPostal,
HasProvince = hasProvince,
+ HasCity = hasCity,
HasCargoTypeFilter = hasCargoFilter,
HasWarehouseFilter = hasWarehouseFilter,
HasShippingMethodFilter = hasShippingFilter,
@@ -87,6 +96,8 @@ internal static class PriceCheckMissingFieldCodes
public const string Province = "province";
+ public const string City = "city";
+
public const string CargoType = "cargoType";
public const string Warehouse = "warehouse";
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
index ba2043d..b5be7bd 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
@@ -110,10 +110,12 @@ public static class OrderTemplateFieldRegistry
Add("warehouseId", "发货仓库", true, "select", "warehouse", "basic", "1", hint: "从下拉选择发货仓库 ID");
Add("actualWeight", "实重(kg)", true, "number", example: "1.2", hint: "包裹实际重量,单位千克");
Add("quantity", "包裹件数", true, "number", example: "1", hint: "一般为 1");
- Add("length", "长(cm)", false, "number", example: "10", hint: "选填,用于材积计费");
- Add("width", "宽(cm)", false, "number", example: "10", hint: "选填,用于材积计费");
- Add("height", "高(cm)", false, "number", example: "10", hint: "选填,用于材积计费");
- Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报");
+ Add("length", "长(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
+ Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
+ Add("height", "高(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
+ Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报;填 9610Summary 时需有默认出口资质或填备案Id,并填电商平台");
+ Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案");
+ Add("platformCode", "电商平台", false, example: "Amazon", hint: "9610汇总申报时必填,写入海关申报电商平台");
Add("receiver.recipientsName", "收件人姓名", true, group: "receiver", example: "John Smith", hint: "必填");
Add("receiver.phone", "收件人手机", true, group: "receiver", example: "13900139000", hint: "必填");
@@ -167,11 +169,13 @@ public static class OrderTemplateFieldRegistry
Add("clientNo", "客户单号", true, example: "C202604230001", hint: "您自己的订单编号,同一客户不可重复");
Add("productCode", "运输方式", true, "select", "sales_product", "basic", "PROD001", hint: "从下拉选择产品编码");
Add("cargoAttribute", "货物属性", true, "select", "cargo_attribute", "basic", "general", hint: "普货选 general");
- Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报");
+ Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报;填 9610Summary 时需有默认出口资质或填备案Id,并填电商平台");
+ Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案");
+ Add("platformCode", "电商平台", false, example: "Amazon", hint: "9610汇总申报时必填,写入海关申报电商平台");
Add("warehouseId", "发货仓库", true, "select", "warehouse", "basic", "1", hint: "从下拉选择仓库 ID");
- Add("length", "长(cm)", false, "number", example: "10", hint: "材积计费用");
- Add("width", "宽(cm)", false, "number", example: "10", hint: "材积计费用");
- Add("height", "高(cm)", false, "number", example: "10", hint: "材积计费用");
+ Add("length", "长(cm)", true, "number", example: "10", hint: "必填,材积计费用");
+ Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,材积计费用");
+ Add("height", "高(cm)", true, "number", example: "10", hint: "必填,材积计费用");
Add("actualWeight", "实重(kg)", true, "number", example: "1.2", hint: "必填");
Add("isCod", "是否COD", false, "boolean", example: "false", hint: "货到付款填 true");
Add("codCurrency", "COD币种", false, "select", "currency", example: "USD", hint: "COD 订单填写");
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs
index 76830e7..7ba39ba 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs
@@ -20,6 +20,10 @@ public static class OrderTemplateHeaderMatcher
["产品编码"] = "productCode",
["货物属性"] = "cargoAttribute",
["出口报关方式"] = "exportCustomsMode",
+ ["出口报关"] = "exportCustomsMode",
+ ["出口资质备案Id"] = "exportQualificationId",
+ ["出口资质备案"] = "exportQualificationId",
+ ["资质备案Id"] = "exportQualificationId",
["仓库"] = "warehouseId",
["仓库ID"] = "warehouseId",
["长cm"] = "length",
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs
index e9146fb..b1470a6 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs
@@ -27,7 +27,7 @@ public static class OrderTemplateRowMapper
private static readonly HashSet ForceNumberFields = new(StringComparer.OrdinalIgnoreCase)
{
- "warehouseId", "length", "width", "height", "actualWeight", "codSum", "declaredSum", "quantity",
+ "warehouseId", "exportQualificationId", "length", "width", "height", "actualWeight", "codSum", "declaredSum", "quantity",
"receiver.taxType", "receiver.certificateType", "items[0].unitPrice", "items[0].unitWeight",
"items[0].quantity", "items[0].amount", "items[0].weight"
};
@@ -75,9 +75,28 @@ public static class OrderTemplateRowMapper
}
ApplyDerivedDeclarationFields(input);
+ ApplyParcelDimensionDefaults(input);
return input;
}
+ private static void ApplyParcelDimensionDefaults(OrderParcelCreateInput input)
+ {
+ if (!input.Length.HasValue || input.Length <= 0)
+ {
+ input.Length = 10;
+ }
+
+ if (!input.Width.HasValue || input.Width <= 0)
+ {
+ input.Width = 10;
+ }
+
+ if (!input.Height.HasValue || input.Height <= 0)
+ {
+ input.Height = 10;
+ }
+ }
+
private static void ApplyDerivedDeclarationFields(OrderParcelCreateInput input)
{
if (string.IsNullOrWhiteSpace(input.DeclaredCurrency))
diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs
index 45ae3aa..8abb5fb 100644
--- a/src/Oms.Application/Services/Rbac/OmsUserService.cs
+++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs
@@ -4,8 +4,10 @@ using Fake.Rbac.Application.Dtos.Common;
using Fake.Rbac.Application.Dtos.Role;
using Fake.Rbac.Application.Dtos.User;
using Fake.Rbac.Application.Services;
+using Fake.Rbac.Domain.Managers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Fake.ObjectStorage;
using Oms.Application.Dtos.Rbac;
using Oms.Application.Rbac;
using Oms.Application.Storage;
@@ -22,9 +24,10 @@ namespace Oms.Application.Services.Rbac;
[ApiExplorerSettings(GroupName = "RBAC-User")]
public class OmsUserService(
UserService userService,
+ AccountManager accountManager,
UserProfileRepository profileRepository,
IEmployeeRepository employeeRepository,
- IFileStorage fileStorage) : ApplicationService
+ IObjectStorage fileStorage) : ApplicationService
{
///
/// 获取用户详情(含扩展资料)。
@@ -98,7 +101,7 @@ public class OmsUserService(
Account = input.Account,
Password = input.Password,
Email = input.Email,
- Avatar = fileStorage.ToStorageKey(input.Avatar),
+ Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
OrganizationId = input.OrganizationId,
RoleIds = input.RoleIds,
}, cancellationToken);
@@ -136,7 +139,7 @@ public class OmsUserService(
{
Name = input.Name,
Email = input.Email,
- Avatar = fileStorage.ToStorageKey(input.Avatar),
+ Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
OrganizationId = input.OrganizationId,
},
cancellationToken);
@@ -166,6 +169,34 @@ public class OmsUserService(
CancellationToken cancellationToken = default) =>
userService.UpdatePasswordAsync(id, input, cancellationToken);
+ ///
+ /// 管理员重置用户密码(无需旧密码)。
+ ///
+ [HttpPost("/rbac/user/reset-password")]
+ public async Task ResetPasswordAsync(
+ [FromQuery] Guid id,
+ [FromBody] ResetUserPasswordInput input,
+ CancellationToken cancellationToken = default)
+ {
+ if (id == Guid.Empty)
+ {
+ throw new BusinessException("用户 Id 无效");
+ }
+
+ var newPassword = input?.NewPassword?.Trim();
+ if (string.IsNullOrWhiteSpace(newPassword))
+ {
+ throw new BusinessException("新密码不能为空");
+ }
+
+ if (newPassword.Length < 6)
+ {
+ throw new BusinessException("密码长度不能少于6位");
+ }
+
+ await accountManager.ResetPasswordAsync(id, newPassword, cancellationToken);
+ }
+
///
/// 更新用户头像。
///
@@ -174,7 +205,7 @@ public class OmsUserService(
[FromQuery] Guid id,
[FromQuery] string avatarUrl,
CancellationToken cancellationToken = default) =>
- userService.UpdateAvatarAsync(id, fileStorage.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken);
+ userService.UpdateAvatarAsync(id, OmsObjectStorageExtensions.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken);
///
/// 删除用户。
diff --git a/src/Oms.Application/Services/Recharge/RechargeService.cs b/src/Oms.Application/Services/Recharge/RechargeService.cs
index 0479431..200f43a 100644
--- a/src/Oms.Application/Services/Recharge/RechargeService.cs
+++ b/src/Oms.Application/Services/Recharge/RechargeService.cs
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
using MiniExcelLibs;
using Oms.Application.Dtos.Recharge;
using Oms.Application.Dtos.Shared;
+using Fake.ObjectStorage;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.CustomersAggregate.Repositories;
@@ -22,7 +23,7 @@ public class RechargeService : ApplicationService
{
private IRechargeRecordRepository RechargeRecordRepository => LazyServiceProvider.GetRequiredService();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService();
- private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService();
+ private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService();
///
/// 提交充值申请。
diff --git a/src/Oms.Application/Services/Storage/FileStorageService.cs b/src/Oms.Application/Services/Storage/FileStorageService.cs
index 1c256d0..d043bc2 100644
--- a/src/Oms.Application/Services/Storage/FileStorageService.cs
+++ b/src/Oms.Application/Services/Storage/FileStorageService.cs
@@ -1,127 +1,158 @@
-using System.Net.Http.Headers;
+using System.Text.RegularExpressions;
using Fake.Application;
-using Microsoft.AspNetCore.Authorization;
+using Fake.ObjectStorage;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Logging;
+using Oms.Application.Dtos.Storage;
using Oms.Application.Storage;
namespace Oms.Application.Services.Storage;
///
-/// 文件存储直传 / 临时访问。私有桶:前端拿凭证直传 COS,后端只存 key。
+/// 对象存储辅助 API(预览 stream / 规范化 key / 直传预签名)。
///
-[Authorize]
-[ApiExplorerSettings(GroupName = "OMS-Storage")]
+[ApiExplorerSettings(GroupName = "OMS")]
public class FileStorageService : ApplicationService
{
- public const string PrintProxyHttpClientName = "FileStoragePrintProxy";
+ public const string UrlFetchClientName = "oms-storage-url-fetch";
- private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService();
- private IHttpClientFactory HttpClientFactory => LazyServiceProvider.GetRequiredService();
- private ILogger FileLogger =>
- LazyServiceProvider.GetRequiredService>();
-
- ///
- /// 是否支持前端直传(Cos=true)。
- ///
- [HttpGet("/file-storage/capabilities")]
- public object GetCapabilities() => new
+ private static readonly HashSet AllowedFolders = new(StringComparer.OrdinalIgnoreCase)
{
- supportsClientDirectUpload = FileStorage.SupportsClientDirectUpload,
- provider = FileStorage.GetType().Name
+ "avatars",
+ "sku",
+ "parcel",
+ "recharge",
+ "order-files",
+ "order-templates",
+ "xhs",
+ "prints",
+ "exports",
+ "misc"
};
+ private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService();
+
+ private IHttpClientFactory HttpClientFactory =>
+ LazyServiceProvider.GetRequiredService();
+
///
- /// 签发预签名 PUT 上传地址。前端对该 URL 直接 PUT 文件体,成功后把返回的 key 交给业务接口落库。
+ /// 是否支持浏览器直传 COS。
///
- [HttpPost("/file-storage/presign-upload")]
- public async Task PresignUploadAsync([FromBody] PresignUploadInput input)
+ [HttpGet("/file-storage/capabilities")]
+ public FileStorageCapabilitiesOutput GetCapabilities()
{
- if (string.IsNullOrWhiteSpace(input.FileName))
+ return new FileStorageCapabilitiesOutput
{
- throw new BusinessException("fileName 不能为空");
- }
-
- var ticket = await FileStorage.CreatePresignedUploadAsync(
- input.Folder ?? "misc",
- input.FileName.Trim(),
- input.ContentType);
-
- return new PresignUploadOutput
- {
- Key = ticket.Key,
- UploadUrl = ticket.UploadUrl,
- Method = ticket.Method,
- ContentType = ticket.ContentType,
- Headers = ticket.Headers,
- ExpireAt = ticket.ExpireAt
+ SupportsClientDirectUpload = !FileStorage.IsLocalProvider(),
+ Provider = FileStorage.Name
};
}
///
- /// 签发 STS 临时密钥。前端用 cos-js-sdk-v5 等 SDK 直传;落库只存 key。
+ /// 将 COS URL / 相对路径规范为 object key。
///
- [HttpPost("/file-storage/sts-upload")]
- public async Task StsUploadAsync([FromBody] PresignUploadInput input)
+ [HttpPost("/file-storage/normalize-key")]
+ public FileStorageKeyOutput NormalizeKey([FromBody] FileStorageNormalizeKeyInput input)
{
- if (string.IsNullOrWhiteSpace(input.FileName))
+ if (string.IsNullOrWhiteSpace(input?.PathOrUrl))
{
- throw new BusinessException("fileName 不能为空");
+ throw new BusinessException("路径不能为空");
}
- var ticket = await FileStorage.CreateStsUploadAsync(
- input.Folder ?? "misc",
- input.FileName.Trim());
-
- return new StsUploadOutput
- {
- Key = ticket.Key,
- CosKey = ticket.CosKey,
- Bucket = ticket.Bucket,
- Region = ticket.Region,
- TmpSecretId = ticket.TmpSecretId,
- TmpSecretKey = ticket.TmpSecretKey,
- SessionToken = ticket.SessionToken,
- StartTime = ticket.StartTime,
- ExpiredTime = ticket.ExpiredTime
- };
+ return new FileStorageKeyOutput { Key = FileStoragePath.NormalizeKey(input.PathOrUrl) };
}
///
- /// 按库内 key 生成临时访问地址(私有桶预签名 GET)。
+ /// 按 key 换临时访问地址。
///
[HttpGet("/file-storage/access-url")]
- public AccessUrlOutput GetAccessUrl([FromQuery] string key)
+ public async Task GetAccessUrlAsync(
+ [FromQuery] string key,
+ CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(key))
{
throw new BusinessException("key 不能为空");
}
- var storageKey = FileStorage.ToStorageKey(key) ?? key.Trim();
- return new AccessUrlOutput
+ var trimmed = key.Trim();
+ var url = await FileStorage.GetPublicUrlAsync(trimmed, cancellationToken);
+ if (string.IsNullOrWhiteSpace(url))
{
- Key = storageKey,
- Url = FileStorage.GetPublicUrl(storageKey)
+ throw new BusinessException("未返回访问地址");
+ }
+
+ return new FileStorageAccessUrlOutput
+ {
+ Key = FileStoragePath.NormalizeKey(trimmed),
+ Url = url
};
}
///
- /// 将完整 URL / 相对路径规范为库内 key(业务保存前可调用)。
+ /// 签发预签名 PUT,供前端直传。
///
- [HttpPost("/file-storage/normalize-key")]
- public NormalizeKeyOutput NormalizeKey([FromBody] NormalizeKeyInput input)
+ [HttpPost("/file-storage/presign-upload")]
+ public async Task PresignUploadAsync(
+ [FromBody] FileStoragePresignInput input,
+ CancellationToken cancellationToken)
{
- var key = FileStorage.ToStorageKey(input.PathOrUrl);
- return new NormalizeKeyOutput { Key = key };
+ var folder = (input.Folder ?? "misc").Trim().Trim('/');
+ if (string.IsNullOrWhiteSpace(folder) || folder.Contains("..") || folder.Contains('/') || folder.Contains('\\'))
+ {
+ throw new BusinessException("folder 无效");
+ }
+
+ if (!AllowedFolders.Contains(folder))
+ {
+ throw new BusinessException("不支持的上传目录");
+ }
+
+ var fileName = string.IsNullOrWhiteSpace(input.FileName) ? "file.bin" : Path.GetFileName(input.FileName.Trim());
+ var ext = Path.GetExtension(fileName);
+ if (ext.Length > 16)
+ {
+ ext = "";
+ }
+
+ var objectKey = $"uploads/{folder}/{DateTime.Now:yyyyMM}/{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
+ var result = await FileStorage.CreatePresignedUploadAsync(
+ new ObjectStoragePresignUploadArgs
+ {
+ ObjectKey = objectKey,
+ ContentType = string.IsNullOrWhiteSpace(input.ContentType) ? null : input.ContentType.Trim()
+ },
+ cancellationToken);
+
+ var uploadUrl = result.UploadUrl;
+ if (string.IsNullOrWhiteSpace(uploadUrl))
+ {
+ throw new BusinessException("预签名上传凭证不完整");
+ }
+
+ Dictionary? headers = null;
+ if (result.Headers is { Count: > 0 })
+ {
+ headers = result.Headers.ToDictionary(x => x.Key, x => x.Value ?? "", StringComparer.OrdinalIgnoreCase);
+ }
+
+ return new FileStoragePresignOutput
+ {
+ Key = string.IsNullOrWhiteSpace(result.ObjectKey) ? objectKey : result.ObjectKey,
+ UploadUrl = uploadUrl,
+ Method = string.IsNullOrWhiteSpace(result.Method) ? "PUT" : result.Method,
+ ContentType = result.ContentType ?? input.ContentType,
+ Headers = headers,
+ ExpireAt = FormatExpireAt(result.ExpireAt)
+ };
}
///
- /// 按 key / URL 流式读取文件(供面单预览等场景,避免前端跨域 iframe 空白)。
- /// 若对象键解析失败,但对受管 COS 的预签名绝对 URL 仍可服务端代拉(绕开浏览器 CORS)。
+ /// 鉴权读取对象字节(面单/附件预览)。key 可为 object key 或 COS 签名 URL。
///
[HttpGet("/file-storage/stream")]
- public async Task StreamAsync([FromQuery] string key, CancellationToken cancellationToken = default)
+ public async Task StreamAsync(
+ [FromQuery] string key,
+ CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(key))
{
@@ -129,262 +160,142 @@ public class FileStorageService : ApplicationService
}
var raw = key.Trim();
- Exception? storageError = null;
-
- try
+ foreach (var candidate in FileStoragePath.ExpandReadKeys(raw))
{
- var storageKey = FileStorage.ToStorageKey(raw) ?? FileStoragePath.NormalizeKey(raw);
- var stream = await FileStorage.OpenReadAsync(storageKey, cancellationToken);
- return new FileStreamResult(stream, GuessStreamContentType(storageKey))
+ var bytes = await TryReadObjectBytesAsync(candidate, cancellationToken);
+ if (bytes is { Length: > 0 })
{
- EnableRangeProcessing = true,
- };
- }
- catch (Exception ex)
- {
- storageError = ex;
- FileLogger.LogWarning(ex, "file-storage/stream 按 key 读取失败,尝试签名 URL 代拉: {Key}", TruncateForLog(raw));
- }
-
- if (IsAllowedPrintProxyUrl(raw))
- {
- try
- {
- return await ProxySignedUrlAsync(raw, cancellationToken);
- }
- catch (Exception ex)
- {
- FileLogger.LogWarning(ex, "file-storage/stream 签名 URL 代拉失败: {Key}", TruncateForLog(raw));
- storageError ??= ex;
+ return FileResult(bytes, candidate);
}
}
- if (storageError is BusinessException business)
+ if (FileStoragePath.IsAbsoluteUrl(raw))
{
- throw business;
+ var fetched = await TryFetchUrlBytesAsync(raw, cancellationToken);
+ if (fetched is { Length: > 0 })
+ {
+ return FileResult(fetched, raw);
+ }
}
- throw new BusinessException("文件不存在或已过期");
+ throw new BusinessException("文件不存在或无法读取");
}
- private async Task ProxySignedUrlAsync(string absoluteUrl, CancellationToken cancellationToken)
+ private async Task TryReadObjectBytesAsync(string objectKey, CancellationToken cancellationToken)
{
- var client = HttpClientFactory.CreateClient(PrintProxyHttpClientName);
- using var request = new HttpRequestMessage(HttpMethod.Get, absoluteUrl);
- request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*"));
-
- var response = await client.SendAsync(
- request,
- HttpCompletionOption.ResponseHeadersRead,
- cancellationToken);
-
- if (!response.IsSuccessStatusCode)
+ try
{
- FileLogger.LogWarning(
- "签名 URL 代拉 HTTP {Status}: {Url}",
- (int)response.StatusCode,
- TruncateForLog(absoluteUrl));
- throw new BusinessException($"面单代拉失败 HTTP {(int)response.StatusCode}");
+ if (!await FileStorage.ExistsAsync(objectKey, cancellationToken))
+ {
+ return null;
+ }
+
+ await using var stream = await FileStorage.GetAsync(objectKey, cancellationToken);
+ using var ms = new MemoryStream();
+ await stream.CopyToAsync(ms, cancellationToken);
+ return ms.ToArray();
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return null;
+ }
+ catch (DirectoryNotFoundException)
+ {
+ return null;
+ }
+ catch (FileNotFoundException)
+ {
+ return null;
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private async Task TryFetchUrlBytesAsync(string url, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var client = HttpClientFactory.CreateClient(UrlFetchClientName);
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.TryAddWithoutValidation("Accept", "*/*");
+ using var response = await client.SendAsync(
+ request,
+ HttpCompletionOption.ResponseHeadersRead,
+ cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ return await response.Content.ReadAsByteArrayAsync(cancellationToken);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private static FileContentResult FileResult(byte[] bytes, string nameHint)
+ {
+ var mime = GuessMime(nameHint);
+ var path = nameHint.Contains('?', StringComparison.Ordinal)
+ ? nameHint[..nameHint.IndexOf('?', StringComparison.Ordinal)]
+ : nameHint;
+ var fileName = Path.GetFileName(path);
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ fileName = "file";
}
- var contentType = response.Content.Headers.ContentType?.ToString();
- if (string.IsNullOrWhiteSpace(contentType))
+ return new FileContentResult(bytes, mime)
{
- contentType = GuessStreamContentType(absoluteUrl);
- }
-
- var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
- // 由 FileStreamResult 在响应结束时释放;HttpResponseMessage 需保持至流读完
- return new FileStreamResult(new HttpResponseStream(response, stream), contentType)
- {
- EnableRangeProcessing = false,
+ FileDownloadName = fileName
};
}
- private static bool IsAllowedPrintProxyUrl(string url)
+ private static string GuessMime(string pathOrUrl)
{
- if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
- {
- return false;
- }
-
- if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- var host = uri.Host.ToLowerInvariant();
- return host is "cos.ideship.com" or "cos.nanafa.com"
- || host.EndsWith(".myqcloud.com", StringComparison.Ordinal)
- || host.StartsWith("cos.", StringComparison.Ordinal);
- }
-
- private static string TruncateForLog(string value) =>
- value.Length <= 160 ? value : value[..160] + "…";
-
- private static string GuessStreamContentType(string storageKey)
- {
- var path = storageKey;
- if (FileStoragePath.IsAbsoluteUrl(storageKey)
- && Uri.TryCreate(storageKey, UriKind.Absolute, out var uri))
- {
- path = uri.AbsolutePath;
- }
-
- if (path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
- || path.Contains(".pdf?", StringComparison.OrdinalIgnoreCase))
+ if (Regex.IsMatch(pathOrUrl, @"\.pdf(\?|#|$)", RegexOptions.IgnoreCase))
{
return "application/pdf";
}
- if (path.EndsWith(".html", StringComparison.OrdinalIgnoreCase)
- || path.EndsWith(".htm", StringComparison.OrdinalIgnoreCase)
- || path.Contains(".html?", StringComparison.OrdinalIgnoreCase)
- || path.Contains(".htm?", StringComparison.OrdinalIgnoreCase))
- {
- return "text/html; charset=utf-8";
- }
-
- if (path.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
+ if (Regex.IsMatch(pathOrUrl, @"\.png(\?|#|$)", RegexOptions.IgnoreCase))
{
return "image/png";
}
- if (path.EndsWith(".webp", StringComparison.OrdinalIgnoreCase))
+ if (Regex.IsMatch(pathOrUrl, @"\.jpe?g(\?|#|$)", RegexOptions.IgnoreCase))
+ {
+ return "image/jpeg";
+ }
+
+ if (Regex.IsMatch(pathOrUrl, @"\.webp(\?|#|$)", RegexOptions.IgnoreCase))
{
return "image/webp";
}
- if (path.EndsWith(".gif", StringComparison.OrdinalIgnoreCase))
+ if (Regex.IsMatch(pathOrUrl, @"\.gif(\?|#|$)", RegexOptions.IgnoreCase))
{
return "image/gif";
}
- if (path.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase))
- {
- return "image/bmp";
- }
-
- if (path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase)
- || path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase))
- {
- return "image/jpeg";
- }
-
return "application/octet-stream";
}
- ///
- /// 包装 HttpResponseMessage + 响应流,确保 FileStreamResult 读完后释放连接。
- ///
- private sealed class HttpResponseStream : Stream
+ private static string? FormatExpireAt(object? expireAt)
{
- private readonly HttpResponseMessage _response;
- private readonly Stream _inner;
-
- public HttpResponseStream(HttpResponseMessage response, Stream inner)
+ return expireAt switch
{
- _response = response;
- _inner = inner;
- }
-
- public override bool CanRead => _inner.CanRead;
- public override bool CanSeek => false;
- public override bool CanWrite => false;
- public override long Length => throw new NotSupportedException();
-
- public override long Position
- {
- get => throw new NotSupportedException();
- set => throw new NotSupportedException();
- }
-
- public override void Flush() => _inner.Flush();
-
- public override int Read(byte[] buffer, int offset, int count) =>
- _inner.Read(buffer, offset, count);
-
- public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
- _inner.ReadAsync(buffer, offset, count, cancellationToken);
-
- public override ValueTask ReadAsync(
- Memory buffer,
- CancellationToken cancellationToken = default) =>
- _inner.ReadAsync(buffer, cancellationToken);
-
- public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
-
- public override void SetLength(long value) => throw new NotSupportedException();
-
- public override void Write(byte[] buffer, int offset, int count) =>
- throw new NotSupportedException();
-
- public override async ValueTask DisposeAsync()
- {
- await _inner.DisposeAsync();
- _response.Dispose();
- await base.DisposeAsync();
- }
-
- protected override void Dispose(bool disposing)
- {
- if (disposing)
- {
- _inner.Dispose();
- _response.Dispose();
- }
-
- base.Dispose(disposing);
- }
+ DateTimeOffset dto when dto != default => dto.ToString("o"),
+ DateTime dt when dt != default => dt.ToString("o"),
+ _ => expireAt?.ToString()
+ };
}
}
-
-public class PresignUploadInput
-{
- /// 业务目录:avatars / sku / parcel / recharge / order-files / xhs 等。
- public string? Folder { get; set; }
-
- public string FileName { get; set; } = string.Empty;
-
- public string? ContentType { get; set; }
-}
-
-public class PresignUploadOutput
-{
- public string Key { get; set; } = string.Empty;
- public string UploadUrl { get; set; } = string.Empty;
- public string Method { get; set; } = "PUT";
- public string? ContentType { get; set; }
- public IReadOnlyDictionary Headers { get; set; } =
- new Dictionary();
- public DateTime ExpireAt { get; set; }
-}
-
-public class StsUploadOutput
-{
- public string Key { get; set; } = string.Empty;
- public string CosKey { get; set; } = string.Empty;
- public string Bucket { get; set; } = string.Empty;
- public string Region { get; set; } = string.Empty;
- public string TmpSecretId { get; set; } = string.Empty;
- public string TmpSecretKey { get; set; } = string.Empty;
- public string SessionToken { get; set; } = string.Empty;
- public long StartTime { get; set; }
- public long ExpiredTime { get; set; }
-}
-
-public class AccessUrlOutput
-{
- public string Key { get; set; } = string.Empty;
- public string Url { get; set; } = string.Empty;
-}
-
-public class NormalizeKeyInput
-{
- public string? PathOrUrl { get; set; }
-}
-
-public class NormalizeKeyOutput
-{
- public string? Key { get; set; }
-}
diff --git a/src/Oms.Application/Services/System/DictService.cs b/src/Oms.Application/Services/System/DictService.cs
index 2bd2737..7329353 100644
--- a/src/Oms.Application/Services/System/DictService.cs
+++ b/src/Oms.Application/Services/System/DictService.cs
@@ -132,14 +132,10 @@ public class DictService : ApplicationService
}
entity ??= await DictTypeRepository.GetByDictTypeAsync(dictType);
+ // 软删后列表查不到,但 uk_dict_type_code 仍占坑;重建时恢复原行
+ entity ??= await DictTypeRepository.GetByDictTypeIncludingDeletedAsync(dictType);
if (entity == null)
{
- var existedType = await DictTypeRepository.GetByDictTypeAsync(dictType);
- if (existedType != null)
- {
- throw new BusinessException("字典类型已存在");
- }
-
await DictTypeRepository.InsertAsync(new DictTypeMeta
{
DictType = dictType,
@@ -151,13 +147,19 @@ public class DictService : ApplicationService
if (!string.Equals(entity.DictType, dictType, StringComparison.OrdinalIgnoreCase))
{
- var duplicate = await DictTypeRepository.GetByDictTypeAsync(dictType);
- if (duplicate != null && duplicate.Id != entity.Id)
+ var duplicate = await DictTypeRepository.GetByDictTypeIncludingDeletedAsync(dictType);
+ if (duplicate != null && duplicate.Id != entity.Id && !duplicate.IsDeleted)
{
throw new BusinessException("字典类型已存在");
}
+
+ if (duplicate != null && duplicate.Id != entity.Id && duplicate.IsDeleted)
+ {
+ throw new BusinessException("字典类型编码与已删除记录冲突,请换编码或联系管理员恢复");
+ }
}
+ entity.IsDeleted = false;
entity.DictType = dictType;
entity.Remark = remark;
entity.TypeSort = input.TypeSort;
@@ -317,6 +319,14 @@ public class DictService : ApplicationService
return;
}
+ var deleted = await DictTypeRepository.GetByDictTypeIncludingDeletedAsync(dictType);
+ if (deleted != null)
+ {
+ deleted.IsDeleted = false;
+ await DictTypeRepository.UpdateAsync(deleted);
+ return;
+ }
+
await DictTypeRepository.InsertAsync(new DictTypeMeta
{
DictType = dictType,
diff --git a/src/Oms.Application/Services/System/SystemOtherConfigService.cs b/src/Oms.Application/Services/System/SystemOtherConfigService.cs
index 2747909..7c52138 100644
--- a/src/Oms.Application/Services/System/SystemOtherConfigService.cs
+++ b/src/Oms.Application/Services/System/SystemOtherConfigService.cs
@@ -61,9 +61,26 @@ public class SystemOtherConfigService : ApplicationService
public async Task CreateAsync(SystemOtherConfigCreateInput input)
{
await ValidateInputAsync(input, null);
+ var configCode = input.ConfigCode.Trim();
+ var deleted = await SystemOtherConfigRepository.GetByConfigCodeIncludingDeletedAsync(configCode);
+ if (deleted != null && deleted.IsDeleted)
+ {
+ deleted.IsDeleted = false;
+ deleted.ConfigCode = configCode;
+ deleted.ConfigName = input.ConfigName.Trim();
+ deleted.Enabled = input.Enabled;
+ deleted.BaseUrl = NormalizeOptional(input.BaseUrl);
+ deleted.AppKey = NormalizeOptional(input.AppKey);
+ deleted.AppSecret = NormalizeOptional(input.AppSecret);
+ deleted.TimeoutSeconds = NormalizeTimeoutSeconds(input.TimeoutSeconds);
+ deleted.Remark = NormalizeOptional(input.Remark);
+ await SystemOtherConfigRepository.UpdateAsync(deleted);
+ return;
+ }
+
await SystemOtherConfigRepository.InsertAsync(new SystemOtherConfig
{
- ConfigCode = input.ConfigCode.Trim(),
+ ConfigCode = configCode,
ConfigName = input.ConfigName.Trim(),
Enabled = input.Enabled,
BaseUrl = NormalizeOptional(input.BaseUrl),
@@ -82,8 +99,16 @@ public class SystemOtherConfigService : ApplicationService
{
var entity = await GetConfigOrThrowAsync(input.Id);
await ValidateInputAsync(input, entity.Id);
+ var configCode = input.ConfigCode.Trim();
+ var softDeleted = await SystemOtherConfigRepository.GetByConfigCodeIncludingDeletedAsync(configCode);
+ if (softDeleted != null && softDeleted.IsDeleted && softDeleted.Id != entity.Id)
+ {
+ softDeleted.ConfigCode = $"{configCode}__deleted_{softDeleted.Id}";
+ await SystemOtherConfigRepository.UpdateAsync(softDeleted);
+ }
+
var clearWeChatAccessToken = ShouldClearWeChatAccessToken(entity, input);
- entity.ConfigCode = input.ConfigCode.Trim();
+ entity.ConfigCode = configCode;
entity.ConfigName = input.ConfigName.Trim();
entity.Enabled = input.Enabled;
entity.BaseUrl = NormalizeOptional(input.BaseUrl);
@@ -188,6 +213,10 @@ public class SystemOtherConfigService : ApplicationService
throw new BusinessException("API 中心应用 AppSecret 不能为空");
}
}
+ else if (string.Equals(configCode, SystemOtherConfigCodes.PickupOpenApi, StringComparison.OrdinalIgnoreCase))
+ {
+ // 鉴权已改由 itfx 上游完成;本配置不再强制 AppKey
+ }
else if (string.Equals(configCode, SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(input.AppKey))
diff --git a/src/Oms.Application/Services/System/WarehouseService.cs b/src/Oms.Application/Services/System/WarehouseService.cs
index 2e27f6c..8532769 100644
--- a/src/Oms.Application/Services/System/WarehouseService.cs
+++ b/src/Oms.Application/Services/System/WarehouseService.cs
@@ -81,15 +81,29 @@ public class WarehouseService : ApplicationService
[HttpPost("/system/warehouse/create")]
public async Task CreateAsync(CreateWarehouseInput input)
{
- var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(input.WarehouseName);
+ var warehouseName = input.WarehouseName.Trim();
+ var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(warehouseName);
if (duplicatedWarehouse != null)
{
throw new BusinessException("仓库名称已存在");
}
+ var deleted = await WarehouseAddressRepository.GetByWarehouseNameIncludingDeletedAsync(warehouseName);
+ if (deleted != null && deleted.IsDeleted)
+ {
+ deleted.IsDeleted = false;
+ deleted.WarehouseName = warehouseName;
+ deleted.WarehouseType = input.WarehouseType;
+ deleted.Recipient = input.Recipient;
+ deleted.ContactPhone = input.ContactPhone;
+ deleted.ShippingAddress = input.ShippingAddress;
+ await WarehouseAddressRepository.UpdateAsync(deleted);
+ return;
+ }
+
var entity = new WarehouseAddress
{
- WarehouseName = input.WarehouseName,
+ WarehouseName = warehouseName,
WarehouseType = input.WarehouseType,
Recipient = input.Recipient,
ContactPhone = input.ContactPhone,
@@ -108,13 +122,21 @@ public class WarehouseService : ApplicationService
public async Task UpdateAsync(UpdateWarehouseInput input)
{
var entity = await GetWarehouseOrThrowAsync(input.Id);
- var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(input.WarehouseName);
+ var warehouseName = input.WarehouseName.Trim();
+ var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(warehouseName);
if (duplicatedWarehouse != null && duplicatedWarehouse.Id != entity.Id)
{
throw new BusinessException("仓库名称已存在");
}
- entity.WarehouseName = input.WarehouseName;
+ var softDeleted = await WarehouseAddressRepository.GetByWarehouseNameIncludingDeletedAsync(warehouseName);
+ if (softDeleted != null && softDeleted.IsDeleted && softDeleted.Id != entity.Id)
+ {
+ softDeleted.WarehouseName = $"{warehouseName}__deleted_{softDeleted.Id}";
+ await WarehouseAddressRepository.UpdateAsync(softDeleted);
+ }
+
+ entity.WarehouseName = warehouseName;
entity.WarehouseType = input.WarehouseType;
entity.Recipient = input.Recipient;
entity.ContactPhone = input.ContactPhone;
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs
index 0c1327e..3fe21b9 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs
@@ -63,7 +63,12 @@ public partial class WarehouseService
}
else
{
- entity = new CBinLocation { WarehouseId = input.WarehouseId };
+ var deleted = await BinLocationRepository.GetByWarehouseAndBinCodeIncludingDeletedAsync(
+ input.WarehouseId,
+ binCode);
+ entity = deleted is { IsDeleted: true }
+ ? deleted
+ : new CBinLocation { WarehouseId = input.WarehouseId };
}
var duplicate = await BinLocationRepository.FirstOrDefaultAsync(x =>
@@ -73,13 +78,26 @@ public partial class WarehouseService
throw new BusinessException("库位编码已存在");
}
+ if (input.Id > 0)
+ {
+ var softDeleted = await BinLocationRepository.GetByWarehouseAndBinCodeIncludingDeletedAsync(
+ input.WarehouseId,
+ binCode);
+ if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id)
+ {
+ softDeleted.BinCode = $"{binCode}__deleted_{softDeleted.Id}";
+ await BinLocationRepository.UpdateAsync(softDeleted);
+ }
+ }
+
+ entity.IsDeleted = false;
entity.BinCode = binCode;
entity.BinName = string.IsNullOrWhiteSpace(input.BinName) ? null : input.BinName.Trim();
entity.ZoneCode = string.IsNullOrWhiteSpace(input.ZoneCode) ? null : input.ZoneCode.Trim();
entity.IsEnabled = input.IsEnabled;
entity.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim();
- if (input.Id > 0)
+ if (entity.Id > 0)
{
await BinLocationRepository.UpdateAsync(entity);
}
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
index 1cf98cd..ab110da 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
@@ -215,7 +215,7 @@ public partial class WarehouseService
throw new BusinessException("商品数量必须大于 0");
}
- var imageUrl = FileStorage.ToStorageKey(itemInput.ImageUrl);
+ var imageUrl = OmsObjectStorageExtensions.ToStorageKey(itemInput.ImageUrl);
if (imageUrl != null && imageUrl.Length > 1024)
{
imageUrl = imageUrl[..1024];
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
index 21cd3d8..c0b0eaa 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse;
+using Fake.ObjectStorage;
using Oms.Application.Shared;
using Oms.Application.Storage;
@@ -11,8 +12,8 @@ namespace Oms.Application.Services.Warehouse;
public partial class WarehouseService
{
- private IFileStorage FileStorage =>
- LazyServiceProvider.GetRequiredService();
+ private IObjectStorage FileStorage =>
+ LazyServiceProvider.GetRequiredService();
///
/// 查询 SKU 列表。
@@ -104,7 +105,10 @@ public partial class WarehouseService
}
else
{
- entity = new Domain.WarehouseAggregate.Entities.CSku { CustomerId = customerId };
+ var deleted = await SkuRepository.GetByCustomerAndSkuCodeIncludingDeletedAsync(customerId, skuCode);
+ entity = deleted is { IsDeleted: true }
+ ? deleted
+ : new Domain.WarehouseAggregate.Entities.CSku { CustomerId = customerId };
}
var duplicate = await SkuRepository.FirstOrDefaultAsync(x =>
@@ -114,10 +118,21 @@ public partial class WarehouseService
throw new BusinessException("SKU 编码已存在");
}
+ if (input.Id > 0)
+ {
+ var softDeleted = await SkuRepository.GetByCustomerAndSkuCodeIncludingDeletedAsync(customerId, skuCode);
+ if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id)
+ {
+ softDeleted.SkuCode = $"{skuCode}__deleted_{softDeleted.Id}";
+ await SkuRepository.UpdateAsync(softDeleted);
+ }
+ }
+
+ entity.IsDeleted = false;
entity.SkuCode = skuCode;
entity.SkuName = skuName;
entity.SkuNameEn = string.IsNullOrWhiteSpace(input.SkuNameEn) ? null : input.SkuNameEn.Trim();
- entity.SkuImageUrl = FileStorage.ToStorageKey(input.SkuImageUrl);
+ entity.SkuImageUrl = OmsObjectStorageExtensions.ToStorageKey(input.SkuImageUrl);
entity.UnitPrice = input.UnitPrice;
entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim();
entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim();
@@ -128,7 +143,7 @@ public partial class WarehouseService
entity.HeightCm = input.HeightCm;
entity.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim();
- if (input.Id > 0)
+ if (entity.Id > 0)
{
await SkuRepository.UpdateAsync(entity);
}
diff --git a/src/Oms.Application/Services/Xhs/XhsMediaService.cs b/src/Oms.Application/Services/Xhs/XhsMediaService.cs
index c4feb9c..d22ff8f 100644
--- a/src/Oms.Application/Services/Xhs/XhsMediaService.cs
+++ b/src/Oms.Application/Services/Xhs/XhsMediaService.cs
@@ -1,4 +1,5 @@
using Fake.Application;
+using Fake.ObjectStorage;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Xhs;
@@ -15,7 +16,7 @@ public class XhsMediaService : ApplicationService
{
private IXhsMediaRepository MediaRepository => LazyServiceProvider.GetRequiredService();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService();
- private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService();
+ private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService();
///
/// 上传媒体文件
@@ -34,7 +35,7 @@ public class XhsMediaService : ApplicationService
var relativeKey = $"uploads/xhs/{customerId}/{storedName}";
await using var stream = file.OpenReadStream();
- var storageKey = await FileStorage.SaveAsync(stream, relativeKey, file.ContentType);
+ var storageKey = await FileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType);
var publicUrl = FileStorage.GetPublicUrl(storageKey);
var media = new XhsMedia
diff --git a/src/Oms.Application/Shared/CustomerBalanceMutator.cs b/src/Oms.Application/Shared/CustomerBalanceMutator.cs
index a2ded58..4a904ea 100644
--- a/src/Oms.Application/Shared/CustomerBalanceMutator.cs
+++ b/src/Oms.Application/Shared/CustomerBalanceMutator.cs
@@ -63,6 +63,58 @@ public class CustomerBalanceMutator : IScopedDependency
cancellationToken);
}
+ public Task CreditReferralRewardAsync(
+ Customer customer,
+ decimal amount,
+ int detailId,
+ string? orderNo,
+ string? remark,
+ CancellationToken cancellationToken = default)
+ {
+ if (amount <= 0)
+ {
+ throw new BusinessException("奖励金额必须大于 0");
+ }
+
+ return ApplyAsync(
+ customer,
+ amount,
+ CustomerAccountFlowBizTypes.ReferralCommission,
+ detailId,
+ orderNo,
+ remark,
+ cancellationToken);
+ }
+
+ public Task DebitReferralClawbackAsync(
+ Customer customer,
+ decimal amount,
+ int detailId,
+ string? orderNo,
+ string? remark,
+ CancellationToken cancellationToken = default)
+ {
+ if (amount <= 0)
+ {
+ return Task.CompletedTask;
+ }
+
+ var debit = Math.Min(amount, customer.Balance);
+ if (debit <= 0)
+ {
+ return Task.CompletedTask;
+ }
+
+ return ApplyAsync(
+ customer,
+ -debit,
+ CustomerAccountFlowBizTypes.ReferralCommission,
+ detailId,
+ orderNo,
+ remark,
+ cancellationToken);
+ }
+
public Task DebitPickupPayAsync(
Customer customer,
decimal amount,
diff --git a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
new file mode 100644
index 0000000..0306371
--- /dev/null
+++ b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
@@ -0,0 +1,155 @@
+using System.Security.Cryptography;
+using System.Text;
+using Fake.Application;
+using Fake.DependencyInjection;
+using Microsoft.AspNetCore.Http;
+using Oms.Domain.CustomersAggregate.Entities;
+using Oms.Domain.CustomersAggregate.Enums;
+using Oms.Domain.CustomersAggregate.Repositories;
+
+namespace Oms.Application.Shared;
+
+///
+/// 客户 OpenAPI 机机鉴权:appKey + timestamp + HMAC-SHA256(sign),Secret 仅本地参与签名。
+///
+public class CustomerOpenApiAuthHelper : IScopedDependency
+{
+ public const string AppKeyHeaderName = "appKey";
+ public const string TimestampHeaderName = "timestamp";
+ public const string SignHeaderName = "sign";
+
+ /// 签名时间窗(秒)。
+ public const int TimestampSkewSeconds = 300;
+
+ private readonly ICustomerOpenApiCredentialRepository _credentialRepository;
+ private readonly ICustomerRepository _customerRepository;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ public CustomerOpenApiAuthHelper(
+ ICustomerOpenApiCredentialRepository credentialRepository,
+ ICustomerRepository customerRepository,
+ IHttpContextAccessor httpContextAccessor)
+ {
+ _credentialRepository = credentialRepository;
+ _customerRepository = customerRepository;
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ ///
+ /// 校验 Header 签名并解析主客户。
+ /// stringToSign = appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)
+ /// sign = hex(HMAC-SHA256(appSecret, stringToSign))
+ ///
+ public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveSignedAsync(
+ string? appKey,
+ string? timestamp,
+ string? sign,
+ string? rawBody,
+ CancellationToken cancellationToken = default)
+ {
+ var key = appKey?.Trim();
+ var tsRaw = timestamp?.Trim();
+ var signRaw = sign?.Trim();
+ if (string.IsNullOrWhiteSpace(key)
+ || string.IsNullOrWhiteSpace(tsRaw)
+ || string.IsNullOrWhiteSpace(signRaw))
+ {
+ throw new BusinessException("凭证无效");
+ }
+
+ if (!long.TryParse(tsRaw, out var unixSeconds))
+ {
+ throw new BusinessException("请求已过期");
+ }
+
+ var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ if (Math.Abs(now - unixSeconds) > TimestampSkewSeconds)
+ {
+ throw new BusinessException("请求已过期");
+ }
+
+ var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken);
+ if (credential == null
+ || !credential.Enabled
+ || string.IsNullOrWhiteSpace(credential.AppSecret))
+ {
+ throw new BusinessException("凭证无效");
+ }
+
+ var expectedSign = ComputeSign(key, tsRaw, rawBody ?? string.Empty, credential.AppSecret);
+ if (!FixedTimeEqualsUtf8(expectedSign, signRaw.ToLowerInvariant()))
+ {
+ throw new BusinessException("签名无效");
+ }
+
+ var customer = await _customerRepository.FirstOrDefaultAsync(
+ x => x.Id == credential.CustomerId,
+ cancellationToken);
+ if (customer == null
+ || customer.ParentCustomerId.HasValue
+ || customer.Status != CustomerStatus.Normal)
+ {
+ throw new BusinessException("凭证无效");
+ }
+
+ credential.LastUsedAt = DateTime.Now;
+ credential.LastUsedIp = ResolveClientIp();
+ await _credentialRepository.UpdateAsync(credential);
+
+ return (customer, credential);
+ }
+
+ ///
+ /// 客户端可复用:计算 sign(小写 hex)。
+ ///
+ public static string ComputeSign(string appKey, string timestamp, string rawBody, string appSecret)
+ {
+ var bodyHash = Sha256Hex(rawBody ?? string.Empty);
+ var stringToSign = $"{appKey}\n{timestamp}\n{bodyHash}";
+ return HmacSha256Hex(appSecret, stringToSign);
+ }
+
+ private static string Sha256Hex(string text)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(text));
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+
+ private static string HmacSha256Hex(string secret, string content)
+ {
+ var keyBytes = Encoding.UTF8.GetBytes(secret);
+ var contentBytes = Encoding.UTF8.GetBytes(content);
+ var hash = HMACSHA256.HashData(keyBytes, contentBytes);
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+
+ private static bool FixedTimeEqualsUtf8(string? expected, string actual)
+ {
+ if (string.IsNullOrEmpty(expected))
+ {
+ return false;
+ }
+
+ var left = Encoding.UTF8.GetBytes(expected);
+ var right = Encoding.UTF8.GetBytes(actual);
+ return left.Length == right.Length
+ && CryptographicOperations.FixedTimeEquals(left, right);
+ }
+
+ private string? ResolveClientIp()
+ {
+ var http = _httpContextAccessor.HttpContext;
+ if (http == null)
+ {
+ return null;
+ }
+
+ var forwarded = http.Request.Headers["X-Forwarded-For"].FirstOrDefault();
+ if (!string.IsNullOrWhiteSpace(forwarded))
+ {
+ return forwarded.Split(',')[0].Trim();
+ }
+
+ return http.Connection.RemoteIpAddress?.ToString();
+ }
+}
diff --git a/src/Oms.Application/Shared/GeoPartitionMatcher.cs b/src/Oms.Application/Shared/GeoPartitionMatcher.cs
new file mode 100644
index 0000000..ace4b34
--- /dev/null
+++ b/src/Oms.Application/Shared/GeoPartitionMatcher.cs
@@ -0,0 +1,852 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using Oms.Application.Dtos.Orders;
+using Oms.Domain.ChannelsAggregate.Entities;
+
+namespace Oms.Application.Shared;
+
+///
+/// 地理分区规则(价格/黑名单/偏远共用)。
+///
+public sealed class GeoRule
+{
+ public int Ordinal { get; init; }
+
+ public object? Tag { get; init; }
+
+ public string CountryCode { get; init; } = string.Empty;
+
+ /// zip / state / city / country
+ public string MatchDim { get; init; } = string.Empty;
+
+ /// prefix / exact / range / all
+ public string MatchMode { get; init; } = string.Empty;
+
+ public int PrefixLen { get; init; }
+
+ public string MatchTextStd { get; init; } = string.Empty;
+
+ public string ZipStartStd { get; init; } = string.Empty;
+
+ public string ZipEndStd { get; init; } = string.Empty;
+
+ public string MatchStateStd { get; init; } = string.Empty;
+}
+
+///
+/// 地址清洗、规则编译、单套分区匹配。价格/黑名单/偏远分区共用。
+///
+public static class GeoPartitionMatcher
+{
+ public const string DimZip = "zip";
+ public const string DimState = "state";
+ public const string DimCity = "city";
+ public const string DimCountry = "country";
+
+ public const string ModePrefix = "prefix";
+ public const string ModeExact = "exact";
+ public const string ModeRange = "range";
+ public const string ModeAll = "all";
+
+ private static readonly Regex PostcodeRangePattern = new(
+ @"^[\[(]\s*([^,)\]]*)\s*,\s*([^,)\]]*)\s*[\])]$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ public static string NormalizeCountry(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
+
+ public static string NormalizeZip(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ var chars = value.Where(ch => !char.IsWhiteSpace(ch) && ch != '-').ToArray();
+ return chars.Length == 0 ? string.Empty : new string(chars).ToUpperInvariant();
+ }
+
+ ///
+ /// 规范化后的邮编比较:纯数字按等长补零;相同字母前缀+数字后缀按数值;其余按 Ordinal。
+ ///
+ public static int CompareNormalizedZip(string? left, string? right)
+ {
+ var a = left ?? string.Empty;
+ var b = right ?? string.Empty;
+ if (a.Length == 0 && b.Length == 0)
+ {
+ return 0;
+ }
+
+ if (IsDigits(a) && IsDigits(b))
+ {
+ var width = Math.Max(a.Length, b.Length);
+ return string.CompareOrdinal(a.PadLeft(width, '0'), b.PadLeft(width, '0'));
+ }
+
+ if (TrySplitLetterPrefixNumber(a, out var prefixA, out var numberA, out var restA)
+ && TrySplitLetterPrefixNumber(b, out var prefixB, out var numberB, out var restB)
+ && string.Equals(prefixA, prefixB, StringComparison.Ordinal))
+ {
+ var numberCmp = numberA.CompareTo(numberB);
+ return numberCmp != 0 ? numberCmp : string.CompareOrdinal(restA, restB);
+ }
+
+ return string.CompareOrdinal(a, b);
+ }
+
+ private static bool IsDigits(string value) =>
+ value.Length > 0 && value.All(char.IsAsciiDigit);
+
+ private static bool TrySplitLetterPrefixNumber(
+ string zip,
+ out string prefix,
+ out int number,
+ out string rest)
+ {
+ prefix = string.Empty;
+ number = 0;
+ rest = string.Empty;
+ if (zip.Length == 0 || !char.IsAsciiLetter(zip[0]))
+ {
+ return false;
+ }
+
+ var i = 0;
+ while (i < zip.Length && char.IsAsciiLetter(zip[i]))
+ {
+ i++;
+ }
+
+ if (i >= zip.Length || !char.IsAsciiDigit(zip[i]))
+ {
+ return false;
+ }
+
+ prefix = zip[..i];
+ var digitStart = i;
+ while (i < zip.Length && char.IsAsciiDigit(zip[i]))
+ {
+ i++;
+ }
+
+ if (!int.TryParse(zip[digitStart..i], NumberStyles.None, CultureInfo.InvariantCulture, out number))
+ {
+ return false;
+ }
+
+ rest = zip[i..];
+ return true;
+ }
+
+ public static string NormalizeRegion(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
+
+ ///
+ /// 执行单套地理分区匹配。调用方需传入已排序规则:zip > state > city > country,同维度 prefixLen 降序。
+ ///
+ public static GeoRule? MatchSinglePartition(
+ string countryCode,
+ string zipStd,
+ string stateStd,
+ string cityStd,
+ IReadOnlyList ruleList)
+ {
+ var country = NormalizeCountry(countryCode);
+ zipStd ??= string.Empty;
+ stateStd ??= string.Empty;
+ cityStd ??= string.Empty;
+ if (ruleList == null || ruleList.Count == 0 || string.IsNullOrEmpty(country))
+ {
+ return null;
+ }
+
+ foreach (var rule in ruleList)
+ {
+ if (!string.Equals(rule.CountryCode, country, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (rule.MatchDim == DimZip)
+ {
+ if (string.IsNullOrEmpty(zipStd))
+ {
+ continue;
+ }
+
+ var hit = false;
+ if (rule.MatchMode == ModePrefix)
+ {
+ if (rule.PrefixLen > 0 && zipStd.Length >= rule.PrefixLen)
+ {
+ var userPrefix = zipStd[..rule.PrefixLen];
+ hit = userPrefix == rule.MatchTextStd;
+ }
+ }
+ else if (rule.MatchMode == ModeExact)
+ {
+ hit = zipStd == rule.MatchTextStd;
+ }
+ else if (rule.MatchMode == ModeRange)
+ {
+ hit = CompareNormalizedZip(zipStd, rule.ZipStartStd) >= 0
+ && CompareNormalizedZip(zipStd, rule.ZipEndStd) <= 0;
+ }
+
+ if (hit)
+ {
+ return rule;
+ }
+ }
+ else if (rule.MatchDim == DimState)
+ {
+ if (!string.IsNullOrEmpty(zipStd))
+ {
+ continue;
+ }
+
+ if (rule.MatchMode == ModeExact && stateStd == rule.MatchTextStd && !string.IsNullOrEmpty(stateStd))
+ {
+ return rule;
+ }
+ }
+ else if (rule.MatchDim == DimCity)
+ {
+ if (!string.IsNullOrEmpty(zipStd))
+ {
+ continue;
+ }
+
+ if (rule.MatchMode == ModeExact
+ && rule.MatchStateStd == stateStd
+ && rule.MatchTextStd == cityStd
+ && !string.IsNullOrEmpty(stateStd)
+ && !string.IsNullOrEmpty(cityStd))
+ {
+ return rule;
+ }
+ }
+ else if (rule.MatchDim == DimCountry)
+ {
+ return rule;
+ }
+ }
+
+ return null;
+ }
+
+ public static IReadOnlyList SortRules(IEnumerable rules) =>
+ rules
+ .OrderBy(DimRank)
+ .ThenByDescending(r => r.PrefixLen)
+ .ThenByDescending(RangeSpecificity)
+ .ThenBy(r => r.Ordinal)
+ .ToList();
+
+ public static GeoRule? Compile(KpiAreaInfo entity, int ordinal = 0, bool attachTag = true)
+ {
+ if (entity == null)
+ {
+ return null;
+ }
+
+ return Compile(
+ entity.CountryCode,
+ entity.Province,
+ entity.City,
+ entity.LeftPostcode,
+ entity.RightPostcode,
+ null,
+ entity.MatchDim,
+ entity.MatchMode,
+ entity.PrefixLen,
+ ordinal,
+ attachTag ? entity : null);
+ }
+
+ public static GeoRule? Compile(KpiAreaSnapshotRuleDto rule, int ordinal = 0, bool attachTag = true)
+ {
+ if (rule == null)
+ {
+ return null;
+ }
+
+ var range = KpiAreaSnapshotMapper.ResolvePostcodeRange(rule);
+ return Compile(
+ rule.CountryCode,
+ rule.Province,
+ rule.City,
+ rule.LeftPostcode,
+ rule.RightPostcode,
+ range,
+ rule.MatchDim,
+ rule.MatchMode,
+ rule.PrefixLen,
+ ordinal,
+ attachTag ? rule : null);
+ }
+
+ public static KpiAreaSnapshotRuleDto? MatchSnapshot(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode)
+ {
+ var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode);
+ return hit?.Tag as KpiAreaSnapshotRuleDto;
+ }
+
+ public static KpiAreaInfo? MatchEntity(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode)
+ {
+ var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode);
+ return hit?.Tag as KpiAreaInfo;
+ }
+
+ public static bool IsHit(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode) =>
+ MatchEntity(infos, countryCode, province, city, zipCode) != null;
+
+ public static bool IsHit(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode) =>
+ MatchSnapshot(infos, countryCode, province, city, zipCode) != null;
+
+ public static GeoRule? Match(
+ IReadOnlyList compiled,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode)
+ {
+ var sorted = SortRules(compiled);
+ return MatchSinglePartition(
+ NormalizeCountry(countryCode),
+ NormalizeZip(zipCode),
+ NormalizeRegion(province),
+ NormalizeRegion(city),
+ sorted);
+ }
+
+ ///
+ /// 清洗并收口为一维规则,写入 MatchDim / MatchMode / PrefixLen。
+ /// 显式维度优先;未传则按字段推断。
+ ///
+ public static void PrepareEntity(KpiAreaInfo entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ entity.CountryCode = string.IsNullOrWhiteSpace(entity.CountryCode) ? null : entity.CountryCode.Trim();
+ entity.CountryName = string.IsNullOrWhiteSpace(entity.CountryName) ? null : entity.CountryName.Trim();
+ entity.Province = EmptyToNull(entity.Province);
+ entity.City = EmptyToNull(entity.City);
+ entity.LeftPostcode = EmptyToNull(entity.LeftPostcode);
+ entity.RightPostcode = EmptyToNull(entity.RightPostcode);
+
+ if (string.IsNullOrWhiteSpace(entity.CountryCode))
+ {
+ throw new InvalidOperationException("国家代码不能为空");
+ }
+
+ var dim = NormalizeDim(entity.MatchDim);
+ var mode = NormalizeMode(entity.MatchMode);
+ if (string.IsNullOrEmpty(dim))
+ {
+ dim = InferDim(entity.LeftPostcode, entity.RightPostcode, entity.Province, entity.City);
+ }
+
+ switch (dim)
+ {
+ case DimZip:
+ ApplyZipFields(entity, mode);
+ break;
+ case DimState:
+ if (string.IsNullOrWhiteSpace(entity.Province))
+ {
+ throw new InvalidOperationException("州/省匹配必须填写州/省");
+ }
+
+ entity.City = null;
+ entity.LeftPostcode = null;
+ entity.RightPostcode = null;
+ entity.MatchDim = DimState;
+ entity.MatchMode = ModeExact;
+ entity.PrefixLen = 0;
+ break;
+ case DimCity:
+ if (string.IsNullOrWhiteSpace(entity.Province) || string.IsNullOrWhiteSpace(entity.City))
+ {
+ throw new InvalidOperationException("城市匹配必须同时填写州/省");
+ }
+
+ entity.LeftPostcode = null;
+ entity.RightPostcode = null;
+ entity.MatchDim = DimCity;
+ entity.MatchMode = ModeExact;
+ entity.PrefixLen = 0;
+ break;
+ default:
+ entity.Province = null;
+ entity.City = null;
+ entity.LeftPostcode = null;
+ entity.RightPostcode = null;
+ entity.MatchDim = DimCountry;
+ entity.MatchMode = ModeAll;
+ entity.PrefixLen = 0;
+ break;
+ }
+ }
+
+ ///
+ /// 同一分区内判定完全相同规则的指纹(不比较 AreaId)。
+ /// 须在 PrepareEntity 之后调用。
+ ///
+ public static string BuildDuplicateKey(KpiAreaInfo entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ var dim = NormalizeDim(entity.MatchDim) ?? DimCountry;
+ var mode = NormalizeMode(entity.MatchMode) ?? ModeAll;
+ var country = NormKey(entity.CountryCode);
+
+ return dim switch
+ {
+ DimState => $"{DimState}|{country}|{NormKey(entity.Province)}",
+ DimCity => $"{DimCity}|{country}|{NormKey(entity.Province)}|{NormKey(entity.City)}",
+ DimZip when mode == ModePrefix =>
+ $"{DimZip}|{ModePrefix}|{country}|{NormKey(entity.LeftPostcode)}|{entity.PrefixLen}",
+ DimZip when mode == ModeExact =>
+ $"{DimZip}|{ModeExact}|{country}|{NormKey(entity.LeftPostcode)}",
+ DimZip =>
+ $"{DimZip}|{ModeRange}|{country}|{NormKey(entity.LeftPostcode)}|{NormKey(entity.RightPostcode)}",
+ _ => $"{DimCountry}|{country}"
+ };
+ }
+
+ private static string NormKey(string? value) => (value ?? string.Empty).Trim().ToUpperInvariant();
+
+ public static (string MatchDim, string MatchMode, int PrefixLen) Describe(KpiAreaInfo? entity)
+ {
+ if (entity == null)
+ {
+ return (DimCountry, ModeAll, 0);
+ }
+
+ if (!string.IsNullOrWhiteSpace(entity.MatchDim))
+ {
+ return (
+ NormalizeDim(entity.MatchDim) ?? DimCountry,
+ NormalizeMode(entity.MatchMode) ?? ModeAll,
+ entity.PrefixLen);
+ }
+
+ var rule = Compile(entity, attachTag: false);
+ if (rule == null)
+ {
+ return (DimCountry, ModeAll, 0);
+ }
+
+ return (rule.MatchDim, rule.MatchMode, rule.PrefixLen);
+ }
+
+ private static GeoRule? Compile(
+ string? countryCode,
+ string? province,
+ string? city,
+ string? leftPostcode,
+ string? rightPostcode,
+ string? postcodeRange,
+ string? matchDim,
+ string? matchMode,
+ int prefixLenHint,
+ int ordinal,
+ object? tag)
+ {
+ var country = NormalizeCountry(countryCode);
+ if (string.IsNullOrEmpty(country))
+ {
+ return null;
+ }
+
+ var dim = NormalizeDim(matchDim);
+ if (string.IsNullOrEmpty(dim))
+ {
+ dim = InferDim(leftPostcode, rightPostcode, province, city, postcodeRange);
+ }
+
+ var encoded = TryParseEncodedRange(postcodeRange);
+ var zipLeft = NormalizeZip(leftPostcode);
+ var zipRight = NormalizeZip(rightPostcode);
+ var zipMode = NormalizeMode(matchMode) ?? encoded?.Mode;
+ var zipText = encoded?.Text ?? string.Empty;
+ var zipStart = encoded?.Start ?? string.Empty;
+ var zipEnd = encoded?.End ?? string.Empty;
+ var prefixLen = prefixLenHint > 0 ? prefixLenHint : encoded?.PrefixLen ?? 0;
+
+ if (zipMode is not (ModePrefix or ModeExact or ModeRange))
+ {
+ zipMode = encoded?.Mode;
+ }
+
+ if (zipMode == null)
+ {
+ if (zipLeft.Length > 0 && zipRight.Length == 0)
+ {
+ zipMode = ModePrefix;
+ zipText = zipLeft;
+ prefixLen = zipLeft.Length;
+ }
+ else if (zipLeft.Length > 0 && zipLeft == zipRight)
+ {
+ zipMode = ModeExact;
+ zipText = zipLeft;
+ prefixLen = zipLeft.Length;
+ }
+ else if (zipLeft.Length > 0 && zipRight.Length > 0)
+ {
+ zipMode = ModeRange;
+ zipStart = zipLeft;
+ zipEnd = zipRight;
+ prefixLen = Math.Min(zipLeft.Length, zipRight.Length);
+ }
+ }
+ else if (dim == DimZip)
+ {
+ if (zipMode == ModePrefix)
+ {
+ zipText = zipLeft.Length > 0 ? zipLeft : zipText;
+ prefixLen = prefixLen > 0 ? prefixLen : zipText.Length;
+ }
+ else if (zipMode == ModeExact)
+ {
+ zipText = zipLeft.Length > 0 ? zipLeft : zipText;
+ prefixLen = zipText.Length;
+ }
+ else if (zipMode == ModeRange)
+ {
+ zipStart = zipLeft.Length > 0 ? zipLeft : zipStart;
+ zipEnd = zipRight.Length > 0 ? zipRight : zipEnd;
+ prefixLen = Math.Min(
+ zipStart.Length == 0 ? prefixLen : zipStart.Length,
+ zipEnd.Length == 0 ? prefixLen : zipEnd.Length);
+ }
+ }
+
+ if (dim == DimZip)
+ {
+ if (zipMode == null)
+ {
+ return null;
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimZip,
+ MatchMode = zipMode,
+ PrefixLen = prefixLen,
+ MatchTextStd = zipText,
+ ZipStartStd = zipStart,
+ ZipEndStd = zipEnd
+ };
+ }
+
+ var state = NormalizeRegion(province);
+ var cityStd = NormalizeRegion(city);
+ if (dim == DimCity)
+ {
+ if (string.IsNullOrEmpty(state) || string.IsNullOrEmpty(cityStd))
+ {
+ return null;
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimCity,
+ MatchMode = ModeExact,
+ MatchTextStd = cityStd,
+ MatchStateStd = state
+ };
+ }
+
+ if (dim == DimState)
+ {
+ if (string.IsNullOrEmpty(state))
+ {
+ return null;
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimState,
+ MatchMode = ModeExact,
+ MatchTextStd = state
+ };
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimCountry,
+ MatchMode = ModeAll
+ };
+ }
+
+ private static void ApplyZipFields(KpiAreaInfo entity, string? requestedMode)
+ {
+ entity.Province = null;
+ entity.City = null;
+ var zipLeft = NormalizeZip(entity.LeftPostcode);
+ var zipRight = NormalizeZip(entity.RightPostcode);
+ if (zipLeft.Length == 0 && zipRight.Length > 0)
+ {
+ zipLeft = zipRight;
+ zipRight = string.Empty;
+ }
+
+ if (zipLeft.Length == 0)
+ {
+ throw new InvalidOperationException("邮编匹配必须填写起始邮编");
+ }
+
+ var mode = requestedMode;
+ if (mode is not (ModePrefix or ModeExact or ModeRange))
+ {
+ if (zipRight.Length == 0)
+ {
+ mode = ModePrefix;
+ }
+ else if (zipLeft == zipRight)
+ {
+ mode = ModeExact;
+ }
+ else
+ {
+ mode = ModeRange;
+ }
+ }
+
+ if (mode == ModePrefix)
+ {
+ entity.LeftPostcode = zipLeft;
+ entity.RightPostcode = null;
+ entity.PrefixLen = zipLeft.Length;
+ }
+ else if (mode == ModeExact)
+ {
+ entity.LeftPostcode = zipLeft;
+ entity.RightPostcode = zipLeft;
+ entity.PrefixLen = zipLeft.Length;
+ }
+ else
+ {
+ if (zipRight.Length == 0)
+ {
+ throw new InvalidOperationException("邮编区间必须填写截止邮编");
+ }
+
+ if (CompareNormalizedZip(zipLeft, zipRight) > 0)
+ {
+ throw new InvalidOperationException("起始邮编不能大于截止邮编");
+ }
+
+ entity.LeftPostcode = zipLeft;
+ entity.RightPostcode = zipRight;
+ entity.PrefixLen = Math.Min(zipLeft.Length, zipRight.Length);
+ }
+
+ entity.MatchDim = DimZip;
+ entity.MatchMode = mode;
+ }
+
+ private static string InferDim(
+ string? leftPostcode,
+ string? rightPostcode,
+ string? province,
+ string? city,
+ string? postcodeRange = null)
+ {
+ if (!string.IsNullOrWhiteSpace(postcodeRange)
+ || !string.IsNullOrWhiteSpace(leftPostcode)
+ || !string.IsNullOrWhiteSpace(rightPostcode))
+ {
+ return DimZip;
+ }
+
+ if (!string.IsNullOrWhiteSpace(city))
+ {
+ return DimCity;
+ }
+
+ if (!string.IsNullOrWhiteSpace(province))
+ {
+ return DimState;
+ }
+
+ return DimCountry;
+ }
+
+ private static string? NormalizeDim(string? value)
+ {
+ var raw = value?.Trim().ToLowerInvariant();
+ return raw switch
+ {
+ DimZip or "postcode" or "postal" or "邮编" => DimZip,
+ DimState or "province" or "州" or "州/省" or "省" => DimState,
+ DimCity or "城市" => DimCity,
+ DimCountry or "国家" or "仅国家" => DimCountry,
+ _ => null
+ };
+ }
+
+ private static string? NormalizeMode(string? value)
+ {
+ var raw = value?.Trim().ToLowerInvariant();
+ return raw switch
+ {
+ ModePrefix or "前缀" => ModePrefix,
+ ModeExact or "精确" or "exactmatch" => ModeExact,
+ ModeRange or "区间" => ModeRange,
+ ModeAll or "全部" => ModeAll,
+ _ => null
+ };
+ }
+
+ private static EncodedZip? TryParseEncodedRange(string? postcodeRange)
+ {
+ if (string.IsNullOrWhiteSpace(postcodeRange))
+ {
+ return null;
+ }
+
+ var raw = postcodeRange.Trim();
+ if (raw.StartsWith("prefix:", StringComparison.OrdinalIgnoreCase))
+ {
+ var text = NormalizeZip(raw["prefix:".Length..]);
+ if (text.Length == 0)
+ {
+ return null;
+ }
+
+ return new EncodedZip(ModePrefix, text, string.Empty, string.Empty, text.Length);
+ }
+
+ if (raw.StartsWith("exact:", StringComparison.OrdinalIgnoreCase))
+ {
+ var text = NormalizeZip(raw["exact:".Length..]);
+ if (text.Length == 0)
+ {
+ return null;
+ }
+
+ return new EncodedZip(ModeExact, text, string.Empty, string.Empty, text.Length);
+ }
+
+ var match = PostcodeRangePattern.Match(raw);
+ if (!match.Success)
+ {
+ return null;
+ }
+
+ var left = NormalizeZip(match.Groups[1].Value);
+ var right = NormalizeZip(match.Groups[2].Value);
+ if (left.Length == 0 && right.Length == 0)
+ {
+ return null;
+ }
+
+ if (left.Length == 0)
+ {
+ left = right;
+ }
+
+ if (right.Length == 0)
+ {
+ return new EncodedZip(ModePrefix, left, string.Empty, string.Empty, left.Length);
+ }
+
+ if (left == right)
+ {
+ return new EncodedZip(ModeExact, left, string.Empty, string.Empty, left.Length);
+ }
+
+ return new EncodedZip(ModeRange, string.Empty, left, right, Math.Min(left.Length, right.Length));
+ }
+
+ private static List CompileList(IEnumerable? sources, Func