20261008-ide1.0发布 #1

Merged
zzh merged 42 commits from test into master 2026-10-08 09:18:00 +08:00
86 changed files with 3043 additions and 1129 deletions
Showing only changes of commit 5648163958 - Show all commits

42
db.sql
View File

@ -233,7 +233,7 @@ CREATE TABLE `t_order_file` (
CREATE TABLE `t_customer_sender_address` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id',
`AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender' COMMENT '地址类型:sender发件/return退件/receiver收件',
`AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender' COMMENT '地址类型:sender国内发件/return国内退件/pickup国内揽收/receiver海外收件',
`AddressName` VARCHAR(64) NULL COMMENT '地址别名',
`SenderName` VARCHAR(50) NOT NULL COMMENT '联系人姓名',
`Company` VARCHAR(255) NULL COMMENT '公司',
@ -261,6 +261,30 @@ CREATE TABLE `t_customer_sender_address` (
KEY `idx_csa_customer_default` (`CustomerId`, `AddressType`, `IsDefault`)
) COMMENT='客户地址簿(发件/退件/收件)';
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='客户出口资质备案';
CREATE TABLE `t_order_template` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`Name` VARCHAR(64) NOT NULL COMMENT '模板名称',
@ -403,7 +427,8 @@ CREATE TABLE `t_order_declaration` (
`HsCode` VARCHAR(50) NULL COMMENT 'HsCode',
`ImHsCode` VARCHAR(50) NULL COMMENT '进口HsCode',
`ExHsCode` VARCHAR(50) NULL COMMENT '出口HsCode',
`SkuImage` VARCHAR(255) NULL COMMENT '图片',
`DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素',
`SkuImage` VARCHAR(512) NULL COMMENT '图片(存 storage key 或短 URL)',
`Category` VARCHAR(20) NULL COMMENT '类别',
`Brand` VARCHAR(50) NULL COMMENT '品牌',
`BrandType` VARCHAR(50) NULL COMMENT '品牌类型',
@ -595,7 +620,8 @@ CREATE TABLE `t_sales_product_allocation_provider` (
`ProviderName` VARCHAR(128) NOT NULL COMMENT '服务商名称',
`ServiceChannelId` INT NULL COMMENT '服务渠道ID',
`Weight` INT NOT NULL COMMENT '权重(评分用)',
`CustomerIds` JSON NULL COMMENT '指定客户ID数组,空表示全部',
`CustomerIds` JSON NULL COMMENT '指定客户ID数组,空表示全部(兼容旧配置)',
`MatchCondition` TEXT NULL COMMENT '排货判断条件(中文公式),空表示全部订单可用',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@ -1350,6 +1376,7 @@ CREATE TABLE `c_sku` (
`UnitPrice` DECIMAL(12,4) NULL COMMENT '单价',
`ExHsCode` VARCHAR(64) NULL COMMENT '出口HSCode',
`ImHsCode` VARCHAR(64) NULL COMMENT '进口HSCode',
`DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素',
`WeightG` DECIMAL(10,2) NULL COMMENT '重量(g)',
`LengthCm` DECIMAL(10,2) NULL COMMENT '长(cm)',
`WidthCm` DECIMAL(10,2) NULL COMMENT '宽(cm)',
@ -1850,7 +1877,8 @@ CREATE TABLE `t_pickup_order` (
`CustomerId` INT NOT NULL COMMENT '客户Id',
`OrganizationId` CHAR(36) NULL COMMENT '数据归属组织',
`SecretLevel` TINYINT NOT NULL DEFAULT 2 COMMENT '数据密级 1-4',
`SenderAddressId` INT NOT NULL COMMENT '发件地址簿Id',
`SenderAddressId` INT NOT NULL COMMENT '客户揽收地址簿Id',
`Direction` VARCHAR(16) NOT NULL DEFAULT 'forward' COMMENT 'forward正向(客户→仓)/reverse逆向(仓→客户)',
`SenderName` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '取件联系人快照',
`SenderCompany` VARCHAR(255) NULL COMMENT '公司快照',
`SenderCountry` INT NULL COMMENT '国家 t_country.Id 快照',
@ -1899,6 +1927,7 @@ CREATE TABLE `t_pickup_order` (
UNIQUE KEY `uk_pickup_no` (`PickupNo`),
UNIQUE KEY `uk_pickup_request_id` (`RequestId`),
KEY `idx_pickup_customer_status` (`CustomerId`, `Status`, `CreateTime`),
KEY `idx_pickup_direction_status` (`Direction`, `Status`, `CreateTime`),
KEY `idx_pickup_warehouse_time` (`WarehouseId`, `ExpectPickupTime`),
KEY `idx_pickup_pay` (`PayStatus`),
KEY `idx_pickup_settle` (`SettlementStatus`),
@ -1974,7 +2003,7 @@ CREATE TABLE `t_9610_order` (
`ManifestSyncTime` DATETIME NULL COMMENT '清单数据最近同步时间',
`FailReason` VARCHAR(512) NULL COMMENT '最近失败原因',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除',
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@ -1995,7 +2024,7 @@ CREATE TABLE `t_9610_order_item` (
`Sku` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'SKU(对齐 t_order_declaration.Sku)',
`CnName` VARCHAR(250) NOT NULL DEFAULT '' COMMENT '中文品名(对齐 t_order_declaration.CnName)',
`ExHsCode` VARCHAR(10) NULL COMMENT '出口海关编码(对齐 t_order_declaration.ExHsCode)',
`DeclareElement` VARCHAR(255) NULL COMMENT '申报要素',
`DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素',
`Barcode` VARCHAR(255) NULL COMMENT '条形码',
`DestCountry` VARCHAR(255) NULL COMMENT '目的国(地区)代码',
`ItemCurrency` VARCHAR(3) NULL COMMENT '币制',
@ -2008,6 +2037,7 @@ CREATE TABLE `t_9610_order_item` (
`Freight` DECIMAL(12,4) NULL COMMENT '行运费',
`SortNo` INT NOT NULL DEFAULT 0 COMMENT '排序',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除',
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',

View File

@ -0,0 +1,21 @@
-- 存量库:排货规则服务商表增加判断条件 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;

View File

@ -0,0 +1,98 @@
-- 存量库:客户出口资质备案表(含 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;

View File

@ -0,0 +1,38 @@
-- 存量库: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;

View File

@ -1,80 +0,0 @@
-- 员工档案「岗位 / 职级」字典初始数据(幂等:已存在同 DictType+DictCode 则跳过)
-- dictType: employee_position / employee_job_level
-- MySQL 5.7+ / 8.0
SET NAMES utf8mb4;
-- ========== 岗位 employee_position ==========
INSERT INTO `t_dict_type` (`DictType`, `Remark`, `TypeSort`, `IsDeleted`, `CreateUserId`)
SELECT 'employee_position', '员工岗位', 0, 0, 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `t_dict_type` WHERE `DictType` = 'employee_position' AND `IsDeleted` = 0
);
INSERT INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `IsDeleted`, `CreateUserId`)
SELECT v.`DictType`, v.`DictSort`, v.`DictCode`, v.`DictName`, 0, 0
FROM (
SELECT 'employee_position' AS DictType, 1 AS DictSort, 'generalManager' AS DictCode, '总经理' AS DictName
UNION ALL SELECT 'employee_position', 2, 'deputyGeneralManager', '副总经理'
UNION ALL SELECT 'employee_position', 3, 'logisticsAdvisor', '物流顾问'
UNION ALL SELECT 'employee_position', 4, 'csManager', '客服主管'
UNION ALL SELECT 'employee_position', 5, 'csSpecialist', '客服专员'
UNION ALL SELECT 'employee_position', 6, 'opsManager', '运营经理'
UNION ALL SELECT 'employee_position', 7, 'opsSpecialist', '运营专员'
UNION ALL SELECT 'employee_position', 8, 'financeManager', '财务经理'
UNION ALL SELECT 'employee_position', 9, 'financeSpecialist', '财务专员'
UNION ALL SELECT 'employee_position', 10, 'warehouseManager', '仓储主管'
UNION ALL SELECT 'employee_position', 11, 'warehouseClerk', '仓管员'
UNION ALL SELECT 'employee_position', 12, 'customsSpecialist', '关务专员'
UNION ALL SELECT 'employee_position', 13, 'productManager', '产品经理'
UNION ALL SELECT 'employee_position', 14, 'techEngineer', '技术工程师'
UNION ALL SELECT 'employee_position', 15, 'hrAdmin', '人事行政'
UNION ALL SELECT 'employee_position', 16, 'marketingSpecialist', '市场专员'
UNION ALL SELECT 'employee_position', 17, 'dispatcher', '调度员'
) AS v
WHERE NOT EXISTS (
SELECT 1
FROM `t_dict` d
WHERE d.`DictType` = v.`DictType`
AND d.`DictCode` = v.`DictCode`
AND d.`IsDeleted` = 0
);
UPDATE `t_dict`
SET `IsDeleted` = 1,
`UpdateTime` = NOW()
WHERE `DictType` = 'employee_position'
AND `DictCode` IN (
'salesDirector',
'salesManager',
'salesSpecialist',
'logisticsAdvisorDirector',
'logisticsAdvisorManager'
)
AND `IsDeleted` = 0;
-- ========== 职级 employee_job_level(一级~五级) ==========
INSERT INTO `t_dict_type` (`DictType`, `Remark`, `TypeSort`, `IsDeleted`, `CreateUserId`)
SELECT 'employee_job_level', '员工职级', 0, 0, 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `t_dict_type` WHERE `DictType` = 'employee_job_level' AND `IsDeleted` = 0
);
INSERT INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `IsDeleted`, `CreateUserId`)
SELECT v.`DictType`, v.`DictSort`, v.`DictCode`, v.`DictName`, 0, 0
FROM (
SELECT 'employee_job_level' AS DictType, 1 AS DictSort, '1' AS DictCode, '一级' AS DictName
UNION ALL SELECT 'employee_job_level', 2, '2', '二级'
UNION ALL SELECT 'employee_job_level', 3, '3', '三级'
UNION ALL SELECT 'employee_job_level', 4, '4', '四级'
UNION ALL SELECT 'employee_job_level', 5, '5', '五级'
) AS v
WHERE NOT EXISTS (
SELECT 1
FROM `t_dict` d
WHERE d.`DictType` = v.`DictType`
AND d.`DictCode` = v.`DictCode`
AND d.`IsDeleted` = 0
);

View File

@ -0,0 +1,45 @@
-- 存量库:为问题模板补充处理人字段(与 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;

View File

@ -0,0 +1,16 @@
-- 揽收方向 + 客户地址类型扩展(国内揽收)
-- 正向 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海外收件';

View File

@ -1,36 +0,0 @@
-- t_sales_product:官网线路展示排序(幂等;列已存在则跳过)
-- 数值越小越靠前;默认 0
SET @db := DATABASE();
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_sales_product'
AND COLUMN_NAME = 'RouteSort'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_sales_product` ADD COLUMN `RouteSort` INT NOT NULL DEFAULT 0 COMMENT ''官网线路排序,越小越靠前'' AFTER `TradeTerms`',
'SELECT ''skip RouteSort'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @idx := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_sales_product'
AND INDEX_NAME = 'idx_sp_route_sort'
);
SET @sql_idx := IF(
@idx = 0,
'ALTER TABLE `t_sales_product` ADD KEY `idx_sp_route_sort` (`RouteSort`, `IsActive`)',
'SELECT ''skip idx_sp_route_sort'' AS msg'
);
PREPARE stmt_idx FROM @sql_idx;
EXECUTE stmt_idx;
DEALLOCATE PREPARE stmt_idx;

View File

@ -1,81 +0,0 @@
-- 9610 商品明细命名对齐 t_order_declaration(幂等)
-- ItemNo→Sku, ItemName→CnName, Gcode→ExHsCode, Price→UnitPrice, Qty→Quantity,
-- TotalAmount→Amount, DeclareUnit→Unit
-- 海关专属保留:Gmodel / Barcode / DestCountry / ItemCurrency / LegalQty / LegalUnit / Freight / SortNo
SET @db := DATABASE();
-- ItemNo → Sku
SET @hasOld := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='ItemNo');
SET @hasNew := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Sku');
SET @sql := IF(@hasOld>0 AND @hasNew=0,
'ALTER TABLE `t_9610_order_item` CHANGE COLUMN `ItemNo` `Sku` VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''SKU(对齐 t_order_declaration.Sku / 上游 itemNo)''',
'SELECT ''skip ItemNo->Sku'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ItemName → CnName
SET @hasOld := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='ItemName');
SET @hasNew := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='CnName');
SET @sql := IF(@hasOld>0 AND @hasNew=0,
'ALTER TABLE `t_9610_order_item` CHANGE COLUMN `ItemName` `CnName` VARCHAR(250) NOT NULL DEFAULT '''' COMMENT ''中文品名(对齐 t_order_declaration.CnName / 上游 itemName)''',
'SELECT ''skip ItemName->CnName'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Gcode → ExHsCode
SET @hasOld := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Gcode');
SET @hasNew := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='ExHsCode');
SET @sql := IF(@hasOld>0 AND @hasNew=0,
'ALTER TABLE `t_9610_order_item` CHANGE COLUMN `Gcode` `ExHsCode` VARCHAR(10) NULL COMMENT ''出口海关编码(对齐 t_order_declaration.ExHsCode / 上游 gcode)''',
'SELECT ''skip Gcode->ExHsCode'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Price → UnitPrice
SET @hasOld := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Price');
SET @hasNew := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='UnitPrice');
SET @sql := IF(@hasOld>0 AND @hasNew=0,
'ALTER TABLE `t_9610_order_item` CHANGE COLUMN `Price` `UnitPrice` DECIMAL(19,5) NOT NULL DEFAULT 0 COMMENT ''单价(对齐 t_order_declaration.UnitPrice)''',
'SELECT ''skip Price->UnitPrice'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- TotalAmount → Amount(可能尚未存在 TotalAmount)
SET @hasOld := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='TotalAmount');
SET @hasNew := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Amount');
SET @sql := IF(@hasOld>0 AND @hasNew=0,
'ALTER TABLE `t_9610_order_item` CHANGE COLUMN `TotalAmount` `Amount` DECIMAL(19,5) NULL COMMENT ''总价(对齐 t_order_declaration.Amount / 上游 totalPrice)''',
'SELECT ''skip TotalAmount->Amount'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @hasNew := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Amount');
SET @sql := IF(@hasNew=0,
'ALTER TABLE `t_9610_order_item` ADD COLUMN `Amount` DECIMAL(19,5) NULL COMMENT ''总价(对齐 t_order_declaration.Amount)'' AFTER `UnitPrice`',
'SELECT ''skip add Amount'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Qty → Quantity
SET @hasOld := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Qty');
SET @hasNew := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Quantity');
SET @sql := IF(@hasOld>0 AND @hasNew=0,
'ALTER TABLE `t_9610_order_item` CHANGE COLUMN `Qty` `Quantity` DECIMAL(19,5) NOT NULL DEFAULT 0 COMMENT ''数量(对齐 t_order_declaration.Quantity,精度保留海关小数)''',
'SELECT ''skip Qty->Quantity'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- DeclareUnit → Unit
SET @hasOld := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='DeclareUnit');
SET @hasNew := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Unit');
SET @sql := IF(@hasOld>0 AND @hasNew=0,
'ALTER TABLE `t_9610_order_item` CHANGE COLUMN `DeclareUnit` `Unit` VARCHAR(3) NULL COMMENT ''计量单位(对齐 t_order_declaration.Unit / 上游 unit)''',
'SELECT ''skip DeclareUnit->Unit'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @hasNew := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Unit');
SET @sql := IF(@hasNew=0,
'ALTER TABLE `t_9610_order_item` ADD COLUMN `Unit` VARCHAR(3) NULL COMMENT ''计量单位(对齐 t_order_declaration.Unit)'' AFTER `LegalQty`',
'SELECT ''skip add Unit'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Gmodel 注释:海关申报要素(由运单 Model/Specifications/Attributes/Material 拼接)
SET @has := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Gmodel');
SET @sql := IF(@has>0,
'ALTER TABLE `t_9610_order_item` MODIFY COLUMN `Gmodel` VARCHAR(255) NULL COMMENT ''申报要素(海关;由运单型号/规格等拼接,上游 gmodel)''',
'SELECT ''skip Gmodel comment'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -1,118 +0,0 @@
-- 9610 单号命名对齐运单 t_order(幂等)
-- ReferenceNo → ClientNo(客户单号)
-- LogisticsNo 并入 ServiceNo(运单号)后删除 LogisticsNo
SET @db := DATABASE();
-- 1) ReferenceNo → ClientNo
SET @hasRef := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ReferenceNo'
);
SET @hasClient := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ClientNo'
);
SET @sql := IF(
@hasRef > 0 AND @hasClient = 0,
'ALTER TABLE `t_9610_order` CHANGE COLUMN `ReferenceNo` `ClientNo` VARCHAR(48) NOT NULL DEFAULT '''' COMMENT ''客户单号(对齐 t_order.ClientNo / 上游 referenceNo)''',
'SELECT ''skip ReferenceNo->ClientNo'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 索引名顺带调整(旧 idx_9610_ref → idx_9610_client_no)
SET @idxRef := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND INDEX_NAME='idx_9610_ref'
);
SET @idxClient := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND INDEX_NAME='idx_9610_client_no'
);
SET @sql := IF(
@idxRef > 0 AND @idxClient = 0,
'ALTER TABLE `t_9610_order` RENAME INDEX `idx_9610_ref` TO `idx_9610_client_no`',
'SELECT ''skip rename idx_9610_ref'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idxClient := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND INDEX_NAME='idx_9610_client_no'
);
SET @sql := IF(
@idxClient = 0,
'ALTER TABLE `t_9610_order` ADD KEY `idx_9610_client_no` (`ClientNo`)',
'SELECT ''skip add idx_9610_client_no'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2) LogisticsNo → 回填 ServiceNo,再删列
SET @hasLog := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='LogisticsNo'
);
SET @sql := IF(
@hasLog > 0,
'UPDATE `t_9610_order` SET `ServiceNo` = NULLIF(TRIM(`LogisticsNo`), '''') WHERE (`ServiceNo` IS NULL OR TRIM(`ServiceNo`) = '''') AND `LogisticsNo` IS NOT NULL AND TRIM(`LogisticsNo`) <> ''''',
'SELECT ''skip backfill ServiceNo'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idxLog := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND INDEX_NAME='idx_9610_logistics'
);
SET @sql := IF(
@idxLog > 0,
'ALTER TABLE `t_9610_order` DROP INDEX `idx_9610_logistics`',
'SELECT ''skip drop idx_9610_logistics'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @hasLog := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='LogisticsNo'
);
SET @sql := IF(
@hasLog > 0,
'ALTER TABLE `t_9610_order` DROP COLUMN `LogisticsNo`',
'SELECT ''skip drop LogisticsNo'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ServiceNo 索引(检索)
SET @idxSvc := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND INDEX_NAME='idx_9610_service_no'
);
SET @sql := IF(
@idxSvc = 0,
'ALTER TABLE `t_9610_order` ADD KEY `idx_9610_service_no` (`ServiceNo`)',
'SELECT ''skip idx_9610_service_no'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- OrderNo 注释对齐:海关下游订单编号(非运单 DownstreamServiceNo)
SET @hasOrderNo := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='OrderNo'
);
SET @sql := IF(
@hasOrderNo > 0,
'ALTER TABLE `t_9610_order` MODIFY COLUMN `OrderNo` VARCHAR(32) NULL COMMENT ''海关下游订单编号(申报回写,非 t_order.DownstreamServiceNo)''',
'SELECT ''skip OrderNo comment'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- DownstreamRefNo 注释
SET @hasDr := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='DownstreamRefNo'
);
SET @sql := IF(
@hasDr > 0,
'ALTER TABLE `t_9610_order` MODIFY COLUMN `DownstreamRefNo` VARCHAR(32) NULL COMMENT ''申报回写的 referenceNo 快照''',
'SELECT ''skip DownstreamRefNo comment'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -1,170 +0,0 @@
-- 9610:补齐「获取清单数据」回写字段(幂等;列已存在则跳过)
-- 字段名为 OMS 自有命名,与上游 CEB 字段对照见注释
SET @db := DATABASE();
-- ========== t_9610_order ==========
-- EnterpriseBizNo ← copNo 企业唯一编号
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='EnterpriseBizNo');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `EnterpriseBizNo` VARCHAR(48) NULL COMMENT ''企业唯一编号(上游 copNo)'' AFTER `DownstreamRefNo`', 'SELECT ''skip EnterpriseBizNo'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ManifestNo ← invtNo 清单编号
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ManifestNo');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `ManifestNo` VARCHAR(48) NULL COMMENT ''清单编号(上游 invtNo)'' AFTER `EnterpriseBizNo`', 'SELECT ''skip ManifestNo'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ClearanceTime ← clearanceTime 结关日期(原文,格式不固定)
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ClearanceTime');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `ClearanceTime` VARCHAR(48) NULL COMMENT ''结关日期原文'' AFTER `ManifestNo`', 'SELECT ''skip ClearanceTime'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- CustomsInboundTime ← warehouseTime 海关入库时间
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='CustomsInboundTime');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `CustomsInboundTime` VARCHAR(48) NULL COMMENT ''海关入库时间原文'' AFTER `ClearanceTime`', 'SELECT ''skip CustomsInboundTime'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- CustomsState ← customsStatus 海关状态
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='CustomsState');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `CustomsState` VARCHAR(4) NULL COMMENT ''海关状态'' AFTER `CustomsInboundTime`', 'SELECT ''skip CustomsState'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- DeclareCustomsCode ← customsCode 申报地海关
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='DeclareCustomsCode');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `DeclareCustomsCode` VARCHAR(4) NULL COMMENT ''申报地海关代码'' AFTER `CustomsState`', 'SELECT ''skip DeclareCustomsCode'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- EbpCode ← ebpCode
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='EbpCode');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `EbpCode` VARCHAR(18) NULL COMMENT ''电商平台代码'' AFTER `DeclareCustomsCode`', 'SELECT ''skip EbpCode'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- EbpName ← ebpName
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='EbpName');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `EbpName` VARCHAR(100) NULL COMMENT ''电商平台名称'' AFTER `EbpCode`', 'SELECT ''skip EbpName'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- LogisticsCorpCode ← logisticsCode
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='LogisticsCorpCode');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `LogisticsCorpCode` VARCHAR(18) NULL COMMENT ''物流企业代码'' AFTER `EbpName`', 'SELECT ''skip LogisticsCorpCode'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- LogisticsCorpName ← logisticsName
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='LogisticsCorpName');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `LogisticsCorpName` VARCHAR(100) NULL COMMENT ''物流企业名称'' AFTER `LogisticsCorpCode`', 'SELECT ''skip LogisticsCorpName'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- IeMark ← ieFlag I/E
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='IeMark');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `IeMark` VARCHAR(4) NULL COMMENT ''进出口标记 I/E'' AFTER `LogisticsCorpName`', 'SELECT ''skip IeMark'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ExportPortCode ← portCode
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ExportPortCode');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `ExportPortCode` VARCHAR(4) NULL COMMENT ''出口口岸代码'' AFTER `IeMark`', 'SELECT ''skip ExportPortCode'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ExportDate ← ieDate
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ExportDate');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `ExportDate` VARCHAR(32) NULL COMMENT ''出口日期原文'' AFTER `ExportPortCode`', 'SELECT ''skip ExportDate'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- DeclareAgentCode ← agentCode
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='DeclareAgentCode');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `DeclareAgentCode` VARCHAR(18) NULL COMMENT ''申报企业代码'' AFTER `ExportDate`', 'SELECT ''skip DeclareAgentCode'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- DeclareAgentName ← agentName
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='DeclareAgentName');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `DeclareAgentName` VARCHAR(100) NULL COMMENT ''申报企业名称'' AFTER `DeclareAgentCode`', 'SELECT ''skip DeclareAgentName'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- TradeModeCode ← tradeMode
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='TradeModeCode');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `TradeModeCode` VARCHAR(4) NULL COMMENT ''贸易方式代码'' AFTER `DeclareAgentName`', 'SELECT ''skip TradeModeCode'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- TransportMode ← trafMode
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='TransportMode');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `TransportMode` VARCHAR(4) NULL COMMENT ''运输方式代码'' AFTER `TradeModeCode`', 'SELECT ''skip TransportMode'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- FreightCurrency ← fCurrency
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='FreightCurrency');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `FreightCurrency` VARCHAR(4) NULL COMMENT ''运费币制'' AFTER `Freight`', 'SELECT ''skip FreightCurrency'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- FreightMark ← fFlag 1率/2单价/3总价
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='FreightMark');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `FreightMark` INT NULL COMMENT ''运费标志 1率2单价3总价'' AFTER `FreightCurrency`', 'SELECT ''skip FreightMark'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- PackageType ← wrapType
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='PackageType');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `PackageType` INT NULL COMMENT ''包装种类代码'' AFTER `FreightMark`', 'SELECT ''skip PackageType'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- PackageQty ← packNo
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='PackageQty');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `PackageQty` INT NULL COMMENT ''件数'' AFTER `PackageType`', 'SELECT ''skip PackageQty'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ManifestSyncTime 最近一次清单回写时间
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ManifestSyncTime');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order` ADD COLUMN `ManifestSyncTime` DATETIME NULL COMMENT ''清单数据最近同步时间'' AFTER `DeclareTime`', 'SELECT ''skip ManifestSyncTime'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 运费精度对齐上游 DECIMAL(19,5)
SET @colType := (
SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='Freight'
);
SET @sql := IF(
@colType IS NOT NULL AND @colType NOT LIKE 'decimal(19,5)%',
'ALTER TABLE `t_9610_order` MODIFY COLUMN `Freight` DECIMAL(19,5) NULL COMMENT ''运费''',
'SELECT ''skip Freight precision'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ManifestNo 索引(幂等)
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND INDEX_NAME='idx_9610_manifest');
SET @sql := IF(@idx=0, 'ALTER TABLE `t_9610_order` ADD KEY `idx_9610_manifest` (`ManifestNo`)', 'SELECT ''skip idx_9610_manifest'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ========== t_9610_order_item ==========
-- Barcode ← barCode
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Barcode');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order_item` ADD COLUMN `Barcode` VARCHAR(255) NULL COMMENT ''条形码'' AFTER `Gmodel`', 'SELECT ''skip Barcode'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- DestCountry ← country 目的国
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='DestCountry');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order_item` ADD COLUMN `DestCountry` VARCHAR(255) NULL COMMENT ''目的国(地区)代码'' AFTER `Barcode`', 'SELECT ''skip DestCountry'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ItemCurrency ← currency
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='ItemCurrency');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order_item` ADD COLUMN `ItemCurrency` VARCHAR(3) NULL COMMENT ''币制'' AFTER `DestCountry`', 'SELECT ''skip ItemCurrency'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- LegalQty ← qty1 法定数量
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='LegalQty');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order_item` ADD COLUMN `LegalQty` DECIMAL(19,5) NULL COMMENT ''法定数量'' AFTER `Quantity`', 'SELECT ''skip LegalQty'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Unit ← unit(对齐运单;旧名 DeclareUnit 由 align_item_fields 迁移)
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Unit');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order_item` ADD COLUMN `Unit` VARCHAR(3) NULL COMMENT ''计量单位'' AFTER `LegalQty`', 'SELECT ''skip Unit'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- LegalUnit ← unit1
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='LegalUnit');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order_item` ADD COLUMN `LegalUnit` VARCHAR(3) NULL COMMENT ''法定计量单位'' AFTER `Unit`', 'SELECT ''skip LegalUnit'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Amount ← totalPrice
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Amount');
SET @sql := IF(@col=0, 'ALTER TABLE `t_9610_order_item` ADD COLUMN `Amount` DECIMAL(19,5) NULL COMMENT ''总价'' AFTER `UnitPrice`', 'SELECT ''skip Amount'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -1,122 +0,0 @@
-- 9610 汇总申报单(ExportCustomsMode=9610Summary)
-- 幂等:表已存在则跳过
SET @db := DATABASE();
-- t_9610_order
SET @exists := (
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_9610_order'
);
SET @sql := IF(
@exists = 0,
'CREATE TABLE `t_9610_order` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
`OrderId` INT NOT NULL COMMENT ''关联运单 t_order.Id'',
`ServiceNo` VARCHAR(64) NULL COMMENT ''运单号冗余'',
`CustomerId` INT NOT NULL DEFAULT 0 COMMENT ''客户Id'',
`Status` VARCHAR(16) NOT NULL DEFAULT ''pending'' COMMENT ''pending待申报 / declared已申报'',
`PlatformCode` VARCHAR(16) NOT NULL DEFAULT '''' COMMENT ''电商平台'',
`EcomCorpCode` VARCHAR(18) NULL COMMENT ''电商企业代码'',
`EcomCorpName` VARCHAR(100) NULL COMMENT ''电商企业名称'',
`ProducerCode` VARCHAR(18) NULL COMMENT ''生产销售单位代码'',
`ProducerName` VARCHAR(100) NULL COMMENT ''生产销售单位'',
`ClientNo` VARCHAR(48) NOT NULL DEFAULT '''' COMMENT ''客户单号(对齐 t_order.ClientNo)'',
`BusinessType` CHAR(1) NOT NULL DEFAULT ''B'' COMMENT ''业务类型 A简化/B汇总'',
`Region` VARCHAR(10) NULL COMMENT ''区域 SZ/other'',
`AgentDeclare` VARCHAR(2) NULL COMMENT ''是否代理申报 是/否'',
`ActualSellerCode` VARCHAR(18) NULL COMMENT ''实际卖家信用代码'',
`PreRecordFlag` CHAR(1) NULL COMMENT ''汇总预录 0已预录 1未预录'',
`IsSummarySimple` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否汇总简化申报'',
`GrossWeight` DECIMAL(19,5) NOT NULL DEFAULT 0 COMMENT ''毛重KG'',
`Currency` VARCHAR(4) NOT NULL DEFAULT ''CNY'' COMMENT ''币制'',
`DestCountry` VARCHAR(4) NOT NULL DEFAULT '''' COMMENT ''目的国'',
`Freight` DECIMAL(19,5) NULL COMMENT ''运费'',
`FreightCurrency` VARCHAR(4) NULL COMMENT ''运费币制'',
`FreightMark` INT NULL COMMENT ''运费标志 1率2单价3总价'',
`PackageType` INT NULL COMMENT ''包装种类代码'',
`PackageQty` INT NULL COMMENT ''件数'',
`ShipmentNo` VARCHAR(32) NULL COMMENT ''出货单号'',
`CustomsOrderNo` VARCHAR(32) NULL COMMENT ''海关订单号(申报回写)'',
`DownstreamRefNo` VARCHAR(32) NULL COMMENT ''申报回写的 referenceNo 快照'',
`EnterpriseBizNo` VARCHAR(48) NULL COMMENT ''企业唯一编号(上游 copNo)'',
`ManifestNo` VARCHAR(48) NULL COMMENT ''清单编号(上游 invtNo)'',
`ClearanceTime` VARCHAR(48) NULL COMMENT ''结关日期原文'',
`CustomsInboundTime` VARCHAR(48) NULL COMMENT ''海关入库时间原文'',
`CustomsState` VARCHAR(4) NULL COMMENT ''海关状态'',
`DeclareCustomsCode` VARCHAR(4) NULL COMMENT ''申报地海关代码'',
`PlatformCorpCode` VARCHAR(18) NULL COMMENT ''电商平台代码'',
`PlatformName` VARCHAR(100) NULL COMMENT ''电商平台名称'',
`LogisticsCorpCode` VARCHAR(18) NULL COMMENT ''物流企业代码'',
`LogisticsCorpName` VARCHAR(100) NULL COMMENT ''物流企业名称'',
`IeMark` VARCHAR(4) NULL COMMENT ''进出口标记 I/E'',
`ExportPortCode` VARCHAR(4) NULL COMMENT ''出口口岸代码'',
`ExportDate` VARCHAR(32) NULL COMMENT ''出口日期原文'',
`DeclareAgentCode` VARCHAR(18) NULL COMMENT ''申报企业代码'',
`DeclareAgentName` VARCHAR(100) NULL COMMENT ''申报企业名称'',
`TradeModeCode` VARCHAR(4) NULL COMMENT ''贸易方式代码'',
`TransportMode` VARCHAR(4) NULL COMMENT ''运输方式代码'',
`DeclareTime` DATETIME NULL COMMENT ''申报成功时间'',
`ManifestSyncTime` DATETIME NULL COMMENT ''清单数据最近同步时间'',
`FailReason` VARCHAR(512) NULL COMMENT ''最近失败原因'',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除'',
`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_9610_order_id` (`OrderId`),
KEY `idx_9610_status_ctime` (`Status`, `CreateTime`),
KEY `idx_9610_service_no` (`ServiceNo`),
KEY `idx_9610_client_no` (`ClientNo`),
KEY `idx_9610_customer` (`CustomerId`, `CreateTime`),
KEY `idx_9610_manifest` (`ManifestNo`)
) COMMENT=''9610汇总申报单''',
'SELECT ''skip t_9610_order'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- t_9610_order_item
SET @exists := (
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_9610_order_item'
);
SET @sql := IF(
@exists = 0,
'CREATE TABLE `t_9610_order_item` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
`Customs9610OrderId` INT NOT NULL COMMENT ''t_9610_order.Id'',
`OrderDeclarationId` INT NULL COMMENT ''溯源 t_order_declaration.Id'',
`Sku` VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''SKU(对齐 t_order_declaration.Sku)'',
`CnName` VARCHAR(250) NOT NULL DEFAULT '''' COMMENT ''中文品名(对齐 t_order_declaration.CnName)'',
`ExHsCode` VARCHAR(10) NULL COMMENT ''出口海关编码(对齐 t_order_declaration.ExHsCode)'',
`DeclareElement` VARCHAR(255) NULL COMMENT ''申报要素'',
`Barcode` VARCHAR(255) NULL COMMENT ''条形码'',
`DestCountry` VARCHAR(255) NULL COMMENT ''目的国(地区)代码'',
`ItemCurrency` VARCHAR(3) NULL COMMENT ''币制'',
`UnitPrice` DECIMAL(19,5) NOT NULL DEFAULT 0 COMMENT ''单价(对齐 t_order_declaration.UnitPrice)'',
`Amount` DECIMAL(19,5) NULL COMMENT ''总价(对齐 t_order_declaration.Amount)'',
`Quantity` DECIMAL(19,5) NOT NULL DEFAULT 0 COMMENT ''数量(对齐 t_order_declaration.Quantity)'',
`LegalQty` DECIMAL(19,5) NULL COMMENT ''法定数量'',
`Unit` VARCHAR(3) NULL COMMENT ''计量单位(对齐 t_order_declaration.Unit)'',
`LegalUnit` VARCHAR(3) NULL COMMENT ''法定计量单位'',
`Freight` DECIMAL(12,4) NULL COMMENT ''行运费'',
`SortNo` INT NOT NULL DEFAULT 0 COMMENT ''排序'',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 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_9610_item_header` (`Customs9610OrderId`, `IsDeleted`)
) COMMENT=''9610申报商品明细''',
'SELECT ''skip t_9610_order_item'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -1,90 +0,0 @@
-- 9610 其余字段按业务含义重命名(幂等 CHANGE)
-- 申报头:Ebp→PlatformCode, Ebc*→EcomCorp*, Owner*→Producer*, FormType→BusinessType,
-- IsAgent→AgentDeclare, ActBuyer→ActualSellerCode, SoLabelFlag→PreRecordFlag,
-- IsSimple→IsSummarySimple, Country→DestCountry, BlNo→ShipmentNo,
-- EbpCode/EbpName→PlatformCorpCode/PlatformName, OrderNo→CustomsOrderNo
-- 明细:Gmodel→DeclareElement
SET @db := DATABASE();
-- helper pattern repeated
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='Ebp');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='PlatformCode');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `Ebp` `PlatformCode` VARCHAR(16) NOT NULL DEFAULT '''' COMMENT ''电商平台''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='EbcCode');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='EcomCorpCode');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `EbcCode` `EcomCorpCode` VARCHAR(18) NULL COMMENT ''电商企业代码''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='EbcName');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='EcomCorpName');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `EbcName` `EcomCorpName` VARCHAR(100) NULL COMMENT ''电商企业名称''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='OwnerCode');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ProducerCode');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `OwnerCode` `ProducerCode` VARCHAR(18) NULL COMMENT ''生产销售单位代码''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='OwnerName');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ProducerName');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `OwnerName` `ProducerName` VARCHAR(100) NULL COMMENT ''生产销售单位''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='FormType');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='BusinessType');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `FormType` `BusinessType` CHAR(1) NOT NULL DEFAULT ''B'' COMMENT ''业务类型 A简化/B汇总''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='IsAgent');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='AgentDeclare');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `IsAgent` `AgentDeclare` VARCHAR(2) NULL COMMENT ''是否代理申报 是/否''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ActBuyer');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ActualSellerCode');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `ActBuyer` `ActualSellerCode` VARCHAR(18) NULL COMMENT ''实际卖家信用代码''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='SoLabelFlag');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='PreRecordFlag');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `SoLabelFlag` `PreRecordFlag` CHAR(1) NULL COMMENT ''汇总预录 0已预录/1未预录''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='IsSimple');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='IsSummarySimple');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `IsSimple` `IsSummarySimple` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否汇总简化申报''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='Country');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='DestCountry');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `Country` `DestCountry` VARCHAR(4) NOT NULL DEFAULT '''' COMMENT ''目的国''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='BlNo');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='ShipmentNo');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `BlNo` `ShipmentNo` VARCHAR(32) NULL COMMENT ''出货单号''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='OrderNo');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='CustomsOrderNo');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `OrderNo` `CustomsOrderNo` VARCHAR(32) NULL COMMENT ''海关订单号''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='EbpCode');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='PlatformCorpCode');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `EbpCode` `PlatformCorpCode` VARCHAR(18) NULL COMMENT ''电商平台代码''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='EbpName');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order' AND COLUMN_NAME='PlatformName');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order` CHANGE COLUMN `EbpName` `PlatformName` VARCHAR(100) NULL COMMENT ''电商平台名称''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @o:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='Gmodel');
SET @n:=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='t_9610_order_item' AND COLUMN_NAME='DeclareElement');
SET @sql:=IF(@o>0 AND @n=0,'ALTER TABLE `t_9610_order_item` CHANGE COLUMN `Gmodel` `DeclareElement` VARCHAR(255) NULL COMMENT ''申报要素''','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

View File

@ -1,118 +0,0 @@
-- 订单异常事件表:补齐 Handler* / ConcurrencyStamp(幂等;列已存在则自动跳过)
-- 修复:Unknown column 'HandlerNames' / 'ConcurrencyStamp' in 'field list'
-- MySQL 5.7+ / 8.0
SET @db := DATABASE();
-- HandlerNames
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order_exception_event'
AND COLUMN_NAME = 'HandlerNames'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_order_exception_event` ADD COLUMN `HandlerNames` VARCHAR(256) NULL COMMENT ''处理人快照''',
'SELECT ''skip HandlerNames'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- HandlerType
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order_exception_event'
AND COLUMN_NAME = 'HandlerType'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_order_exception_event` ADD COLUMN `HandlerType` VARCHAR(16) NULL COMMENT ''处理人类型''',
'SELECT ''skip HandlerType'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- HandlerUserId
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order_exception_event'
AND COLUMN_NAME = 'HandlerUserId'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_order_exception_event` ADD COLUMN `HandlerUserId` INT NULL COMMENT ''处理人用户Id(customer 时为订单客户账号)''',
'SELECT ''skip HandlerUserId'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ConcurrencyStamp(AggregateRoot 并发戳;避免 DEFAULT(UUID()) 触发 1674 复制不安全)
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order_exception_event'
AND COLUMN_NAME = 'ConcurrencyStamp'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_order_exception_event` ADD COLUMN `ConcurrencyStamp` CHAR(36) NULL COMMENT ''并发戳''',
'SELECT ''skip ConcurrencyStamp add'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order_exception_event'
AND COLUMN_NAME = 'ConcurrencyStamp'
);
SET @sql := IF(
@col > 0,
'UPDATE `t_order_exception_event`
SET `ConcurrencyStamp` = LOWER(CONCAT(
LPAD(HEX(`Id`), 8, ''0''),
''-0000-4000-8000-'',
LPAD(HEX(`Id` * 9973), 12, ''0'')
))
WHERE `ConcurrencyStamp` IS NULL OR `ConcurrencyStamp` = ''''',
'SELECT ''skip ConcurrencyStamp backfill'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order_exception_event'
AND COLUMN_NAME = 'ConcurrencyStamp'
);
SET @isNullable := (
SELECT IFNULL(MAX(CASE WHEN IS_NULLABLE = 'YES' THEN 1 ELSE 0 END), 0)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order_exception_event'
AND COLUMN_NAME = 'ConcurrencyStamp'
);
SET @sql := IF(
@col > 0 AND @isNullable = 1,
'ALTER TABLE `t_order_exception_event` MODIFY COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL COMMENT ''并发戳''',
'SELECT ''skip ConcurrencyStamp not null'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -1,70 +0,0 @@
-- 代发订单:t_order 履约类型字段(幂等;列/索引已存在则自动跳过)
-- MySQL 5.7+ / 8.0
SET @db := DATABASE();
-- FulfillmentType
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order'
AND COLUMN_NAME = 'FulfillmentType'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_order` ADD COLUMN `FulfillmentType` VARCHAR(16) NULL DEFAULT ''direct'' COMMENT ''履约类型 direct/warehouse'' AFTER `AdditionalServices`',
'SELECT ''skip FulfillmentType'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- FulfillmentSource
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order'
AND COLUMN_NAME = 'FulfillmentSource'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_order` ADD COLUMN `FulfillmentSource` VARCHAR(32) NULL COMMENT ''代发来源 storeFulfill/postBind'' AFTER `FulfillmentType`',
'SELECT ''skip FulfillmentSource'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- idx_ord_fulfillment_customer
SET @idx := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order'
AND INDEX_NAME = 'idx_ord_fulfillment_customer'
);
SET @sql := IF(
@idx = 0,
'ALTER TABLE `t_order` ADD KEY `idx_ord_fulfillment_customer` (`FulfillmentType`, `CustomerId`, `OrderStatus`)',
'SELECT ''skip idx_ord_fulfillment_customer'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 历史:已有绑定行的订单标记为 warehouse
UPDATE `t_order` o
INNER JOIN (
SELECT DISTINCT b.`OrderId`
FROM `t_order_stock_bind` b
WHERE b.`IsDeleted` = 0
AND b.`Status` IN ('bound', 'deducted')
) x ON x.`OrderId` = o.`Id`
SET o.`FulfillmentType` = 'warehouse';
-- 其余空值兜底为 direct
UPDATE `t_order`
SET `FulfillmentType` = 'direct'
WHERE `FulfillmentType` IS NULL OR `FulfillmentType` = '';

View File

@ -1,49 +0,0 @@
-- 揽收明细:补齐运单号/客户单号快照(幂等;列已存在则跳过)
-- 对应实体 PickupOrderLine.ServiceNo / ClientNo
-- 报错 Unknown column 'ServiceNo' in 'field list' 时执行本脚本
-- MySQL 5.7+ / 8.0
SET @db := DATABASE();
-- ServiceNo
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_pickup_order_line'
AND COLUMN_NAME = 'ServiceNo'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_pickup_order_line` ADD COLUMN `ServiceNo` VARCHAR(64) NULL COMMENT ''运单号快照(便于列表)'' AFTER `OrderId`',
'SELECT ''skip ServiceNo'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ClientNo
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_pickup_order_line'
AND COLUMN_NAME = 'ClientNo'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_pickup_order_line` ADD COLUMN `ClientNo` VARCHAR(64) NULL COMMENT ''客户单号快照'' AFTER `ServiceNo`',
'SELECT ''skip ClientNo'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 存量行从订单表回填快照(仅空值)
UPDATE `t_pickup_order_line` pl
INNER JOIN `t_order` o ON o.`Id` = pl.`OrderId`
SET
pl.`ServiceNo` = IFNULL(NULLIF(pl.`ServiceNo`, ''), o.`ServiceNo`),
pl.`ClientNo` = IFNULL(NULLIF(pl.`ClientNo`, ''), o.`ClientNo`)
WHERE pl.`IsDeleted` = 0
AND (pl.`ServiceNo` IS NULL OR pl.`ServiceNo` = '' OR pl.`ClientNo` IS NULL OR pl.`ClientNo` = '');

View File

@ -1,20 +0,0 @@
-- user 表:企业微信客服接入链接(幂等;列已存在则跳过)
-- MySQL 5.7+ / 8.0
SET @db := DATABASE();
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 'user'
AND COLUMN_NAME = 'WecomKfUrl'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `user` ADD COLUMN `WecomKfUrl` VARCHAR(512) NULL COMMENT ''企业微信客服接入链接''',
'SELECT ''skip WecomKfUrl'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -31,7 +31,12 @@ public class SalesProductAllocationProviderInput
public int Weight { get; set; }
/// <summary>
/// 客户主键集合。
/// 客户主键集合(兼容旧配置;新配置请用 MatchCondition)。
/// </summary>
public List<int> CustomerIds { get; set; } = [];
/// <summary>
/// 排货判断条件(中文公式);空表示全部订单可用。
/// </summary>
public string? MatchCondition { get; set; }
}

View File

@ -41,10 +41,15 @@ public class SalesProductAllocationProviderOutput
public int Weight { get; set; }
/// <summary>
/// 客户主键集合。
/// 客户主键集合(兼容旧配置)。
/// </summary>
public List<int> CustomerIds { get; set; } = [];
/// <summary>
/// 排货判断条件(中文公式);空表示全部订单可用。
/// </summary>
public string? MatchCondition { get; set; }
/// <summary>
/// 创建时间。
/// </summary>

View File

@ -41,10 +41,15 @@ public class SalesProductAllocationProviderSnapshotDto
public int Weight { get; set; }
/// <summary>
/// 指定客户主键集合,空表示全部。
/// 指定客户主键集合,空表示全部(兼容旧快照)。
/// </summary>
public List<int> CustomerIds { get; set; } = [];
/// <summary>
/// 排货判断条件(中文公式);空表示全部订单可用。
/// </summary>
public string? MatchCondition { get; set; }
/// <summary>
/// 服务渠道编码(发布快照固化)。
/// </summary>

View File

@ -0,0 +1,109 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.Customers;
/// <summary>
/// 出口资质备案分页查询。
/// </summary>
public class CustomerExportQualificationPaginatedInput
{
[Newtonsoft.Json.JsonProperty("page")]
public int Page { get; set; } = 1;
[Newtonsoft.Json.JsonProperty("pageSize")]
public int PageSize { get; set; } = 50;
/// <summary>
/// 客户 ID(管理端可选;不传则查全部)。
/// </summary>
[Newtonsoft.Json.JsonProperty("customerId")]
public int? CustomerId { get; set; }
[Newtonsoft.Json.JsonProperty("keyword")]
public string? Keyword { get; set; }
[Newtonsoft.Json.JsonProperty("onlyDefault")]
public bool OnlyDefault { get; set; }
}
/// <summary>
/// 新增出口资质备案。
/// </summary>
public class CreateCustomerExportQualificationInput
{
/// <summary>
/// 客户 ID(管理端代操作必传;客户门户可省略)。
/// </summary>
public int? CustomerId { get; set; }
[Required]
[StringLength(200)]
public string CompanyName { get; set; } = string.Empty;
[Required]
[StringLength(64)]
public string SocialCreditCode { get; set; } = string.Empty;
[Required]
[StringLength(200)]
public string DomesticConsignor { get; set; } = string.Empty;
[StringLength(64)]
public string? DomesticConsignorCustomsCode { get; set; }
[Required]
[StringLength(200)]
public string ProducerName { get; set; } = string.Empty;
[StringLength(64)]
public string? ProducerCustomsCode { get; set; }
[StringLength(100)]
public string? CustomsPort { get; set; }
public bool IsDefault { get; set; }
[StringLength(512)]
public string? Remark { get; set; }
}
/// <summary>
/// 更新出口资质备案。
/// </summary>
public class UpdateCustomerExportQualificationInput : CreateCustomerExportQualificationInput
{
[Required]
public int Id { get; set; }
}
/// <summary>
/// 出口资质备案出参。
/// </summary>
public class CustomerExportQualificationOutput
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string CompanyName { get; set; } = string.Empty;
public string SocialCreditCode { get; set; } = string.Empty;
public string DomesticConsignor { get; set; } = string.Empty;
public string? DomesticConsignorCustomsCode { get; set; }
public string ProducerName { get; set; } = string.Empty;
public string? ProducerCustomsCode { get; set; }
public string? CustomsPort { get; set; }
public bool IsDefault { get; set; }
public string? Remark { get; set; }
public DateTime? CreateTime { get; set; }
public DateTime? UpdateTime { get; set; }
}

View File

@ -187,6 +187,11 @@ public class OrderPickupCreateInput
[Required]
public int SenderAddressId { get; set; }
/// <summary>
/// 揽收方向:forward / reverse。默认正向。
/// </summary>
public string? Direction { get; set; }
[Required]
public DateTime PickupTime { get; set; }

View File

@ -301,6 +301,14 @@ public class Customs9610DeclareResult
public int? HttpStatusCode { get; set; }
public string? ResponseBody { get; set; }
public string? Url { get; set; }
public string? RequestJson { get; set; }
public string? ServiceNo { get; set; }
public string? ClientNo { get; set; }
}
public class Customs9610DeclareBatchOutput

View File

@ -0,0 +1,57 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// itfx createOrderOne(ForwardWithSubscribe)标准请求体。
/// 仅包含下游接口字段,不含 OMS 内部扩展参数。
/// </summary>
public class ItfxCreateOrderOneBody
{
public string? ClientNo { get; set; }
public string? TrackNo { get; set; }
public string? ServiceNo { get; set; }
/// <summary>
/// 下游产品代码:取服务渠道「预报渠道 code」(ForecastCode)。
/// </summary>
public string? ProductCode { get; set; }
public string? CargoAttribute { get; set; }
public int? WarehouseId { get; set; }
public int? CustomerId { get; set; }
public decimal? Length { get; set; }
public decimal? Width { get; set; }
public decimal? Height { get; set; }
public decimal? ActualWeight { get; set; }
public decimal? VolumetricWeight { get; set; }
public decimal? ChargeableWeight { get; set; }
public decimal? Cubic { get; set; }
public bool IsCod { get; set; }
public string? CodCurrency { get; set; }
public string? CodSum { get; set; }
public string? DeclaredCurrency { get; set; }
public string? DeclaredSum { get; set; }
public string? Quantity { get; set; }
public OrderParcelReceiverInput? Receiver { get; set; }
public OrderParcelSenderInput? Sender { get; set; }
public List<OrderParcelItemInput> Items { get; set; } = [];
}

View File

@ -36,6 +36,15 @@ public class OrderAllocationTraceOutput
public DateTime? TraceTime { get; set; }
/// <summary>排货池行数。</summary>
public int PoolCount { get; set; }
/// <summary>判断条件通过的行数。</summary>
public int ConditionPassCount { get; set; }
/// <summary>最终进入比选的候选数。</summary>
public int CandidateCount { get; set; }
public List<OrderAllocationCandidateOutput> Candidates { get; set; } = [];
public List<OrderAllocationSkippedOutput> Skipped { get; set; } = [];
@ -71,6 +80,12 @@ public class OrderAllocationSkippedOutput
public string? ChannelName { get; set; }
/// <summary>跳过阶段:condition / channel / buyPrice / customer。</summary>
public string? Stage { get; set; }
/// <summary>当时评价的判断条件摘要(截断)。</summary>
public string? MatchCondition { get; set; }
public string Reason { get; set; } = string.Empty;
}

View File

@ -13,7 +13,7 @@ public class OrderDownstreamCreateOneBodyOutput
public string SupplierCode { get; set; } = string.Empty;
public OrderParcelCreateInput Body { get; set; } = new();
public ItfxCreateOrderOneBody Body { get; set; } = new();
}
/// <summary>

View File

@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Http;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 对账件重尺(downstream)Excel 导入入参。
/// </summary>
public class OrderDownstreamWeightImportInput
{
/// <summary>
/// Excel 文件。
/// </summary>
public IFormFile File { get; set; } = null!;
}

View File

@ -0,0 +1,25 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 对账件重尺导入结果。
/// </summary>
public class OrderDownstreamWeightImportOutput
{
public int SuccessCount { get; set; }
public int FailCount { get; set; }
public List<OrderDownstreamWeightImportFailRow> Failures { get; set; } = [];
}
/// <summary>
/// 导入失败行。
/// </summary>
public class OrderDownstreamWeightImportFailRow
{
public int RowNumber { get; set; }
public string? WaybillNo { get; set; }
public string Reason { get; set; } = string.Empty;
}

View File

@ -65,6 +65,11 @@ public class OrderItemOutput
/// </summary>
public string? ExHsCode { get; set; }
/// <summary>
/// 出口申报要素。
/// </summary>
public string? DeclareElement { get; set; }
/// <summary>
/// SKU 图片。
/// </summary>

View File

@ -170,6 +170,11 @@ public class OrderParcelCreateInput
/// </summary>
public DateTime? ExpectPickupTime { get; set; }
/// <summary>
/// 揽收方向:forward / reverse(上门揽收挂载揽收单时使用)。
/// </summary>
public string? PickupDirection { get; set; }
/// <summary>
/// 承运商编码(可后补)。
/// </summary>

View File

@ -60,6 +60,11 @@ public class OrderParcelItemInput
/// </summary>
public string? ExHsCode { get; set; }
/// <summary>
/// 出口申报要素。
/// </summary>
public string? DeclareElement { get; set; }
/// <summary>
/// SKU 图片。
/// </summary>

View File

@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.Orders;
/// <summary>
@ -20,3 +22,37 @@ public class OrderPrintOutput
/// </summary>
public string? LabelGenerateError { get; set; }
}
/// <summary>
/// 批量获取面单打印地址入参。
/// </summary>
public class OrderPrintBatchInput
{
[Required]
[MinLength(1)]
public List<int> OrderIds { get; set; } = [];
}
/// <summary>
/// 批量获取面单打印地址单条结果。
/// </summary>
public class OrderPrintBatchItemOutput
{
public int OrderId { get; set; }
public bool Success { get; set; }
public string PrintUrl { get; set; } = string.Empty;
public string? LabelGenerateStatus { get; set; }
public string? Error { get; set; }
}
/// <summary>
/// 批量获取面单打印地址出参。
/// </summary>
public class OrderPrintBatchOutput
{
public List<OrderPrintBatchItemOutput> Items { get; set; } = [];
}

View File

@ -14,6 +14,11 @@ public class PickupOrderCreateInput
[Required]
public int SenderAddressId { get; set; }
/// <summary>
/// 揽收方向:forward 正向(客户地址→仓库)/ reverse 逆向(仓库→客户地址)。默认正向。
/// </summary>
public string? Direction { get; set; }
[Required]
public int WarehouseId { get; set; }
@ -102,6 +107,11 @@ public class PickupOrderListInput
public int? WarehouseId { get; set; }
/// <summary>
/// 揽收方向:forward / reverse。
/// </summary>
public string? Direction { get; set; }
public DateTime? CreateTimeStart { get; set; }
public DateTime? CreateTimeEnd { get; set; }
@ -208,6 +218,11 @@ public class PickupOrderOutput
public int SenderAddressId { get; set; }
/// <summary>
/// 揽收方向:forward / reverse。
/// </summary>
public string Direction { get; set; } = "forward";
public string SenderName { get; set; } = string.Empty;
public string? SenderPhone { get; set; }

View File

@ -42,6 +42,11 @@ public class WarehouseSkuOutput
public string? ImHsCode { get; set; }
/// <summary>
/// 出口申报要素。
/// </summary>
public string? DeclareElement { get; set; }
public decimal? WeightG { get; set; }
public decimal? LengthCm { get; set; }
@ -81,6 +86,11 @@ public class WarehouseSkuSaveInput
public string? ImHsCode { get; set; }
/// <summary>
/// 出口申报要素。
/// </summary>
public string? DeclareElement { get; set; }
public decimal? WeightG { get; set; }
public decimal? LengthCm { get; set; }
@ -335,6 +345,15 @@ public class WarehouseInboundSummaryOutput
public int Completed { get; set; }
}
public class WarehouseParcelSummaryOutput
{
public int InTransit { get; set; }
public int Arrived { get; set; }
public int Stored { get; set; }
}
// --- Parcel ---
public class WarehouseParcelListInput : WarehousePageInput

View File

@ -32,4 +32,14 @@ public interface IItfxForwardClient
string appSecret,
object body,
CancellationToken cancellationToken = default);
/// <summary>
/// 向指定绝对 URL 发起 ForwardWithSubscribe(用于写死网关地址的场景)。
/// </summary>
Task<ItfxForwardResult> ForwardToAbsoluteUrlAsync(
string absoluteUrl,
string appKey,
string appSecret,
object body,
CancellationToken cancellationToken = default);
}

View File

@ -3,13 +3,12 @@ using System.Net.Http.Json;
using System.Text.Json;
using Fake.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace Oms.Application.Integrations.Itfx;
internal sealed class ItfxForwardClient(
IHttpClientFactory httpClientFactory,
IOptions<ItfxGatewayOptions> options,
IItfxGatewayConfigProvider configProvider,
IHttpContextAccessor httpContextAccessor) : IItfxForwardClient, IScopedDependency
{
public const string HttpClientName = nameof(ItfxForwardClient);
@ -18,6 +17,7 @@ internal sealed class ItfxForwardClient(
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
@ -45,10 +45,10 @@ internal sealed class ItfxForwardClient(
object body,
CancellationToken cancellationToken = default)
{
var settings = options.Value;
var settings = await configProvider.GetAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
{
throw new InvalidOperationException("Itfx BaseUrl not configured");
throw new InvalidOperationException("API 中心服务地址未配置,请在「其他配置」中启用 api_center");
}
if (string.IsNullOrWhiteSpace(standardCode))
@ -64,6 +64,23 @@ internal sealed class ItfxForwardClient(
var root = $"{settings.BaseUrl.Trim().TrimEnd('/')}/InterfaceForward/ForwardWithSubscribe";
var url =
$"{root}?standardCode={Uri.EscapeDataString(standardCode.Trim())}&supplierCode={Uri.EscapeDataString(supplierCode.Trim())}";
return await ForwardToAbsoluteUrlAsync(url, appKey, appSecret, body, cancellationToken);
}
public async Task<ItfxForwardResult> ForwardToAbsoluteUrlAsync(
string absoluteUrl,
string appKey,
string appSecret,
object body,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(absoluteUrl))
{
throw new InvalidOperationException("Itfx absoluteUrl is empty");
}
var settings = await configProvider.GetAsync(cancellationToken);
var url = absoluteUrl.Trim();
var requestJson = JsonSerializer.Serialize(body, JsonOptions);
using var request = new HttpRequestMessage(HttpMethod.Post, url);

View File

@ -0,0 +1,57 @@
using Fake.DependencyInjection;
using Microsoft.Extensions.Options;
using Oms.Domain.SystemAggregate.Consts;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Integrations.Itfx;
/// <summary>
/// itfx / API 中心运行时配置(优先「其他配置」api_center,回退 appsettings Oms:Itfx)。
/// </summary>
public interface IItfxGatewayConfigProvider
{
Task<ItfxGatewayOptions> GetAsync(CancellationToken cancellationToken = default);
}
internal sealed class ItfxGatewayConfigProvider(
ISystemOtherConfigRepository repository,
IOptions<ItfxGatewayOptions> options) : IItfxGatewayConfigProvider, IScopedDependency
{
public async Task<ItfxGatewayOptions> GetAsync(CancellationToken cancellationToken = default)
{
var fallback = options.Value;
var entity = await repository.GetByConfigCodeAsync(SystemOtherConfigCodes.ApiCenter, cancellationToken);
if (entity == null || !entity.Enabled)
{
return Clone(fallback);
}
return new ItfxGatewayOptions
{
BaseUrl = Coalesce(entity.BaseUrl, fallback.BaseUrl),
AppKey = Coalesce(entity.AppKey, fallback.AppKey),
AppSecret = Coalesce(entity.AppSecret, fallback.AppSecret),
TimeoutSeconds = entity.TimeoutSeconds > 0 ? entity.TimeoutSeconds : fallback.TimeoutSeconds,
Customs9610StandardCode = fallback.Customs9610StandardCode,
Customs9610ManifestStandardCode = fallback.Customs9610ManifestStandardCode,
Customs9610DeclarationFormStandardCode = fallback.Customs9610DeclarationFormStandardCode,
Customs9610SupplierCode = fallback.Customs9610SupplierCode
};
}
private static ItfxGatewayOptions Clone(ItfxGatewayOptions src) =>
new()
{
BaseUrl = src.BaseUrl,
AppKey = src.AppKey,
AppSecret = src.AppSecret,
TimeoutSeconds = src.TimeoutSeconds,
Customs9610StandardCode = src.Customs9610StandardCode,
Customs9610ManifestStandardCode = src.Customs9610ManifestStandardCode,
Customs9610DeclarationFormStandardCode = src.Customs9610DeclarationFormStandardCode,
Customs9610SupplierCode = src.Customs9610SupplierCode
};
private static string Coalesce(string? primary, string fallback) =>
string.IsNullOrWhiteSpace(primary) ? fallback : primary.Trim();
}

View File

@ -12,6 +12,16 @@ public class ItfxGatewayOptions
/// </summary>
public string BaseUrl { get; set; } = "https://api.ideship.com/itfx";
/// <summary>
/// 默认应用 AppKey(其他配置 api_center;GetInfo 无 Secret 时回退)。
/// </summary>
public string AppKey { get; set; } = string.Empty;
/// <summary>
/// 默认应用 AppSecret(其他配置 api_center;GetInfo 无 Secret 时回退)。
/// </summary>
public string AppSecret { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; } = 30;
/// <summary>

View File

@ -3,13 +3,12 @@ using System.Text.Json;
using Fake.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Oms.Application.Integrations.Itfx;
internal sealed class ItfxMetadataClient(
IHttpClientFactory httpClientFactory,
IOptions<ItfxGatewayOptions> options,
IItfxGatewayConfigProvider configProvider,
IHttpContextAccessor httpContextAccessor,
ILogger<ItfxMetadataClient> logger) : IItfxMetadataClient, IScopedDependency
{
@ -67,7 +66,7 @@ internal sealed class ItfxMetadataClient(
CancellationToken cancellationToken)
where T : class
{
var settings = options.Value;
var settings = await configProvider.GetAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
{
logger.LogDebug("Itfx BaseUrl not configured, skip {Url}", relativeUrl);

View File

@ -10,6 +10,7 @@ public static class ItfxServiceExtensions
services.Configure<ItfxGatewayOptions>(configuration.GetSection(ItfxGatewayOptions.SectionName));
services.AddHttpClient(ItfxMetadataClient.HttpClientName);
services.AddHttpClient(ItfxForwardClient.HttpClientName);
services.AddScoped<IItfxGatewayConfigProvider, ItfxGatewayConfigProvider>();
services.AddScoped<IItfxMetadataClient, ItfxMetadataClient>();
services.AddScoped<IItfxForwardClient, ItfxForwardClient>();
return services;

View File

@ -326,7 +326,18 @@ public partial class SalesProductService
: (await GetAllocationCoreAsync(product.Id)).Providers
.Select(PublishSnapshotSlimmer.SlimAllocationProvider)
.ToList();
var filtered = AllocationResolver.FilterProviders(providers, customerId: null);
var filtered = AllocationResolver.FilterProvidersDetailed(
providers,
customerId: null,
OrderRuleEvaluationContext.FromOrderOnly(new Order
{
ProductCode = product.ProductCode,
CargoAttribute = cargoAttribute,
WarehouseId = warehouseId,
DeclaredSum = declaredSum,
DeclaredCurrency = declaredCurrency,
IsRemote = isRemote ? (byte)1 : (byte)0
})).Passed;
var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0);
if (provider?.ServiceChannelId is not > 0)
{

View File

@ -123,10 +123,11 @@ public partial class SalesProductService
ProviderName = provider.ProviderName,
ServiceChannelId = provider.ServiceChannelId,
ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null,
Weight = provider.Weight,
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? [],
CreateTime = provider.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(provider.UpdateTime)
Weight = provider.Weight,
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? [],
MatchCondition = provider.MatchCondition,
CreateTime = provider.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(provider.UpdateTime)
}).ToList()
};
}
@ -319,7 +320,8 @@ public partial class SalesProductService
ProviderName = provider.ProviderName,
ServiceChannelId = provider.ServiceChannelId,
Weight = provider.Weight,
CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds)
CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds),
MatchCondition = provider.MatchCondition
}).ToList();
await SalesProductRepository.ReplaceAllocationProvidersAsync(input.SalesProductId, providers);
}

View File

@ -311,7 +311,8 @@ public partial class SalesProductService : ApplicationService
ProviderName = provider.ProviderName,
ServiceChannelId = provider.ServiceChannelId,
Weight = provider.Weight,
CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds)
CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds),
MatchCondition = provider.MatchCondition
}).ToList();
await SalesProductRepository.ReplaceAllocationProvidersAsync(input.SalesProductId, providers);

View File

@ -64,6 +64,21 @@ public partial class ServiceChannelService
}
}
/// <summary>
/// 更新时若目标名称被软删记录占用,则改名释放唯一键,避免 uk_scg_name 冲突。
/// </summary>
private async Task FreeSoftDeletedNameIfNeededAsync(string name, int currentId)
{
var conflict = await ServiceChannelGroupRepository.GetByNameIncludingDeletedAsync(name);
if (conflict == null || !conflict.IsDeleted || conflict.Id == currentId)
{
return;
}
conflict.Name = $"{name}__deleted_{conflict.Id}";
await ServiceChannelGroupRepository.UpdateAsync(conflict);
}
/// <summary>
/// 校验渠道编码唯一性。
/// </summary>

View File

@ -128,9 +128,21 @@ public partial class ServiceChannelService : ApplicationService
public async Task CreateGroupAsync(ServiceChannelGroupSaveInput input)
{
await ValidateGroupInputAsync(input, null);
var name = input.Name.Trim();
var deleted = await ServiceChannelGroupRepository.GetByNameIncludingDeletedAsync(name);
if (deleted != null && deleted.IsDeleted)
{
deleted.IsDeleted = false;
deleted.Name = name;
deleted.AppId = input.AppId;
deleted.ApiServiceProviderId = input.ApiServiceProviderId;
await ServiceChannelGroupRepository.UpdateAsync(deleted);
return;
}
await ServiceChannelGroupRepository.InsertAsync(new ServiceChannelGroup
{
Name = input.Name.Trim(),
Name = name,
AppId = input.AppId,
ApiServiceProviderId = input.ApiServiceProviderId
});
@ -146,7 +158,9 @@ public partial class ServiceChannelService : ApplicationService
{
var group = await GetGroupOrThrowAsync(input.Id);
await ValidateGroupInputAsync(input, group.Id);
group.Name = input.Name.Trim();
var name = input.Name.Trim();
await FreeSoftDeletedNameIfNeededAsync(name, group.Id);
group.Name = name;
group.AppId = input.AppId;
group.ApiServiceProviderId = input.ApiServiceProviderId;
await ServiceChannelGroupRepository.UpdateAsync(group);

View File

@ -0,0 +1,239 @@
using System.Linq.Expressions;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Customers;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Services.Customers;
/// <summary>
/// 客户出口资质备案 API。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Customer")]
public class CustomerExportQualificationService : ApplicationService
{
private ICustomerRepository CustomerRepository =>
LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private ICustomerExportQualificationRepository ExportQualificationRepository =>
LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>();
/// <summary>
/// 出口资质备案分页列表。
/// 管理端未传 customerId 时查询全部;客户门户仅查自身。
/// </summary>
[HttpPost("/customer/export-qualification/list")]
public async Task<OmsPageResult<CustomerExportQualificationOutput>> GetListAsync(
[FromBody] CustomerExportQualificationPaginatedInput input)
{
var customerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var predicate = BuildPredicate(customerId, input);
var (entities, total) = await RepositoryPageHelper.GetPageAsync(
ExportQualificationRepository,
predicate,
pageIndex,
pageSize,
q => q.OrderByDescending(x => x.IsDefault).ThenByDescending(x => x.Id));
return new OmsPageResult<CustomerExportQualificationOutput>
{
Items = entities.Select(MapOutput).ToList(),
Total = total
};
}
/// <summary>
/// 出口资质备案详情。
/// </summary>
[HttpGet("/customer/export-qualification/detail")]
public async Task<CustomerExportQualificationOutput> GetDetailAsync(
[FromQuery] int id,
[FromQuery] int? customerId = null)
{
var entity = await GetOwnedOrThrowAsync(id, customerId);
return MapOutput(entity);
}
/// <summary>
/// 新增出口资质备案。
/// </summary>
[HttpPost("/customer/export-qualification/create")]
public async Task<object> CreateAsync([FromBody] CreateCustomerExportQualificationInput input)
{
var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
ValidateInput(input);
if (input.IsDefault)
{
await ExportQualificationRepository.ClearDefaultAsync(customerId);
}
var entity = new CustomerExportQualification
{
CustomerId = customerId,
CompanyName = input.CompanyName.Trim(),
SocialCreditCode = input.SocialCreditCode.Trim(),
DomesticConsignor = input.DomesticConsignor.Trim(),
DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode),
ProducerName = input.ProducerName.Trim(),
ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode),
CustomsPort = TrimOrNull(input.CustomsPort),
IsDefault = input.IsDefault ? (byte)1 : (byte)0,
Remark = TrimOrNull(input.Remark)
};
entity = await ExportQualificationRepository.InsertAsync(entity);
return new { id = entity.Id };
}
/// <summary>
/// 更新出口资质备案。
/// </summary>
[HttpPost("/customer/export-qualification/update")]
public async Task UpdateAsync([FromBody] UpdateCustomerExportQualificationInput input)
{
var entity = await GetOwnedOrThrowAsync(input.Id, input.CustomerId);
ValidateInput(input);
if (input.IsDefault)
{
await ExportQualificationRepository.ClearDefaultAsync(entity.CustomerId, entity.Id);
}
entity.CompanyName = input.CompanyName.Trim();
entity.SocialCreditCode = input.SocialCreditCode.Trim();
entity.DomesticConsignor = input.DomesticConsignor.Trim();
entity.DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode);
entity.ProducerName = input.ProducerName.Trim();
entity.ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode);
entity.CustomsPort = TrimOrNull(input.CustomsPort);
entity.IsDefault = input.IsDefault ? (byte)1 : (byte)0;
entity.Remark = TrimOrNull(input.Remark);
await ExportQualificationRepository.UpdateAsync(entity);
}
/// <summary>
/// 删除出口资质备案(软删)。
/// </summary>
[HttpPost("/customer/export-qualification/delete")]
public async Task DeleteAsync([FromBody] IdInput input)
{
var entity = await GetOwnedOrThrowAsync(input.Id, null);
await ExportQualificationRepository.DeleteAsync(entity);
}
/// <summary>
/// 设为默认出口资质备案(同客户仅一条)。
/// </summary>
[HttpPost("/customer/export-qualification/set-default")]
public async Task SetDefaultAsync([FromBody] IdInput input)
{
var entity = await GetOwnedOrThrowAsync(input.Id, null);
await ExportQualificationRepository.ClearDefaultAsync(entity.CustomerId, entity.Id);
entity.IsDefault = 1;
await ExportQualificationRepository.UpdateAsync(entity);
}
private async Task<CustomerExportQualification> GetOwnedOrThrowAsync(int id, int? requestedCustomerId)
{
var customerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
requestedCustomerId);
var (hasCustomerFilter, customerIdValue) = WarehouseCustomerHelper.ToCustomerFilter(customerId);
var entity = hasCustomerFilter
? await ExportQualificationRepository.GetOwnedAsync(id, customerIdValue)
: await ExportQualificationRepository.FirstOrDefaultAsync(x => x.Id == id);
if (entity == null)
{
throw new BusinessException("出口资质备案不存在");
}
return entity;
}
private static void ValidateInput(CreateCustomerExportQualificationInput input)
{
if (string.IsNullOrWhiteSpace(input.CompanyName))
{
throw new BusinessException("企业名称不能为空");
}
if (string.IsNullOrWhiteSpace(input.SocialCreditCode))
{
throw new BusinessException("企业社会信用代码不能为空");
}
if (string.IsNullOrWhiteSpace(input.DomesticConsignor))
{
throw new BusinessException("境内发货人不能为空");
}
if (string.IsNullOrWhiteSpace(input.ProducerName))
{
throw new BusinessException("生产销售单位不能为空");
}
}
private static Expression<Func<CustomerExportQualification, bool>> BuildPredicate(
int? customerId,
CustomerExportQualificationPaginatedInput input)
{
var (hasCustomerFilter, customerIdValue) = WarehouseCustomerHelper.ToCustomerFilter(customerId);
var keyword = input.Keyword?.Trim();
var hasKeyword = !string.IsNullOrWhiteSpace(keyword);
var onlyDefault = input.OnlyDefault;
return x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
&& (!onlyDefault || x.IsDefault == 1)
&& (!hasKeyword
|| x.CompanyName.Contains(keyword!)
|| x.SocialCreditCode.Contains(keyword!)
|| x.DomesticConsignor.Contains(keyword!)
|| (x.DomesticConsignorCustomsCode != null && x.DomesticConsignorCustomsCode.Contains(keyword!))
|| x.ProducerName.Contains(keyword!)
|| (x.ProducerCustomsCode != null && x.ProducerCustomsCode.Contains(keyword!))
|| (x.CustomsPort != null && x.CustomsPort.Contains(keyword!))
|| (x.Remark != null && x.Remark.Contains(keyword!)));
}
private static CustomerExportQualificationOutput MapOutput(CustomerExportQualification entity)
{
return new CustomerExportQualificationOutput
{
Id = entity.Id,
CustomerId = entity.CustomerId,
CompanyName = entity.CompanyName,
SocialCreditCode = entity.SocialCreditCode,
DomesticConsignor = entity.DomesticConsignor,
DomesticConsignorCustomsCode = entity.DomesticConsignorCustomsCode,
ProducerName = entity.ProducerName,
ProducerCustomsCode = entity.ProducerCustomsCode,
CustomsPort = entity.CustomsPort,
IsDefault = entity.IsDefault > 0,
Remark = entity.Remark,
CreateTime = entity.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
}
private static string? TrimOrNull(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View File

@ -6,6 +6,8 @@ using Oms.Application.Integrations.Itfx;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Messaging;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
@ -16,6 +18,8 @@ namespace Oms.Application.Services.Orders.Batch;
public sealed class OrderBatchActionExecutor(
IOrderRepository orderRepository,
ICountryRepository countryRepository,
IServiceChannelRepository serviceChannelRepository,
IFileStorage fileStorage,
ISmallBagTrackOrderClient smallBagTrackOrderClient,
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
OrderDownstreamCredentialResolver downstreamCredentialResolver,
@ -143,12 +147,12 @@ public sealed class OrderBatchActionExecutor(
try
{
OrderParcelCreateInput body;
ItfxCreateOrderOneBody body;
Order order;
if (!string.IsNullOrWhiteSpace(message.PayloadJson))
{
body = JsonConvert.DeserializeObject<OrderParcelCreateInput>(message.PayloadJson!)
body = JsonConvert.DeserializeObject<ItfxCreateOrderOneBody>(message.PayloadJson!)
?? throw new BusinessException("下游预报消息体无效");
// 凭证解析仍需订单上的渠道/下游绑定字段;有 payload 时只查主表一次
@ -176,6 +180,8 @@ public sealed class OrderBatchActionExecutor(
bundle.Declarations,
bundle.Weights,
countryRepository,
serviceChannelRepository,
fileStorage,
cancellationToken);
}

View File

@ -8,6 +8,8 @@ using Oms.Application.Dtos.Shared;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Messaging;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using System.Linq.Expressions;
using Oms.Domain.OrdersAggregate.Entities;
@ -23,6 +25,8 @@ public sealed class OrderBatchJobCoordinator(
IOrderBatchJobRepository batchJobRepository,
IOrderRepository orderRepository,
ICountryRepository countryRepository,
IServiceChannelRepository serviceChannelRepository,
IFileStorage fileStorage,
IOrderBatchJobPublisher publisher,
OrderBatchJobProcessor batchJobProcessor,
IOptions<OrderBatchRabbitMqOptions> batchOptions,
@ -649,6 +653,8 @@ public sealed class OrderBatchJobCoordinator(
bundle.Declarations,
bundle.Weights,
countryRepository,
serviceChannelRepository,
fileStorage,
cancellationToken);
result[orderId] = JsonConvert.SerializeObject(body);
}

View File

@ -242,17 +242,138 @@ public class Customs9610OrderService : ApplicationService
}
/// <summary>
/// 批量获取报关单(调 API 中心上游,标准码暂定)。
/// 预报下游(写死 create9610Order / BH 网关地址)。
/// </summary>
[HttpPost("/order/customs-9610/fetch-declaration-form-batch")]
public async Task<Customs9610DeclareBatchOutput> FetchDeclarationFormBatchAsync([FromBody] Customs9610IdsInput input)
{
return await FetchDocumentBatchAsync(
input,
settings => string.IsNullOrWhiteSpace(settings.Customs9610DeclarationFormStandardCode)
? "customs9610DeclarationForm"
: settings.Customs9610DeclarationFormStandardCode.Trim(),
"获取报关单");
var results = new List<Customs9610DeclareResult>();
foreach (var id in input.Ids.Where(x => x > 0).Distinct())
{
try
{
results.Add(await DownstreamForwardOneAsync(id));
}
catch (Exception ex)
{
results.Add(new Customs9610DeclareResult
{
Id = id,
Success = false,
Message = string.IsNullOrWhiteSpace(ex.Message) ? "预报下游失败" : ex.Message,
});
}
}
return new Customs9610DeclareBatchOutput
{
SuccessCount = results.Count(x => x.Success),
FailCount = results.Count(x => !x.Success),
Items = results,
};
}
/// <summary>
/// 写死的 9610 预报下游地址:create9610Order + supplierCode=BH。
/// </summary>
private const string Create9610OrderForwardUrl =
"https://api.ideship.com/itfx/InterfaceForward/ForwardWithSubscribe?standardCode=create9610Order&supplierCode=BH";
private async Task<Customs9610DeclareResult> DownstreamForwardOneAsync(int id)
{
var header = await GetOwnedOrThrowAsync(id);
if (!string.Equals(header.Status, Customs9610StatusCodes.Pending, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("仅待申报单据可预报下游");
}
var items = await ItemRepository.GetByHeaderIdAsync(header.Id);
if (items.Count == 0)
{
throw new BusinessException("申报商品不能为空");
}
ValidateHeaderForDeclare(header);
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == header.OrderId)
?? throw new BusinessException("关联运单不存在");
var creds = await CredentialResolver.ResolveAsync(order);
var body = BuildDeclareBody(header, items);
ItfxForwardResult forward;
try
{
forward = await ItfxForwardClient.ForwardToAbsoluteUrlAsync(
Create9610OrderForwardUrl,
creds.AppKey,
creds.AppSecret,
body);
}
catch (Exception ex)
{
header.FailReason = Truncate(ex.Message, 512);
header.UpdateTime = DateTime.Now;
await HeaderRepository.UpdateAsync(header);
return new Customs9610DeclareResult
{
Id = header.Id,
Success = false,
Message = header.FailReason ?? "调用 API 中心失败",
Status = header.Status,
ServiceNo = header.ServiceNo,
ClientNo = header.ClientNo,
Url = Create9610OrderForwardUrl,
};
}
if (!forward.Success)
{
var reason = ItfxForwardResultEvaluator.BuildFailReason(
forward.StatusCode,
forward.ResponseBody,
null,
null);
header.FailReason = Truncate(reason, 512);
header.UpdateTime = DateTime.Now;
await HeaderRepository.UpdateAsync(header);
return new Customs9610DeclareResult
{
Id = header.Id,
Success = false,
Message = header.FailReason ?? "预报下游失败",
Status = header.Status,
ServiceNo = header.ServiceNo,
ClientNo = header.ClientNo,
HttpStatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
Url = forward.Url,
RequestJson = forward.RequestJson,
};
}
TryParseDeclareResponse(forward.ResponseBody, out var customsOrderNo, out var downstreamRef);
header.Status = Customs9610StatusCodes.Declared;
header.CustomsOrderNo = Truncate(customsOrderNo, 32) ?? header.CustomsOrderNo;
header.DownstreamRefNo = Truncate(downstreamRef, 32) ?? header.DownstreamRefNo;
header.DeclareTime = DateTime.Now;
header.FailReason = null;
header.UpdateTime = DateTime.Now;
await HeaderRepository.UpdateAsync(header);
return new Customs9610DeclareResult
{
Id = header.Id,
Success = true,
Message = "预报下游成功",
CustomsOrderNo = header.CustomsOrderNo,
Status = header.Status,
ServiceNo = header.ServiceNo,
ClientNo = header.ClientNo,
HttpStatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
Url = forward.Url,
RequestJson = forward.RequestJson,
};
}
private async Task<Customs9610DeclareResult> FetchManifestOneAsync(int id)

View File

@ -119,7 +119,9 @@ public class Customs9610OrderWriter : IScopedDependency
{
var itemName = FirstNonEmpty(d.CnName, d.EnName) ?? string.Empty;
var sku = FirstNonEmpty(d.Sku, $"ITEM{sort + 1}")!;
var declareElement = JoinNonEmpty(" ", d.Model, d.Specifications, d.Attributes, d.Material);
var declareElement = FirstNonEmpty(
d.DeclareElement,
JoinNonEmpty(" ", d.Model, d.Specifications, d.Attributes, d.Material));
await _itemRepository.InsertAsync(new Customs9610OrderItem
{
Customs9610OrderId = header.Id,

View File

@ -198,6 +198,7 @@ public static class AutoTriggerFormulaMeta
yield return ("HS编码", "t_order_declaration", "HsCode", FormulaValueType.String, false);
yield return ("进口HS编码", "t_order_declaration", "ImHsCode", FormulaValueType.String, false);
yield return ("出口HS编码", "t_order_declaration", "ExHsCode", FormulaValueType.String, false);
yield return ("出口申报要素", "t_order_declaration", "DeclareElement", FormulaValueType.String, false);
yield return ("申报类别", "t_order_declaration", "Category", FormulaValueType.String, false);
yield return ("品牌", "t_order_declaration", "Brand", FormulaValueType.String, false);
yield return ("品牌类型", "t_order_declaration", "BrandType", FormulaValueType.String, false);

View File

@ -35,6 +35,37 @@ public sealed class OrderFileManager(
return string.IsNullOrWhiteSpace(file?.FileUrl) ? null : fileStorage.GetPublicUrl(file.FileUrl.Trim());
}
/// <summary>
/// 批量取系统面单公开地址(已存在才返回)。
/// </summary>
public async Task<Dictionary<int, string>> GetSystemLabelUrlsAsync(
IReadOnlyCollection<int> orderIds,
CancellationToken cancellationToken = default)
{
var result = new Dictionary<int, string>();
var ids = orderIds?.Where(x => x > 0).Distinct().ToList() ?? [];
if (ids.Count == 0)
{
return result;
}
var files = await orderFileRepository.GetByOrderIdsAndTypeAsync(
ids,
OrderFileTypeCodes.SystemLabel,
cancellationToken);
foreach (var file in files)
{
if (string.IsNullOrWhiteSpace(file.FileUrl) || result.ContainsKey(file.OrderId))
{
continue;
}
result[file.OrderId] = fileStorage.GetPublicUrl(file.FileUrl.Trim());
}
return result;
}
public async Task<OrderFileGroupedOutput> GetGroupedAsync(
int orderId,
bool customerView,

View File

@ -82,7 +82,7 @@ public partial class OrderService
};
}
private async Task<(Domain.OrdersAggregate.Entities.Order order, OrderParcelCreateInput body)> LoadDownstreamCreateOneContextAsync(
private async Task<(Domain.OrdersAggregate.Entities.Order order, ItfxCreateOrderOneBody body)> LoadDownstreamCreateOneContextAsync(
int orderId)
{
var bundles = await OrderRepository.GetDownstreamCreateOneBundlesAsync([orderId]);
@ -97,7 +97,9 @@ public partial class OrderService
bundle.Recipient,
bundle.Declarations,
bundle.Weights,
CountryRepository);
CountryRepository,
ServiceChannelRepository,
FileStorage);
return (bundle.Order, body);
}

View File

@ -743,7 +743,7 @@ public partial class OrderService
};
}
private static OrderItemOutput MapDeclaration(OrderDeclaration x)
private OrderItemOutput MapDeclaration(OrderDeclaration x)
{
return new OrderItemOutput
{
@ -759,7 +759,8 @@ public partial class OrderService
HsCode = x.ExHsCode ?? x.ImHsCode ?? x.HsCode,
ImHsCode = x.ImHsCode ?? x.HsCode,
ExHsCode = x.ExHsCode ?? x.HsCode,
SkuImage = x.SkuImage,
DeclareElement = x.DeclareElement,
SkuImage = FileStorage.GetPublicUrl(x.SkuImage),
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,

View File

@ -256,11 +256,85 @@ public partial class OrderService
public async Task<OrderOutput> RerunInboundBillingAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
EnsureReceivableMutable(order);
await ProcessInboundBillingForBatchAsync(order.Id);
order = await GetOrderOrThrowAsync(input.Id);
return (await MapOrderOutputsAsync([order])).First();
}
/// <summary>
/// 下载对账件重尺(downstream)导入模板。导入后只重算应付,不改应收。
/// </summary>
[HttpGet("/order/weight/downstream-import-template")]
public async Task<FileContentResult> DownloadDownstreamWeightImportTemplateAsync()
{
var bytes = await OrderDownstreamWeightImportHelper.BuildTemplateBytesAsync();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = OrderDownstreamWeightImportHelper.TemplateFileName
};
}
/// <summary>
/// 导入对账件重尺:写入 Type=downstream,锁定当前渠道重算应付;不改应收。
/// </summary>
[HttpPost("/order/weight/downstream-import")]
public async Task<OrderDownstreamWeightImportOutput> ImportDownstreamWeightsAsync(
[FromForm] OrderDownstreamWeightImportInput input)
{
if (input.File == null || input.File.Length == 0)
{
throw new BusinessException("请上传 Excel 文件");
}
await using var stream = input.File.OpenReadStream();
List<(int RowNumber, OrderDownstreamWeightImportRow Row)> parsedRows;
try
{
parsedRows = await OrderDownstreamWeightImportHelper.ParseImportRowsAsync(stream);
}
catch (Exception ex)
{
throw new BusinessException($"Excel 解析失败:{ex.Message}");
}
if (parsedRows.Count == 0)
{
throw new BusinessException("Excel 中无有效数据行");
}
if (parsedRows.Count > OrderDownstreamWeightImportHelper.MaxRows)
{
throw new BusinessException(
$"单次最多导入 {OrderDownstreamWeightImportHelper.MaxRows} 行,当前 {parsedRows.Count} 行");
}
var output = new OrderDownstreamWeightImportOutput();
foreach (var (rowNumber, row) in parsedRows)
{
try
{
OrderDownstreamWeightImportHelper.ValidateRow(row);
await ApplyDownstreamWeightAndRecalculatePayableAsync(row);
output.SuccessCount++;
}
catch (Exception ex)
{
output.FailCount++;
output.Failures.Add(new OrderDownstreamWeightImportFailRow
{
RowNumber = rowNumber,
WaybillNo = row.WaybillNo,
Reason = ex is BusinessException or InvalidOperationException
? ex.Message
: $"导入失败:{ex.Message}"
});
}
}
return output;
}
/// <summary>
/// 查询排货过程:优先返回快照;无快照或 live=true 时按当前规则现算(不落库)。
/// </summary>
@ -432,7 +506,11 @@ public partial class OrderService
var binding = await ResolveSnapshotBindingAsync(product, DateTime.Now);
var providers = await ResolveAllocationProvidersAsync(product, binding.Rules);
var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId);
var ruleContext = await OrderAutoTriggerService.BuildPostCommitContextAsync(order);
var filtered = AllocationResolver.FilterProvidersDetailed(
providers,
order.CustomerId,
ruleContext).Passed;
var channelIds = new HashSet<int>();
foreach (var provider in filtered)
{
@ -701,7 +779,121 @@ public partial class OrderService
private async Task RecalculatePayableCoreAsync(Order order)
{
await ReallocateCoreAsync(order, scheme: null, serviceChannelId: null, enforceReallocateGate: false);
// 已有渠道时锁定渠道只重算金额,避免「重算应付」误换渠道清下游单号。
await ReallocateCoreAsync(
order,
scheme: null,
serviceChannelId: null,
enforceReallocateGate: false,
lockCurrentChannel: true);
}
/// <summary>
/// 写入对账件重尺并锁定当前渠道重算应付(不改应收)。
/// </summary>
private async Task ApplyDownstreamWeightAndRecalculatePayableAsync(OrderDownstreamWeightImportRow row)
{
var order = await OrderRepository.GetByWaybillNoAsync(row.WaybillNo!.Trim());
if (order == null)
{
throw new BusinessException("未找到对应订单,请检查单号");
}
if (string.IsNullOrWhiteSpace(order.ChannelCode))
{
throw new BusinessException("订单未确认服务渠道,无法按对账重量重算应付");
}
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法重算应付");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
throw new BusinessException("销售产品不存在");
}
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
var downstream = weights.FirstOrDefault(x =>
string.Equals(x.Type, OrderWeightTypeCodes.Downstream, StringComparison.OrdinalIgnoreCase));
if (downstream == null)
{
throw new BusinessException("订单缺少下游件重尺记录");
}
// 尺寸可选:未填则沿用下游已有,仍缺则回落收货→发货→预报。
var length = row.Length ?? downstream.Length;
var width = row.Width ?? downstream.Width;
var height = row.Height ?? downstream.Height;
if (length is not > 0 || width is not > 0 || height is not > 0)
{
FillDimensionsFromFallback(weights, ref length, ref width, ref height);
}
if (length is not > 0 || width is not > 0 || height is not > 0)
{
throw new BusinessException("件重尺尺寸不完整,请在 Excel 填写长/宽/高,或先完善订单收货/发货尺寸");
}
ApplyMeasuredDimensions(downstream, length.Value, width.Value, height.Value, row.ActualWeight!.Value);
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, downstream);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
await ReallocateCoreAsync(
order,
scheme: null,
serviceChannelId: null,
enforceReallocateGate: false,
lockCurrentChannel: true,
preferDownstreamWeight: true);
}
private static void FillDimensionsFromFallback(
IReadOnlyList<OrderWeight> weights,
ref decimal? length,
ref decimal? width,
ref decimal? height)
{
foreach (var type in new[]
{
OrderWeightTypeCodes.Receive,
OrderWeightTypeCodes.Ship,
OrderWeightTypeCodes.Forecast
})
{
var src = weights.FirstOrDefault(x =>
string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase));
if (src == null)
{
continue;
}
if (length is not > 0)
{
length = src.Length;
}
if (width is not > 0)
{
width = src.Width;
}
if (height is not > 0)
{
height = src.Height;
}
if (length is > 0 && width is > 0 && height is > 0)
{
return;
}
}
}
/// <summary>
@ -711,7 +903,9 @@ public partial class OrderService
Order order,
string? scheme,
int? serviceChannelId,
bool enforceReallocateGate = true)
bool enforceReallocateGate = true,
bool lockCurrentChannel = false,
bool preferDownstreamWeight = false)
{
string? schemeOverride = null;
int? forceChannelId = null;
@ -723,6 +917,18 @@ public partial class OrderService
? AllocationSchemeCodes.Designated
: normalizedScheme;
}
else if (lockCurrentChannel && !string.IsNullOrWhiteSpace(order.ChannelCode))
{
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x =>
x.ChannelCode == order.ChannelCode.Trim());
if (channel == null)
{
throw new BusinessException("订单服务渠道不存在,无法重算应付");
}
forceChannelId = channel.Id;
schemeOverride = AllocationSchemeCodes.Designated;
}
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
@ -736,13 +942,18 @@ public partial class OrderService
}
var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
var weightRow = ResolvePayableWeightRow(order, weights);
var weightRow = preferDownstreamWeight
? weights.FirstOrDefault(x =>
string.Equals(x.Type, OrderWeightTypeCodes.Downstream, StringComparison.OrdinalIgnoreCase))
?? throw new BusinessException("缺少件重尺类型 downstream")
: ResolvePayableWeightRow(order, weights);
if (!weightRow.ChargeableWeight.HasValue || weightRow.ChargeableWeight <= 0)
{
throw new BusinessException("计费重无效,请先完善件重尺");
}
// 重新排货:产品规则/分区与渠道买价均取当前有效发布,避免沿用下单时旧快照。
// 对账重算 / 普通重算应付:沿用订单快照绑定(分区等),买价仍由排货评估取当前有效发布。
var snapshotBinding = enforceReallocateGate
? await ResolveSnapshotBindingAsync(product, DateTime.Now)
: await ResolveSnapshotBindingFromOrderAsync(product, order);
@ -753,6 +964,11 @@ public partial class OrderService
snapshotBinding);
var (_, chargingStage) = ResolveReceivableBillingStage(order);
// 对账件重尺导入:费用阶段记 outbound(终稿成本),与入库应收阶段解耦。
if (preferDownstreamWeight)
{
chargingStage = OrderFeeChargingStageCodes.Outbound;
}
await ConfirmServiceChannelAndPayableAsync(
order,
@ -823,11 +1039,12 @@ public partial class OrderService
var weightType = chargingStage switch
{
OrderFeeChargingStageCodes.Inbound => OrderWeightTypeCodes.Receive,
_ => OrderWeightTypeCodes.Downstream
OrderFeeChargingStageCodes.Outbound => OrderWeightTypeCodes.Ship,
_ => OrderWeightTypeCodes.Forecast
};
return weights.FirstOrDefault(x => x.Type == weightType)
?? weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Downstream)
return weights.FirstOrDefault(x =>
string.Equals(x.Type, weightType, StringComparison.OrdinalIgnoreCase))
?? throw new BusinessException($"缺少件重尺类型 {weightType}");
}
@ -894,7 +1111,7 @@ public partial class OrderService
_ => (OrderWeightTypeCodes.Forecast, OrderFeeChargingStageCodes.Forecast)
};
private static OrderDeclaration MapDeclarationInput(int orderId, OrderParcelItemInput x)
private OrderDeclaration MapDeclarationInput(int orderId, OrderParcelItemInput x)
{
return new OrderDeclaration
{
@ -911,7 +1128,8 @@ public partial class OrderService
HsCode = x.ExHsCode ?? x.ImHsCode,
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode ?? x.ImHsCode,
SkuImage = x.SkuImage,
DeclareElement = x.DeclareElement,
SkuImage = FileStorage.ToStorageKey(x.SkuImage),
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,

View File

@ -774,7 +774,8 @@ public partial class OrderService
{
if (replaceExistingReceivable)
{
await OrderRepository.DeleteReceivableFeesByStageAsync(order.Id, chargingStage);
// 有效应收只保留一套:清空全部阶段后再写入当前阶段,避免预报+入库叠算。
await OrderRepository.DeleteReceivableFeesAsync(order.Id);
}
await OrderRepository.UpsertFeesAsync(order.Id, fees);
@ -860,6 +861,8 @@ public partial class OrderService
public IReadOnlyList<string> ZoneKeys { get; init; } = [];
public List<AllocationResolver.ChannelCandidate> Candidates { get; init; } = [];
public List<OrderAllocationSkippedOutput> Skipped { get; init; } = [];
public int PoolCount { get; init; }
public int ConditionPassCount { get; init; }
public AllocationResolver.ChannelCandidate? Selected { get; set; }
public string? FailReason { get; set; }
}
@ -874,10 +877,18 @@ public partial class OrderService
string? schemeOverride,
int? forceServiceChannelId)
{
var skipped = new List<OrderAllocationSkippedOutput>();
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules);
var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId);
// 判断条件需要客户名、件重尺等,与自动触发共用完整上下文
var ruleContext = await OrderAutoTriggerService.BuildPostCommitContextAsync(order);
var filterResult = AllocationResolver.FilterProvidersDetailed(
providers,
order.CustomerId,
ruleContext);
var skipped = filterResult.Skipped.ToList();
var filtered = filterResult.Passed;
var poolCount = filterResult.PoolCount;
var conditionPassCount = filterResult.ConditionPassCount;
var productScheme = snapshotBinding.Rules.Product?.Scheme
?? product.Scheme
@ -899,52 +910,68 @@ public partial class OrderService
? AllocationSchemeCodes.CostLowest
: storedScheme;
AllocationEvaluation BaseEval(string? failReason = null) => new()
{
StoredScheme = storedScheme,
SelectScheme = selectScheme,
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Skipped = skipped,
PoolCount = poolCount,
ConditionPassCount = conditionPassCount,
FailReason = failReason
};
if (forceServiceChannelId is > 0)
{
var before = filtered;
filtered = filtered
var designated = filtered
.Where(x => x.ServiceChannelId == forceServiceChannelId.Value)
.ToList();
if (filtered.Count == 0)
if (designated.Count == 0)
{
foreach (var p in before.Where(x => x.ServiceChannelId != forceServiceChannelId))
var poolRow = providers.FirstOrDefault(x => x.ServiceChannelId == forceServiceChannelId.Value);
var failReason = poolRow == null
? "指定渠道不在该销售产品排货池"
: "指定渠道不满足判断条件";
if (poolRow != null
&& skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value))
{
// 非指定渠道不记跳过;指定不在池内单独失败
skipped.Add(new OrderAllocationSkippedOutput
{
ServiceChannelId = forceServiceChannelId.Value,
ChannelCode = poolRow.ChannelCode,
ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName,
Stage = AllocationResolver.SkipStageCondition,
MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition),
Reason = failReason
});
}
return new AllocationEvaluation
{
StoredScheme = storedScheme,
SelectScheme = selectScheme,
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Skipped = skipped,
FailReason = "指定渠道不在该销售产品排货池或当前客户不可用"
};
return BaseEval(failReason);
}
filtered = designated;
}
if (filtered.Count == 0)
{
return new AllocationEvaluation
{
StoredScheme = storedScheme,
SelectScheme = selectScheme,
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Skipped = skipped,
FailReason = "排货规则未匹配到可用服务渠道"
};
var conditionMiss = skipped.Count(x =>
x.Stage is AllocationResolver.SkipStageCondition or AllocationResolver.SkipStageCustomer);
var failReason = poolCount == 0
? "排货规则未配置可用服务渠道"
: conditionMiss > 0
? $"排货规则未匹配到可用服务渠道(条件未命中 {conditionMiss}/{poolCount})"
: "排货规则未匹配到可用服务渠道";
return BaseEval(failReason);
}
// 应付买价取「当前」有效发布(PublishTime<=Now),不用下单时间,避免重新排货/入库漏掉后续发布的买价。
var bindTime = DateTime.Now;
var candidates = new List<AllocationResolver.ChannelCandidate>();
var recipient = (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault();
var recipient = ruleContext.Recipient
?? (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault();
var cargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General;
foreach (var provider in filtered)
@ -956,6 +983,8 @@ public partial class OrderService
skipped.Add(new OrderAllocationSkippedOutput
{
ServiceChannelId = channelId,
Stage = AllocationResolver.SkipStageChannel,
MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition),
Reason = "服务渠道不存在"
});
continue;
@ -968,6 +997,8 @@ public partial class OrderService
ServiceChannelId = channel.Id,
ChannelCode = channel.ChannelCode,
ChannelName = channel.ChannelName,
Stage = AllocationResolver.SkipStageChannel,
MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition),
Reason = "服务渠道未启用"
});
continue;
@ -1016,6 +1047,8 @@ public partial class OrderService
ServiceChannelId = channel.Id,
ChannelCode = channel.ChannelCode,
ChannelName = channel.ChannelName,
Stage = AllocationResolver.SkipStageBuyPrice,
MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition),
Reason = string.IsNullOrWhiteSpace(miss)
? $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion})"
: $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion}):{miss}"
@ -1034,7 +1067,17 @@ public partial class OrderService
if (candidates.Count == 0)
{
var detail = skipped.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Reason))?.Reason;
var buyPriceMiss = skipped.Count(x => x.Stage == AllocationResolver.SkipStageBuyPrice);
var detail = skipped.FirstOrDefault(x =>
x.Stage == AllocationResolver.SkipStageBuyPrice
&& !string.IsNullOrWhiteSpace(x.Reason))?.Reason;
var failReason = chargeableWeight <= 0
? "计费重无效,无法计算应付费用"
: buyPriceMiss > 0
? (string.IsNullOrWhiteSpace(detail)
? $"排货规则未匹配到有效渠道买价(买价未命中 {buyPriceMiss})"
: $"排货规则未匹配到有效渠道买价;{detail}")
: "排货规则未匹配到有效渠道买价";
return new AllocationEvaluation
{
StoredScheme = storedScheme,
@ -1045,11 +1088,9 @@ public partial class OrderService
ZoneKeys = zoneKeys,
Candidates = candidates,
Skipped = skipped,
FailReason = chargeableWeight <= 0
? "计费重无效,无法计算应付费用"
: string.IsNullOrWhiteSpace(detail)
? "排货规则未匹配到有效渠道买价"
: $"排货规则未匹配到有效渠道买价;{detail}"
PoolCount = poolCount,
ConditionPassCount = conditionPassCount,
FailReason = failReason
};
}
@ -1064,6 +1105,8 @@ public partial class OrderService
ZoneKeys = zoneKeys,
Candidates = candidates,
Skipped = skipped,
PoolCount = poolCount,
ConditionPassCount = conditionPassCount,
Selected = selected
};
}
@ -1126,6 +1169,9 @@ public partial class OrderService
SelectedChannelName = evaluation.Selected?.Channel.ChannelName,
FailReason = evaluation.FailReason,
TraceTime = DateTime.Now,
PoolCount = evaluation.PoolCount,
ConditionPassCount = evaluation.ConditionPassCount,
CandidateCount = candidates.Count,
Candidates = candidates,
Skipped = evaluation.Skipped
};
@ -1254,7 +1300,8 @@ public partial class OrderService
ServiceChannelId = provider.ServiceChannelId,
ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null,
Weight = provider.Weight,
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? []
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? [],
MatchCondition = provider.MatchCondition
})).ToList();
await AllocationProviderSnapshotEnricher.EnrichAsync(providers);
}
@ -1965,7 +2012,8 @@ public partial class OrderService
HsCode = x.ExHsCode ?? x.ImHsCode,
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode,
SkuImage = x.SkuImage,
DeclareElement = x.DeclareElement,
SkuImage = FileStorage.ToStorageKey(x.SkuImage),
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,

View File

@ -22,7 +22,7 @@ public partial class OrderService
if (input.SenderAddressId <= 0)
{
throw new BusinessException("发件地址不能为空");
throw new BusinessException("揽收地址不能为空");
}
if (string.IsNullOrWhiteSpace(input.ProductCode))
@ -55,15 +55,15 @@ public partial class OrderService
var senderAddress = await CustomerSenderAddressRepository.GetOwnedAsync(input.SenderAddressId, customerId);
if (senderAddress == null)
{
throw new BusinessException("发件地址不存在");
throw new BusinessException("揽收地址不存在");
}
var addressType = string.IsNullOrWhiteSpace(senderAddress.AddressType)
? CustomerAddressTypeCodes.Sender
: senderAddress.AddressType;
if (!string.Equals(addressType, CustomerAddressTypeCodes.Sender, StringComparison.OrdinalIgnoreCase))
if (!CustomerAddressTypeCodes.IsPickupEligible(addressType))
{
throw new BusinessException("揽收订单仅可选择发件地址");
throw new BusinessException("揽收订单请选择「国内揽收」类型地址");
}
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId);

View File

@ -318,6 +318,89 @@ public partial class OrderService : ApplicationService
};
}
/// <summary>
/// 批量获取打印面单地址(同步返回,避免前端逐单请求)。
/// </summary>
/// <param name="input">入参:订单 ID 列表。</param>
/// <returns>各订单打印结果。</returns>
[HttpPost("/order/print/batch")]
public async Task<OrderPrintBatchOutput> PrintBatchAsync([FromBody] OrderPrintBatchInput input)
{
var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList();
if (orderIds.Count == 0)
{
throw new BusinessException("请选择要打印的订单");
}
const int maxBatchSize = 100;
if (orderIds.Count > maxBatchSize)
{
throw new BusinessException($"单次最多获取 {maxBatchSize} 张面单");
}
var output = new OrderPrintBatchOutput
{
Items = new List<OrderPrintBatchItemOutput>(orderIds.Count)
};
// 已有系统面单一律批量查出,避免 N 次附件查询
var existingUrls = await OrderFileManager.GetSystemLabelUrlsAsync(orderIds);
var pendingIds = new List<int>();
foreach (var orderId in orderIds)
{
if (existingUrls.TryGetValue(orderId, out var url) && !string.IsNullOrWhiteSpace(url))
{
output.Items.Add(new OrderPrintBatchItemOutput
{
OrderId = orderId,
Success = true,
PrintUrl = url,
LabelGenerateStatus = LabelGenerateStatusCodes.Ready
});
}
else
{
pendingIds.Add(orderId);
}
}
// 未就绪的走单票打印逻辑(含即时补生成),单条失败不影响其它
foreach (var orderId in pendingIds)
{
try
{
var print = await PrintAsync(new OrderActionInput { Id = orderId });
var ok = !string.IsNullOrWhiteSpace(print.PrintUrl);
output.Items.Add(new OrderPrintBatchItemOutput
{
OrderId = orderId,
Success = ok,
PrintUrl = print.PrintUrl ?? string.Empty,
LabelGenerateStatus = print.LabelGenerateStatus,
Error = ok ? null : (print.LabelGenerateError ?? "未获取到打印文件")
});
}
catch (Exception ex)
{
output.Items.Add(new OrderPrintBatchItemOutput
{
OrderId = orderId,
Success = false,
Error = ex is BusinessException be ? be.Message : ex.Message
});
}
}
// 保持与入参顺序一致
var byId = output.Items.ToDictionary(x => x.OrderId);
output.Items = orderIds
.Select(id => byId.TryGetValue(id, out var item)
? item
: new OrderPrintBatchItemOutput { OrderId = id, Success = false, Error = "未处理" })
.ToList();
return output;
}
/// <summary>
/// 导出订单。
/// </summary>
@ -459,7 +542,7 @@ public partial class OrderService : ApplicationService
if (input.SenderAddressId is not > 0)
{
output.PickupCreateError = "上门揽收需选择发件地址";
output.PickupCreateError = "上门揽收需选择揽收地址";
return;
}
@ -482,6 +565,7 @@ public partial class OrderService : ApplicationService
{
CustomerId = input.CustomerId,
SenderAddressId = input.SenderAddressId.Value,
Direction = PickupDirectionCodes.NormalizeOrDefault(input.PickupDirection),
WarehouseId = input.WarehouseId.Value,
CarrierCode = input.CarrierCode,
CarrierService = input.CarrierService,

View File

@ -23,13 +23,71 @@ public static class PickupOrderParcelBuilder
? $"PK{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}"
: input.ClientNo.Trim();
var receiverName = string.IsNullOrWhiteSpace(warehouse.Recipient)
var warehouseContact = string.IsNullOrWhiteSpace(warehouse.Recipient)
? warehouse.WarehouseName
: warehouse.Recipient!.Trim();
var street = string.IsNullOrWhiteSpace(warehouse.ShippingAddress)
var warehouseStreet = string.IsNullOrWhiteSpace(warehouse.ShippingAddress)
? warehouse.WarehouseName
: warehouse.ShippingAddress!.Trim();
var isReverse = string.Equals(
PickupDirectionCodes.NormalizeOrDefault(input.Direction),
PickupDirectionCodes.Reverse,
StringComparison.OrdinalIgnoreCase);
// 正向:客户揽收地址 → 仓库;逆向:仓库 → 客户揽收地址
var sender = isReverse
? new OrderParcelSenderInput
{
SenderName = warehouseContact,
Phone = warehouse.ContactPhone,
CountryCode = "CN",
StreetAddress1 = warehouseStreet,
ZipCode = "000000"
}
: new OrderParcelSenderInput
{
SenderName = senderAddress.SenderName,
Company = senderAddress.Company,
Phone = senderAddress.Phone,
TelePhone = senderAddress.TelePhone,
Email = senderAddress.Email,
CountryCode = senderCountryCode ?? "CN",
Province = senderAddress.Province,
City = senderAddress.City,
ZipCode = senderAddress.ZipCode,
StreetAddress1 = senderAddress.StreetAddress1,
StreetAddress2 = senderAddress.StreetAddress2,
StreetAddress3 = senderAddress.StreetAddress3,
HouseNumber = senderAddress.HouseNumber
};
var receiver = isReverse
? new OrderParcelReceiverInput
{
RecipientsName = senderAddress.SenderName,
CompanyName = senderAddress.Company,
Phone = senderAddress.Phone,
TelePhone = senderAddress.TelePhone,
Email = senderAddress.Email,
CountryCode = senderCountryCode ?? "CN",
Province = senderAddress.Province,
City = senderAddress.City,
ZipCode = senderAddress.ZipCode ?? "000000",
StreetAddress1 = senderAddress.StreetAddress1,
StreetAddress2 = senderAddress.StreetAddress2,
StreetAddress3 = senderAddress.StreetAddress3,
StreetNumber = senderAddress.HouseNumber
}
: new OrderParcelReceiverInput
{
RecipientsName = warehouseContact,
Phone = warehouse.ContactPhone,
CountryCode = "CN",
ZipCode = "000000",
StreetAddress1 = warehouseStreet
};
return new OrderParcelCreateInput
{
RequestId = string.IsNullOrWhiteSpace(input.RequestId) ? null : input.RequestId.Trim(),
@ -47,30 +105,12 @@ public static class PickupOrderParcelBuilder
DeclaredCurrency = "CNY",
DeclaredSum = 1,
Quantity = 1,
Sender = new OrderParcelSenderInput
{
SenderName = senderAddress.SenderName,
Company = senderAddress.Company,
Phone = senderAddress.Phone,
TelePhone = senderAddress.TelePhone,
Email = senderAddress.Email,
CountryCode = senderCountryCode ?? "CN",
Province = senderAddress.Province,
City = senderAddress.City,
ZipCode = senderAddress.ZipCode,
StreetAddress1 = senderAddress.StreetAddress1,
StreetAddress2 = senderAddress.StreetAddress2,
StreetAddress3 = senderAddress.StreetAddress3,
HouseNumber = senderAddress.HouseNumber
},
Receiver = new OrderParcelReceiverInput
{
RecipientsName = receiverName,
Phone = warehouse.ContactPhone,
CountryCode = "CN",
ZipCode = "000000",
StreetAddress1 = street
},
InboundMethod = OrderInboundMethodCodes.DoorPickup,
SenderAddressId = input.SenderAddressId,
ExpectPickupTime = input.PickupTime,
PickupDirection = PickupDirectionCodes.NormalizeOrDefault(input.Direction),
Sender = sender,
Receiver = receiver,
Items =
[
new OrderParcelItemInput

View File

@ -2,7 +2,9 @@ using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Integrations.Itfx;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
@ -48,6 +50,21 @@ public class PickupOrderService : ApplicationService
private ICountryRepository CountryRepository =>
LazyServiceProvider.GetRequiredService<ICountryRepository>();
private ISalesProductRepository SalesProductRepository =>
LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private IServiceChannelRepository ServiceChannelRepository =>
LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
private IServiceChannelGroupRepository ServiceChannelGroupRepository =>
LazyServiceProvider.GetRequiredService<IServiceChannelGroupRepository>();
private IItfxForwardClient ItfxForwardClient =>
LazyServiceProvider.GetRequiredService<IItfxForwardClient>();
private IItfxMetadataClient ItfxMetadataClient =>
LazyServiceProvider.GetRequiredService<IItfxMetadataClient>();
/// <summary>
/// 创建揽收单并挂载物流单(原子:全部校验通过才落库)。
/// </summary>
@ -63,7 +80,7 @@ public class PickupOrderService : ApplicationService
if (input.SenderAddressId <= 0)
{
throw new BusinessException("发件地址不能为空");
throw new BusinessException("揽收地址不能为空");
}
if (input.WarehouseId <= 0)
@ -76,6 +93,8 @@ public class PickupOrderService : ApplicationService
throw new BusinessException("期望揽收时间不能为空");
}
var direction = PickupDirectionCodes.NormalizeOrDefault(input.Direction);
var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList();
if (orderIds.Count == 0)
{
@ -85,15 +104,15 @@ public class PickupOrderService : ApplicationService
var senderAddress = await CustomerSenderAddressRepository.GetOwnedAsync(input.SenderAddressId, customerId);
if (senderAddress == null)
{
throw new BusinessException("发件地址不存在");
throw new BusinessException("揽收地址不存在");
}
var addressType = string.IsNullOrWhiteSpace(senderAddress.AddressType)
? CustomerAddressTypeCodes.Sender
: senderAddress.AddressType;
if (!string.Equals(addressType, CustomerAddressTypeCodes.Sender, StringComparison.OrdinalIgnoreCase))
if (!CustomerAddressTypeCodes.IsPickupEligible(addressType))
{
throw new BusinessException("揽收单仅可选择发件地址");
throw new BusinessException("揽收单请选择「国内揽收」类型地址");
}
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId);
@ -198,6 +217,7 @@ public class PickupOrderService : ApplicationService
OrganizationId = orgId,
SecretLevel = secretLevel,
SenderAddressId = senderAddress.Id,
Direction = direction,
SenderName = senderAddress.SenderName ?? string.Empty,
SenderCompany = senderAddress.Company,
SenderCountry = senderAddress.Country,
@ -408,9 +428,13 @@ public class PickupOrderService : ApplicationService
var keyword = NormalizeOptional(input.Keyword);
var status = NormalizeOptional(input.Status);
var payStatus = NormalizeOptional(input.PayStatus);
var direction = string.IsNullOrWhiteSpace(input.Direction)
? null
: PickupDirectionCodes.NormalizeOrDefault(input.Direction);
var all = await PickupOrderRepository.GetListAsync(x =>
(customerId == null || x.CustomerId == customerId.Value) &&
(direction == null || x.Direction == direction) &&
(status == null || x.Status == status) &&
(payStatus == null || x.PayStatus == payStatus) &&
(input.WarehouseId == null || input.WarehouseId <= 0 || x.WarehouseId == input.WarehouseId.Value) &&
@ -438,15 +462,22 @@ public class PickupOrderService : ApplicationService
/// 揽收单状态计数(Tab 徽章)。
/// </summary>
[HttpGet("/order/pickup-order/status-counts")]
public async Task<PickupOrderStatusCountsOutput> GetStatusCountsAsync([FromQuery] int? customerId)
public async Task<PickupOrderStatusCountsOutput> GetStatusCountsAsync(
[FromQuery] int? customerId,
[FromQuery] string? direction = null)
{
var resolvedCustomerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
customerId);
var directionFilter = string.IsNullOrWhiteSpace(direction)
? null
: PickupDirectionCodes.NormalizeOrDefault(direction);
var all = await PickupOrderRepository.GetListAsync(x =>
resolvedCustomerId == null || x.CustomerId == resolvedCustomerId.Value);
(resolvedCustomerId == null || x.CustomerId == resolvedCustomerId.Value) &&
(directionFilter == null || x.Direction == directionFilter));
var terminal = new[] { PickupOrderStatusCodes.Cancelled, PickupOrderStatusCodes.Failed };
var pendingPayment = all.Count(x =>
@ -888,6 +919,157 @@ public class PickupOrderService : ApplicationService
await PickupOrderRepository.UpdateAsync(pickup);
}
/// <summary>
/// 揽收单预报下游:createOrderOne 请求体全部从 t_pickup_order 组装(不读关联物流单)。
/// </summary>
[HttpPost("/order/pickup-order/downstream-forward")]
public async Task<OrderDownstreamForwardResult> DownstreamForwardAsync([FromBody] PickupOrderIdInput input)
{
var pickup = await GetOwnedPickupOrThrowAsync(input.Id);
if (string.IsNullOrWhiteSpace(pickup.CarrierService))
{
throw new BusinessException("请先选择承运服务后再预报下游");
}
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == pickup.WarehouseId);
if (warehouse == null)
{
throw new BusinessException("仓库不存在");
}
var (body, _) = await PickupOrderDownstreamCreateOneBodyBuilder.BuildAsync(
pickup,
warehouse,
SalesProductRepository,
ServiceChannelRepository);
if (string.IsNullOrWhiteSpace(body.ProductCode))
{
throw new BusinessException("无法解析下游 productCode,请检查承运服务对应渠道的预报编码");
}
var creds = await ResolvePickupDownstreamCredentialsAsync(pickup);
var forward = await ItfxForwardClient.ForwardCreateOrderOneAsync(
creds.SupplierCode,
creds.AppKey,
creds.AppSecret,
body);
if (forward.Success
&& ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null)
{
var changed = false;
var waybill = !string.IsNullOrWhiteSpace(data.TrackNo)
? data.TrackNo.Trim()
: data.ServiceNo?.Trim();
if (!string.IsNullOrWhiteSpace(waybill)
&& !string.Equals(pickup.CarrierWaybillNo, waybill, StringComparison.Ordinal))
{
pickup.CarrierWaybillNo = waybill;
changed = true;
}
if (string.IsNullOrWhiteSpace(pickup.CarrierCode)
&& !string.IsNullOrWhiteSpace(creds.SupplierCode))
{
pickup.CarrierCode = creds.SupplierCode;
changed = true;
}
if (changed)
{
await PickupOrderRepository.UpdateAsync(pickup);
}
}
return new OrderDownstreamForwardResult
{
Success = forward.Success,
SupplierCode = creds.SupplierCode,
Url = forward.Url,
RequestJson = forward.RequestJson,
StatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
DownstreamServiceNo = pickup.CarrierWaybillNo,
TrackNo = pickup.CarrierWaybillNo,
};
}
/// <summary>
/// 凭证:优先揽收单 CarrierCode 作为 supplierCode;AppKey/Secret 仍按承运服务→渠道分组解析。
/// </summary>
private async Task<OrderDownstreamCredentials> ResolvePickupDownstreamCredentialsAsync(PickupOrder pickup)
{
var salesProductCode = pickup.CarrierService!.Trim();
var product = await SalesProductRepository.GetByProductCodeAsync(salesProductCode);
if (product == null)
{
throw new BusinessException($"未找到承运服务对应销售产品「{salesProductCode}」");
}
var providers = await SalesProductRepository.GetAllocationProvidersAsync(product.Id);
var channelId = providers
.Where(x => x.ServiceChannelId is > 0)
.OrderBy(x => x.SerialNumber)
.Select(x => x.ServiceChannelId!.Value)
.FirstOrDefault();
if (channelId <= 0)
{
throw new BusinessException($"销售产品「{salesProductCode}」未配置服务渠道");
}
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId);
if (channel == null)
{
throw new BusinessException("承运服务关联的服务渠道不存在");
}
if (!channel.GroupId.HasValue || channel.GroupId.Value <= 0)
{
throw new BusinessException($"渠道「{channel.ChannelCode}」未关联服务商分组");
}
var group = await ServiceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == channel.GroupId.Value);
if (group == null)
{
throw new BusinessException("服务商分组不存在或已删除");
}
if (group.AppId <= 0 || group.ApiServiceProviderId <= 0)
{
throw new BusinessException($"服务商「{group.Name}」未配置应用或 API 服务商");
}
var appInfo = await ItfxMetadataClient.GetAppAsync(group.AppId);
var appKey = appInfo?.AppKey?.Trim();
var appSecret = appInfo?.AppSecret?.Trim();
if (string.IsNullOrWhiteSpace(appKey) || string.IsNullOrWhiteSpace(appSecret))
{
throw new BusinessException(
"无法获取应用 AppKey/AppSecret,请检查服务商应用配置或「其他配置-API中心服务」");
}
var supplierCode = pickup.CarrierCode?.Trim();
if (string.IsNullOrWhiteSpace(supplierCode))
{
var sp = await ItfxMetadataClient.GetServiceProviderAsync(group.ApiServiceProviderId);
if (sp == null || string.IsNullOrWhiteSpace(sp.Code))
{
throw new BusinessException("API 服务商编码(supplierCode)未配置");
}
supplierCode = sp.Code.Trim();
}
return new OrderDownstreamCredentials
{
SupplierCode = supplierCode,
AppKey = appKey,
AppSecret = appSecret
};
}
private async Task<string> GeneratePickupNoAsync()
{
for (var i = 0; i < 8; i++)
@ -929,6 +1111,7 @@ public class PickupOrderService : ApplicationService
OrganizationId = entity.OrganizationId,
SecretLevel = entity.SecretLevel,
SenderAddressId = entity.SenderAddressId,
Direction = PickupDirectionCodes.NormalizeOrDefault(entity.Direction),
SenderName = entity.SenderName,
SenderPhone = entity.SenderPhone,
SenderTelePhone = entity.SenderTelePhone,

View File

@ -171,6 +171,23 @@ public class SystemOtherConfigService : ApplicationService
throw new BusinessException("轨迹服务 AppSecret 不能为空");
}
}
else if (string.Equals(configCode, SystemOtherConfigCodes.ApiCenter, StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(input.BaseUrl))
{
throw new BusinessException("API 中心服务地址不能为空");
}
if (string.IsNullOrWhiteSpace(input.AppKey))
{
throw new BusinessException("API 中心应用 AppKey 不能为空");
}
if (excludeId == null && ShouldKeepExistingSecret(input.AppSecret))
{
throw new BusinessException("API 中心应用 AppSecret 不能为空");
}
}
else if (string.Equals(configCode, SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(input.AppKey))

View File

@ -79,6 +79,43 @@ public partial class WarehouseService
};
}
/// <summary>
/// 查询包裹生命周期状态汇总。
/// </summary>
/// <param name="customerId">客户 ID(可选)。</param>
/// <param name="warehouseId">仓库 ID(可选)。</param>
/// <returns>各状态数量汇总。</returns>
[HttpGet("/warehouse/parcel/summary")]
public async Task<WarehouseParcelSummaryOutput> GetParcelSummaryAsync(
[FromQuery] int? customerId = null,
[FromQuery] int? warehouseId = null)
{
var resolvedCustomerId = await ResolveListCustomerIdAsync(customerId);
var (hasCustomerFilter, customerIdValue) = CustomerFilter(resolvedCustomerId);
var hasWarehouseFilter = warehouseId.HasValue;
var warehouseIdValue = warehouseId.GetValueOrDefault();
var inTransit = await ParcelRepository.CountAsync(x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
&& (!hasWarehouseFilter || x.WarehouseId == warehouseIdValue)
&& x.LifecycleStatus == WarehouseParcelLifecycleCodes.InTransit);
var arrived = await ParcelRepository.CountAsync(x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
&& (!hasWarehouseFilter || x.WarehouseId == warehouseIdValue)
&& x.LifecycleStatus == WarehouseParcelLifecycleCodes.Arrived);
var stored = await ParcelRepository.CountAsync(x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
&& (!hasWarehouseFilter || x.WarehouseId == warehouseIdValue)
&& x.LifecycleStatus == WarehouseParcelLifecycleCodes.Stored);
return new WarehouseParcelSummaryOutput
{
InTransit = inTransit,
Arrived = arrived,
Stored = stored
};
}
/// <summary>
/// 规范化列表状态筛选:空 / all 表示不按状态过滤;已知编码统一为规范值。
/// </summary>
@ -209,9 +246,9 @@ public partial class WarehouseService
/// </summary>
[HttpPost("/warehouse/parcel/upload-image")]
[Consumes("multipart/form-data")]
public async Task<object> UploadParcelImageAsync(IFormFile file)
public async Task<object> UploadParcelImageAsync(IFormFile file, [FromQuery] int? customerId = null)
{
await ResolveMutationCustomerIdAsync(null);
await ResolveMutationCustomerIdAsync(customerId);
var key = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, FileStorage);
var url = FileStorage.GetPublicUrl(key);
return new { key, url, fileUrl = key };

View File

@ -122,6 +122,7 @@ public partial class WarehouseService
entity.UnitPrice = input.UnitPrice;
entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim();
entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim();
entity.DeclareElement = string.IsNullOrWhiteSpace(input.DeclareElement) ? null : input.DeclareElement.Trim();
entity.WeightG = input.WeightG;
entity.LengthCm = input.LengthCm;
entity.WidthCm = input.WidthCm;
@ -164,9 +165,9 @@ public partial class WarehouseService
/// </summary>
[HttpPost("/warehouse/sku/upload-image")]
[Consumes("multipart/form-data")]
public async Task<object> UploadSkuImageAsync(IFormFile file)
public async Task<object> UploadSkuImageAsync(IFormFile file, [FromQuery] int? customerId = null)
{
await ResolveMutationCustomerIdAsync(null);
await ResolveMutationCustomerIdAsync(customerId);
var key = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, FileStorage);
var url = FileStorage.GetPublicUrl(key);
return new { key, url, fileUrl = key };
@ -231,6 +232,7 @@ public partial class WarehouseService
UnitPrice = entity.UnitPrice,
ExHsCode = entity.ExHsCode,
ImHsCode = entity.ImHsCode,
DeclareElement = entity.DeclareElement,
WeightG = entity.WeightG,
LengthCm = entity.LengthCm,
WidthCm = entity.WidthCm,
@ -252,6 +254,7 @@ public partial class WarehouseService
UnitPrice = entity.UnitPrice,
ExHsCode = entity.ExHsCode,
ImHsCode = entity.ImHsCode,
DeclareElement = entity.DeclareElement,
WeightG = entity.WeightG,
LengthCm = entity.LengthCm,
WidthCm = entity.WidthCm,

View File

@ -1,4 +1,6 @@
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders.Issues;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.OrdersAggregate.Entities;
@ -10,6 +12,13 @@ namespace Oms.Application.Shared;
/// </summary>
public static class AllocationResolver
{
public const string SkipStageCondition = "condition";
public const string SkipStageCustomer = "customer";
public const string SkipStageChannel = "channel";
public const string SkipStageBuyPrice = "buyPrice";
private const int MatchConditionTraceMaxLen = 200;
public sealed class ChannelCandidate
{
public SalesProductAllocationProviderSnapshotDto Provider { get; init; } = null!;
@ -22,16 +31,140 @@ public static class AllocationResolver
public IReadOnlyList<OrderFee> PayableFees { get; init; } = [];
}
public sealed class FilterProvidersResult
{
public List<SalesProductAllocationProviderSnapshotDto> Passed { get; init; } = [];
public List<OrderAllocationSkippedOutput> Skipped { get; init; } = [];
public int PoolCount { get; init; }
public int ConditionPassCount { get; init; }
}
/// <summary>
/// 兼容旧调用:仅按客户名单过滤(无判断条件上下文时)。
/// </summary>
public static List<SalesProductAllocationProviderSnapshotDto> FilterProviders(
IEnumerable<SalesProductAllocationProviderSnapshotDto> providers,
int? customerId) =>
providers
FilterProvidersDetailed(providers, customerId, context: null).Passed;
/// <summary>
/// 先按判断条件(或兼容客户名单)筛出可用渠道;空条件恒真。
/// </summary>
public static FilterProvidersResult FilterProvidersDetailed(
IEnumerable<SalesProductAllocationProviderSnapshotDto> providers,
int? customerId,
OrderRuleEvaluationContext? context)
{
var pool = providers
.Where(x => x.ServiceChannelId.HasValue && x.ServiceChannelId > 0)
.Where(x => x.CustomerIds.Count == 0
|| (customerId.HasValue && x.CustomerIds.Contains(customerId.Value)))
.OrderBy(x => x.SerialNumber)
.ToList();
var passed = new List<SalesProductAllocationProviderSnapshotDto>();
var skipped = new List<OrderAllocationSkippedOutput>();
foreach (var provider in pool)
{
var match = EvaluateProviderMatch(provider, customerId, context);
if (match.Pass)
{
passed.Add(provider);
continue;
}
skipped.Add(new OrderAllocationSkippedOutput
{
ServiceChannelId = provider.ServiceChannelId,
ChannelCode = provider.ChannelCode,
ChannelName = provider.ServiceChannelName ?? provider.ProviderName,
Stage = match.Stage,
MatchCondition = TruncateMatchCondition(provider.MatchCondition),
Reason = match.Reason
});
}
// 同一渠道多行命中时保留权重更高、序号更小的一行
var deduped = DeduplicateByChannel(passed);
return new FilterProvidersResult
{
Passed = deduped,
Skipped = skipped,
PoolCount = pool.Count,
ConditionPassCount = passed.Count
};
}
private static List<SalesProductAllocationProviderSnapshotDto> DeduplicateByChannel(
List<SalesProductAllocationProviderSnapshotDto> passed)
{
return passed
.GroupBy(x => x.ServiceChannelId!.Value)
.Select(g => g
.OrderByDescending(x => x.Weight)
.ThenBy(x => x.SerialNumber)
.First())
.OrderBy(x => x.SerialNumber)
.ToList();
}
private readonly record struct MatchOutcome(bool Pass, string Stage, string Reason);
private static MatchOutcome EvaluateProviderMatch(
SalesProductAllocationProviderSnapshotDto provider,
int? customerId,
OrderRuleEvaluationContext? context)
{
var formula = provider.MatchCondition?.Trim();
if (!string.IsNullOrWhiteSpace(formula)
&& !string.Equals(formula, "无", StringComparison.OrdinalIgnoreCase))
{
if (context == null)
{
return new MatchOutcome(false, SkipStageCondition, "判断条件缺少订单上下文");
}
if (!ChineseFormulaEvaluator.TryEvaluate(formula, context, out var hit))
{
return new MatchOutcome(false, SkipStageCondition, "判断条件公式无效");
}
if (!hit)
{
return new MatchOutcome(false, SkipStageCondition, "判断条件未命中");
}
return new MatchOutcome(true, SkipStageCondition, string.Empty);
}
// 无公式:兼容旧 CustomerIds 名单
if (provider.CustomerIds.Count > 0)
{
if (customerId.HasValue && provider.CustomerIds.Contains(customerId.Value))
{
return new MatchOutcome(true, SkipStageCustomer, string.Empty);
}
return new MatchOutcome(false, SkipStageCustomer, "客户不在名单内");
}
return new MatchOutcome(true, SkipStageCondition, string.Empty);
}
public static string TruncateMatchCondition(string? condition)
{
var text = condition?.Trim() ?? string.Empty;
if (text.Length <= MatchConditionTraceMaxLen)
{
return text;
}
return text[..MatchConditionTraceMaxLen] + "…";
}
public static ServiceChannel SelectChannel(string? scheme, IReadOnlyList<ChannelCandidate> candidates) =>
SelectCandidate(scheme, candidates).Channel;

View File

@ -1,4 +1,6 @@
using Oms.Application.Dtos.Orders;
using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories;
@ -6,17 +8,19 @@ using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 从订单表及关联子表组装 createOrderOne 标准请求体。
/// 从订单表及关联子表组装 itfx createOrderOne 标准请求体。
/// </summary>
public static class OrderDownstreamCreateOneBodyBuilder
{
public static async Task<OrderParcelCreateInput> BuildAsync(
public static async Task<ItfxCreateOrderOneBody> BuildAsync(
Order order,
OrderSender? sender,
OrderRecipient? recipient,
IReadOnlyList<OrderDeclaration> declarations,
IReadOnlyList<OrderWeight> weights,
ICountryRepository countryRepository,
IServiceChannelRepository serviceChannelRepository,
IFileStorage? fileStorage = null,
CancellationToken cancellationToken = default)
{
var countryIds = new List<int>();
@ -39,16 +43,18 @@ public static class OrderDownstreamCreateOneBodyBuilder
string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase))
?? weights.FirstOrDefault();
return new OrderParcelCreateInput
var productCode = await ResolveForecastProductCodeAsync(
order,
serviceChannelRepository,
cancellationToken);
return new ItfxCreateOrderOneBody
{
RequestId = order.RequestId,
ClientNo = order.ClientNo,
TrackNo = order.TrackNo,
ServiceNo = order.ServiceNo,
ProductCode = order.ProductCode,
ChannelCode = order.ChannelCode,
ProductCode = productCode,
CargoAttribute = order.CargoAttribute,
ExportCustomsMode = order.ExportCustomsMode,
WarehouseId = order.WarehouseId,
CustomerId = order.CustomerId,
Length = forecast?.Length,
@ -60,16 +66,45 @@ public static class OrderDownstreamCreateOneBodyBuilder
Cubic = forecast?.Cubic,
IsCod = order.IsCod > 0,
CodCurrency = order.CodCurrency,
CodSum = order.CodSum,
CodSum = FormatDecimal(order.CodSum),
DeclaredCurrency = order.DeclaredCurrency,
DeclaredSum = order.DeclaredSum,
Quantity = order.Quantity,
DeclaredSum = FormatDecimal(order.DeclaredSum),
Quantity = order.Quantity?.ToString(),
Receiver = MapReceiver(recipient, order.ConsigneeCountry, idToCode),
Sender = MapSender(sender, idToCode),
Items = declarations.Select(MapItem).ToList()
Items = declarations.Select(x => MapItem(x, fileStorage)).ToList()
};
}
/// <summary>
/// createOrderOne.productCode = 渠道「预报渠道 code」:
/// 优先订单固化的 ChannelForecastCode,否则 live 查 t_service_channel.ForecastCode。
/// </summary>
private static async Task<string?> ResolveForecastProductCodeAsync(
Order order,
IServiceChannelRepository serviceChannelRepository,
CancellationToken cancellationToken)
{
var snapshotted = order.ChannelForecastCode?.Trim();
if (!string.IsNullOrWhiteSpace(snapshotted))
{
return snapshotted;
}
var channelCode = order.ChannelCode?.Trim();
if (string.IsNullOrWhiteSpace(channelCode))
{
return null;
}
var channel = await serviceChannelRepository.GetByChannelCodeAsync(channelCode, cancellationToken);
var forecastCode = channel?.ForecastCode?.Trim();
return string.IsNullOrWhiteSpace(forecastCode) ? null : forecastCode;
}
private static string? FormatDecimal(decimal? value) =>
value.HasValue ? value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
private static string? ResolveCountryIso(int? countryId, string? orderConsigneeCountry, IReadOnlyDictionary<int, string> idToCode)
{
if (!string.IsNullOrWhiteSpace(orderConsigneeCountry))
@ -81,9 +116,9 @@ public static class OrderDownstreamCreateOneBodyBuilder
}
}
if (countryId is > 0 && idToCode.TryGetValue(countryId.Value, out var code) && !string.IsNullOrWhiteSpace(code))
if (countryId is > 0 && idToCode.TryGetValue(countryId.Value, out var iso) && !string.IsNullOrWhiteSpace(iso))
{
return code.Trim().ToUpperInvariant();
return iso.Trim().ToUpperInvariant();
}
return null;
@ -150,7 +185,7 @@ public static class OrderDownstreamCreateOneBodyBuilder
};
}
private static OrderParcelItemInput MapItem(OrderDeclaration x) =>
private static OrderParcelItemInput MapItem(OrderDeclaration x, IFileStorage? fileStorage) =>
new()
{
CnName = x.CnName,
@ -164,7 +199,8 @@ public static class OrderDownstreamCreateOneBodyBuilder
Weight = x.Weight,
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode ?? x.HsCode,
SkuImage = x.SkuImage,
DeclareElement = x.DeclareElement,
SkuImage = fileStorage?.GetPublicUrl(x.SkuImage) ?? x.SkuImage,
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,

View File

@ -17,12 +17,15 @@ public sealed class OrderDownstreamCredentials
}
/// <summary>
/// 解析订单下游 itfx 凭证(优先订单绑定字段,回退渠道分组)。
/// 解析订单下游 itfx 凭证。
/// 优先级:服务渠道规则快照(整套同源)→ 订单绑定字段 → 渠道分组实时查询。
/// 快照命中时 SupplierCode/AppKey/AppSecret 必须同出快照,禁止与订单字段混拼。
/// </summary>
public class OrderDownstreamCredentialResolver(
IServiceChannelRepository serviceChannelRepository,
IServiceChannelGroupRepository serviceChannelGroupRepository,
IItfxMetadataClient itfxMetadataClient) : IScopedDependency
IItfxMetadataClient itfxMetadataClient,
IItfxGatewayConfigProvider itfxGatewayConfigProvider) : IScopedDependency
{
public async Task<OrderDownstreamCredentials> ResolveAsync(Order order, CancellationToken cancellationToken = default)
{
@ -40,7 +43,19 @@ public class OrderDownstreamCredentialResolver(
var appSecret = app?.AppSecret?.Trim();
if (string.IsNullOrWhiteSpace(appSecret))
{
throw new BusinessException("应用 AppSecret 未配置");
appSecret = await TryResolveAppSecretFromApiCenterAsync(order.DownstreamAppKey, cancellationToken);
}
if (string.IsNullOrWhiteSpace(appSecret))
{
if (app == null)
{
throw new BusinessException(
"无法从 API 中心获取应用信息,请检查「其他配置-API中心服务」地址/AppKey/AppSecret,以及下游应用是否存在");
}
throw new BusinessException(
"应用 AppSecret 未配置,请在「其他配置-API中心服务」填写与下游一致的 AppKey/AppSecret");
}
return new OrderDownstreamCredentials
@ -84,9 +99,28 @@ public class OrderDownstreamCredentialResolver(
}
var appInfo = await itfxMetadataClient.GetAppAsync(group.AppId, cancellationToken);
if (appInfo == null || string.IsNullOrWhiteSpace(appInfo.AppKey) || string.IsNullOrWhiteSpace(appInfo.AppSecret))
var resolvedAppKey = appInfo?.AppKey?.Trim();
var resolvedAppSecret = appInfo?.AppSecret?.Trim();
if (string.IsNullOrWhiteSpace(resolvedAppKey) || string.IsNullOrWhiteSpace(resolvedAppSecret))
{
throw new BusinessException("应用 AppKey / AppSecret 未配置");
var fromCenter = await TryResolveCredentialsFromApiCenterAsync(cancellationToken);
if (fromCenter != null)
{
resolvedAppKey = string.IsNullOrWhiteSpace(resolvedAppKey) ? fromCenter.AppKey : resolvedAppKey;
resolvedAppSecret = string.IsNullOrWhiteSpace(resolvedAppSecret) ? fromCenter.AppSecret : resolvedAppSecret;
}
}
if (string.IsNullOrWhiteSpace(resolvedAppKey) || string.IsNullOrWhiteSpace(resolvedAppSecret))
{
if (appInfo == null)
{
throw new BusinessException(
"无法从 API 中心获取应用信息,请检查「其他配置-API中心服务」地址/AppKey/AppSecret,以及服务商应用是否存在");
}
throw new BusinessException(
"应用 AppKey / AppSecret 未配置,请在「其他配置-API中心服务」填写应用凭证");
}
var sp = await itfxMetadataClient.GetServiceProviderAsync(group.ApiServiceProviderId, cancellationToken);
@ -98,8 +132,42 @@ public class OrderDownstreamCredentialResolver(
return new OrderDownstreamCredentials
{
SupplierCode = sp.Code.Trim(),
AppKey = appInfo.AppKey.Trim(),
AppSecret = appInfo.AppSecret.Trim()
AppKey = resolvedAppKey,
AppSecret = resolvedAppSecret
};
}
private async Task<string?> TryResolveAppSecretFromApiCenterAsync(
string expectedAppKey,
CancellationToken cancellationToken)
{
var cfg = await itfxGatewayConfigProvider.GetAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(cfg.AppKey) || string.IsNullOrWhiteSpace(cfg.AppSecret))
{
return null;
}
if (!string.Equals(cfg.AppKey.Trim(), expectedAppKey.Trim(), StringComparison.OrdinalIgnoreCase))
{
return null;
}
return cfg.AppSecret.Trim();
}
private async Task<OrderDownstreamCredentials?> TryResolveCredentialsFromApiCenterAsync(
CancellationToken cancellationToken)
{
var cfg = await itfxGatewayConfigProvider.GetAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(cfg.AppKey) || string.IsNullOrWhiteSpace(cfg.AppSecret))
{
return null;
}
return new OrderDownstreamCredentials
{
AppKey = cfg.AppKey.Trim(),
AppSecret = cfg.AppSecret.Trim()
};
}
@ -147,23 +215,20 @@ public class OrderDownstreamCredentialResolver(
return null;
}
// 整套同源:禁止用订单 SupplierCode/AppKey 拼快照 AppSecret,避免两边发布不同步时混用。
var appSecret = downstream.AppSecret?.Trim();
if (string.IsNullOrWhiteSpace(appSecret))
{
return null;
}
var appKey = !string.IsNullOrWhiteSpace(order.DownstreamAppKey)
? order.DownstreamAppKey.Trim()
: downstream.AppKey?.Trim();
var appKey = downstream.AppKey?.Trim();
if (string.IsNullOrWhiteSpace(appKey))
{
return null;
}
var supplierCode = !string.IsNullOrWhiteSpace(order.DownstreamSupplierCode)
? order.DownstreamSupplierCode.Trim()
: downstream.SupplierCode?.Trim();
var supplierCode = downstream.SupplierCode?.Trim();
if (string.IsNullOrWhiteSpace(supplierCode))
{
return null;

View File

@ -0,0 +1,251 @@
using System.Globalization;
using MiniExcelLibs;
using Oms.Application.Dtos.Orders;
namespace Oms.Application.Shared;
/// <summary>
/// 对账件重尺(downstream)Excel 模板与解析。
/// 仅写入下游对账重量,触发应付重算;不改应收。
/// </summary>
public static class OrderDownstreamWeightImportHelper
{
public const int MaxRows = 2000;
private static readonly Dictionary<string, string> HeaderAliases = new(StringComparer.OrdinalIgnoreCase)
{
["运单号"] = "WaybillNo",
["单号"] = "WaybillNo",
["服务商单号"] = "WaybillNo",
["WaybillNo"] = "WaybillNo",
["waybillNo"] = "WaybillNo",
["ServiceNo"] = "WaybillNo",
["serviceNo"] = "WaybillNo",
["长"] = "Length",
["长度"] = "Length",
["Length"] = "Length",
["length"] = "Length",
["宽"] = "Width",
["宽度"] = "Width",
["Width"] = "Width",
["width"] = "Width",
["高"] = "Height",
["高度"] = "Height",
["Height"] = "Height",
["height"] = "Height",
["实重"] = "ActualWeight",
["重量"] = "ActualWeight",
["ActualWeight"] = "ActualWeight",
["actualWeight"] = "ActualWeight",
["Weight"] = "ActualWeight",
["weight"] = "ActualWeight",
};
public static async Task<byte[]> BuildTemplateBytesAsync()
{
var rows = new List<Dictionary<string, object?>>
{
new()
{
["运单号"] = "OMS202601010001",
["长"] = 30,
["宽"] = 20,
["高"] = 10,
["实重"] = 1.25
}
};
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, rows);
return stream.ToArray();
}
public static string TemplateFileName => "对账件重尺导入模板.xlsx";
public static async Task<List<(int RowNumber, OrderDownstreamWeightImportRow Row)>> ParseImportRowsAsync(
Stream fileStream)
{
var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true);
var result = new List<(int RowNumber, OrderDownstreamWeightImportRow Row)>();
var rowNumber = 1;
foreach (var row in rows)
{
rowNumber++;
if (row is not IDictionary<string, object> rowDict)
{
continue;
}
if (IsEmptyRow(rowDict))
{
continue;
}
try
{
result.Add((rowNumber, ParseRow(rowDict)));
}
catch (InvalidOperationException ex)
{
result.Add((rowNumber, new OrderDownstreamWeightImportRow
{
WaybillNo = TryPeekWaybillNo(rowDict),
ParseError = ex.Message
}));
}
}
return result;
}
public static void ValidateRow(OrderDownstreamWeightImportRow row)
{
if (!string.IsNullOrWhiteSpace(row.ParseError))
{
throw new InvalidOperationException(row.ParseError);
}
if (string.IsNullOrWhiteSpace(row.WaybillNo))
{
throw new InvalidOperationException("运单号不能为空");
}
row.WaybillNo = row.WaybillNo.Trim();
if (row.WaybillNo.Length > 64)
{
throw new InvalidOperationException("运单号不可超过64个字符");
}
if (!row.ActualWeight.HasValue || row.ActualWeight.Value <= 0)
{
throw new InvalidOperationException("实重必须大于0");
}
ValidateOptionalDimension(row.Length, "长");
ValidateOptionalDimension(row.Width, "宽");
ValidateOptionalDimension(row.Height, "高");
var hasAnyDim = row.Length.HasValue || row.Width.HasValue || row.Height.HasValue;
var hasAllDim = row.Length.HasValue && row.Width.HasValue && row.Height.HasValue;
if (hasAnyDim && !hasAllDim)
{
throw new InvalidOperationException("长/宽/高需同时填写或全部留空(留空则沿用订单已有尺寸)");
}
}
private static string? TryPeekWaybillNo(IDictionary<string, object> rowDict)
{
foreach (var (header, value) in rowDict)
{
if (string.IsNullOrWhiteSpace(header))
{
continue;
}
if (HeaderAliases.TryGetValue(header.Trim(), out var field)
&& string.Equals(field, "WaybillNo", StringComparison.OrdinalIgnoreCase))
{
return NormalizeCell(value);
}
}
return null;
}
private static void ValidateOptionalDimension(decimal? value, string label)
{
if (!value.HasValue)
{
return;
}
if (value.Value <= 0)
{
throw new InvalidOperationException($"{label}必须大于0");
}
}
private static OrderDownstreamWeightImportRow ParseRow(IDictionary<string, object> rowDict)
{
var mapped = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var (header, value) in rowDict)
{
if (string.IsNullOrWhiteSpace(header))
{
continue;
}
if (!HeaderAliases.TryGetValue(header.Trim(), out var field))
{
continue;
}
mapped[field] = NormalizeCell(value);
}
return new OrderDownstreamWeightImportRow
{
WaybillNo = mapped.GetValueOrDefault("WaybillNo"),
Length = ParseDecimal(mapped.GetValueOrDefault("Length")),
Width = ParseDecimal(mapped.GetValueOrDefault("Width")),
Height = ParseDecimal(mapped.GetValueOrDefault("Height")),
ActualWeight = ParseDecimal(mapped.GetValueOrDefault("ActualWeight"))
};
}
private static decimal? ParseDecimal(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var value))
{
return value;
}
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("zh-CN"), out value))
{
return value;
}
throw new InvalidOperationException($"数值格式无效:{text}");
}
private static string? NormalizeCell(object? value)
{
if (value is null)
{
return null;
}
var text = Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
private static bool IsEmptyRow(IDictionary<string, object> rowDict) =>
rowDict.Values.All(value => string.IsNullOrWhiteSpace(NormalizeCell(value)));
}
/// <summary>
/// 对账件重尺导入行。
/// </summary>
public sealed class OrderDownstreamWeightImportRow
{
public string? WaybillNo { get; set; }
public decimal? Length { get; set; }
public decimal? Width { get; set; }
public decimal? Height { get; set; }
public decimal? ActualWeight { get; set; }
/// <summary>
/// Excel 单元格解析错误(校验阶段直接抛出)。
/// </summary>
public string? ParseError { get; set; }
}

View File

@ -0,0 +1,190 @@
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.SystemAggregate.Entities;
namespace Oms.Application.Shared;
/// <summary>
/// 从揽收单表组装 itfx createOrderOne 标准请求体(不读关联物流单)。
/// </summary>
public static class PickupOrderDownstreamCreateOneBodyBuilder
{
public static async Task<(ItfxCreateOrderOneBody Body, string? ForecastProductCode)> BuildAsync(
PickupOrder pickup,
WarehouseAddress warehouse,
ISalesProductRepository salesProductRepository,
IServiceChannelRepository serviceChannelRepository,
CancellationToken cancellationToken = default)
{
var productCode = await ResolveForecastProductCodeAsync(
pickup.CarrierService,
salesProductRepository,
serviceChannelRepository,
cancellationToken);
var isReverse = string.Equals(
PickupDirectionCodes.NormalizeOrDefault(pickup.Direction),
PickupDirectionCodes.Reverse,
StringComparison.OrdinalIgnoreCase);
var customerSide = MapCustomerSide(pickup);
var warehouseSide = MapWarehouseSide(warehouse);
var weight = pickup.TotalActualWeight ?? pickup.ChargeableWeight;
var qty = pickup.ParcelCount > 0 ? pickup.ParcelCount : 1;
var cargoName = string.IsNullOrWhiteSpace(pickup.Remark)
? "上门揽收包裹"
: pickup.Remark.Trim();
return (new ItfxCreateOrderOneBody
{
ClientNo = pickup.PickupNo,
ServiceNo = pickup.PickupNo,
TrackNo = pickup.CarrierWaybillNo,
ProductCode = productCode,
CargoAttribute = CargoAttributeCodes.General,
WarehouseId = pickup.WarehouseId,
CustomerId = pickup.CustomerId,
Length = 1,
Width = 1,
Height = 1,
ActualWeight = weight,
ChargeableWeight = pickup.ChargeableWeight ?? weight,
IsCod = false,
DeclaredCurrency = pickup.Currency,
DeclaredSum = FormatDecimal(pickup.ReceivableAmount ?? pickup.FreightAmount ?? 1),
Quantity = qty.ToString(),
Sender = isReverse ? warehouseSide.Sender : customerSide.Sender,
Receiver = isReverse ? customerSide.Receiver : warehouseSide.Receiver,
Items =
[
new OrderParcelItemInput
{
CnName = cargoName,
EnName = cargoName,
UnitPrice = 1,
UnitWeight = weight,
Quantity = qty,
Amount = 1,
Weight = weight
}
]
}, productCode);
}
/// <summary>
/// createOrderOne.productCode:承运服务(销售产品)对应渠道的 ForecastCode,缺省回退 CarrierService。
/// </summary>
public static async Task<string?> ResolveForecastProductCodeAsync(
string? carrierService,
ISalesProductRepository salesProductRepository,
IServiceChannelRepository serviceChannelRepository,
CancellationToken cancellationToken = default)
{
var salesProductCode = carrierService?.Trim();
if (string.IsNullOrWhiteSpace(salesProductCode))
{
return null;
}
var product = await salesProductRepository.GetByProductCodeAsync(salesProductCode, cancellationToken);
if (product == null)
{
return salesProductCode;
}
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 salesProductCode;
}
var channel = await serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId, cancellationToken);
var forecastCode = channel?.ForecastCode?.Trim();
return string.IsNullOrWhiteSpace(forecastCode) ? salesProductCode : forecastCode;
}
private static string? FormatDecimal(decimal? value) =>
value.HasValue ? value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
private static (OrderParcelSenderInput Sender, OrderParcelReceiverInput Receiver) MapCustomerSide(PickupOrder pickup)
{
var sender = new OrderParcelSenderInput
{
SenderName = pickup.SenderName,
Company = pickup.SenderCompany,
Phone = pickup.SenderPhone,
TelePhone = pickup.SenderTelePhone,
Email = pickup.SenderEmail,
CountryCode = string.IsNullOrWhiteSpace(pickup.SenderCountryCode)
? "CN"
: pickup.SenderCountryCode.Trim().ToUpperInvariant(),
Province = pickup.SenderProvince,
City = pickup.SenderCity,
ZipCode = pickup.SenderZipCode,
StreetAddress1 = pickup.SenderStreetAddress1,
StreetAddress2 = pickup.SenderStreetAddress2,
StreetAddress3 = pickup.SenderStreetAddress3,
HouseNumber = pickup.SenderHouseNumber
};
var receiver = new OrderParcelReceiverInput
{
RecipientsName = pickup.SenderName,
CompanyName = pickup.SenderCompany,
Phone = pickup.SenderPhone,
TelePhone = pickup.SenderTelePhone,
Email = pickup.SenderEmail,
CountryCode = string.IsNullOrWhiteSpace(pickup.SenderCountryCode)
? "CN"
: pickup.SenderCountryCode.Trim().ToUpperInvariant(),
Province = pickup.SenderProvince,
City = pickup.SenderCity,
ZipCode = string.IsNullOrWhiteSpace(pickup.SenderZipCode) ? "000000" : pickup.SenderZipCode,
StreetAddress1 = pickup.SenderStreetAddress1,
StreetAddress2 = pickup.SenderStreetAddress2,
StreetAddress3 = pickup.SenderStreetAddress3,
StreetNumber = pickup.SenderHouseNumber
};
return (sender, receiver);
}
private static (OrderParcelSenderInput Sender, OrderParcelReceiverInput Receiver) MapWarehouseSide(
WarehouseAddress warehouse)
{
var contact = string.IsNullOrWhiteSpace(warehouse.Recipient)
? warehouse.WarehouseName
: warehouse.Recipient!.Trim();
var street = string.IsNullOrWhiteSpace(warehouse.ShippingAddress)
? warehouse.WarehouseName
: warehouse.ShippingAddress!.Trim();
var sender = new OrderParcelSenderInput
{
SenderName = contact,
Phone = warehouse.ContactPhone,
CountryCode = "CN",
StreetAddress1 = street,
ZipCode = "000000"
};
var receiver = new OrderParcelReceiverInput
{
RecipientsName = contact,
Phone = warehouse.ContactPhone,
CountryCode = "CN",
ZipCode = "000000",
StreetAddress1 = street
};
return (sender, receiver);
}
}

View File

@ -67,7 +67,8 @@ public static class PublishSnapshotSlimmer
ServiceChannelId = provider.ServiceChannelId,
ServiceChannelName = provider.ServiceChannelName,
Weight = provider.Weight,
CustomerIds = provider.CustomerIds
CustomerIds = provider.CustomerIds,
MatchCondition = provider.MatchCondition
};
public static WeightRuleSnapshotRuleDto SlimWeightRule(WeightRuleInfo entity) =>

View File

@ -18,4 +18,10 @@ public class SalesProductAllocationProvider : OmsAuditedEntity
[SugarColumn(ColumnDataType = "json", IsNullable = true)]
public string? CustomerIds { get; set; }
/// <summary>
/// 排货判断条件(中文公式);空表示全部订单可用。
/// </summary>
[SugarColumn(ColumnDataType = "text", IsNullable = true)]
public string? MatchCondition { get; set; }
}

View File

@ -6,4 +6,9 @@ namespace Oms.Domain.ChannelsAggregate.Repositories;
public interface IServiceChannelGroupRepository : IRepository<ServiceChannelGroup>
{
Task<ServiceChannelGroup?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>
/// 按名称查询分组(含软删除),用于唯一名冲突时恢复或释放名称。
/// </summary>
Task<ServiceChannelGroup?> GetByNameIncludingDeletedAsync(string name, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,58 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.CustomersAggregate.Entities;
/// <summary>
/// 客户出口资质备案。
/// </summary>
[SugarTable("t_customer_export_qualification")]
public class CustomerExportQualification : OmsAuditedAggregateRoot
{
public int CustomerId { get; set; }
/// <summary>
/// 企业名称。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 企业社会信用代码。
/// </summary>
public string SocialCreditCode { get; set; } = string.Empty;
/// <summary>
/// 境内发货人。
/// </summary>
public string DomesticConsignor { get; set; } = string.Empty;
/// <summary>
/// 境内发货人海关代码。
/// </summary>
public string? DomesticConsignorCustomsCode { get; set; }
/// <summary>
/// 生产销售单位。
/// </summary>
public string ProducerName { get; set; } = string.Empty;
/// <summary>
/// 生产销售单位海关代码。
/// </summary>
public string? ProducerCustomsCode { get; set; }
/// <summary>
/// 备案海关港口地。
/// </summary>
public string? CustomsPort { get; set; }
/// <summary>
/// 是否默认:同客户仅一条为默认。
/// </summary>
public byte IsDefault { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; set; }
}

View File

@ -0,0 +1,17 @@
using Fake.Domain.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
namespace Oms.Domain.CustomersAggregate.Repositories;
public interface ICustomerExportQualificationRepository : IRepository<CustomerExportQualification>
{
Task<CustomerExportQualification?> GetOwnedAsync(
int id,
int customerId,
CancellationToken cancellationToken = default);
Task ClearDefaultAsync(
int customerId,
int? excludeId = null,
CancellationToken cancellationToken = default);
}

View File

@ -1,18 +1,27 @@
namespace Oms.Domain.OrdersAggregate.Consts;
/// <summary>
/// 客户地址类型:发件 / 退件 / 收件。
/// 客户地址类型:国内发件 / 国内退件 / 国内揽收 / 海外收件。
/// </summary>
public static class CustomerAddressTypeCodes
{
/// <summary>国内发件(国际件发件人地址)</summary>
public const string Sender = "sender";
/// <summary>国内退件</summary>
public const string Return = "return";
/// <summary>国内揽收(上门取件地址)</summary>
public const string Pickup = "pickup";
/// <summary>海外收件</summary>
public const string Receiver = "receiver";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{
Sender,
Return,
Pickup,
Receiver
};
@ -21,7 +30,7 @@ public static class CustomerAddressTypeCodes
var code = string.IsNullOrWhiteSpace(addressType) ? Sender : addressType.Trim();
if (!All.Contains(code))
{
throw new ArgumentException("地址类型不合法,仅支持 sender/return/receiver");
throw new ArgumentException("地址类型不合法,仅支持 sender/return/pickup/receiver");
}
return All.First(x => string.Equals(x, code, StringComparison.OrdinalIgnoreCase));
@ -31,10 +40,19 @@ public static class CustomerAddressTypeCodes
{
return addressType?.Trim().ToLowerInvariant() switch
{
Sender => "发件",
Return => "退件",
Receiver => "收件",
Sender => "国内发件",
Return => "国内退件",
Pickup => "国内揽收",
Receiver => "海外收件",
_ => addressType ?? string.Empty
};
}
/// <summary>是否可作为揽收单客户地址(国内揽收;兼容历史发件地址)。</summary>
public static bool IsPickupEligible(string? addressType)
{
var code = string.IsNullOrWhiteSpace(addressType) ? Sender : addressType.Trim();
return string.Equals(code, Pickup, StringComparison.OrdinalIgnoreCase)
|| string.Equals(code, Sender, StringComparison.OrdinalIgnoreCase);
}
}

View File

@ -0,0 +1,39 @@
namespace Oms.Domain.OrdersAggregate.Consts;
/// <summary>
/// 揽收方向:正向(客户地址→仓库)/ 逆向(仓库→客户地址)。
/// </summary>
public static class PickupDirectionCodes
{
/// <summary>正向:客户揽收地址 → 仓库</summary>
public const string Forward = "forward";
/// <summary>逆向:仓库 → 客户揽收地址</summary>
public const string Reverse = "reverse";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{
Forward,
Reverse
};
public static string NormalizeOrDefault(string? direction)
{
var code = string.IsNullOrWhiteSpace(direction) ? Forward : direction.Trim();
if (!All.Contains(code))
{
return Forward;
}
return All.First(x => string.Equals(x, code, StringComparison.OrdinalIgnoreCase));
}
public static string GetDisplayName(string? direction)
{
return NormalizeOrDefault(direction) switch
{
Reverse => "逆向",
_ => "正向"
};
}
}

View File

@ -32,6 +32,11 @@ public class OrderDeclaration : OmsAuditedEntity
public string? ExHsCode { get; set; }
/// <summary>
/// 出口申报要素。
/// </summary>
public string? DeclareElement { get; set; }
public string? SkuImage { get; set; }
public string? Category { get; set; }

View File

@ -24,6 +24,12 @@ public class PickupOrder : OmsAuditedAggregateRoot
public int SenderAddressId { get; set; }
/// <summary>
/// 揽收方向:forward 正向(客户→仓)/ reverse 逆向(仓→客户)。
/// </summary>
[SugarColumn(Length = 16)]
public string Direction { get; set; } = "forward";
[SugarColumn(Length = 50)]
public string SenderName { get; set; } = string.Empty;

View File

@ -17,6 +17,14 @@ public interface IOrderFileRepository : IRepository<OrderFile>
string fileType,
CancellationToken cancellationToken = default);
/// <summary>
/// 按订单 ID 集合 + 附件类型批量查询。
/// </summary>
Task<List<OrderFile>> GetByOrderIdsAndTypeAsync(
IReadOnlyCollection<int> orderIds,
string fileType,
CancellationToken cancellationToken = default);
Task SoftDeleteByOrderAndTypeAsync(
int orderId,
string fileType,

View File

@ -49,4 +49,9 @@ public static class SystemOtherConfigCodes
/// 全局默认面单模板(AppKey=模板 Id,Enabled 控制是否生效)。
/// </summary>
public const string LabelDefaultTemplate = "label_default_template";
/// <summary>
/// API 中心(接口通 / itfx)网关:BaseUrl=itfx 根地址,AppKey/AppSecret=默认应用凭证,TimeoutSeconds=超时。
/// </summary>
public const string ApiCenter = "api_center";
}

View File

@ -22,6 +22,11 @@ public class CSku : OmsAuditedAggregateRoot
public string? ImHsCode { get; set; }
/// <summary>
/// 出口申报要素。
/// </summary>
public string? DeclareElement { get; set; }
public decimal? WeightG { get; set; }
public decimal? LengthCm { get; set; }

View File

@ -2,6 +2,7 @@ using Fake.DependencyInjection;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
using SqlSugar;
namespace Oms.SqlSugarCore.Channels;
@ -11,4 +12,17 @@ public class ServiceChannelGroupRepository : OmsSqlSugarRepository<ServiceChanne
{
return FirstOrDefaultAsync(x => x.Name == name, cancellationToken);
}
public async Task<ServiceChannelGroup?> GetByNameIncludingDeletedAsync(
string name,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var rows = await db.Queryable<ServiceChannelGroup>()
.ClearFilter()
.Where(x => x.Name == name)
.Take(1)
.ToListAsync(cancellationToken);
return rows.FirstOrDefault();
}
}

View File

@ -0,0 +1,44 @@
using Fake.DependencyInjection;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
namespace Oms.SqlSugarCore.Customers;
public class CustomerExportQualificationRepository
: OmsSqlSugarRepository<CustomerExportQualification>,
ICustomerExportQualificationRepository,
IScopedDependency
{
public Task<CustomerExportQualification?> GetOwnedAsync(
int id,
int customerId,
CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken);
}
public async Task ClearDefaultAsync(
int customerId,
int? excludeId = null,
CancellationToken cancellationToken = default)
{
var hasExcludeId = excludeId.HasValue;
var excludeIdValue = excludeId.GetValueOrDefault();
var list = await GetListAsync(
x => x.CustomerId == customerId
&& x.IsDefault == 1
&& (!hasExcludeId || x.Id != excludeIdValue),
cancellationToken: cancellationToken);
if (list.Count == 0)
{
return;
}
foreach (var item in list)
{
item.IsDefault = 0;
await UpdateAsync(item);
}
}
}

View File

@ -31,6 +31,22 @@ public class OrderFileRepository
x => x.OrderId == orderId && x.FileType == fileType,
cancellationToken: cancellationToken);
public Task<List<OrderFile>> GetByOrderIdsAndTypeAsync(
IReadOnlyCollection<int> orderIds,
string fileType,
CancellationToken cancellationToken = default)
{
var ids = orderIds?.Where(x => x > 0).Distinct().ToList() ?? [];
if (ids.Count == 0)
{
return Task.FromResult(new List<OrderFile>());
}
return GetListAsync(
x => ids.Contains(x.OrderId) && x.FileType == fileType,
cancellationToken: cancellationToken);
}
public async Task SoftDeleteByOrderAndTypeAsync(
int orderId,
string fileType,

View File

@ -996,6 +996,7 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
fee.OrderId = orderId;
var existingList = await db.Queryable<OrderFee>()
.Where(x => x.OrderId == orderId
&& !x.IsDeleted
&& x.Type == fee.Type
&& x.CostItem == fee.CostItem
&& x.ChargingStage == fee.ChargingStage)
@ -1004,6 +1005,7 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
var existing = existingList.FirstOrDefault();
if (existing == null)
{
fee.IsDeleted = false;
await db.Insertable(fee).ExecuteCommandAsync(cancellationToken);
continue;
}
@ -1013,6 +1015,7 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
existing.PriceNo = fee.PriceNo;
existing.PriceEffectiveTime = fee.PriceEffectiveTime;
existing.DownstreamProvider = fee.DownstreamProvider;
existing.IsDeleted = false;
existing.UpdateTime = DateTime.Now;
await db.Updateable(existing).ExecuteCommandAsync(cancellationToken);
}
@ -1021,36 +1024,46 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
public async Task DeleteReceivableFeesAsync(int orderId, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
.Where(x => x.OrderId == orderId && x.Type == OrderFeeTypeCodes.Receivable)
await db.Updateable<OrderFee>()
.SetColumns(x => new OrderFee { IsDeleted = true, UpdateTime = DateTime.Now })
.Where(x => x.OrderId == orderId
&& x.Type == OrderFeeTypeCodes.Receivable
&& !x.IsDeleted)
.ExecuteCommandAsync(cancellationToken);
}
public async Task DeleteReceivableFeesByStageAsync(int orderId, string chargingStage, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
await db.Updateable<OrderFee>()
.SetColumns(x => new OrderFee { IsDeleted = true, UpdateTime = DateTime.Now })
.Where(x => x.OrderId == orderId
&& x.Type == OrderFeeTypeCodes.Receivable
&& x.ChargingStage == chargingStage)
&& x.ChargingStage == chargingStage
&& !x.IsDeleted)
.ExecuteCommandAsync(cancellationToken);
}
public async Task DeletePayableFeesAsync(int orderId, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
.Where(x => x.OrderId == orderId && x.Type == OrderFeeTypeCodes.Payable)
await db.Updateable<OrderFee>()
.SetColumns(x => new OrderFee { IsDeleted = true, UpdateTime = DateTime.Now })
.Where(x => x.OrderId == orderId
&& x.Type == OrderFeeTypeCodes.Payable
&& !x.IsDeleted)
.ExecuteCommandAsync(cancellationToken);
}
public async Task DeletePayableFeesByStageAsync(int orderId, string chargingStage, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
await db.Updateable<OrderFee>()
.SetColumns(x => new OrderFee { IsDeleted = true, UpdateTime = DateTime.Now })
.Where(x => x.OrderId == orderId
&& x.Type == OrderFeeTypeCodes.Payable
&& x.ChargingStage == chargingStage)
&& x.ChargingStage == chargingStage
&& !x.IsDeleted)
.ExecuteCommandAsync(cancellationToken);
}
@ -1078,7 +1091,7 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
var db = await GetDbContextAsync(cancellationToken);
return await db.Queryable<OrderFee>()
.Where(x => ids.Contains(x.OrderId))
.Where(x => ids.Contains(x.OrderId) && !x.IsDeleted)
.ToListAsync(cancellationToken);
}

View File

@ -1,8 +1,11 @@
using FluentAssertions;
using Oms.Application.Dtos.Channels;
using Oms.Application.Shared;
using Oms.Application.Services.Orders.Issues;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Tests;
@ -23,6 +26,159 @@ public class AllocationResolverTests
filtered.Select(x => x.ServiceChannelId).Should().BeEquivalentTo([1, 2]);
}
[Fact]
public void FilterProvidersDetailed_EmptyCondition_ShouldPass()
{
var providers = new List<SalesProductAllocationProviderSnapshotDto>
{
new() { SerialNumber = 1, ServiceChannelId = 1, MatchCondition = null },
new() { SerialNumber = 2, ServiceChannelId = 2, MatchCondition = " " }
};
var result = AllocationResolver.FilterProvidersDetailed(providers, 1, Context());
result.Passed.Should().HaveCount(2);
result.Skipped.Should().BeEmpty();
result.PoolCount.Should().Be(2);
result.ConditionPassCount.Should().Be(2);
}
[Fact]
public void FilterProvidersDetailed_CargoAttribute_ShouldFilter()
{
var providers = new List<SalesProductAllocationProviderSnapshotDto>
{
new()
{
SerialNumber = 1,
ServiceChannelId = 1,
ChannelCode = "SENS",
MatchCondition = "货物属性包含('sensitive')"
},
new()
{
SerialNumber = 2,
ServiceChannelId = 2,
ChannelCode = "GEN",
MatchCondition = "货物属性包含('general')"
}
};
var result = AllocationResolver.FilterProvidersDetailed(
providers,
customerId: null,
Context(cargoAttribute: "sensitive"));
result.Passed.Should().HaveCount(1);
result.Passed[0].ServiceChannelId.Should().Be(1);
result.Skipped.Should().HaveCount(1);
result.Skipped[0].Stage.Should().Be(AllocationResolver.SkipStageCondition);
result.Skipped[0].Reason.Should().Be("判断条件未命中");
result.Skipped[0].MatchCondition.Should().Contain("货物属性包含");
}
[Fact]
public void FilterProvidersDetailed_CustomerContains_ShouldUseCustomerEntity()
{
var providers = new List<SalesProductAllocationProviderSnapshotDto>
{
new()
{
SerialNumber = 1,
ServiceChannelId = 1,
MatchCondition = "客户包含('阿里')"
},
new()
{
SerialNumber = 2,
ServiceChannelId = 2,
MatchCondition = "客户包含('京东')"
}
};
var result = AllocationResolver.FilterProvidersDetailed(
providers,
customerId: 9,
Context(customerName: "阿里国际站"));
result.Passed.Should().HaveCount(1);
result.Passed[0].ServiceChannelId.Should().Be(1);
result.Skipped[0].Reason.Should().Be("判断条件未命中");
}
[Fact]
public void FilterProvidersDetailed_InvalidFormula_ShouldSkip()
{
var providers = new List<SalesProductAllocationProviderSnapshotDto>
{
new()
{
SerialNumber = 1,
ServiceChannelId = 1,
MatchCondition = "实重()>"
}
};
var result = AllocationResolver.FilterProvidersDetailed(providers, null, Context());
result.Passed.Should().BeEmpty();
result.Skipped.Should().HaveCount(1);
result.Skipped[0].Reason.Should().Be("判断条件公式无效");
result.PoolCount.Should().Be(1);
result.ConditionPassCount.Should().Be(0);
}
[Fact]
public void FilterProvidersDetailed_LegacyCustomerIds_WhenNoFormula()
{
var providers = new List<SalesProductAllocationProviderSnapshotDto>
{
new() { SerialNumber = 1, ServiceChannelId = 1, CustomerIds = [10] },
new() { SerialNumber = 2, ServiceChannelId = 2, CustomerIds = [99] }
};
var result = AllocationResolver.FilterProvidersDetailed(providers, 10, Context());
result.Passed.Should().HaveCount(1);
result.Passed[0].ServiceChannelId.Should().Be(1);
result.Skipped[0].Stage.Should().Be(AllocationResolver.SkipStageCustomer);
result.Skipped[0].Reason.Should().Be("客户不在名单内");
}
[Fact]
public void FilterProvidersDetailed_MatchCondition_IgnoresLegacyCustomerIds()
{
var providers = new List<SalesProductAllocationProviderSnapshotDto>
{
new()
{
SerialNumber = 1,
ServiceChannelId = 1,
CustomerIds = [99],
MatchCondition = "货物属性包含('general')"
}
};
var result = AllocationResolver.FilterProvidersDetailed(
providers,
customerId: 1,
Context(cargoAttribute: "general"));
result.Passed.Should().HaveCount(1);
}
[Fact]
public void FilterProvidersDetailed_DeduplicateSameChannel_KeepsHigherWeight()
{
var providers = new List<SalesProductAllocationProviderSnapshotDto>
{
new() { SerialNumber = 1, ServiceChannelId = 7, Weight = 10, MatchCondition = "" },
new() { SerialNumber = 2, ServiceChannelId = 7, Weight = 30, MatchCondition = "" }
};
var result = AllocationResolver.FilterProvidersDetailed(providers, null, Context());
result.ConditionPassCount.Should().Be(2);
result.Passed.Should().HaveCount(1);
result.Passed[0].Weight.Should().Be(30);
}
[Fact]
public void SelectChannel_CostLowest_ShouldPickCheapest()
{
@ -56,6 +212,20 @@ public class AllocationResolverTests
channel.Id.Should().Be(2);
}
private static OrderRuleEvaluationContext Context(
string cargoAttribute = "general",
string? customerName = null) =>
OrderRuleEvaluationContext.FromOrder(
new Order { CargoAttribute = cargoAttribute },
string.IsNullOrWhiteSpace(customerName)
? null
: new Customer { CustomerName = customerName },
null,
null,
null,
null,
null);
private static List<AllocationResolver.ChannelCandidate> BuildCandidates(
params (int channelId, decimal cost, int minTime, int weight)[] rows)
{

View File

@ -6,6 +6,8 @@ using Moq;
using Oms.Application.Dtos.Orders;
using Oms.Application.Messaging;
using Oms.Application.Services.Orders.Batch;
using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
@ -18,6 +20,8 @@ public class OrderBatchJobCoordinatorGovernanceTests
private readonly Mock<IOrderBatchJobRepository> _batchJobRepository = new(MockBehavior.Strict);
private readonly Mock<IOrderRepository> _orderRepository = new(MockBehavior.Loose);
private readonly Mock<ICountryRepository> _countryRepository = new(MockBehavior.Loose);
private readonly Mock<IServiceChannelRepository> _serviceChannelRepository = new(MockBehavior.Loose);
private readonly Mock<IFileStorage> _fileStorage = new(MockBehavior.Loose);
private readonly Mock<IOrderBatchJobPublisher> _publisher = new(MockBehavior.Strict);
private readonly OrderBatchJobCoordinator _coordinator;
@ -36,6 +40,8 @@ public class OrderBatchJobCoordinatorGovernanceTests
_batchJobRepository.Object,
_orderRepository.Object,
_countryRepository.Object,
_serviceChannelRepository.Object,
_fileStorage.Object,
_publisher.Object,
processor,
batchOptions,