diff --git a/.gitignore b/.gitignore
index 02f9e1c..e72706e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,8 +9,13 @@ riderModule.iml
.idea/
/.vs
/Fake.sln.DotSettings.user
+*.DotSettings.user
+SimpleAdmin.sln.DotSettings.user
Logs/
+*.log
+api-*.log
+build-warnings.txt
node_modules/
diff --git a/SimpleAdmin.sln.DotSettings.user b/SimpleAdmin.sln.DotSettings.user
deleted file mode 100644
index 39396e3..0000000
--- a/SimpleAdmin.sln.DotSettings.user
+++ /dev/null
@@ -1,25 +0,0 @@
-
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from <src>\<SimpleAdmin.Api.IntegrationTests>" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
- <Project Location="D:\my\itfx\admin\src\SimpleAdmin.Api.IntegrationTests" Presentation="<src>\<SimpleAdmin.Api.IntegrationTests>" />
-</SessionState>
\ No newline at end of file
diff --git a/sql/fix_concurrency_stamp_missing.sql b/sql/fix_concurrency_stamp_missing.sql
new file mode 100644
index 0000000..6658e5a
--- /dev/null
+++ b/sql/fix_concurrency_stamp_missing.sql
@@ -0,0 +1,63 @@
+-- 给缺少 ConcurrencyStamp 的 OMS 表补列(可重复执行)
+-- Fake AggregateRoot 自带该字段,SqlSugar 查询会带上;库表没有就会报:
+-- Unknown column 'ConcurrencyStamp' in 'field list'
+--
+-- 注意:不要用 DEFAULT (UUID())。在 STATEMENT/MIXED 复制下会触发:
+-- 1674 Statement is unsafe because it uses a system function that may return a different value on the replica.
+-- 默认用固定 GUID;插入/更新时由 ORM 写入真实 UUID。
+--
+-- 用法:在业务库执行本脚本;若仍报错,把报错 SQL 里的表名追加到下方列表。
+
+SET @tables := 't_order_stock_bind,C_inventory,C_parcel,C_parcel_item,C_parcel_arrival,C_sku,C_bin_location,C_inbound_notice,C_inbound_box,C_inbound_box_line,C_sku_platform_map,t_sales_store,t_sales_store_order,t_sales_store_order_line,t_customer_sender_address';
+
+DROP PROCEDURE IF EXISTS sp_add_concurrency_stamp_if_missing;
+DELIMITER $$
+CREATE PROCEDURE sp_add_concurrency_stamp_if_missing()
+BEGIN
+ DECLARE done INT DEFAULT 0;
+ DECLARE tname VARCHAR(128);
+ DECLARE cur CURSOR FOR
+ SELECT TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(@tables, ',', n.n), ',', -1)) AS t
+ FROM (
+ SELECT a.N + b.N * 10 + 1 AS n
+ FROM
+ (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a,
+ (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b
+ ) n
+ WHERE n.n <= 1 + (LENGTH(@tables) - LENGTH(REPLACE(@tables, ',', '')));
+ DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
+
+ OPEN cur;
+ read_loop: LOOP
+ FETCH cur INTO tname;
+ IF done THEN
+ LEAVE read_loop;
+ END IF;
+ IF tname IS NULL OR tname = '' THEN
+ ITERATE read_loop;
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = tname
+ ) THEN
+ ITERATE read_loop;
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = tname AND COLUMN_NAME = 'ConcurrencyStamp'
+ ) THEN
+ SET @ddl := CONCAT(
+ 'ALTER TABLE `', tname,
+ '` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT ''00000000-0000-0000-0000-000000000000'' COMMENT ''并发戳'''
+ );
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+ END LOOP;
+ CLOSE cur;
+END$$
+DELIMITER ;
+
+CALL sp_add_concurrency_stamp_if_missing();
+DROP PROCEDURE IF EXISTS sp_add_concurrency_stamp_if_missing;
diff --git a/sql/migrate_label_pdf_fields.sql b/sql/migrate_label_pdf_fields.sql
new file mode 100644
index 0000000..c7f736a
--- /dev/null
+++ b/sql/migrate_label_pdf_fields.sql
@@ -0,0 +1,87 @@
+-- 已有库增量:订单面单 PDF 字段 + 销售产品 LabelTemplateId
+-- 对应:docs/面单标签模板与PDF生成方案.md §4.2 / §4.3
+-- 可重复执行
+
+-- ========== t_order ==========
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'LabelUrl'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `t_order` ADD COLUMN `LabelUrl` VARCHAR(500) NULL COMMENT ''面单PDF相对路径'' AFTER `IsRemote`',
+ 'SELECT ''t_order.LabelUrl already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'LabelTemplateId'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `t_order` ADD COLUMN `LabelTemplateId` INT NULL COMMENT ''面单模板Id'' AFTER `LabelUrl`',
+ 'SELECT ''t_order.LabelTemplateId already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'LabelGenerateStatus'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `t_order` ADD COLUMN `LabelGenerateStatus` VARCHAR(32) NULL COMMENT ''Pending/Ready/Failed'' AFTER `LabelTemplateId`',
+ 'SELECT ''t_order.LabelGenerateStatus already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'LabelGenerateError'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `t_order` ADD COLUMN `LabelGenerateError` VARCHAR(500) NULL COMMENT ''面单生成失败原因'' AFTER `LabelGenerateStatus`',
+ 'SELECT ''t_order.LabelGenerateError already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @idx_exists := (
+ SELECT COUNT(1) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_order' AND INDEX_NAME = 'idx_ord_label_status'
+);
+SET @sql := IF(
+ @idx_exists = 0,
+ 'ALTER TABLE `t_order` ADD KEY `idx_ord_label_status` (`LabelGenerateStatus`)',
+ 'SELECT ''idx_ord_label_status already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ========== t_sales_product ==========
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_sales_product' AND COLUMN_NAME = 'LabelTemplateId'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `t_sales_product` ADD COLUMN `LabelTemplateId` INT NULL COMMENT ''客户面单模板Id'' AFTER `CustomerWaybill`',
+ 'SELECT ''t_sales_product.LabelTemplateId already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ========== 全局默认模板配置种子(AppKey 填模板 Id,Enabled=0 默认关闭)==========
+INSERT INTO `t_system_other_config`
+ (`ConfigCode`, `ConfigName`, `Enabled`, `AppKey`, `TimeoutSeconds`, `Remark`, `CreateTime`)
+SELECT
+ 'label_default_template',
+ '全局默认面单模板',
+ 0,
+ NULL,
+ 30,
+ 'AppKey=标签模板Id;启用后产品未绑模板时回落此 Id',
+ NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_system_other_config` WHERE `ConfigCode` = 'label_default_template'
+);
diff --git a/sql/migrate_t_order_stock_bind.sql b/sql/migrate_t_order_stock_bind.sql
new file mode 100644
index 0000000..3df34ca
--- /dev/null
+++ b/sql/migrate_t_order_stock_bind.sql
@@ -0,0 +1,106 @@
+-- 物流单绑定包裹/库存:t_order_stock_bind + C_inventory/C_parcel_item 冻结与可用字段
+-- 见 oms系统设计-050801.md §4.2.11 / §4.4.6 / §3.1.2
+-- ConcurrencyStamp 默认用固定 GUID,避免 DEFAULT (UUID()) 触发复制 1674
+-- 可重复执行
+
+CREATE TABLE IF NOT EXISTS `t_order_stock_bind` (
+ `Id` INT NOT NULL AUTO_INCREMENT,
+ `OrderId` INT NOT NULL COMMENT '物流单 t_order.Id',
+ `CustomerId` INT NOT NULL COMMENT '客户隔离键',
+ `WarehouseId` INT NOT NULL COMMENT '仓库',
+ `BindType` VARCHAR(20) NOT NULL COMMENT 'inventory / parcelItem',
+ `SkuId` INT NULL COMMENT 'SKU',
+ `InventoryId` INT NULL COMMENT 'C_inventory.Id,可空=按SKU分摊',
+ `BinLocationId` INT NULL COMMENT '库位快照',
+ `ParcelId` INT NULL COMMENT 'C_parcel.Id',
+ `ParcelItemId` INT NULL COMMENT 'C_parcel_item.Id',
+ `BindQty` INT NOT NULL COMMENT '绑定数量',
+ `Status` VARCHAR(20) NOT NULL DEFAULT 'bound' COMMENT 'bound/deducted/released',
+ `SkuCode` VARCHAR(64) NULL,
+ `SkuName` VARCHAR(128) NULL,
+ `TrackingNo` VARCHAR(64) NULL,
+ `ProductName` VARCHAR(256) NULL,
+ `ImageUrl` VARCHAR(512) NULL,
+ `AttributeText` VARCHAR(256) NULL,
+ `Remark` VARCHAR(512) NULL,
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0,
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000' COMMENT '并发戳',
+ `CreateUserId` INT NOT NULL,
+ `CreateTime` DATETIME NOT NULL,
+ `UpdateUserId` INT NULL,
+ `UpdateTime` DATETIME NULL,
+ PRIMARY KEY (`Id`),
+ KEY `idx_osb_order` (`OrderId`, `IsDeleted`, `Status`),
+ KEY `idx_osb_parcel_item` (`ParcelItemId`, `Status`, `IsDeleted`),
+ KEY `idx_osb_sku` (`CustomerId`, `WarehouseId`, `SkuId`, `Status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物流单仓储绑定明细';
+
+-- 已建表但缺 ConcurrencyStamp 时补列(可重复执行)
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_order_stock_bind' AND COLUMN_NAME = 'ConcurrencyStamp'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `t_order_stock_bind` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT ''00000000-0000-0000-0000-000000000000'' COMMENT ''并发戳'' AFTER `IsDeleted`',
+ 'SELECT ''t_order_stock_bind.ConcurrencyStamp already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- C_inventory.FrozenQty
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'C_inventory' AND COLUMN_NAME = 'FrozenQty'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `C_inventory` ADD COLUMN `FrozenQty` INT NOT NULL DEFAULT 0 COMMENT ''冻结数量'' AFTER `Qty`',
+ 'SELECT ''C_inventory.FrozenQty already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- C_inventory.AvailableQty
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'C_inventory' AND COLUMN_NAME = 'AvailableQty'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `C_inventory` ADD COLUMN `AvailableQty` INT NOT NULL DEFAULT 0 COMMENT ''可用=Qty-FrozenQty'' AFTER `FrozenQty`',
+ 'SELECT ''C_inventory.AvailableQty already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+UPDATE `C_inventory`
+SET `AvailableQty` = GREATEST(0, `Qty` - IFNULL(`FrozenQty`, 0))
+WHERE `AvailableQty` <> GREATEST(0, `Qty` - IFNULL(`FrozenQty`, 0))
+ OR (`AvailableQty` = 0 AND `Qty` > 0 AND IFNULL(`FrozenQty`, 0) = 0);
+
+-- C_parcel_item.FrozenQty
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'C_parcel_item' AND COLUMN_NAME = 'FrozenQty'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `C_parcel_item` ADD COLUMN `FrozenQty` INT NOT NULL DEFAULT 0 COMMENT ''冻结数量'' AFTER `Quantity`',
+ 'SELECT ''C_parcel_item.FrozenQty already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- C_parcel_item.AvailableQty
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'C_parcel_item' AND COLUMN_NAME = 'AvailableQty'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `C_parcel_item` ADD COLUMN `AvailableQty` INT NOT NULL DEFAULT 0 COMMENT ''可用=Quantity-FrozenQty'' AFTER `FrozenQty`',
+ 'SELECT ''C_parcel_item.AvailableQty already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+UPDATE `C_parcel_item`
+SET `AvailableQty` = GREATEST(0, `Quantity` - IFNULL(`FrozenQty`, 0))
+WHERE `AvailableQty` <> GREATEST(0, `Quantity` - IFNULL(`FrozenQty`, 0))
+ OR (`AvailableQty` = 0 AND `Quantity` > 0 AND IFNULL(`FrozenQty`, 0) = 0);
diff --git a/src/Oms.Application/Dtos/Channels/LabelTemplateListItemOutput.cs b/src/Oms.Application/Dtos/Channels/LabelTemplateListItemOutput.cs
new file mode 100644
index 0000000..10c393e
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/LabelTemplateListItemOutput.cs
@@ -0,0 +1,17 @@
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 标签模板列表项(不含 TemplateJson,列表更轻)。
+///
+public class LabelTemplateListItemOutput
+{
+ public int Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public string? TemplateType { get; set; }
+
+ public DateTime CreateTime { get; set; }
+
+ public DateTime? UpdateTime { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Channels/LabelTemplatePreviewInput.cs b/src/Oms.Application/Dtos/Channels/LabelTemplatePreviewInput.cs
new file mode 100644
index 0000000..112714e
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/LabelTemplatePreviewInput.cs
@@ -0,0 +1,16 @@
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 标签模板预览入参。
+///
+public class LabelTemplatePreviewInput
+{
+ /// 已保存模板 Id(与 TemplateJson 二选一,优先 Id)。
+ public int? TemplateId { get; set; }
+
+ /// 未保存时可直接传设计器 JSON。
+ public object? TemplateJson { get; set; }
+
+ /// 可选 PrintData;空则用 Mock。
+ public Dictionary? PrintData { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs
index 3bbedf3..e91886b 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs
@@ -40,6 +40,11 @@ public class SalesProductDetailOutput
///
public string? CustomerWaybill { get; set; }
+ ///
+ /// 客户面单绑定的标签模板 Id。
+ ///
+ public int? LabelTemplateId { get; set; }
+
///
/// 黑名单分区主键。
///
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
index 9b16759..8ac24f0 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
@@ -40,6 +40,11 @@ public class SalesProductListOutput
///
public string? CustomerWaybill { get; set; }
+ ///
+ /// 客户面单绑定的标签模板 Id。
+ ///
+ public int? LabelTemplateId { get; set; }
+
///
/// 黑名单分区主键。
///
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs
index 9ce59ed..65b1aa6 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs
@@ -38,10 +38,15 @@ public class SalesProductSaveInput
public int? MaxTimeliness { get; set; }
///
- /// 客户面单类型。
+ /// 客户面单类型(旧枚举,兼容展示)。
///
public string? CustomerWaybill { get; set; }
+ ///
+ /// 客户面单绑定的标签模板 Id。
+ ///
+ public int? LabelTemplateId { get; set; }
+
///
/// 黑名单分区主键。
///
diff --git a/src/Oms.Application/Dtos/Operations/LabelPrintRecordOutput.cs b/src/Oms.Application/Dtos/Operations/LabelPrintRecordOutput.cs
index 5acd01e..5cd1302 100644
--- a/src/Oms.Application/Dtos/Operations/LabelPrintRecordOutput.cs
+++ b/src/Oms.Application/Dtos/Operations/LabelPrintRecordOutput.cs
@@ -62,6 +62,11 @@ public class LabelPrintRecordOutput
///
public string? OperatorName { get; set; }
+ ///
+ /// 面单 PDF 地址。
+ ///
+ public string? PrintUrl { get; set; }
+
///
/// 创建时间。
///
diff --git a/src/Oms.Application/Dtos/Operations/PrintLabelLookupOutput.cs b/src/Oms.Application/Dtos/Operations/PrintLabelLookupOutput.cs
new file mode 100644
index 0000000..be30c83
--- /dev/null
+++ b/src/Oms.Application/Dtos/Operations/PrintLabelLookupOutput.cs
@@ -0,0 +1,109 @@
+namespace Oms.Application.Dtos.Operations;
+
+///
+/// 打印标签扫码查单入参(Query)。
+///
+public class PrintLabelLookupInput
+{
+ /// 扫描号码。
+ public string Number { get; set; } = string.Empty;
+
+ ///
+ /// 号码类型:auto / waybill / customerOrder / tracking / other / parcel / sku。
+ /// 空视为 auto。
+ ///
+ public string? NumberType { get; set; }
+}
+
+///
+/// 打印标签查单结果:订单头 + 绑定明细,供操作员核对后打印。
+///
+public class PrintLabelLookupOutput
+{
+ /// 是否唯一命中订单。
+ public bool Matched { get; set; }
+
+ /// 实际命中的号码类型。
+ public string? MatchedNumberType { get; set; }
+
+ /// 告警:noneBind / multiMatch / alreadyPrinted 等。
+ public List Warnings { get; set; } = [];
+
+ public int? OrderId { get; set; }
+
+ public string? ServiceNo { get; set; }
+
+ public string? ClientNo { get; set; }
+
+ public string? TrackNo { get; set; }
+
+ public string? ProductCode { get; set; }
+
+ public string? SalesProductName { get; set; }
+
+ public string? DestinationCountry { get; set; }
+
+ public string? DestinationCountryName { get; set; }
+
+ public string? OrderStatus { get; set; }
+
+ public string? OrderStatusName { get; set; }
+
+ public int? WarehouseId { get; set; }
+
+ public string? WarehouseName { get; set; }
+
+ /// none / bound / deducted / released
+ public string BindStatus { get; set; } = "none";
+
+ /// 绑定件数合计。
+ public int BindQtyTotal { get; set; }
+
+ /// 绑定行数。
+ public int BindLineCount { get; set; }
+
+ public List BindItems { get; set; } = [];
+
+ /// SKU/模糊扫到多单时的候选列表。
+ public List Candidates { get; set; } = [];
+}
+
+public class PrintLabelBindItemOutput
+{
+ public string BindType { get; set; } = string.Empty;
+
+ public string? SkuCode { get; set; }
+
+ public string? SkuName { get; set; }
+
+ public string? TrackingNo { get; set; }
+
+ public string? ProductName { get; set; }
+
+ public string? AttributeText { get; set; }
+
+ public string? ImageUrl { get; set; }
+
+ public string? BinCode { get; set; }
+
+ public int BindQty { get; set; }
+
+ public string Status { get; set; } = string.Empty;
+}
+
+public class PrintLabelLookupCandidateOutput
+{
+ public int OrderId { get; set; }
+
+ public string? ServiceNo { get; set; }
+
+ public string? ClientNo { get; set; }
+
+ public string? TrackNo { get; set; }
+
+ public string? OrderStatusName { get; set; }
+
+ public int BindQtyTotal { get; set; }
+
+ public string? MatchedHint { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
index 6e375d6..2ac4836 100644
--- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
@@ -95,6 +95,16 @@ public class OrderOutput
///
public int? CustomerId { get; set; }
+ ///
+ /// 客户名称。
+ ///
+ public string? CustomerName { get; set; }
+
+ ///
+ /// 客户编码。
+ ///
+ public string? CustomerCode { get; set; }
+
///
/// 订单来源。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderPrintOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPrintOutput.cs
index 268c93f..95f2988 100644
--- a/src/Oms.Application/Dtos/Orders/OrderPrintOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderPrintOutput.cs
@@ -6,7 +6,17 @@ namespace Oms.Application.Dtos.Orders;
public class OrderPrintOutput
{
///
- /// 打印地址。
+ /// 打印地址(PDF)。
///
public string PrintUrl { get; set; } = string.Empty;
+
+ ///
+ /// 面单生成状态:Pending / Ready / Failed。
+ ///
+ public string? LabelGenerateStatus { get; set; }
+
+ ///
+ /// 面单生成失败原因。
+ ///
+ public string? LabelGenerateError { get; set; }
}
diff --git a/src/Oms.Application/Dtos/Orders/OrderStockBindDtos.cs b/src/Oms.Application/Dtos/Orders/OrderStockBindDtos.cs
new file mode 100644
index 0000000..0d02cde
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderStockBindDtos.cs
@@ -0,0 +1,159 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Orders;
+
+public class OrderStockBindDetailOutput
+{
+ public int OrderId { get; set; }
+
+ public int? WarehouseId { get; set; }
+
+ public string? WarehouseName { get; set; }
+
+ /// none / bound / deducted
+ public string BindStatus { get; set; } = "none";
+
+ public List Items { get; set; } = [];
+}
+
+public class OrderStockBindItemOutput
+{
+ public int Id { get; set; }
+
+ public string BindType { get; set; } = string.Empty;
+
+ public int? SkuId { get; set; }
+
+ public string? SkuCode { get; set; }
+
+ public string? SkuName { get; set; }
+
+ public int? InventoryId { get; set; }
+
+ public int? BinLocationId { get; set; }
+
+ public string? BinCode { get; set; }
+
+ public int? ParcelId { get; set; }
+
+ public int? ParcelItemId { get; set; }
+
+ public string? TrackingNo { get; set; }
+
+ public string? ProductName { get; set; }
+
+ public string? AttributeText { get; set; }
+
+ public string? ImageUrl { get; set; }
+
+ public int BindQty { get; set; }
+
+ public int AvailableQty { get; set; }
+
+ public string Status { get; set; } = string.Empty;
+}
+
+public class OrderStockBindCandidatesInput
+{
+ [Required]
+ public int OrderId { get; set; }
+
+ public int? WarehouseId { get; set; }
+
+ public string? Keyword { get; set; }
+
+ /// 包裹候选:空/all=全部生命周期。
+ public string? LifecycleStatus { get; set; }
+
+ public int Page { get; set; } = 1;
+
+ public int PageSize { get; set; } = 30;
+}
+
+public class OrderStockBindInventoryCandidateOutput
+{
+ public int SkuId { get; set; }
+
+ public string SkuCode { get; set; } = string.Empty;
+
+ public string SkuName { get; set; } = string.Empty;
+
+ public string? SkuImageUrl { get; set; }
+
+ public int WarehouseId { get; set; }
+
+ public int TotalQty { get; set; }
+
+ public int FrozenQty { get; set; }
+
+ public int AvailableQty { get; set; }
+
+ public int BinCount { get; set; }
+}
+
+public class OrderStockBindParcelCandidateOutput
+{
+ public int ParcelId { get; set; }
+
+ public string TrackingNo { get; set; } = string.Empty;
+
+ public string? CarrierName { get; set; }
+
+ public string LifecycleStatus { get; set; } = string.Empty;
+
+ public int WarehouseId { get; set; }
+
+ public List Items { get; set; } = [];
+}
+
+public class OrderStockBindParcelItemCandidateOutput
+{
+ public int ParcelItemId { get; set; }
+
+ public string? ProductName { get; set; }
+
+ public string? AttributeText { get; set; }
+
+ public string? ImageUrl { get; set; }
+
+ public int? SkuId { get; set; }
+
+ public string? SkuCode { get; set; }
+
+ public int Quantity { get; set; }
+
+ public int FrozenQty { get; set; }
+
+ public int AvailableQty { get; set; }
+}
+
+public class OrderStockBindSaveInput
+{
+ [Required]
+ public int OrderId { get; set; }
+
+ public List Items { get; set; } = [];
+}
+
+public class OrderStockBindSaveItemInput
+{
+ [Required]
+ public string BindType { get; set; } = string.Empty;
+
+ public int? SkuId { get; set; }
+
+ public int? InventoryId { get; set; }
+
+ public int? ParcelId { get; set; }
+
+ public int? ParcelItemId { get; set; }
+
+ [Range(1, int.MaxValue)]
+ public int BindQty { get; set; }
+}
+
+public class OrderStockBindOrderInput
+{
+ [Required]
+ public int OrderId { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs
index 16a0f34..2cc2f38 100644
--- a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs
+++ b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs
@@ -24,6 +24,10 @@ public class PlatformStoreOutput
public int CustomerId { get; set; }
+ public string? CustomerName { get; set; }
+
+ public string? CustomerCode { get; set; }
+
public string PlatformCode { get; set; } = string.Empty;
public string PlatformName { get; set; } = string.Empty;
@@ -186,6 +190,10 @@ public class PlatformStoreOrderOutput
public int CustomerId { get; set; }
+ public string? CustomerName { get; set; }
+
+ public string? CustomerCode { get; set; }
+
public int StoreId { get; set; }
public string StoreNickname { get; set; } = string.Empty;
diff --git a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs
index 490478e..5b0e84f 100644
--- a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs
+++ b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs
@@ -24,6 +24,10 @@ public class WarehouseSkuOutput
public int CustomerId { get; set; }
+ public string? CustomerName { get; set; }
+
+ public string? CustomerCode { get; set; }
+
public string SkuCode { get; set; } = string.Empty;
public string SkuName { get; set; } = string.Empty;
@@ -166,6 +170,10 @@ public class WarehouseInboundOutput
public int CustomerId { get; set; }
+ public string? CustomerName { get; set; }
+
+ public string? CustomerCode { get; set; }
+
public int WarehouseId { get; set; }
public string NoticeNo { get; set; } = string.Empty;
@@ -354,6 +362,10 @@ public class WarehouseParcelItemOutput
public int Quantity { get; set; }
+ public int FrozenQty { get; set; }
+
+ public int AvailableQty { get; set; }
+
public int? SkuId { get; set; }
public string? SkuCode { get; set; }
@@ -369,6 +381,10 @@ public class WarehouseParcelOutput
public int CustomerId { get; set; }
+ public string? CustomerName { get; set; }
+
+ public string? CustomerCode { get; set; }
+
public int WarehouseId { get; set; }
public string TrackingNo { get; set; } = string.Empty;
@@ -381,6 +397,15 @@ public class WarehouseParcelOutput
public string? Remark { get; set; }
+ /// 商品总数量。
+ public int TotalQty { get; set; }
+
+ /// 已冻结数量(订单绑定占用)。
+ public int FrozenQty { get; set; }
+
+ /// 可用数量 = 总量 - 冻结。
+ public int AvailableQty { get; set; }
+
public List Items { get; set; } = [];
public DateTime CreateTime { get; set; }
@@ -437,6 +462,12 @@ public class WarehouseInventoryListInput : WarehousePageInput
public class WarehouseInventoryOutput
{
+ public int CustomerId { get; set; }
+
+ public string? CustomerName { get; set; }
+
+ public string? CustomerCode { get; set; }
+
public int SkuId { get; set; }
public string SkuCode { get; set; } = string.Empty;
@@ -447,6 +478,10 @@ public class WarehouseInventoryOutput
public int TotalQty { get; set; }
+ public int FrozenQty { get; set; }
+
+ public int AvailableQty { get; set; }
+
public int BinCount { get; set; }
}
@@ -461,6 +496,10 @@ public class WarehouseInventoryBinDetailOutput
public string? BinName { get; set; }
public int Qty { get; set; }
+
+ public int FrozenQty { get; set; }
+
+ public int AvailableQty { get; set; }
}
public class WarehouseInventoryRelocateInput
@@ -483,6 +522,36 @@ public class WarehouseInventoryRelocateInput
public int Qty { get; set; }
}
+public class WarehouseInventoryHistoryInput : WarehousePageInput
+{
+ [Required]
+ public int SkuId { get; set; }
+
+ [Required]
+ public int WarehouseId { get; set; }
+
+ ///
+ /// 流水类型:all / inbound / deduct;空视为 all。
+ ///
+ public string? MovementType { get; set; }
+}
+
+public class WarehouseInventoryHistoryOutput
+{
+ /// inbound 入库 / deduct 扣减。
+ public string MovementType { get; set; } = string.Empty;
+
+ /// 变动数量(入库为正,扣减为正数,前端按类型展示符号)。
+ public int Qty { get; set; }
+
+ /// 关联单号:入库预报号 / 物流单号。
+ public string? RefNo { get; set; }
+
+ public string? Remark { get; set; }
+
+ public DateTime OccurredAt { get; set; }
+}
+
// --- Parcel arrival(快递到仓)---
public class WarehouseParcelArrivalListInput : WarehousePageInput
diff --git a/src/Oms.Application/Labeling/Hiprint/BarcodeImageGenerator.cs b/src/Oms.Application/Labeling/Hiprint/BarcodeImageGenerator.cs
new file mode 100644
index 0000000..49b11a9
--- /dev/null
+++ b/src/Oms.Application/Labeling/Hiprint/BarcodeImageGenerator.cs
@@ -0,0 +1,37 @@
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Formats.Png;
+using SixLabors.ImageSharp.PixelFormats;
+using ZXing;
+using ZXing.Common;
+using ZXingImageSharp = ZXing.ImageSharp;
+
+namespace Oms.Application.Labeling.Hiprint;
+
+///
+/// 条码 / 二维码 PNG 生成。
+///
+public static class BarcodeImageGenerator
+{
+ public static byte[] CreateBarcodePng(string content, int widthPx, int heightPx, bool qrCode)
+ {
+ widthPx = Math.Clamp(widthPx, 40, 1200);
+ heightPx = Math.Clamp(heightPx, 40, 1200);
+
+ var writer = new ZXingImageSharp.BarcodeWriter
+ {
+ Format = qrCode ? BarcodeFormat.QR_CODE : BarcodeFormat.CODE_128,
+ Options = new EncodingOptions
+ {
+ Width = widthPx,
+ Height = heightPx,
+ Margin = 1,
+ PureBarcode = !qrCode
+ }
+ };
+
+ using var image = writer.Write(content);
+ using var ms = new MemoryStream();
+ image.Save(ms, new PngEncoder());
+ return ms.ToArray();
+ }
+}
diff --git a/src/Oms.Application/Labeling/Hiprint/HiprintModels.cs b/src/Oms.Application/Labeling/Hiprint/HiprintModels.cs
new file mode 100644
index 0000000..50c401b
--- /dev/null
+++ b/src/Oms.Application/Labeling/Hiprint/HiprintModels.cs
@@ -0,0 +1,95 @@
+using System.Text.Json.Serialization;
+
+namespace Oms.Application.Labeling.Hiprint;
+
+///
+/// vg-print / hiprint 导出的模板根节点(只映射渲染所需字段)。
+///
+public sealed class HiprintTemplateDocument
+{
+ [JsonPropertyName("panels")]
+ public List Panels { get; set; } = [];
+}
+
+public sealed class HiprintPanel
+{
+ [JsonPropertyName("index")]
+ public int Index { get; set; }
+
+ /// 纸宽,单位 mm。
+ [JsonPropertyName("width")]
+ public float Width { get; set; } = 100f;
+
+ /// 纸高,单位 mm。
+ [JsonPropertyName("height")]
+ public float Height { get; set; } = 150f;
+
+ [JsonPropertyName("printElements")]
+ public List PrintElements { get; set; } = [];
+}
+
+public sealed class HiprintPrintElement
+{
+ [JsonPropertyName("options")]
+ public HiprintElementOptions Options { get; set; } = new();
+
+ [JsonPropertyName("printElementType")]
+ public HiprintElementType? PrintElementType { get; set; }
+}
+
+public sealed class HiprintElementType
+{
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+}
+
+public sealed class HiprintElementOptions
+{
+ [JsonPropertyName("left")]
+ public float Left { get; set; }
+
+ [JsonPropertyName("top")]
+ public float Top { get; set; }
+
+ [JsonPropertyName("width")]
+ public float Width { get; set; }
+
+ [JsonPropertyName("height")]
+ public float Height { get; set; }
+
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ [JsonPropertyName("field")]
+ public string? Field { get; set; }
+
+ [JsonPropertyName("fontSize")]
+ public float? FontSize { get; set; }
+
+ [JsonPropertyName("fontWeight")]
+ public string? FontWeight { get; set; }
+
+ [JsonPropertyName("fontFamily")]
+ public string? FontFamily { get; set; }
+
+ [JsonPropertyName("color")]
+ public string? Color { get; set; }
+
+ [JsonPropertyName("textAlign")]
+ public string? TextAlign { get; set; }
+
+ [JsonPropertyName("textType")]
+ public string? TextType { get; set; }
+
+ [JsonPropertyName("hideTitle")]
+ public bool? HideTitle { get; set; }
+
+ [JsonPropertyName("src")]
+ public string? Src { get; set; }
+
+ [JsonPropertyName("borderWidth")]
+ public float? BorderWidth { get; set; }
+}
diff --git a/src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs b/src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs
new file mode 100644
index 0000000..ea2e5ec
--- /dev/null
+++ b/src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs
@@ -0,0 +1,201 @@
+using System.Globalization;
+using System.Text.Json;
+using Fake.Application;
+using Fake.DependencyInjection;
+
+namespace Oms.Application.Labeling.Hiprint;
+
+///
+/// 将 hiprint TemplateJson 转为中间绘制 IR。
+///
+public sealed class HiprintTemplateParser : ITransientDependency
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true
+ };
+
+ public LabelPageIr Parse(string templateJson, IReadOnlyDictionary printData)
+ {
+ if (string.IsNullOrWhiteSpace(templateJson))
+ {
+ throw new ArgumentException("模板 JSON 为空", nameof(templateJson));
+ }
+
+ var doc = JsonSerializer.Deserialize(templateJson, JsonOptions)
+ ?? throw new BusinessException("模板 JSON 无法解析");
+
+ var panel = doc.Panels.OrderBy(x => x.Index).FirstOrDefault()
+ ?? throw new BusinessException("模板缺少 panels");
+
+ var widthMm = panel.Width > 0 ? panel.Width : 100f;
+ var heightMm = panel.Height > 0 ? panel.Height : 150f;
+ var ops = new List();
+
+ foreach (var element in panel.PrintElements)
+ {
+ var op = MapElement(element, printData);
+ if (op != null)
+ {
+ ops.Add(op);
+ }
+ }
+
+ return new LabelPageIr
+ {
+ WidthMm = widthMm,
+ HeightMm = heightMm,
+ Ops = ops
+ };
+ }
+
+ private static LabelDrawOp? MapElement(
+ HiprintPrintElement element,
+ IReadOnlyDictionary printData)
+ {
+ var type = element.PrintElementType?.Type?.Trim().ToLowerInvariant() ?? "text";
+ var opt = element.Options;
+ var left = opt.Left;
+ var top = opt.Top;
+ var width = Math.Max(opt.Width, 1f);
+ var height = Math.Max(opt.Height, 1f);
+
+ switch (type)
+ {
+ case "hline":
+ return new LabelLineOp
+ {
+ LeftPt = left,
+ TopPt = top,
+ WidthPt = width,
+ HeightPt = Math.Max(opt.BorderWidth ?? 1f, 0.5f),
+ Vertical = false,
+ ColorHex = opt.Color,
+ ThicknessPt = opt.BorderWidth ?? 1f
+ };
+ case "vline":
+ return new LabelLineOp
+ {
+ LeftPt = left,
+ TopPt = top,
+ WidthPt = Math.Max(opt.BorderWidth ?? 1f, 0.5f),
+ HeightPt = height,
+ Vertical = true,
+ ColorHex = opt.Color,
+ ThicknessPt = opt.BorderWidth ?? 1f
+ };
+ case "rect":
+ return new LabelRectOp
+ {
+ LeftPt = left,
+ TopPt = top,
+ WidthPt = width,
+ HeightPt = height,
+ BorderColorHex = opt.Color,
+ BorderWidthPt = opt.BorderWidth ?? 1f
+ };
+ case "image":
+ return new LabelImageOp
+ {
+ LeftPt = left,
+ TopPt = top,
+ WidthPt = width,
+ HeightPt = height,
+ Src = ResolveValue(opt.Field, opt.Src ?? opt.Title, printData)
+ };
+ case "longtext":
+ return BuildTextOp(opt, left, top, width, height, printData, wrap: true);
+ case "text":
+ default:
+ {
+ var textType = opt.TextType?.Trim().ToLowerInvariant();
+ if (textType is "barcode" or "qrcode")
+ {
+ var content = ResolveValue(opt.Field, opt.Title, printData);
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ return null;
+ }
+
+ return new LabelBarcodeOp
+ {
+ LeftPt = left,
+ TopPt = top,
+ WidthPt = width,
+ HeightPt = height,
+ Content = content,
+ QrCode = textType == "qrcode"
+ };
+ }
+
+ return BuildTextOp(opt, left, top, width, height, printData, wrap: false);
+ }
+ }
+ }
+
+ private static LabelTextOp? BuildTextOp(
+ HiprintElementOptions opt,
+ float left,
+ float top,
+ float width,
+ float height,
+ IReadOnlyDictionary printData,
+ bool wrap)
+ {
+ var value = ResolveValue(opt.Field, null, printData);
+ string text;
+ if (!string.IsNullOrWhiteSpace(opt.Field))
+ {
+ // 有 field:默认只打字段值;hideTitle=false 且有 title 时拼「标题:值」
+ if (opt.HideTitle == false && !string.IsNullOrWhiteSpace(opt.Title) && !string.IsNullOrWhiteSpace(value))
+ {
+ text = $"{opt.Title}:{value}";
+ }
+ else
+ {
+ text = value ?? string.Empty;
+ }
+ }
+ else
+ {
+ text = opt.Title ?? string.Empty;
+ }
+
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return null;
+ }
+
+ var bold = string.Equals(opt.FontWeight, "bold", StringComparison.OrdinalIgnoreCase)
+ || opt.FontWeight is "700" or "600";
+
+ return new LabelTextOp
+ {
+ LeftPt = left,
+ TopPt = top,
+ WidthPt = width,
+ HeightPt = height,
+ Text = text,
+ FontSizePt = opt.FontSize is > 0 ? opt.FontSize.Value : 10f,
+ Bold = bold,
+ ColorHex = opt.Color,
+ Align = (opt.TextAlign ?? "left").ToLowerInvariant(),
+ Wrap = wrap
+ };
+ }
+
+ private static string? ResolveValue(
+ string? field,
+ string? fallback,
+ IReadOnlyDictionary printData)
+ {
+ if (!string.IsNullOrWhiteSpace(field) && printData.TryGetValue(field, out var raw) && raw != null)
+ {
+ return Convert.ToString(raw, CultureInfo.InvariantCulture);
+ }
+
+ return fallback;
+ }
+}
diff --git a/src/Oms.Application/Labeling/Hiprint/LabelDrawOps.cs b/src/Oms.Application/Labeling/Hiprint/LabelDrawOps.cs
new file mode 100644
index 0000000..03329e5
--- /dev/null
+++ b/src/Oms.Application/Labeling/Hiprint/LabelDrawOps.cs
@@ -0,0 +1,56 @@
+namespace Oms.Application.Labeling.Hiprint;
+
+///
+/// 中间绘制指令(与具体 PDF 引擎解耦)。
+/// 坐标单位:pt;页面尺寸:mm(再换算)。
+///
+public sealed class LabelPageIr
+{
+ public float WidthMm { get; init; } = 100f;
+
+ public float HeightMm { get; init; } = 150f;
+
+ public List Ops { get; init; } = [];
+}
+
+public abstract class LabelDrawOp
+{
+ public float LeftPt { get; init; }
+ public float TopPt { get; init; }
+ public float WidthPt { get; init; }
+ public float HeightPt { get; init; }
+}
+
+public sealed class LabelTextOp : LabelDrawOp
+{
+ public string Text { get; init; } = string.Empty;
+ public float FontSizePt { get; init; } = 10f;
+ public bool Bold { get; init; }
+ public string? ColorHex { get; init; }
+ public string Align { get; init; } = "left";
+ public bool Wrap { get; init; }
+}
+
+public sealed class LabelBarcodeOp : LabelDrawOp
+{
+ public string Content { get; init; } = string.Empty;
+ public bool QrCode { get; init; }
+}
+
+public sealed class LabelLineOp : LabelDrawOp
+{
+ public bool Vertical { get; init; }
+ public string? ColorHex { get; init; }
+ public float ThicknessPt { get; init; } = 1f;
+}
+
+public sealed class LabelRectOp : LabelDrawOp
+{
+ public string? BorderColorHex { get; init; }
+ public float BorderWidthPt { get; init; } = 1f;
+}
+
+public sealed class LabelImageOp : LabelDrawOp
+{
+ public string? Src { get; init; }
+}
diff --git a/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs b/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs
new file mode 100644
index 0000000..1d6f98f
--- /dev/null
+++ b/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs
@@ -0,0 +1,170 @@
+using Fake.DependencyInjection;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace Oms.Application.Labeling.Hiprint;
+
+///
+/// QuestPDF 实现的面单渲染器。
+///
+public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabelPdfRenderer, ITransientDependency
+{
+ private static int _licenseConfigured;
+
+ public Task RenderAsync(
+ string templateJson,
+ IReadOnlyDictionary printData,
+ CancellationToken cancellationToken = default)
+ {
+ EnsureLicense();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var pageIr = parser.Parse(templateJson, printData);
+ var pageWidthPt = MmToPt(pageIr.WidthMm);
+ var pageHeightPt = MmToPt(pageIr.HeightMm);
+
+ var bytes = Document.Create(container =>
+ {
+ container.Page(page =>
+ {
+ page.Size(pageWidthPt, pageHeightPt);
+ page.Margin(0);
+ page.Content().Layers(layers =>
+ {
+ layers.PrimaryLayer().Background(Colors.White);
+
+ foreach (var op in pageIr.Ops)
+ {
+ DrawOp(layers, op);
+ }
+ });
+ });
+ }).GeneratePdf();
+
+ return Task.FromResult(bytes);
+ }
+
+ private static void DrawOp(LayersDescriptor layers, LabelDrawOp op)
+ {
+ switch (op)
+ {
+ case LabelTextOp text:
+ {
+ var box = layers.Layer()
+ .Unconstrained()
+ .TranslateX(text.LeftPt)
+ .TranslateY(text.TopPt)
+ .Width(text.WidthPt)
+ .Height(text.HeightPt);
+
+ var aligned = text.Align switch
+ {
+ "center" => box.AlignCenter(),
+ "right" or "end" => box.AlignRight(),
+ _ => box.AlignLeft()
+ };
+
+ var span = aligned
+ .AlignTop()
+ .Text(text.Text)
+ .FontSize(text.FontSizePt)
+ .FontColor(ParseColor(text.ColorHex));
+
+ if (text.Bold)
+ {
+ span.Bold();
+ }
+
+ break;
+ }
+
+ case LabelBarcodeOp barcode:
+ {
+ var pxW = Math.Max(40, (int)(barcode.WidthPt * 2));
+ var pxH = Math.Max(40, (int)(barcode.HeightPt * 2));
+ var png = BarcodeImageGenerator.CreateBarcodePng(barcode.Content, pxW, pxH, barcode.QrCode);
+ layers.Layer()
+ .Unconstrained()
+ .TranslateX(barcode.LeftPt)
+ .TranslateY(barcode.TopPt)
+ .Width(barcode.WidthPt)
+ .Height(barcode.HeightPt)
+ .Image(png)
+ .FitArea();
+ break;
+ }
+
+ case LabelLineOp line:
+ {
+ var w = line.Vertical ? Math.Max(line.ThicknessPt, 0.5f) : line.WidthPt;
+ var h = line.Vertical ? line.HeightPt : Math.Max(line.ThicknessPt, 0.5f);
+ layers.Layer()
+ .Unconstrained()
+ .TranslateX(line.LeftPt)
+ .TranslateY(line.TopPt)
+ .Width(w)
+ .Height(h)
+ .Background(ParseColor(line.ColorHex));
+ break;
+ }
+
+ case LabelRectOp rect:
+ layers.Layer()
+ .Unconstrained()
+ .TranslateX(rect.LeftPt)
+ .TranslateY(rect.TopPt)
+ .Width(rect.WidthPt)
+ .Height(rect.HeightPt)
+ .BorderColor(ParseColor(rect.BorderColorHex))
+ .Border(rect.BorderWidthPt);
+ break;
+
+ case LabelImageOp image when !string.IsNullOrWhiteSpace(image.Src) && File.Exists(image.Src):
+ layers.Layer()
+ .Unconstrained()
+ .TranslateX(image.LeftPt)
+ .TranslateY(image.TopPt)
+ .Width(image.WidthPt)
+ .Height(image.HeightPt)
+ .Image(image.Src)
+ .FitArea();
+ break;
+ }
+ }
+
+ private static void EnsureLicense()
+ {
+ if (Interlocked.Exchange(ref _licenseConfigured, 1) == 1)
+ {
+ return;
+ }
+
+ QuestPDF.Settings.License = LicenseType.Community;
+ }
+
+ private static float MmToPt(float mm) => mm * 72f / 25.4f;
+
+ private static Color ParseColor(string? hex)
+ {
+ if (string.IsNullOrWhiteSpace(hex))
+ {
+ return Colors.Black;
+ }
+
+ var value = hex.Trim();
+ if (!value.StartsWith('#'))
+ {
+ value = "#" + value;
+ }
+
+ try
+ {
+ return Color.FromHex(value);
+ }
+ catch
+ {
+ return Colors.Black;
+ }
+ }
+}
diff --git a/src/Oms.Application/Labeling/ILabelPdfRenderer.cs b/src/Oms.Application/Labeling/ILabelPdfRenderer.cs
new file mode 100644
index 0000000..5b60aef
--- /dev/null
+++ b/src/Oms.Application/Labeling/ILabelPdfRenderer.cs
@@ -0,0 +1,12 @@
+namespace Oms.Application.Labeling;
+
+///
+/// 面单 PDF 渲染器:TemplateJson + PrintData → PDF bytes。
+///
+public interface ILabelPdfRenderer
+{
+ Task RenderAsync(
+ string templateJson,
+ IReadOnlyDictionary printData,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Application/Labeling/LabelFieldDictionary.cs b/src/Oms.Application/Labeling/LabelFieldDictionary.cs
new file mode 100644
index 0000000..32113c6
--- /dev/null
+++ b/src/Oms.Application/Labeling/LabelFieldDictionary.cs
@@ -0,0 +1,120 @@
+namespace Oms.Application.Labeling;
+
+///
+/// 设计器字段字典(与 PrintData camelCase 对齐)。
+///
+public static class LabelFieldDictionary
+{
+ public static IReadOnlyList GetGroups() =>
+ [
+ new LabelFieldGroup
+ {
+ Code = "numbers",
+ Title = "单号",
+ Fields =
+ [
+ Field("serviceNo", "运单号"),
+ Field("clientNo", "客户单号"),
+ Field("trackNo", "跟踪号"),
+ Field("requestId", "请求号")
+ ]
+ },
+ new LabelFieldGroup
+ {
+ Code = "product",
+ Title = "产品 / 渠道 / 仓库",
+ Fields =
+ [
+ Field("productCode", "产品编码"),
+ Field("salesProductName", "销售产品"),
+ Field("channelCode", "渠道编码"),
+ Field("channelName", "渠道名称"),
+ Field("warehouseName", "仓库"),
+ Field("customerCode", "客户编码"),
+ Field("customerName", "客户名称")
+ ]
+ },
+ new LabelFieldGroup
+ {
+ Code = "recipient",
+ Title = "收件人",
+ Fields =
+ [
+ Field("recipientName", "收件人"),
+ Field("recipientCompany", "收件公司"),
+ Field("recipientPhone", "收件电话"),
+ Field("recipientEmail", "收件邮箱"),
+ Field("recipientZip", "邮编"),
+ Field("recipientCountry", "国家代码"),
+ Field("recipientCountryName", "国家名称"),
+ Field("recipientProvince", "省/州"),
+ Field("recipientCity", "城市"),
+ Field("recipientStreet1", "街道1"),
+ Field("recipientStreet2", "街道2"),
+ Field("recipientStreet3", "街道3"),
+ Field("recipientAddressFull", "完整地址"),
+ Field("recipientTaxNo", "税号"),
+ Field("vatNo", "VAT"),
+ Field("eori", "EORI")
+ ]
+ },
+ new LabelFieldGroup
+ {
+ Code = "sender",
+ Title = "发件人",
+ Fields =
+ [
+ Field("senderName", "发件人"),
+ Field("senderCompany", "发件公司"),
+ Field("senderPhone", "发件电话"),
+ Field("senderEmail", "发件邮箱"),
+ Field("senderZip", "发件邮编"),
+ Field("senderProvince", "发件省/州"),
+ Field("senderCity", "发件城市"),
+ Field("senderStreet1", "发件街道1"),
+ Field("senderAddressFull", "发件完整地址")
+ ]
+ },
+ new LabelFieldGroup
+ {
+ Code = "weight",
+ Title = "重量尺寸",
+ Fields =
+ [
+ Field("actualWeight", "实重"),
+ Field("volumetricWeight", "体积重"),
+ Field("chargeableWeight", "计费重"),
+ Field("length", "长"),
+ Field("width", "宽"),
+ Field("height", "高"),
+ Field("weightUnit", "重量单位"),
+ Field("dimUnit", "尺寸单位")
+ ]
+ },
+ new LabelFieldGroup
+ {
+ Code = "system",
+ Title = "系统",
+ Fields =
+ [
+ Field("printTime", "打印时间"),
+ Field("operatorName", "操作人")
+ ]
+ }
+ ];
+
+ private static LabelFieldItem Field(string field, string title) => new() { Field = field, Title = title };
+}
+
+public sealed class LabelFieldGroup
+{
+ public string Code { get; init; } = string.Empty;
+ public string Title { get; init; } = string.Empty;
+ public List Fields { get; init; } = [];
+}
+
+public sealed class LabelFieldItem
+{
+ public string Field { get; init; } = string.Empty;
+ public string Title { get; init; } = string.Empty;
+}
diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs
new file mode 100644
index 0000000..c86365d
--- /dev/null
+++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs
@@ -0,0 +1,183 @@
+using Fake.Application;
+using Fake.DependencyInjection;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Logging;
+using Oms.Application.Dtos.Orders;
+using Oms.Domain.ChannelsAggregate.Repositories;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+using Oms.Domain.SystemAggregate.Consts;
+using Oms.Domain.SystemAggregate.Repositories;
+
+namespace Oms.Application.Labeling;
+
+///
+/// 面单 PDF 生成:解析模板 → 渲染 → 落盘 → 回写订单。
+///
+public sealed class LabelPdfGenerationService(
+ ILabelPdfRenderer renderer,
+ ILabelTemplateRepository labelTemplateRepository,
+ ISalesProductRepository salesProductRepository,
+ ISystemOtherConfigRepository systemOtherConfigRepository,
+ IOrderRepository orderRepository,
+ IWebHostEnvironment webHostEnvironment,
+ ILogger logger) : ITransientDependency
+{
+ public async Task GenerateAndPersistAsync(
+ Order order,
+ OrderOutput orderOutput,
+ int? templateIdOverride = null,
+ string? operatorName = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var templateId = await ResolveTemplateIdAsync(order, templateIdOverride, cancellationToken);
+ if (!templateId.HasValue)
+ {
+ return await MarkFailedAsync(order, "未配置标签模板(产品未绑且无全局默认)", cancellationToken);
+ }
+
+ var template = await labelTemplateRepository.FirstOrDefaultAsync(x => x.Id == templateId.Value);
+ if (template == null || string.IsNullOrWhiteSpace(template.TemplateJson))
+ {
+ return await MarkFailedAsync(order, $"标签模板不存在或 JSON 为空: {templateId}", cancellationToken);
+ }
+
+ var printData = LabelPrintDataBuilder.Build(orderOutput, operatorName);
+ var pdfBytes = await renderer.RenderAsync(template.TemplateJson, printData, cancellationToken);
+
+ var relativeUrl = await SavePdfAsync(order.ServiceNo, pdfBytes, cancellationToken);
+ order.LabelTemplateId = templateId;
+ order.LabelUrl = relativeUrl;
+ order.LabelGenerateStatus = LabelGenerateStatusCodes.Ready;
+ order.LabelGenerateError = null;
+ order.UpdateTime = DateTime.Now;
+ await orderRepository.UpdateAsync(order);
+
+ return new LabelPdfGenerateResult
+ {
+ Success = true,
+ LabelUrl = relativeUrl,
+ LabelTemplateId = templateId,
+ Status = LabelGenerateStatusCodes.Ready
+ };
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Generate label PDF failed for order {OrderId}", order.Id);
+ return await MarkFailedAsync(order, ex.Message, cancellationToken);
+ }
+ }
+
+ public async Task PreviewAsync(
+ string templateJson,
+ IReadOnlyDictionary? printData = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(templateJson))
+ {
+ throw new BusinessException("模板 JSON 为空");
+ }
+
+ var data = printData ?? LabelPrintDataBuilder.BuildMock();
+ return await renderer.RenderAsync(templateJson, data, cancellationToken);
+ }
+
+ public async Task ResolveTemplateIdAsync(
+ Order order,
+ int? templateIdOverride = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (templateIdOverride is > 0)
+ {
+ return templateIdOverride;
+ }
+
+ if (order.LabelTemplateId is > 0)
+ {
+ return order.LabelTemplateId;
+ }
+
+ if (!string.IsNullOrWhiteSpace(order.ProductCode))
+ {
+ var product = await salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
+ if (product?.LabelTemplateId is > 0)
+ {
+ return product.LabelTemplateId;
+ }
+ }
+
+ return await GetGlobalDefaultTemplateIdAsync(cancellationToken);
+ }
+
+ public async Task GetGlobalDefaultTemplateIdAsync(CancellationToken cancellationToken = default)
+ {
+ var config = await systemOtherConfigRepository.GetByConfigCodeAsync(
+ SystemOtherConfigCodes.LabelDefaultTemplate,
+ cancellationToken);
+ if (config == null || !config.Enabled)
+ {
+ return null;
+ }
+
+ if (int.TryParse(config.AppKey?.Trim(), out var id) && id > 0)
+ {
+ return id;
+ }
+
+ if (int.TryParse(config.Remark?.Trim(), out id) && id > 0)
+ {
+ return id;
+ }
+
+ return null;
+ }
+
+ private async Task MarkFailedAsync(
+ Order order,
+ string error,
+ CancellationToken cancellationToken)
+ {
+ order.LabelGenerateStatus = LabelGenerateStatusCodes.Failed;
+ order.LabelGenerateError = error.Length > 500 ? error[..500] : error;
+ order.UpdateTime = DateTime.Now;
+ await orderRepository.UpdateAsync(order);
+ return new LabelPdfGenerateResult
+ {
+ Success = false,
+ Status = LabelGenerateStatusCodes.Failed,
+ Error = order.LabelGenerateError,
+ LabelTemplateId = order.LabelTemplateId
+ };
+ }
+
+ private async Task SavePdfAsync(string serviceNo, byte[] pdfBytes, CancellationToken cancellationToken)
+ {
+ var webRoot = string.IsNullOrWhiteSpace(webHostEnvironment.WebRootPath)
+ ? Path.Combine(webHostEnvironment.ContentRootPath, "wwwroot")
+ : webHostEnvironment.WebRootPath;
+ var directory = Path.Combine(webRoot, "uploads", "prints", "orders");
+ Directory.CreateDirectory(directory);
+ var safeNo = string.IsNullOrWhiteSpace(serviceNo) ? "order" : serviceNo.Trim();
+ foreach (var c in Path.GetInvalidFileNameChars())
+ {
+ safeNo = safeNo.Replace(c, '_');
+ }
+
+ var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf";
+ var absolute = Path.Combine(directory, fileName);
+ await File.WriteAllBytesAsync(absolute, pdfBytes, cancellationToken);
+ return $"/uploads/prints/orders/{fileName}";
+ }
+}
+
+public sealed class LabelPdfGenerateResult
+{
+ public bool Success { get; init; }
+ public string? LabelUrl { get; init; }
+ public int? LabelTemplateId { get; init; }
+ public string Status { get; init; } = string.Empty;
+ public string? Error { get; init; }
+}
diff --git a/src/Oms.Application/Labeling/LabelPrintDataBuilder.cs b/src/Oms.Application/Labeling/LabelPrintDataBuilder.cs
new file mode 100644
index 0000000..5192d79
--- /dev/null
+++ b/src/Oms.Application/Labeling/LabelPrintDataBuilder.cs
@@ -0,0 +1,199 @@
+using System.Globalization;
+using Oms.Application.Dtos.Orders;
+
+namespace Oms.Application.Labeling;
+
+///
+/// 将订单出参映射为 hiprint PrintData(camelCase)。
+///
+public static class LabelPrintDataBuilder
+{
+ public static IReadOnlyDictionary Build(
+ OrderOutput order,
+ string? operatorName = null,
+ DateTime? printTime = null)
+ {
+ var receiver = order.Receiver;
+ var sender = order.Sender;
+ var weight = PickWeight(order.WeightList);
+ var data = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["serviceNo"] = order.ServiceNo,
+ ["clientNo"] = order.ClientNo,
+ ["trackNo"] = order.TrackNo,
+ ["requestId"] = order.RequestId,
+ ["productCode"] = order.ProductCode,
+ ["salesProductName"] = order.ProductName,
+ ["channelCode"] = order.ChannelCode,
+ ["channelName"] = order.ChannelName,
+ ["warehouseName"] = order.Warehouse,
+ ["customerCode"] = order.CustomerCode,
+ ["customerName"] = order.CustomerName,
+
+ ["recipientName"] = receiver?.RecipientsName,
+ ["recipientCompany"] = receiver?.CompanyName,
+ ["recipientPhone"] = FirstNonEmpty(receiver?.Phone, receiver?.TelePhone),
+ ["recipientEmail"] = receiver?.Email,
+ ["recipientZip"] = receiver?.ZipCode,
+ ["recipientCountry"] = order.ConsigneeCountry,
+ ["recipientCountryName"] = order.ConsigneeCountryName,
+ ["recipientProvince"] = receiver?.Province,
+ ["recipientCity"] = receiver?.City,
+ ["recipientStreet1"] = receiver?.StreetAddress1,
+ ["recipientStreet2"] = receiver?.StreetAddress2,
+ ["recipientStreet3"] = receiver?.StreetAddress3,
+ ["recipientAddressFull"] = BuildAddressFull(
+ receiver?.StreetAddress1,
+ receiver?.StreetAddress2,
+ receiver?.StreetAddress3,
+ receiver?.StreetNumber,
+ receiver?.City,
+ receiver?.Province,
+ receiver?.ZipCode,
+ order.ConsigneeCountryName ?? order.ConsigneeCountry),
+ ["recipientTaxNo"] = receiver?.TaxNo,
+ ["vatNo"] = receiver?.Vat,
+ ["eori"] = receiver?.Eori,
+
+ ["senderName"] = sender?.SenderName,
+ ["senderCompany"] = sender?.Company,
+ ["senderPhone"] = FirstNonEmpty(sender?.Phone, sender?.TelePhone),
+ ["senderEmail"] = sender?.Email,
+ ["senderZip"] = sender?.ZipCode,
+ ["senderProvince"] = sender?.Province,
+ ["senderCity"] = sender?.City,
+ ["senderStreet1"] = sender?.StreetAddress1,
+ ["senderStreet2"] = sender?.StreetAddress2,
+ ["senderStreet3"] = sender?.StreetAddress3,
+ ["senderAddressFull"] = BuildAddressFull(
+ sender?.StreetAddress1,
+ sender?.StreetAddress2,
+ sender?.StreetAddress3,
+ sender?.HouseNumber,
+ sender?.City,
+ sender?.Province,
+ sender?.ZipCode,
+ null),
+
+ ["actualWeight"] = weight?.ActualWeight,
+ ["volumetricWeight"] = weight?.VolumetricWeight,
+ ["chargeableWeight"] = order.ChargeableWeight ?? weight?.ChargeableWeight,
+ ["length"] = weight?.Length,
+ ["width"] = weight?.Width,
+ ["height"] = weight?.Height,
+ ["weightUnit"] = "KG",
+ ["dimUnit"] = "CM",
+
+ ["printTime"] = (printTime ?? DateTime.Now).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
+ ["operatorName"] = operatorName
+ };
+
+ data["items"] = order.Items.Select(x => new Dictionary
+ {
+ ["skuCode"] = x.Sku,
+ ["skuName"] = FirstNonEmpty(x.CnName, x.EnName),
+ ["qty"] = x.Quantity,
+ ["weight"] = x.Weight ?? x.UnitWeight,
+ ["declareNameEn"] = x.EnName,
+ ["declareValue"] = x.UnitPrice ?? x.Amount
+ }).ToList();
+
+ return data;
+ }
+
+ ///
+ /// 设计预览用的 Mock PrintData。
+ ///
+ public static IReadOnlyDictionary BuildMock() =>
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["serviceNo"] = "S202607180001",
+ ["clientNo"] = "C-10086",
+ ["trackNo"] = "YT1234567890",
+ ["requestId"] = "REQ-DEMO",
+ ["productCode"] = "DEMO-PRODUCT",
+ ["salesProductName"] = "专线-普货",
+ ["channelCode"] = "CH-01",
+ ["channelName"] = "Demo Channel",
+ ["warehouseName"] = "SZ-01",
+ ["customerCode"] = "AF0A1",
+ ["customerName"] = "Demo Customer",
+ ["recipientName"] = "John Lee",
+ ["recipientCompany"] = "ACME Inc",
+ ["recipientPhone"] = "+1-555-0100",
+ ["recipientEmail"] = "john@example.com",
+ ["recipientZip"] = "90001",
+ ["recipientCountry"] = "US",
+ ["recipientCountryName"] = "United States",
+ ["recipientProvince"] = "CA",
+ ["recipientCity"] = "Los Angeles",
+ ["recipientStreet1"] = "100 Demo Street",
+ ["recipientStreet2"] = "Suite 8",
+ ["recipientStreet3"] = null,
+ ["recipientAddressFull"] = "100 Demo Street, Suite 8, Los Angeles, CA 90001, United States",
+ ["recipientTaxNo"] = null,
+ ["vatNo"] = null,
+ ["eori"] = null,
+ ["senderName"] = "OMS Warehouse",
+ ["senderCompany"] = "OMS Co",
+ ["senderPhone"] = "+86-755-0000000",
+ ["senderEmail"] = "ops@example.com",
+ ["senderZip"] = "518000",
+ ["senderProvince"] = "Guangdong",
+ ["senderCity"] = "Shenzhen",
+ ["senderStreet1"] = "Warehouse Rd 1",
+ ["senderStreet2"] = null,
+ ["senderStreet3"] = null,
+ ["senderAddressFull"] = "Warehouse Rd 1, Shenzhen, Guangdong 518000",
+ ["actualWeight"] = 1.25m,
+ ["volumetricWeight"] = 1.10m,
+ ["chargeableWeight"] = 1.25m,
+ ["length"] = 30m,
+ ["width"] = 20m,
+ ["height"] = 10m,
+ ["weightUnit"] = "KG",
+ ["dimUnit"] = "CM",
+ ["printTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
+ ["operatorName"] = "preview",
+ ["items"] = new List>
+ {
+ new()
+ {
+ ["skuCode"] = "SKU001",
+ ["skuName"] = "T-Shirt",
+ ["qty"] = 2,
+ ["weight"] = 0.2m,
+ ["declareNameEn"] = "T-Shirt",
+ ["declareValue"] = 9.9m
+ }
+ }
+ };
+
+ private static OrderWeightOutput? PickWeight(IReadOnlyList list)
+ {
+ if (list.Count == 0)
+ {
+ return null;
+ }
+
+ return list.FirstOrDefault(x =>
+ string.Equals(x.Type, "receive", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(x.Type, "收货", StringComparison.OrdinalIgnoreCase))
+ ?? list.FirstOrDefault(x =>
+ string.Equals(x.Type, "forecast", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(x.Type, "预报", StringComparison.OrdinalIgnoreCase))
+ ?? list[0];
+ }
+
+ private static string? BuildAddressFull(params string?[] parts)
+ {
+ var values = parts
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x!.Trim())
+ .ToArray();
+ return values.Length == 0 ? null : string.Join(", ", values);
+ }
+
+ private static string? FirstNonEmpty(params string?[] values) =>
+ values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
+}
diff --git a/src/Oms.Application/Messaging/OrderBatchQueueTopology.cs b/src/Oms.Application/Messaging/OrderBatchQueueTopology.cs
index 1a278a1..e61399d 100644
--- a/src/Oms.Application/Messaging/OrderBatchQueueTopology.cs
+++ b/src/Oms.Application/Messaging/OrderBatchQueueTopology.cs
@@ -41,6 +41,7 @@ public static class OrderBatchQueueTopology
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.MarkProblematic => RoutingExceptionPrefix + "mark-problematic",
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.MarkClaimed => RoutingExceptionPrefix + "mark-claimed",
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.Print => RoutingDocumentPrefix + "print",
+ Domain.OrdersAggregate.Consts.OrderBatchActionCodes.GenerateLabelPdf => RoutingDocumentPrefix + "generate-label-pdf",
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.Export => RoutingDocumentPrefix + "export",
_ => "unknown." + action
};
diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj
index 51fa64c..015e68f 100644
--- a/src/Oms.Application/Oms.Application.csproj
+++ b/src/Oms.Application/Oms.Application.csproj
@@ -26,7 +26,10 @@
+
+
+
diff --git a/src/Oms.Application/Services/Channels/LabelTemplateService.cs b/src/Oms.Application/Services/Channels/LabelTemplateService.cs
index 43995b5..b1ce432 100644
--- a/src/Oms.Application/Services/Channels/LabelTemplateService.cs
+++ b/src/Oms.Application/Services/Channels/LabelTemplateService.cs
@@ -1,6 +1,8 @@
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Channels;
+using Oms.Application.Dtos.Shared;
+using Oms.Application.Labeling;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
@@ -14,12 +16,105 @@ namespace Oms.Application.Services.Channels;
public class LabelTemplateService : ApplicationService
{
private ILabelTemplateRepository LabelTemplateRepository => LazyServiceProvider.GetRequiredService();
+ private LabelPdfGenerationService LabelPdfGenerationService =>
+ LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 标签模板列表(不含 JSON,供浏览/下拉)。
+ ///
+ [HttpGet("/channel/label-template/list")]
+ public async Task> GetListAsync(
+ [FromQuery] string? keyword = null,
+ [FromQuery] string? templateType = null)
+ {
+ var keywordValue = keyword?.Trim();
+ var typeValue = templateType?.Trim();
+ var hasKeyword = !string.IsNullOrEmpty(keywordValue);
+ var hasType = !string.IsNullOrEmpty(typeValue);
+
+ var entities = await LabelTemplateRepository.GetListAsync(x =>
+ (!hasKeyword || x.Name.Contains(keywordValue!)) &&
+ (!hasType || x.TemplateType == typeValue));
+
+ var items = entities
+ .OrderByDescending(x => x.UpdateTime)
+ .ThenByDescending(x => x.Id)
+ .Select(x => new LabelTemplateListItemOutput
+ {
+ Id = x.Id,
+ Name = x.Name,
+ TemplateType = x.TemplateType,
+ CreateTime = x.CreateTime,
+ UpdateTime = OmsDocHelper.NullIfDefault(x.UpdateTime)
+ })
+ .ToList();
+
+ return new OmsItemsResult { Items = items };
+ }
+
+ ///
+ /// 标签模板详情(含 TemplateJson,编辑用)。
+ ///
+ [HttpGet("/channel/label-template/{id:int}")]
+ public async Task GetAsync(int id)
+ {
+ var entity = await GetEntityOrThrowAsync(id);
+ return MapOutput(entity);
+ }
+
+ ///
+ /// 设计器字段字典(可拖字段分组)。
+ ///
+ [HttpGet("/channel/label-template/field-dictionary")]
+ public Task> GetFieldDictionaryAsync()
+ {
+ return Task.FromResult(new OmsItemsResult
+ {
+ Items = LabelFieldDictionary.GetGroups().ToList()
+ });
+ }
+
+ ///
+ /// 预览 PDF(同一套 ILabelPdfRenderer)。
+ ///
+ [HttpPost("/channel/label-template/preview-pdf")]
+ public async Task PreviewPdfAsync([FromBody] LabelTemplatePreviewInput input)
+ {
+ string? templateJson = null;
+ if (input.TemplateId is > 0)
+ {
+ var entity = await GetEntityOrThrowAsync(input.TemplateId.Value);
+ templateJson = entity.TemplateJson;
+ }
+ else
+ {
+ templateJson = OmsDocHelper.SerializeJson(input.TemplateJson);
+ }
+
+ if (string.IsNullOrWhiteSpace(templateJson))
+ {
+ throw new BusinessException("模板 JSON 为空");
+ }
+
+ IReadOnlyDictionary? printData = null;
+ if (input.PrintData is { Count: > 0 })
+ {
+ printData = input.PrintData.ToDictionary(
+ x => x.Key,
+ x => x.Value,
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ var bytes = await LabelPdfGenerationService.PreviewAsync(templateJson, printData);
+ return new FileContentResult(bytes, "application/pdf")
+ {
+ FileDownloadName = $"label-preview-{DateTime.Now:yyyyMMddHHmmss}.pdf"
+ };
+ }
///
/// 保存标签模板。
///
- /// 入参:标签模板保存参数。
- /// 标签模板保存结果。
[HttpPost("/channel/label-template/save")]
public async Task SaveAsync(LabelTemplateSaveInput input)
{
@@ -48,6 +143,16 @@ public class LabelTemplateService : ApplicationService
return MapOutput(entity);
}
+ ///
+ /// 删除标签模板。
+ ///
+ [HttpPost("/channel/label-template/delete")]
+ public async Task DeleteAsync(IdInput input)
+ {
+ var entity = await GetEntityOrThrowAsync(input.Id);
+ await LabelTemplateRepository.DeleteAsync(entity);
+ }
+
private async Task ValidateUniqueNameAsync(string name, string? templateType, int? id)
{
var existed = await LabelTemplateRepository.GetByNameAndTypeAsync(name, templateType);
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
index 9d1a61e..578c4e2 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
@@ -207,6 +207,7 @@ public partial class SalesProductService
MinTimeliness = product.MinTimeliness,
MaxTimeliness = product.MaxTimeliness,
CustomerWaybill = product.CustomerWaybill,
+ LabelTemplateId = product.LabelTemplateId,
BlacklistAreaId = product.BlacklistAreaId,
RemoteAreaId = product.RemoteAreaId,
PriceAreaId = product.PriceAreaId,
@@ -311,6 +312,7 @@ public partial class SalesProductService
entity.MinTimeliness = input.MinTimeliness;
entity.MaxTimeliness = input.MaxTimeliness;
entity.CustomerWaybill = input.CustomerWaybill;
+ entity.LabelTemplateId = input.LabelTemplateId;
entity.BlacklistAreaId = input.BlacklistAreaId;
entity.RemoteAreaId = input.RemoteAreaId;
entity.PriceAreaId = input.PriceAreaId;
@@ -334,6 +336,7 @@ public partial class SalesProductService
MinTimeliness = entity.MinTimeliness,
MaxTimeliness = entity.MaxTimeliness,
CustomerWaybill = entity.CustomerWaybill,
+ LabelTemplateId = entity.LabelTemplateId,
BlacklistAreaId = entity.BlacklistAreaId,
RemoteAreaId = entity.RemoteAreaId,
PriceAreaId = entity.PriceAreaId,
@@ -360,6 +363,7 @@ public partial class SalesProductService
MinTimeliness = entity.MinTimeliness,
MaxTimeliness = entity.MaxTimeliness,
CustomerWaybill = entity.CustomerWaybill,
+ LabelTemplateId = entity.LabelTemplateId,
BlacklistAreaId = entity.BlacklistAreaId,
RemoteAreaId = entity.RemoteAreaId,
PriceAreaId = entity.PriceAreaId,
diff --git a/src/Oms.Application/Services/Operations/LabelPrintService.cs b/src/Oms.Application/Services/Operations/LabelPrintService.cs
index 95442ef..d711a56 100644
--- a/src/Oms.Application/Services/Operations/LabelPrintService.cs
+++ b/src/Oms.Application/Services/Operations/LabelPrintService.cs
@@ -1,12 +1,20 @@
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Operations;
+using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
+using Oms.Application.Services.Orders;
+using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OperationsAggregate.Entities;
using Oms.Domain.OperationsAggregate.Repositories;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
using Oms.Domain.Shared.Enums;
+using Oms.Domain.SystemAggregate.Repositories;
+using Oms.Domain.WarehouseAggregate.Repositories;
namespace Oms.Application.Services.Operations;
@@ -18,12 +26,71 @@ public class LabelPrintService : ApplicationService
{
private ILabelPrintRecordRepository LabelPrintRecordRepository => LazyServiceProvider.GetRequiredService();
private ILabelTemplateRepository LabelTemplateRepository => LazyServiceProvider.GetRequiredService();
+ private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService();
+ private IOrderStockBindRepository OrderStockBindRepository => LazyServiceProvider.GetRequiredService();
+ private ICParcelRepository ParcelRepository => LazyServiceProvider.GetRequiredService();
+ private ICSkuRepository SkuRepository => LazyServiceProvider.GetRequiredService();
+ private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService();
+ private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService();
+ private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService();
+ private OrderService OrderService => LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 扫码查单:运单/客户单/跟踪号/其他单号/包裹快递单号/SKU → 订单 + 绑定明细。
+ ///
+ [HttpGet("/operation/print-label/lookup")]
+ public async Task LookupAsync(
+ [FromQuery] string number,
+ [FromQuery] string? numberType = null)
+ {
+ var raw = number?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ throw new BusinessException("请扫描或输入单号");
+ }
+
+ var type = NormalizeNumberType(numberType);
+ Order? order = null;
+ string? matchedType = null;
+ List? candidates = null;
+
+ if (type is "auto" or "")
+ {
+ (order, matchedType, candidates) = await ResolveAutoAsync(raw);
+ }
+ else
+ {
+ (order, matchedType, candidates) = await ResolveByTypeAsync(raw, type);
+ }
+
+ if (candidates is { Count: > 1 })
+ {
+ return new PrintLabelLookupOutput
+ {
+ Matched = false,
+ MatchedNumberType = matchedType,
+ Warnings = ["multiMatch"],
+ Candidates = candidates
+ };
+ }
+
+ if (order == null && candidates is { Count: 1 })
+ {
+ order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == candidates[0].OrderId);
+ matchedType ??= "sku";
+ }
+
+ if (order == null)
+ {
+ throw new BusinessException("未找到对应订单,请检查号码或类型");
+ }
+
+ return await BuildLookupOutputAsync(order, matchedType ?? type);
+ }
///
/// 执行面单打印。
///
- /// 入参:面单打印参数。
- /// 面单打印记录。
[HttpPost("/operation/print-label/print")]
public async Task PrintAsync(PrintLabelInput input)
{
@@ -43,6 +110,32 @@ public class LabelPrintService : ApplicationService
labelTemplate = list.OrderByDescending(x => x.UpdateTime).ThenByDescending(x => x.Id).FirstOrDefault();
}
+ var raw = input.Number?.Trim() ?? string.Empty;
+ var type = NormalizeNumberType(input.NumberType);
+ Order? order;
+ if (type is "auto" or "")
+ {
+ (order, _, _) = await ResolveAutoAsync(raw);
+ }
+ else
+ {
+ (order, _, _) = await ResolveByTypeAsync(raw, type);
+ }
+
+ if (order == null)
+ {
+ throw new BusinessException("未找到对应订单,无法打印面单");
+ }
+
+ if (labelTemplate != null)
+ {
+ order.LabelTemplateId = labelTemplate.Id;
+ order.LabelGenerateStatus = LabelGenerateStatusCodes.Pending;
+ await OrderRepository.UpdateAsync(order);
+ }
+
+ var print = await OrderService.PrintAsync(new OrderActionInput { Id = order.Id });
+
var now = DateTime.Now;
var operatorId = CurrentUser.GetId();
var waybillNo = string.Empty;
@@ -63,17 +156,17 @@ public class LabelPrintService : ApplicationService
otherNumber = input.Number;
break;
default:
- waybillNo = input.Number;
+ waybillNo = string.IsNullOrWhiteSpace(input.Number) ? order.ServiceNo : input.Number;
break;
}
var entity = new LabelPrintRecord
{
- WaybillNo = waybillNo,
- CustomerOrderNo = customerOrderNo,
- TrackingNo = trackingNo,
+ WaybillNo = string.IsNullOrWhiteSpace(waybillNo) ? order.ServiceNo : waybillNo,
+ CustomerOrderNo = customerOrderNo ?? order.ClientNo,
+ TrackingNo = trackingNo ?? order.TrackNo,
OtherNumber = otherNumber,
- LabelTemplateId = labelTemplate?.Id ?? input.LabelTemplateId,
+ LabelTemplateId = labelTemplate?.Id ?? order.LabelTemplateId ?? input.LabelTemplateId,
PrintSuccess = EnableStatus.Enabled,
PrintTime = now,
OperatorId = operatorId
@@ -83,17 +176,17 @@ public class LabelPrintService : ApplicationService
var output = ObjectMapper.Map(savedEntity);
output.LabelTemplateName = labelTemplate?.Name;
output.OperatorName = CurrentUser.UserName;
+ output.PrintUrl = print.PrintUrl;
return output;
}
///
/// 查询最近面单打印记录列表。
///
- /// 入参:返回记录条数上限。
- /// 入参:标签模板主键,可选。
- /// 面单打印记录列表结果。
[HttpGet("/operation/print-label/recent-list")]
- public async Task> GetRecentListAsync([FromQuery] int limit = 20, [FromQuery] int? labelTemplateId = null)
+ public async Task> GetRecentListAsync(
+ [FromQuery] int limit = 20,
+ [FromQuery] int? labelTemplateId = null)
{
var hasTemplateFilter = labelTemplateId.HasValue;
var labelTemplateIdValue = labelTemplateId.GetValueOrDefault();
@@ -127,10 +220,9 @@ public class LabelPrintService : ApplicationService
///
/// 查询标签模板列表。
///
- /// 入参:模板类型,可选。
- /// 标签模板列表结果。
[HttpGet("/operation/print-label/template-list")]
- public async Task> GetTemplateDropdownListAsync([FromQuery] string? templateType = null)
+ public async Task> GetTemplateDropdownListAsync(
+ [FromQuery] string? templateType = null)
{
var entities = await LabelTemplateRepository.GetListAsync(
x => string.IsNullOrWhiteSpace(templateType) || x.TemplateType == templateType);
@@ -140,4 +232,244 @@ public class LabelPrintService : ApplicationService
.ToList();
return new OmsItemsResult { Items = items };
}
+
+ private static string NormalizeNumberType(string? numberType)
+ {
+ var value = numberType?.Trim().ToLowerInvariant() ?? "auto";
+ return value switch
+ {
+ "customerorderno" or "customer-order" or "client" or "clientno" => "customerorder",
+ "track" or "trackno" => "tracking",
+ "parceltracking" or "parcelno" or "express" => "parcel",
+ "skucode" => "sku",
+ "" or "all" => "auto",
+ _ => value
+ };
+ }
+
+ private async Task<(Order? Order, string? MatchedType, List? Candidates)>
+ ResolveAutoAsync(string number)
+ {
+ var order = await OrderRepository.GetByWaybillNoAsync(number);
+ if (order != null)
+ {
+ var matched =
+ string.Equals(order.ServiceNo, number, StringComparison.OrdinalIgnoreCase) ? "waybill"
+ : string.Equals(order.TrackNo, number, StringComparison.OrdinalIgnoreCase) ? "tracking"
+ : string.Equals(order.ClientNo, number, StringComparison.OrdinalIgnoreCase) ? "customerorder"
+ : "waybill";
+ return (order, matched, null);
+ }
+
+ var byParcel = await ResolveByParcelTrackingAsync(number);
+ if (byParcel.Order != null || byParcel.Candidates is { Count: > 0 })
+ {
+ return byParcel;
+ }
+
+ return await ResolveBySkuAsync(number);
+ }
+
+ private async Task<(Order? Order, string? MatchedType, List? Candidates)>
+ ResolveByTypeAsync(string number, string type)
+ {
+ return type switch
+ {
+ "waybill" => (await OrderRepository.GetByServiceNoAsync(number)
+ ?? await OrderRepository.GetByWaybillNoAsync(number), "waybill", null),
+ "customerorder" => (
+ await OrderRepository.FirstOrDefaultAsync(x => x.ClientNo == number),
+ "customerorder",
+ null),
+ "tracking" => (
+ await OrderRepository.GetByTrackNoAsync(number),
+ "tracking",
+ null),
+ "other" => (
+ await OrderRepository.FirstOrDefaultAsync(x => x.RequestId == number || x.ClientNo == number)
+ ?? await OrderRepository.GetByWaybillNoAsync(number),
+ "other",
+ null),
+ "parcel" => await ResolveByParcelTrackingAsync(number),
+ "sku" => await ResolveBySkuAsync(number),
+ _ => await ResolveAutoAsync(number)
+ };
+ }
+
+ private async Task<(Order? Order, string? MatchedType, List? Candidates)>
+ ResolveByParcelTrackingAsync(string trackingNo)
+ {
+ var parcels = await ParcelRepository.GetListAsync(x => x.TrackingNo == trackingNo);
+ if (parcels.Count == 0)
+ {
+ return (null, "parcel", null);
+ }
+
+ var parcelIds = parcels.Select(x => x.Id).ToArray();
+ var binds = await OrderStockBindRepository.GetListAsync(x =>
+ x.ParcelId.HasValue
+ && parcelIds.Contains(x.ParcelId.Value)
+ && (x.Status == OrderStockBindStatusCodes.Bound || x.Status == OrderStockBindStatusCodes.Deducted));
+
+ var orderIds = binds.Select(x => x.OrderId).Distinct().ToArray();
+ if (orderIds.Length == 0)
+ {
+ throw new BusinessException("找到包裹但未绑定到任何订单,请先绑定包裹/库存");
+ }
+
+ if (orderIds.Length == 1)
+ {
+ var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderIds[0]);
+ return (order, "parcel", null);
+ }
+
+ var candidates = await BuildCandidatesAsync(orderIds, "包裹快递单号命中多笔订单");
+ return (null, "parcel", candidates);
+ }
+
+ private async Task<(Order? Order, string? MatchedType, List? Candidates)>
+ ResolveBySkuAsync(string skuCode)
+ {
+ var skus = await SkuRepository.GetListAsync(x => x.SkuCode == skuCode);
+ if (skus.Count == 0)
+ {
+ return (null, "sku", null);
+ }
+
+ var skuIds = skus.Select(x => x.Id).ToArray();
+ var binds = await OrderStockBindRepository.GetListAsync(x =>
+ x.SkuId.HasValue
+ && skuIds.Contains(x.SkuId.Value)
+ && (x.Status == OrderStockBindStatusCodes.Bound || x.Status == OrderStockBindStatusCodes.Deducted));
+
+ var orderIds = binds.Select(x => x.OrderId).Distinct().ToArray();
+ if (orderIds.Length == 0)
+ {
+ throw new BusinessException("找到 SKU 但没有绑定中的订单,请确认是否已绑定");
+ }
+
+ if (orderIds.Length == 1)
+ {
+ var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderIds[0]);
+ return (order, "sku", null);
+ }
+
+ var candidates = await BuildCandidatesAsync(orderIds, $"SKU {skuCode} 命中多笔订单,请选择");
+ return (null, "sku", candidates);
+ }
+
+ private async Task> BuildCandidatesAsync(int[] orderIds, string hint)
+ {
+ var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
+ var binds = await OrderStockBindRepository.GetListAsync(x =>
+ orderIds.Contains(x.OrderId)
+ && (x.Status == OrderStockBindStatusCodes.Bound || x.Status == OrderStockBindStatusCodes.Deducted));
+ var qtyMap = binds.GroupBy(x => x.OrderId).ToDictionary(g => g.Key, g => g.Sum(x => x.BindQty));
+
+ return orders
+ .OrderByDescending(x => x.UpdateTime)
+ .Select(o => new PrintLabelLookupCandidateOutput
+ {
+ OrderId = o.Id,
+ ServiceNo = o.ServiceNo,
+ ClientNo = o.ClientNo,
+ TrackNo = o.TrackNo,
+ OrderStatusName = OmsDocHelper.GetOrderStatusName(o.OrderStatus),
+ BindQtyTotal = qtyMap.GetValueOrDefault(o.Id),
+ MatchedHint = hint
+ })
+ .ToList();
+ }
+
+ private async Task BuildLookupOutputAsync(Order order, string matchedType)
+ {
+ var binds = await OrderStockBindRepository.GetListAsync(x =>
+ x.OrderId == order.Id
+ && (x.Status == OrderStockBindStatusCodes.Bound || x.Status == OrderStockBindStatusCodes.Deducted));
+ binds = binds.OrderBy(x => x.Id).ToList();
+
+ var warehouseId = order.WarehouseId ?? binds.FirstOrDefault()?.WarehouseId;
+ string? warehouseName = null;
+ if (warehouseId is > 0)
+ {
+ var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == warehouseId.Value);
+ warehouseName = warehouse?.WarehouseName;
+ }
+
+ string? salesProductName = null;
+ if (!string.IsNullOrWhiteSpace(order.ProductCode))
+ {
+ var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
+ salesProductName = product?.ProductName;
+ }
+
+ string? countryName = order.ConsigneeCountry;
+ if (!string.IsNullOrWhiteSpace(order.ConsigneeCountry))
+ {
+ var country = await CountryRepository.FirstOrDefaultAsync(x => x.CountryCode == order.ConsigneeCountry);
+ countryName = country?.ChineseName ?? country?.EnglishName ?? order.ConsigneeCountry;
+ }
+
+ var bindStatus = "none";
+ if (binds.Any(x => x.Status == OrderStockBindStatusCodes.Deducted))
+ {
+ bindStatus = OrderStockBindStatusCodes.Deducted;
+ }
+ else if (binds.Any(x => x.Status == OrderStockBindStatusCodes.Bound))
+ {
+ bindStatus = OrderStockBindStatusCodes.Bound;
+ }
+
+ var warnings = new List();
+ if (binds.Count == 0)
+ {
+ warnings.Add("noneBind");
+ }
+
+ var binIds = binds.Where(x => x.BinLocationId.HasValue).Select(x => x.BinLocationId!.Value).Distinct().ToArray();
+ var binMap = new Dictionary();
+ if (binIds.Length > 0)
+ {
+ var binRepo = LazyServiceProvider.GetRequiredService();
+ var bins = await binRepo.GetListAsync(x => binIds.Contains(x.Id));
+ binMap = bins.ToDictionary(x => x.Id, x => x.BinCode);
+ }
+
+ var bindItems = binds.Select(b => new PrintLabelBindItemOutput
+ {
+ BindType = b.BindType,
+ SkuCode = b.SkuCode,
+ SkuName = b.SkuName,
+ TrackingNo = b.TrackingNo,
+ ProductName = b.ProductName,
+ AttributeText = b.AttributeText,
+ ImageUrl = b.ImageUrl,
+ BinCode = b.BinLocationId is > 0 && binMap.TryGetValue(b.BinLocationId.Value, out var code) ? code : null,
+ BindQty = b.BindQty,
+ Status = b.Status
+ }).ToList();
+
+ return new PrintLabelLookupOutput
+ {
+ Matched = true,
+ MatchedNumberType = matchedType,
+ Warnings = warnings,
+ OrderId = order.Id,
+ ServiceNo = order.ServiceNo,
+ ClientNo = order.ClientNo,
+ TrackNo = order.TrackNo,
+ ProductCode = order.ProductCode,
+ SalesProductName = salesProductName,
+ DestinationCountry = order.ConsigneeCountry,
+ DestinationCountryName = countryName,
+ OrderStatus = order.OrderStatus,
+ OrderStatusName = OmsDocHelper.GetOrderStatusName(order.OrderStatus),
+ WarehouseId = warehouseId,
+ WarehouseName = warehouseName,
+ BindStatus = bindStatus,
+ BindQtyTotal = binds.Sum(x => x.BindQty),
+ BindLineCount = binds.Count,
+ BindItems = bindItems
+ };
+ }
}
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
index 6ab74ae..b203e7c 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
@@ -39,6 +39,7 @@ public sealed class OrderBatchActionExecutor(
OrderBatchActionCodes.CancelIntercept => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.Delete => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.Print => await orderActionHandler.ExecuteAsync(message, cancellationToken),
+ OrderBatchActionCodes.GenerateLabelPdf => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.MarkProblematic => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.MarkClaimed => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.HandleStatusUpdate => await orderActionHandler.ExecuteAsync(message, cancellationToken),
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs
index 008b906..4e48e98 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs
@@ -51,6 +51,18 @@ public sealed class OrderBatchOrderActionHandler(IServiceScopeFactory scopeFacto
resultJson: JsonConvert.SerializeObject(new { printUrl = print.PrintUrl }));
}
+ case OrderBatchActionCodes.GenerateLabelPdf:
+ {
+ var result = await orderService.GenerateLabelPdfForBatchAsync(message.OrderId, cancellationToken);
+ if (!result.Success)
+ {
+ return OrderBatchActionExecuteResult.BusinessFail(result.Error ?? "面单生成失败");
+ }
+
+ return OrderBatchActionExecuteResult.Ok(
+ resultJson: JsonConvert.SerializeObject(new { labelUrl = result.LabelUrl }));
+ }
+
case OrderBatchActionCodes.MarkProblematic:
await orderService.MarkProblematicForBatchAsync(
message.OrderId,
diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
index b8942c1..b681d90 100644
--- a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
@@ -1,6 +1,7 @@
using Fake.Application;
using Newtonsoft.Json;
using Oms.Application.Dtos.Orders;
+using Oms.Application.Labeling;
using Oms.Application.Services.Orders.Issues;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
@@ -27,6 +28,20 @@ public partial class OrderService
public Task PrintForBatchAsync(int orderId, CancellationToken cancellationToken = default) =>
PrintAsync(new OrderActionInput { Id = orderId });
+ public async Task GenerateLabelPdfForBatchAsync(
+ int orderId,
+ CancellationToken cancellationToken = default)
+ {
+ var order = await GetOrderOrThrowAsync(orderId);
+ var mapped = (await MapOrderOutputsAsync([order])).First();
+ return await LabelPdfGenerationService.GenerateAndPersistAsync(
+ order,
+ mapped,
+ templateIdOverride: null,
+ operatorName: null,
+ cancellationToken);
+ }
+
public async Task MarkProblematicForBatchAsync(
int orderId,
string? payloadJson,
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index bf023c5..cf1e869 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -79,6 +79,16 @@ public partial class OrderService
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
orders.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId }));
+ var customerIds = orders
+ .Where(x => x.CustomerId is > 0)
+ .Select(x => x.CustomerId!.Value)
+ .Distinct()
+ .ToArray();
+ var customers = customerIds.Length == 0
+ ? []
+ : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
+ var customerMap = customers.ToDictionary(x => x.Id);
+
return orders.Select(order =>
{
senderMap.TryGetValue(order.Id, out var sender);
@@ -91,6 +101,14 @@ public partial class OrderService
var receivableAmount = feeOutputs
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
.Sum(x => x.Amount);
+ string? customerName = null;
+ string? customerCode = null;
+ if (order.CustomerId is > 0 && customerMap.TryGetValue(order.CustomerId.Value, out var customer))
+ {
+ customerName = customer.CustomerName;
+ customerCode = customer.CustomerCode;
+ }
+
return new OrderOutput
{
Id = order.Id,
@@ -119,6 +137,8 @@ public partial class OrderService
? warehouse.WarehouseName
: null,
CustomerId = order.CustomerId,
+ CustomerName = customerName,
+ CustomerCode = customerCode,
OrderSource = string.IsNullOrWhiteSpace(order.OrderSource)
? OrderSourceCodes.Manual
: order.OrderSource,
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index b463def..bb94ad3 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -4,6 +4,8 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.SmallBagTrack;
+using Oms.Application.Labeling;
+using Oms.Application.Messaging;
using Oms.Application.Shared;
using Oms.Application.Services.Orders.Issues;
using Oms.Domain.ChannelsAggregate.Consts;
@@ -166,6 +168,7 @@ public partial class OrderService
await OrderRepository.UpdateAsync(order);
await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver);
+ await EnqueueGenerateLabelPdfAsync(order);
try
{
@@ -1128,6 +1131,8 @@ public partial class OrderService
PredeclareFailReason = string.Empty,
SalesProductRulesVersion = snapshotBinding.RulesVersion,
SalesProductPriceVersion = snapshotBinding.PriceVersion,
+ LabelTemplateId = product.LabelTemplateId,
+ LabelGenerateStatus = LabelGenerateStatusCodes.Pending,
OrderStatus = OrderStatusCodes.Draft
};
}
@@ -1482,15 +1487,45 @@ public partial class OrderService
TrackNo = order.TrackNo,
ConsigneeCountry = order.ConsigneeCountry,
ConsigneeCountryName = order.ConsigneeCountryName,
- LabelUrl = order.OrderStatus == OrderStatusCodes.Predeclared
- ? await CreateOrderPrintFileAsync(mapped)
- : string.Empty
+ LabelUrl = !string.IsNullOrWhiteSpace(order.LabelUrl)
+ ? order.LabelUrl!
+ : order.OrderStatus == OrderStatusCodes.Predeclared
+ ? await CreateOrderPrintFileAsync(mapped)
+ : string.Empty
};
}
private ISmallBagTrackOrderNotifier SmallBagTrackOrderNotifier =>
LazyServiceProvider.GetRequiredService();
+ private async Task EnqueueGenerateLabelPdfAsync(Order order)
+ {
+ try
+ {
+ var publisher = LazyServiceProvider.GetRequiredService();
+ if (!publisher.IsEnabled)
+ {
+ // 本地未开队列:同步生成,避免 Pending 悬挂
+ var mapped = (await MapOrderOutputsAsync([order])).First();
+ await LabelPdfGenerationService.GenerateAndPersistAsync(order, mapped);
+ return;
+ }
+
+ await BatchJobCoordinator.CreateAndEnqueueAsync(
+ new OrderBatchJobCreateInput
+ {
+ Action = OrderBatchActionCodes.GenerateLabelPdf,
+ OrderIds = [order.Id],
+ RequestId = $"label-pdf:{order.Id}:{Guid.NewGuid():N}"
+ },
+ CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0);
+ }
+ catch (Exception)
+ {
+ // 投递失败不阻断创单;打印时会补生成
+ }
+ }
+
///
/// 小包预报成功后异步同步轨迹订单(POST /OrderV2/UpsertSmallBag)。
///
diff --git a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs
new file mode 100644
index 0000000..f67978f
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs
@@ -0,0 +1,774 @@
+using Fake.Application;
+using Fake.Domain.Exceptions;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Dtos.Shared;
+using Oms.Application.Shared;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+using Oms.Domain.WarehouseAggregate.Entities;
+using Oms.Domain.WarehouseAggregate.Repositories;
+
+namespace Oms.Application.Services.Orders;
+
+public partial class OrderService
+{
+ private IOrderStockBindRepository OrderStockBindRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICInventoryRepository StockBindInventoryRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICParcelRepository StockBindParcelRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICParcelItemRepository StockBindParcelItemRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICSkuRepository StockBindSkuRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICBinLocationRepository StockBindBinLocationRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 查询物流单当前仓储绑定明细。
+ ///
+ [HttpGet("/order/stock-bind/detail")]
+ public async Task GetStockBindDetailAsync([FromQuery] int orderId)
+ {
+ var order = await GetOrderOrThrowAsync(orderId);
+ return await BuildStockBindDetailAsync(order);
+ }
+
+ ///
+ /// 库存候选(左侧库存 Tab)。
+ ///
+ [HttpPost("/order/stock-bind/inventory-candidates")]
+ public async Task> GetStockBindInventoryCandidatesAsync(
+ [FromBody] OrderStockBindCandidatesInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.OrderId);
+ EnsureOrderCanBindStock(order);
+ var warehouseId = ResolveBindWarehouseId(order, input.WarehouseId);
+ var customerId = await ResolveStockBindCustomerIdAsync(order);
+
+ var inventories = await StockBindInventoryRepository.GetListAsync(x =>
+ x.CustomerId == customerId
+ && x.WarehouseId == warehouseId
+ && (x.AvailableQty > 0 || x.Qty > x.FrozenQty));
+
+ var skuIds = inventories.Select(x => x.SkuId).Distinct().ToArray();
+ var skus = skuIds.Length == 0
+ ? []
+ : await StockBindSkuRepository.GetListAsync(x => x.CustomerId == customerId && skuIds.Contains(x.Id));
+ var skuMap = skus.ToDictionary(x => x.Id);
+ var keyword = input.Keyword?.Trim();
+
+ var grouped = inventories
+ .GroupBy(x => x.SkuId)
+ .Select(g =>
+ {
+ skuMap.TryGetValue(g.Key, out var sku);
+ return new OrderStockBindInventoryCandidateOutput
+ {
+ SkuId = g.Key,
+ SkuCode = sku?.SkuCode ?? string.Empty,
+ SkuName = sku?.SkuName ?? string.Empty,
+ SkuImageUrl = sku?.SkuImageUrl,
+ WarehouseId = warehouseId,
+ TotalQty = g.Sum(x => x.Qty),
+ FrozenQty = g.Sum(x => x.FrozenQty),
+ AvailableQty = g.Sum(x => x.AvailableQty),
+ BinCount = g.Count()
+ };
+ })
+ .Where(x =>
+ string.IsNullOrWhiteSpace(keyword)
+ || x.SkuCode.Contains(keyword!, StringComparison.OrdinalIgnoreCase)
+ || x.SkuName.Contains(keyword!, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(x => x.SkuCode)
+ .ToList();
+
+ var pageIndex = OmsDocHelper.NormalizePage(input.Page);
+ var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
+ var skip = (pageIndex - 1) * pageSize;
+ return new OmsPageResult
+ {
+ Items = grouped.Skip(skip).Take(pageSize).ToList(),
+ Total = grouped.Count
+ };
+ }
+
+ ///
+ /// 包裹候选(左侧包裹 Tab,允许全部生命周期)。
+ ///
+ [HttpPost("/order/stock-bind/parcel-candidates")]
+ public async Task> GetStockBindParcelCandidatesAsync(
+ [FromBody] OrderStockBindCandidatesInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.OrderId);
+ EnsureOrderCanBindStock(order);
+ var warehouseId = ResolveBindWarehouseId(order, input.WarehouseId);
+ var customerId = await ResolveStockBindCustomerIdAsync(order);
+
+ var lifecycle = input.LifecycleStatus?.Trim();
+ var filterLifecycle = !string.IsNullOrWhiteSpace(lifecycle)
+ && !string.Equals(lifecycle, "all", StringComparison.OrdinalIgnoreCase);
+ var keyword = input.Keyword?.Trim();
+
+ var parcels = await StockBindParcelRepository.GetListAsync(x =>
+ x.CustomerId == customerId
+ && x.WarehouseId == warehouseId
+ && (!filterLifecycle || x.LifecycleStatus == lifecycle));
+
+ if (!string.IsNullOrWhiteSpace(keyword))
+ {
+ parcels = parcels
+ .Where(x =>
+ x.TrackingNo.Contains(keyword, StringComparison.OrdinalIgnoreCase)
+ || (x.CarrierName?.Contains(keyword, StringComparison.OrdinalIgnoreCase) ?? false))
+ .ToList();
+ }
+
+ parcels = parcels.OrderByDescending(x => x.Id).ToList();
+ var pageIndex = OmsDocHelper.NormalizePage(input.Page);
+ var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
+ var pageParcels = parcels.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
+ var parcelIds = pageParcels.Select(x => x.Id).ToArray();
+ var allItems = parcelIds.Length == 0
+ ? []
+ : await StockBindParcelItemRepository.GetListAsync(x => parcelIds.Contains(x.ParcelId));
+ var itemsByParcel = allItems.GroupBy(x => x.ParcelId).ToDictionary(g => g.Key, g => g.ToList());
+
+ var outputs = new List();
+ foreach (var parcel in pageParcels)
+ {
+ itemsByParcel.TryGetValue(parcel.Id, out var items);
+ items ??= [];
+ var availableItems = items
+ .Where(x => GetParcelItemAvailableQty(x) > 0)
+ .Select(x => new OrderStockBindParcelItemCandidateOutput
+ {
+ ParcelItemId = x.Id,
+ ProductName = x.ProductName,
+ AttributeText = x.AttributeText,
+ ImageUrl = x.ImageUrl,
+ SkuId = x.SkuId,
+ SkuCode = x.SkuCode,
+ Quantity = x.Quantity,
+ FrozenQty = x.FrozenQty,
+ AvailableQty = GetParcelItemAvailableQty(x)
+ })
+ .ToList();
+ if (availableItems.Count == 0)
+ {
+ continue;
+ }
+
+ outputs.Add(new OrderStockBindParcelCandidateOutput
+ {
+ ParcelId = parcel.Id,
+ TrackingNo = parcel.TrackingNo,
+ CarrierName = parcel.CarrierName,
+ LifecycleStatus = parcel.LifecycleStatus,
+ WarehouseId = parcel.WarehouseId,
+ Items = availableItems
+ });
+ }
+
+ return new OmsPageResult
+ {
+ Items = outputs,
+ Total = parcels.Count
+ };
+ }
+
+ ///
+ /// 保存绑定(全量覆盖 + 冻结)。
+ ///
+ [HttpPost("/order/stock-bind/save")]
+ public async Task SaveStockBindAsync([FromBody] OrderStockBindSaveInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.OrderId);
+ EnsureOrderCanBindStock(order);
+ await EnsureNoDeductedBindsAsync(order.Id);
+ var customerId = await ResolveStockBindCustomerIdAsync(order);
+ var warehouseId = ResolveBindWarehouseId(order, null);
+ var items = input.Items ?? [];
+
+ await ReleaseBoundBindsAsync(order.Id);
+
+ foreach (var item in items)
+ {
+ if (item.BindQty <= 0)
+ {
+ throw new BusinessException("绑定数量必须大于 0");
+ }
+
+ var bindType = (item.BindType ?? string.Empty).Trim();
+ if (string.Equals(bindType, OrderStockBindTypeCodes.Inventory, StringComparison.OrdinalIgnoreCase))
+ {
+ await BindInventoryLineAsync(order, customerId, warehouseId, item);
+ }
+ else if (string.Equals(bindType, OrderStockBindTypeCodes.ParcelItem, StringComparison.OrdinalIgnoreCase))
+ {
+ await BindParcelItemLineAsync(order, customerId, warehouseId, item);
+ }
+ else
+ {
+ throw new BusinessException($"不支持的绑定类型:{item.BindType}");
+ }
+ }
+
+ if (!order.WarehouseId.HasValue || order.WarehouseId.Value <= 0)
+ {
+ order.WarehouseId = warehouseId;
+ await UpdateOrderAsync(order);
+ }
+
+ return await BuildStockBindDetailAsync(order);
+ }
+
+ ///
+ /// 清空绑定并释放冻结。
+ ///
+ [HttpPost("/order/stock-bind/clear")]
+ public async Task ClearStockBindAsync([FromBody] OrderStockBindOrderInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.OrderId);
+ EnsureOrderCanBindStock(order);
+ await EnsureNoDeductedBindsAsync(order.Id);
+ await ReleaseBoundBindsAsync(order.Id);
+ return await BuildStockBindDetailAsync(order);
+ }
+
+ ///
+ /// 确认扣减:将 bound 明细转为 deducted,扣减实物并释放冻结。
+ ///
+ [HttpPost("/order/stock-bind/confirm-deduct")]
+ public async Task ConfirmStockBindDeductAsync([FromBody] OrderStockBindOrderInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.OrderId);
+ var binds = await OrderStockBindRepository.GetListAsync(x =>
+ x.OrderId == order.Id && x.Status == OrderStockBindStatusCodes.Bound);
+ foreach (var bind in binds)
+ {
+ await DeductBoundLineAsync(bind);
+ bind.Status = OrderStockBindStatusCodes.Deducted;
+ await OrderStockBindRepository.UpdateAsync(bind);
+ }
+
+ return await BuildStockBindDetailAsync(order);
+ }
+
+ private async Task BindInventoryLineAsync(
+ Order order,
+ int customerId,
+ int warehouseId,
+ OrderStockBindSaveItemInput item)
+ {
+ if (!item.SkuId.HasValue || item.SkuId.Value <= 0)
+ {
+ throw new BusinessException("库存绑定必须指定 skuId");
+ }
+
+ var sku = await StockBindSkuRepository.FirstOrDefaultAsync(x =>
+ x.Id == item.SkuId.Value && x.CustomerId == customerId)
+ ?? throw new BusinessException("SKU 不存在");
+
+ var deductions = await FreezeInventoryAsync(
+ customerId,
+ warehouseId,
+ item.SkuId.Value,
+ item.BindQty,
+ item.InventoryId);
+
+ foreach (var d in deductions)
+ {
+ await OrderStockBindRepository.InsertAsync(new OrderStockBind
+ {
+ OrderId = order.Id,
+ CustomerId = customerId,
+ WarehouseId = warehouseId,
+ BindType = OrderStockBindTypeCodes.Inventory,
+ SkuId = sku.Id,
+ InventoryId = d.InventoryId,
+ BinLocationId = d.BinLocationId,
+ BindQty = d.Qty,
+ Status = OrderStockBindStatusCodes.Bound,
+ SkuCode = sku.SkuCode,
+ SkuName = sku.SkuName,
+ ImageUrl = sku.SkuImageUrl
+ });
+ }
+ }
+
+ private async Task BindParcelItemLineAsync(
+ Order order,
+ int customerId,
+ int warehouseId,
+ OrderStockBindSaveItemInput item)
+ {
+ if (!item.ParcelItemId.HasValue || item.ParcelItemId.Value <= 0)
+ {
+ throw new BusinessException("包裹绑定必须指定 parcelItemId");
+ }
+
+ var parcelItem = await StockBindParcelItemRepository.FirstOrDefaultAsync(x => x.Id == item.ParcelItemId.Value)
+ ?? throw new BusinessException("包裹明细不存在");
+ var parcel = await StockBindParcelRepository.FirstOrDefaultAsync(x =>
+ x.Id == parcelItem.ParcelId && x.CustomerId == customerId)
+ ?? throw new BusinessException("包裹不存在");
+ if (parcel.WarehouseId != warehouseId)
+ {
+ throw new BusinessException("包裹仓库与订单仓库不一致");
+ }
+
+ if (item.ParcelId.HasValue && item.ParcelId.Value != parcel.Id)
+ {
+ throw new BusinessException("parcelId 与 parcelItemId 不匹配");
+ }
+
+ var available = GetParcelItemAvailableQty(parcelItem);
+ if (item.BindQty > available)
+ {
+ throw new BusinessException($"包裹明细可用不足,需要 {item.BindQty},可用 {available}");
+ }
+
+ parcelItem.FrozenQty += item.BindQty;
+ SyncParcelItemAvailable(parcelItem);
+ await StockBindParcelItemRepository.UpdateAsync(parcelItem);
+
+ // 已上架行同步冻结库存,避免重复售卖
+ if (parcelItem.SkuId is > 0)
+ {
+ await FreezeInventoryAsync(customerId, warehouseId, parcelItem.SkuId.Value, item.BindQty, null);
+ }
+
+ CSku? sku = null;
+ if (parcelItem.SkuId is > 0)
+ {
+ sku = await StockBindSkuRepository.FirstOrDefaultAsync(x => x.Id == parcelItem.SkuId.Value);
+ }
+
+ await OrderStockBindRepository.InsertAsync(new OrderStockBind
+ {
+ OrderId = order.Id,
+ CustomerId = customerId,
+ WarehouseId = warehouseId,
+ BindType = OrderStockBindTypeCodes.ParcelItem,
+ SkuId = parcelItem.SkuId,
+ ParcelId = parcel.Id,
+ ParcelItemId = parcelItem.Id,
+ BindQty = item.BindQty,
+ Status = OrderStockBindStatusCodes.Bound,
+ SkuCode = sku?.SkuCode ?? parcelItem.SkuCode,
+ SkuName = sku?.SkuName,
+ TrackingNo = parcel.TrackingNo,
+ ProductName = parcelItem.ProductName,
+ ImageUrl = parcelItem.ImageUrl,
+ AttributeText = parcelItem.AttributeText
+ });
+ }
+
+ private async Task ReleaseBoundBindsAsync(int orderId)
+ {
+ var binds = await OrderStockBindRepository.GetListAsync(x =>
+ x.OrderId == orderId && x.Status == OrderStockBindStatusCodes.Bound);
+ foreach (var bind in binds)
+ {
+ await UnfreezeBoundLineAsync(bind);
+ bind.Status = OrderStockBindStatusCodes.Released;
+ bind.IsDeleted = true;
+ await OrderStockBindRepository.UpdateAsync(bind);
+ }
+ }
+
+ private async Task UnfreezeBoundLineAsync(OrderStockBind bind)
+ {
+ if (string.Equals(bind.BindType, OrderStockBindTypeCodes.Inventory, StringComparison.OrdinalIgnoreCase))
+ {
+ if (bind.InventoryId is > 0)
+ {
+ await UnfreezeInventoryByIdAsync(bind.InventoryId.Value, bind.BindQty);
+ }
+ else if (bind.SkuId is > 0)
+ {
+ await UnfreezeInventoryBySkuAsync(bind.CustomerId, bind.WarehouseId, bind.SkuId.Value, bind.BindQty);
+ }
+
+ return;
+ }
+
+ if (string.Equals(bind.BindType, OrderStockBindTypeCodes.ParcelItem, StringComparison.OrdinalIgnoreCase)
+ && bind.ParcelItemId is > 0)
+ {
+ var parcelItem = await StockBindParcelItemRepository.FirstOrDefaultAsync(x => x.Id == bind.ParcelItemId.Value);
+ if (parcelItem != null)
+ {
+ parcelItem.FrozenQty = Math.Max(0, parcelItem.FrozenQty - bind.BindQty);
+ SyncParcelItemAvailable(parcelItem);
+ await StockBindParcelItemRepository.UpdateAsync(parcelItem);
+
+ if (parcelItem.SkuId is > 0)
+ {
+ await UnfreezeInventoryBySkuAsync(
+ bind.CustomerId,
+ bind.WarehouseId,
+ parcelItem.SkuId.Value,
+ bind.BindQty);
+ }
+ }
+ }
+ }
+
+ private async Task DeductBoundLineAsync(OrderStockBind bind)
+ {
+ if (string.Equals(bind.BindType, OrderStockBindTypeCodes.Inventory, StringComparison.OrdinalIgnoreCase))
+ {
+ if (bind.InventoryId is > 0)
+ {
+ var inv = await StockBindInventoryRepository.FirstOrDefaultAsync(x => x.Id == bind.InventoryId.Value)
+ ?? throw new BusinessException("库存行不存在");
+ if (inv.FrozenQty < bind.BindQty || inv.Qty < bind.BindQty)
+ {
+ throw new BusinessException("库存冻结量不足,无法扣减");
+ }
+
+ inv.Qty -= bind.BindQty;
+ inv.FrozenQty -= bind.BindQty;
+ SyncInventoryAvailable(inv);
+ await StockBindInventoryRepository.UpdateAsync(inv);
+ }
+ else if (bind.SkuId is > 0)
+ {
+ await DeductFrozenInventoryBySkuAsync(bind.CustomerId, bind.WarehouseId, bind.SkuId.Value, bind.BindQty);
+ }
+
+ return;
+ }
+
+ if (string.Equals(bind.BindType, OrderStockBindTypeCodes.ParcelItem, StringComparison.OrdinalIgnoreCase)
+ && bind.ParcelItemId is > 0)
+ {
+ var parcelItem = await StockBindParcelItemRepository.FirstOrDefaultAsync(x => x.Id == bind.ParcelItemId.Value)
+ ?? throw new BusinessException("包裹明细不存在");
+ if (parcelItem.FrozenQty < bind.BindQty || parcelItem.Quantity < bind.BindQty)
+ {
+ throw new BusinessException("包裹冻结量不足,无法扣减");
+ }
+
+ parcelItem.Quantity -= bind.BindQty;
+ parcelItem.FrozenQty -= bind.BindQty;
+ SyncParcelItemAvailable(parcelItem);
+ await StockBindParcelItemRepository.UpdateAsync(parcelItem);
+
+ if (parcelItem.SkuId is > 0)
+ {
+ await DeductFrozenInventoryBySkuAsync(
+ bind.CustomerId,
+ bind.WarehouseId,
+ parcelItem.SkuId.Value,
+ bind.BindQty);
+ }
+ }
+ }
+
+ private async Task> FreezeInventoryAsync(
+ int customerId,
+ int warehouseId,
+ int skuId,
+ int requiredQty,
+ int? inventoryId)
+ {
+ List rows;
+ if (inventoryId is > 0)
+ {
+ var row = await StockBindInventoryRepository.FirstOrDefaultAsync(x =>
+ x.Id == inventoryId.Value
+ && x.CustomerId == customerId
+ && x.WarehouseId == warehouseId
+ && x.SkuId == skuId)
+ ?? throw new BusinessException("指定库存行不存在");
+ rows = [row];
+ }
+ else
+ {
+ rows = await StockBindInventoryRepository.GetListAsync(x =>
+ x.CustomerId == customerId
+ && x.WarehouseId == warehouseId
+ && x.SkuId == skuId
+ && (x.AvailableQty > 0 || x.Qty > x.FrozenQty));
+ rows = rows.OrderBy(x => x.BinLocationId).ToList();
+ }
+
+ var available = rows.Sum(GetInventoryAvailableQty);
+ if (available < requiredQty)
+ {
+ throw new BusinessException($"SKU {skuId} 库存可用不足,需要 {requiredQty},可用 {available}");
+ }
+
+ var result = new List<(int InventoryId, int BinLocationId, int Qty)>();
+ var remaining = requiredQty;
+ foreach (var row in rows)
+ {
+ if (remaining <= 0)
+ {
+ break;
+ }
+
+ var take = Math.Min(GetInventoryAvailableQty(row), remaining);
+ if (take <= 0)
+ {
+ continue;
+ }
+
+ row.FrozenQty += take;
+ SyncInventoryAvailable(row);
+ await StockBindInventoryRepository.UpdateAsync(row);
+ result.Add((row.Id, row.BinLocationId, take));
+ remaining -= take;
+ }
+
+ if (remaining > 0)
+ {
+ throw new BusinessException($"SKU {skuId} 冻结失败");
+ }
+
+ return result;
+ }
+
+ private async Task UnfreezeInventoryByIdAsync(int inventoryId, int qty)
+ {
+ var inv = await StockBindInventoryRepository.FirstOrDefaultAsync(x => x.Id == inventoryId);
+ if (inv == null)
+ {
+ return;
+ }
+
+ inv.FrozenQty = Math.Max(0, inv.FrozenQty - qty);
+ SyncInventoryAvailable(inv);
+ await StockBindInventoryRepository.UpdateAsync(inv);
+ }
+
+ private async Task UnfreezeInventoryBySkuAsync(int customerId, int warehouseId, int skuId, int qty)
+ {
+ var rows = await StockBindInventoryRepository.GetListAsync(x =>
+ x.CustomerId == customerId
+ && x.WarehouseId == warehouseId
+ && x.SkuId == skuId
+ && x.FrozenQty > 0);
+ rows = rows.OrderByDescending(x => x.FrozenQty).ToList();
+ var remaining = qty;
+ foreach (var row in rows)
+ {
+ if (remaining <= 0)
+ {
+ break;
+ }
+
+ var release = Math.Min(row.FrozenQty, remaining);
+ row.FrozenQty -= release;
+ SyncInventoryAvailable(row);
+ await StockBindInventoryRepository.UpdateAsync(row);
+ remaining -= release;
+ }
+ }
+
+ private async Task DeductFrozenInventoryBySkuAsync(int customerId, int warehouseId, int skuId, int qty)
+ {
+ var rows = await StockBindInventoryRepository.GetListAsync(x =>
+ x.CustomerId == customerId
+ && x.WarehouseId == warehouseId
+ && x.SkuId == skuId
+ && x.FrozenQty > 0);
+ rows = rows.OrderBy(x => x.BinLocationId).ToList();
+ var remaining = qty;
+ foreach (var row in rows)
+ {
+ if (remaining <= 0)
+ {
+ break;
+ }
+
+ var take = Math.Min(row.FrozenQty, remaining);
+ take = Math.Min(take, row.Qty);
+ if (take <= 0)
+ {
+ continue;
+ }
+
+ row.Qty -= take;
+ row.FrozenQty -= take;
+ SyncInventoryAvailable(row);
+ await StockBindInventoryRepository.UpdateAsync(row);
+ remaining -= take;
+ }
+
+ if (remaining > 0)
+ {
+ throw new BusinessException($"SKU {skuId} 扣减失败,冻结量不足");
+ }
+ }
+
+ private async Task EnsureNoDeductedBindsAsync(int orderId)
+ {
+ var hasDeducted = await OrderStockBindRepository.AnyAsync(x =>
+ x.OrderId == orderId && x.Status == OrderStockBindStatusCodes.Deducted);
+ if (hasDeducted)
+ {
+ throw new BusinessException("订单已确认扣减库存,不能改绑;如需调整请联系运营");
+ }
+ }
+
+ private static void EnsureOrderCanBindStock(Order order)
+ {
+ if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new BusinessException("已删除订单不能绑定");
+ }
+ }
+
+ private static int RequireOrderCustomerId(Order order)
+ {
+ if (!order.CustomerId.HasValue || order.CustomerId.Value <= 0)
+ {
+ throw new BusinessException("订单未关联客户,无法绑定仓储资源");
+ }
+
+ return order.CustomerId.Value;
+ }
+
+ ///
+ /// 绑定客户上下文:客户登录与仓储模块一致(会话客户);管理端按订单客户。
+ ///
+ private async Task ResolveStockBindCustomerIdAsync(Order order)
+ {
+ var sessionCustomerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
+ CustomerRepository,
+ CurrentUser,
+ null);
+ if (sessionCustomerId is > 0)
+ {
+ return sessionCustomerId.Value;
+ }
+
+ return RequireOrderCustomerId(order);
+ }
+
+ private static int ResolveBindWarehouseId(Order order, int? requestedWarehouseId)
+ {
+ if (requestedWarehouseId is > 0)
+ {
+ return requestedWarehouseId.Value;
+ }
+
+ if (order.WarehouseId is > 0)
+ {
+ return order.WarehouseId.Value;
+ }
+
+ throw new BusinessException("请先为订单指定仓库");
+ }
+
+ private async Task BuildStockBindDetailAsync(Order order)
+ {
+ var binds = await OrderStockBindRepository.GetListAsync(x =>
+ x.OrderId == order.Id
+ && (x.Status == OrderStockBindStatusCodes.Bound || x.Status == OrderStockBindStatusCodes.Deducted));
+ binds = binds.OrderBy(x => x.Id).ToList();
+
+ string? warehouseName = null;
+ var warehouseId = order.WarehouseId ?? binds.FirstOrDefault()?.WarehouseId;
+ if (warehouseId is > 0)
+ {
+ var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == warehouseId.Value);
+ warehouseName = warehouse?.WarehouseName;
+ }
+
+ var binIds = binds.Where(x => x.BinLocationId.HasValue).Select(x => x.BinLocationId!.Value).Distinct().ToArray();
+ var bins = binIds.Length == 0
+ ? []
+ : await StockBindBinLocationRepository.GetListAsync(x => binIds.Contains(x.Id));
+ var binMap = bins.ToDictionary(x => x.Id);
+
+ var items = new List();
+ foreach (var bind in binds)
+ {
+ var availableQty = 0;
+ if (string.Equals(bind.BindType, OrderStockBindTypeCodes.Inventory, StringComparison.OrdinalIgnoreCase)
+ && bind.SkuId is > 0
+ && warehouseId is > 0)
+ {
+ var invRows = await StockBindInventoryRepository.GetListAsync(x =>
+ x.CustomerId == bind.CustomerId
+ && x.WarehouseId == warehouseId.Value
+ && x.SkuId == bind.SkuId.Value);
+ availableQty = invRows.Sum(GetInventoryAvailableQty) + bind.BindQty;
+ }
+ else if (bind.ParcelItemId is > 0)
+ {
+ var parcelItem = await StockBindParcelItemRepository.FirstOrDefaultAsync(x => x.Id == bind.ParcelItemId.Value);
+ if (parcelItem != null)
+ {
+ availableQty = GetParcelItemAvailableQty(parcelItem) + bind.BindQty;
+ }
+ }
+
+ binMap.TryGetValue(bind.BinLocationId ?? 0, out var bin);
+ items.Add(new OrderStockBindItemOutput
+ {
+ Id = bind.Id,
+ BindType = bind.BindType,
+ SkuId = bind.SkuId,
+ SkuCode = bind.SkuCode,
+ SkuName = bind.SkuName,
+ InventoryId = bind.InventoryId,
+ BinLocationId = bind.BinLocationId,
+ BinCode = bin?.BinCode,
+ ParcelId = bind.ParcelId,
+ ParcelItemId = bind.ParcelItemId,
+ TrackingNo = bind.TrackingNo,
+ ProductName = bind.ProductName,
+ AttributeText = bind.AttributeText,
+ ImageUrl = bind.ImageUrl,
+ BindQty = bind.BindQty,
+ AvailableQty = availableQty,
+ Status = bind.Status
+ });
+ }
+
+ var bindStatus = "none";
+ if (binds.Any(x => x.Status == OrderStockBindStatusCodes.Deducted))
+ {
+ bindStatus = OrderStockBindStatusCodes.Deducted;
+ }
+ else if (binds.Any(x => x.Status == OrderStockBindStatusCodes.Bound))
+ {
+ bindStatus = OrderStockBindStatusCodes.Bound;
+ }
+
+ return new OrderStockBindDetailOutput
+ {
+ OrderId = order.Id,
+ WarehouseId = warehouseId,
+ WarehouseName = warehouseName,
+ BindStatus = bindStatus,
+ Items = items
+ };
+ }
+
+ private static int GetInventoryAvailableQty(CInventory inv) =>
+ inv.AvailableQty > 0 ? inv.AvailableQty : Math.Max(0, inv.Qty - inv.FrozenQty);
+
+ private static int GetParcelItemAvailableQty(CParcelItem item) =>
+ Math.Max(0, item.Quantity - item.FrozenQty);
+
+ private static void SyncInventoryAvailable(CInventory inv) =>
+ inv.AvailableQty = Math.Max(0, inv.Qty - inv.FrozenQty);
+
+ private static void SyncParcelItemAvailable(CParcelItem item) =>
+ item.AvailableQty = Math.Max(0, item.Quantity - item.FrozenQty);
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index 9b98ca5..8afe2a8 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -6,6 +6,7 @@ using MiniExcelLibs;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.DataPerm;
+using Oms.Application.Labeling;
using Oms.Application.Shared;
using Newtonsoft.Json;
using Oms.Application.Services.Orders.Batch;
@@ -33,6 +34,8 @@ public partial class OrderService : ApplicationService
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService();
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService();
+ private LabelPdfGenerationService LabelPdfGenerationService =>
+ LazyServiceProvider.GetRequiredService();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
LazyServiceProvider.GetRequiredService();
@@ -223,8 +226,44 @@ public partial class OrderService : ApplicationService
public async Task PrintAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
- var printUrl = await CreateOrderPrintFileAsync((await MapOrderOutputsAsync([order])).First());
- return new OrderPrintOutput { PrintUrl = printUrl };
+ var mapped = (await MapOrderOutputsAsync([order])).First();
+
+ if (string.Equals(order.LabelGenerateStatus, LabelGenerateStatusCodes.Ready, StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrWhiteSpace(order.LabelUrl))
+ {
+ return new OrderPrintOutput
+ {
+ PrintUrl = order.LabelUrl!,
+ LabelGenerateStatus = order.LabelGenerateStatus,
+ LabelGenerateError = order.LabelGenerateError
+ };
+ }
+
+ // Pending / Failed / 无状态:即时补生成(打印场景优先出 PDF)
+ var result = await LabelPdfGenerationService.GenerateAndPersistAsync(
+ order,
+ mapped,
+ templateIdOverride: null,
+ operatorName: CurrentUser.UserName);
+
+ if (!result.Success || string.IsNullOrWhiteSpace(result.LabelUrl))
+ {
+ // 兼容回落:仍无模板时给 HTML stub,避免彻底不可打
+ var fallbackUrl = await CreateOrderPrintFileAsync(mapped);
+ return new OrderPrintOutput
+ {
+ PrintUrl = fallbackUrl,
+ LabelGenerateStatus = result.Status,
+ LabelGenerateError = result.Error ?? "面单 PDF 未就绪,已回落 HTML"
+ };
+ }
+
+ return new OrderPrintOutput
+ {
+ PrintUrl = result.LabelUrl!,
+ LabelGenerateStatus = result.Status,
+ LabelGenerateError = result.Error
+ };
}
///
@@ -483,10 +522,19 @@ public partial class OrderService : ApplicationService
return;
}
- if (order.CustomerId != customerId.Value)
+ if (order.CustomerId == customerId.Value)
{
- throw new BusinessException("订单不存在");
+ return;
}
+
+ // 兼容历史脏数据:下单人是当前客户账号,但订单 CustomerId 未写对
+ var mapId = OmsSessionHelper.TryGetCustomerMapId(CurrentUser);
+ if (mapId is > 0 && order.CreateUserId == mapId.Value)
+ {
+ return;
+ }
+
+ throw new BusinessException("订单不存在");
}
private async Task GetOrderOrThrowAsync(int id)
diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs
index 0433bf3..45f11ee 100644
--- a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs
+++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs
@@ -71,6 +71,12 @@ public class PlatformStoreOrderService : ApplicationService
: await OrderRepository.GetListAsync(x => linkedOrderIds.Contains(x.Id));
var linkedOrderMap = linkedOrders.ToDictionary(x => x.Id);
+ var customerIds = entities.Select(x => x.CustomerId).Distinct().ToArray();
+ var customers = customerIds.Length == 0
+ ? []
+ : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
+ var customerMap = customers.ToDictionary(x => x.Id);
+
return new OmsPageResult
{
Items = entities.Select(x =>
@@ -82,7 +88,14 @@ public class PlatformStoreOrderService : ApplicationService
linkedServiceNo = linkedOrder.ServiceNo;
}
- return MapOrderOutput(x, store?.StoreNickname ?? string.Empty, [], linkedServiceNo: linkedServiceNo);
+ customerMap.TryGetValue(x.CustomerId, out var customer);
+ return MapOrderOutput(
+ x,
+ store?.StoreNickname ?? string.Empty,
+ [],
+ linkedServiceNo: linkedServiceNo,
+ customerName: customer?.CustomerName,
+ customerCode: customer?.CustomerCode);
}).ToList(),
Total = total
};
@@ -110,8 +123,16 @@ public class PlatformStoreOrderService : ApplicationService
: await SkuRepository.GetListAsync(x => skuIds.Contains(x.Id));
var skuMap = skus.ToDictionary(x => x.Id);
var linkedServiceNo = await ResolveLinkedServiceNoAsync(order.LinkedOrderId);
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
- return MapOrderOutput(order, store?.StoreNickname ?? string.Empty, lines, skuMap, linkedServiceNo);
+ return MapOrderOutput(
+ order,
+ store?.StoreNickname ?? string.Empty,
+ lines,
+ skuMap,
+ linkedServiceNo,
+ customer?.CustomerName,
+ customer?.CustomerCode);
}
///
@@ -487,8 +508,8 @@ public class PlatformStoreOrderService : ApplicationService
x.CustomerId == customerId
&& x.WarehouseId == warehouseId
&& x.SkuId == skuId
- && x.Qty > 0);
- var available = total.Sum(x => x.Qty);
+ && (x.AvailableQty > 0 || x.Qty - x.FrozenQty > 0));
+ var available = total.Sum(x => Math.Max(0, x.Qty - x.FrozenQty));
if (available < qty)
{
throw new BusinessException($"SKU {skuId} 库存不足,需要 {qty},可用 {available}");
@@ -526,7 +547,7 @@ public class PlatformStoreOrderService : ApplicationService
x.CustomerId == customerId
&& x.WarehouseId == warehouseId
&& x.SkuId == skuId
- && x.Qty > 0);
+ && (x.AvailableQty > 0 || x.Qty > x.FrozenQty));
rows = rows.OrderBy(x => x.BinLocationId).ToList();
var deductions = new List();
@@ -538,8 +559,15 @@ public class PlatformStoreOrderService : ApplicationService
break;
}
- var deduct = Math.Min(row.Qty, remaining);
+ var available = Math.Max(0, row.Qty - row.FrozenQty);
+ var deduct = Math.Min(available, remaining);
+ if (deduct <= 0)
+ {
+ continue;
+ }
+
row.Qty -= deduct;
+ row.AvailableQty = Math.Max(0, row.Qty - row.FrozenQty);
remaining -= deduct;
await InventoryRepository.UpdateAsync(row);
deductions.Add(new InventoryDeduction(skuId, row.BinLocationId, deduct));
@@ -570,13 +598,16 @@ public class PlatformStoreOrderService : ApplicationService
WarehouseId = warehouseId,
SkuId = item.SkuId,
BinLocationId = item.BinLocationId,
- Qty = item.Qty
+ Qty = item.Qty,
+ FrozenQty = 0,
+ AvailableQty = item.Qty
};
await InventoryRepository.InsertAsync(inventory);
continue;
}
inventory.Qty += item.Qty;
+ inventory.AvailableQty = Math.Max(0, inventory.Qty - inventory.FrozenQty);
await InventoryRepository.UpdateAsync(inventory);
}
}
@@ -682,11 +713,15 @@ public class PlatformStoreOrderService : ApplicationService
string storeNickname,
IReadOnlyCollection lines,
IReadOnlyDictionary? skuMap = null,
- string? linkedServiceNo = null) =>
+ string? linkedServiceNo = null,
+ string? customerName = null,
+ string? customerCode = null) =>
new()
{
Id = order.Id,
CustomerId = order.CustomerId,
+ CustomerName = customerName,
+ CustomerCode = customerCode,
StoreId = order.StoreId,
StoreNickname = storeNickname,
PlatformCode = order.PlatformCode,
diff --git a/src/Oms.Application/Services/Orders/PlatformStoreService.cs b/src/Oms.Application/Services/Orders/PlatformStoreService.cs
index 3244f7e..c7ce7c4 100644
--- a/src/Oms.Application/Services/Orders/PlatformStoreService.cs
+++ b/src/Oms.Application/Services/Orders/PlatformStoreService.cs
@@ -43,9 +43,15 @@ public class PlatformStoreService : ApplicationService
new Dictionary { [nameof(SalesStore.UpdateTime)] = false, [nameof(SalesStore.Id)] = false });
var total = await StoreRepository.CountAsync(predicate);
+ var customerIds = entities.Select(x => x.CustomerId).Distinct().ToArray();
+ var customers = customerIds.Length == 0
+ ? []
+ : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
+ var customerMap = customers.ToDictionary(x => x.Id);
+
return new OmsPageResult
{
- Items = entities.Select(MapOutput).ToList(),
+ Items = entities.Select(x => MapOutput(x, customerMap)).ToList(),
Total = total
};
}
@@ -445,11 +451,24 @@ public class PlatformStoreService : ApplicationService
};
}
- private static PlatformStoreOutput MapOutput(SalesStore entity) =>
- new()
+ private static PlatformStoreOutput MapOutput(
+ SalesStore entity,
+ IReadOnlyDictionary? customerMap = null)
+ {
+ string? customerName = null;
+ string? customerCode = null;
+ if (customerMap != null && customerMap.TryGetValue(entity.CustomerId, out var customer))
+ {
+ customerName = customer.CustomerName;
+ customerCode = customer.CustomerCode;
+ }
+
+ return new PlatformStoreOutput
{
Id = entity.Id,
CustomerId = entity.CustomerId,
+ CustomerName = customerName,
+ CustomerCode = customerCode,
PlatformCode = entity.PlatformCode,
PlatformName = ResolvePlatformName(entity.PlatformCode),
StoreNickname = entity.StoreNickname,
@@ -462,6 +481,7 @@ public class PlatformStoreService : ApplicationService
Remark = entity.Remark,
CreateTime = entity.CreateTime
};
+ }
private static string ResolvePlatformName(string platformCode) => platformCode.ToLowerInvariant() switch
{
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Helpers.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Helpers.cs
index fd40d93..7b8869d 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Helpers.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Helpers.cs
@@ -64,7 +64,9 @@ public partial class WarehouseService
WarehouseId = warehouseId,
SkuId = skuId,
BinLocationId = binLocationId,
- Qty = deltaQty
+ Qty = deltaQty,
+ FrozenQty = 0,
+ AvailableQty = deltaQty
};
await InventoryRepository.InsertAsync(inventory);
return;
@@ -76,7 +78,13 @@ public partial class WarehouseService
throw new BusinessException("库存不足");
}
+ if (deltaQty < 0 && inventory.AvailableQty < -deltaQty)
+ {
+ throw new BusinessException("库存可用不足");
+ }
+
inventory.Qty = newQty;
+ inventory.AvailableQty = Math.Max(0, inventory.Qty - inventory.FrozenQty);
await InventoryRepository.UpdateAsync(inventory);
}
@@ -95,7 +103,7 @@ public partial class WarehouseService
x.CustomerId == customerId
&& x.WarehouseId == warehouseId
&& x.SkuId == skuId
- && x.Qty > 0);
+ && x.AvailableQty > 0);
rows = rows.OrderBy(x => x.BinLocationId).ToList();
var remaining = requiredQty;
@@ -106,8 +114,15 @@ public partial class WarehouseService
break;
}
- var deduct = Math.Min(row.Qty, remaining);
+ var available = Math.Max(0, row.Qty - row.FrozenQty);
+ var deduct = Math.Min(available, remaining);
+ if (deduct <= 0)
+ {
+ continue;
+ }
+
row.Qty -= deduct;
+ row.AvailableQty = Math.Max(0, row.Qty - row.FrozenQty);
remaining -= deduct;
await InventoryRepository.UpdateAsync(row);
}
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs
index 6471831..f4ce3cf 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs
@@ -58,9 +58,25 @@ public partial class WarehouseService
? []
: await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId));
+ var customerIds = entities.Select(x => x.CustomerId).Distinct().ToArray();
+ var customers = customerIds.Length == 0
+ ? []
+ : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
+ var customerMap = customers.ToDictionary(x => x.Id);
+
return new OmsPageResult
{
- Items = entities.Select(x => MapInboundOutput(x, boxes, lines)).ToList(),
+ Items = entities.Select(x =>
+ {
+ var output = MapInboundOutput(x, boxes, lines);
+ if (customerMap.TryGetValue(x.CustomerId, out var customer))
+ {
+ output.CustomerName = customer.CustomerName;
+ output.CustomerCode = customer.CustomerCode;
+ }
+
+ return output;
+ }).ToList(),
Total = total
};
}
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Inventory.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Inventory.cs
index b79bf54..0aca173 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Inventory.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Inventory.cs
@@ -3,11 +3,19 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse;
using Oms.Application.Shared;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Services.Warehouse;
public partial class WarehouseService
{
+ private IOrderStockBindRepository OrderStockBindRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private IOrderRepository OrderRepository =>
+ LazyServiceProvider.GetRequiredService();
+
///
/// 查询库存列表。
///
@@ -20,6 +28,7 @@ public partial class WarehouseService
var (hasCustomerFilter, customerIdValue) = CustomerFilter(customerId);
var hasWarehouseFilter = input.WarehouseId.HasValue;
var warehouseIdValue = input.WarehouseId.GetValueOrDefault();
+ // 有总量即展示(含全部冻结、可用为 0 的行),避免客户看不到被占用库存
var inventories = await InventoryRepository.GetListAsync(x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
&& x.Qty > 0
@@ -32,27 +41,42 @@ public partial class WarehouseService
(!hasCustomerFilter || x.CustomerId == customerIdValue) && skuIds.Contains(x.Id));
var skuMap = skus.ToDictionary(x => x.Id);
+ var customerIds = inventories.Select(x => x.CustomerId).Distinct().ToArray();
+ var customers = customerIds.Length == 0
+ ? []
+ : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
+ var customerMap = customers.ToDictionary(x => x.Id);
+
var keyword = input.Keyword?.Trim();
var grouped = inventories
- .GroupBy(x => new { x.SkuId, x.WarehouseId })
+ .GroupBy(x => new { x.CustomerId, x.SkuId, x.WarehouseId })
.Select(g =>
{
skuMap.TryGetValue(g.Key.SkuId, out var sku);
+ customerMap.TryGetValue(g.Key.CustomerId, out var customer);
return new WarehouseInventoryOutput
{
+ CustomerId = g.Key.CustomerId,
+ CustomerName = customer?.CustomerName,
+ CustomerCode = customer?.CustomerCode,
SkuId = g.Key.SkuId,
SkuCode = sku?.SkuCode ?? string.Empty,
SkuName = sku?.SkuName ?? string.Empty,
WarehouseId = g.Key.WarehouseId,
TotalQty = g.Sum(x => x.Qty),
+ FrozenQty = g.Sum(x => x.FrozenQty),
+ AvailableQty = g.Sum(x => x.AvailableQty > 0 ? x.AvailableQty : Math.Max(0, x.Qty - x.FrozenQty)),
BinCount = g.Count()
};
})
.Where(x =>
string.IsNullOrWhiteSpace(keyword)
|| x.SkuCode.Contains(keyword!, StringComparison.OrdinalIgnoreCase)
- || x.SkuName.Contains(keyword!, StringComparison.OrdinalIgnoreCase))
- .OrderBy(x => x.SkuCode)
+ || x.SkuName.Contains(keyword!, StringComparison.OrdinalIgnoreCase)
+ || (x.CustomerName?.Contains(keyword!, StringComparison.OrdinalIgnoreCase) ?? false)
+ || (x.CustomerCode?.Contains(keyword!, StringComparison.OrdinalIgnoreCase) ?? false))
+ .OrderBy(x => x.CustomerCode)
+ .ThenBy(x => x.SkuCode)
.ThenBy(x => x.WarehouseId)
.ToList();
@@ -113,7 +137,9 @@ public partial class WarehouseService
BinLocationId = x.BinLocationId,
BinCode = bin?.BinCode ?? string.Empty,
BinName = bin?.BinName,
- Qty = x.Qty
+ Qty = x.Qty,
+ FrozenQty = x.FrozenQty,
+ AvailableQty = x.AvailableQty > 0 ? x.AvailableQty : Math.Max(0, x.Qty - x.FrozenQty)
};
})
.ToList()
@@ -148,4 +174,137 @@ public partial class WarehouseService
await UpsertInventoryQtyAsync(customerId, input.WarehouseId, input.SkuId, input.SourceBinLocationId, -input.Qty);
await UpsertInventoryQtyAsync(customerId, input.WarehouseId, input.SkuId, input.TargetBinLocationId, input.Qty);
}
+
+ ///
+ /// 查询 SKU 库存变动历史:入库收货 + 订单扣减。
+ ///
+ [HttpPost("/warehouse/inventory/history")]
+ public async Task> GetInventoryHistoryAsync(
+ WarehouseInventoryHistoryInput input)
+ {
+ if (input.SkuId <= 0 || input.WarehouseId <= 0)
+ {
+ throw new BusinessException("SKU 和仓库不能为空");
+ }
+
+ var customerId = await ResolveListCustomerIdAsync(input.CustomerId);
+ var (hasCustomerFilter, customerIdValue) = CustomerFilter(customerId);
+ await GetSkuOrThrowAsync(customerId, input.SkuId);
+
+ var movementType = (input.MovementType ?? "all").Trim().ToLowerInvariant();
+ if (movementType is not ("all" or "inbound" or "deduct"))
+ {
+ movementType = "all";
+ }
+
+ var rows = new List();
+
+ if (movementType is "all" or "inbound")
+ {
+ var notices = await InboundNoticeRepository.GetListAsync(x =>
+ (!hasCustomerFilter || x.CustomerId == customerIdValue)
+ && x.WarehouseId == input.WarehouseId);
+ var noticeMap = notices.ToDictionary(x => x.Id);
+ var noticeIds = notices.Select(x => x.Id).ToArray();
+ var boxes = noticeIds.Length == 0
+ ? []
+ : await InboundBoxRepository.GetListAsync(x => noticeIds.Contains(x.InboundNoticeId));
+ var boxMap = boxes.ToDictionary(x => x.Id);
+ var boxIds = boxes.Select(x => x.Id).ToArray();
+ var lines = boxIds.Length == 0
+ ? []
+ : await InboundBoxLineRepository.GetListAsync(x =>
+ boxIds.Contains(x.InboundBoxId)
+ && x.SkuId == input.SkuId
+ && x.ReceivedQty != null
+ && x.ReceivedQty > 0);
+
+ foreach (var line in lines)
+ {
+ if (!boxMap.TryGetValue(line.InboundBoxId, out var box))
+ {
+ continue;
+ }
+
+ noticeMap.TryGetValue(box.InboundNoticeId, out var notice);
+ var occurredAt = line.UpdateTime != default ? line.UpdateTime : line.CreateTime;
+ if (notice?.ActualArrivalTime != null)
+ {
+ occurredAt = notice.ActualArrivalTime.Value;
+ }
+
+ rows.Add(new WarehouseInventoryHistoryOutput
+ {
+ MovementType = "inbound",
+ Qty = line.ReceivedQty.GetValueOrDefault(),
+ RefNo = notice?.NoticeNo,
+ Remark = string.IsNullOrWhiteSpace(box.MarkBoxNo)
+ ? null
+ : $"箱 {box.MarkBoxNo}",
+ OccurredAt = occurredAt
+ });
+ }
+ }
+
+ if (movementType is "all" or "deduct")
+ {
+ var binds = await OrderStockBindRepository.GetListAsync(x =>
+ (!hasCustomerFilter || x.CustomerId == customerIdValue)
+ && x.WarehouseId == input.WarehouseId
+ && x.SkuId == input.SkuId
+ && x.BindType == OrderStockBindTypeCodes.Inventory
+ && x.Status == OrderStockBindStatusCodes.Deducted
+ && x.BindQty > 0);
+
+ var orderIds = binds.Select(x => x.OrderId).Distinct().ToArray();
+ var orders = orderIds.Length == 0
+ ? []
+ : await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
+ var orderMap = orders.ToDictionary(x => x.Id);
+
+ foreach (var bind in binds)
+ {
+ orderMap.TryGetValue(bind.OrderId, out var order);
+ var refNo = order?.ServiceNo;
+ if (string.IsNullOrWhiteSpace(refNo))
+ {
+ refNo = order?.TrackNo;
+ }
+
+ if (string.IsNullOrWhiteSpace(refNo))
+ {
+ refNo = order?.ClientNo;
+ }
+
+ if (string.IsNullOrWhiteSpace(refNo))
+ {
+ refNo = $"#{bind.OrderId}";
+ }
+
+ rows.Add(new WarehouseInventoryHistoryOutput
+ {
+ MovementType = "deduct",
+ Qty = bind.BindQty,
+ RefNo = refNo,
+ Remark = string.IsNullOrWhiteSpace(bind.Remark) ? "订单出库扣减" : bind.Remark,
+ OccurredAt = bind.UpdateTime != default ? bind.UpdateTime : bind.CreateTime
+ });
+ }
+ }
+
+ var ordered = rows
+ .OrderByDescending(x => x.OccurredAt)
+ .ThenByDescending(x => x.RefNo)
+ .ToList();
+
+ var pageIndex = OmsDocHelper.NormalizePage(input.Page);
+ var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
+ var skip = (pageIndex - 1) * pageSize;
+
+ return new OmsPageResult
+ {
+ Items = ordered.Skip(skip).Take(pageSize).ToList(),
+ Total = ordered.Count
+ };
+ }
}
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
index 7549fde..3f7cbff 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
@@ -55,9 +55,25 @@ public partial class WarehouseService
? []
: await ParcelItemRepository.GetListAsync(x => parcelIds.Contains(x.ParcelId));
+ var customerIds = entities.Select(x => x.CustomerId).Distinct().ToArray();
+ var customers = customerIds.Length == 0
+ ? []
+ : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
+ var customerMap = customers.ToDictionary(x => x.Id);
+
return new OmsPageResult
{
- Items = entities.Select(x => MapParcelOutput(x, items.Where(i => i.ParcelId == x.Id))).ToList(),
+ Items = entities.Select(x =>
+ {
+ var output = MapParcelOutput(x, items.Where(i => i.ParcelId == x.Id));
+ if (customerMap.TryGetValue(x.CustomerId, out var customer))
+ {
+ output.CustomerName = customer.CustomerName;
+ output.CustomerCode = customer.CustomerCode;
+ }
+
+ return output;
+ }).ToList(),
Total = total
};
}
@@ -176,6 +192,8 @@ public partial class WarehouseService
: itemInput.AttributeText.Trim(),
ImageUrl = imageUrl,
Quantity = itemInput.Quantity,
+ FrozenQty = 0,
+ AvailableQty = itemInput.Quantity,
Remark = string.IsNullOrWhiteSpace(itemInput.Remark) ? null : itemInput.Remark.Trim()
};
await ParcelItemRepository.InsertAsync(item);
@@ -290,8 +308,25 @@ public partial class WarehouseService
}
}
- private static WarehouseParcelOutput MapParcelOutput(CParcel parcel, IEnumerable items) =>
- new()
+ private static WarehouseParcelOutput MapParcelOutput(CParcel parcel, IEnumerable items)
+ {
+ var itemList = items.ToList();
+ var mappedItems = itemList.Select(item => new WarehouseParcelItemOutput
+ {
+ Id = item.Id,
+ ProductName = item.ProductName,
+ AttributeText = item.AttributeText,
+ ImageUrl = item.ImageUrl,
+ Quantity = item.Quantity,
+ FrozenQty = item.FrozenQty,
+ AvailableQty = item.AvailableQty > 0 ? item.AvailableQty : Math.Max(0, item.Quantity - item.FrozenQty),
+ SkuId = item.SkuId,
+ SkuCode = item.SkuCode,
+ BinLocationId = item.BinLocationId,
+ Remark = item.Remark
+ }).ToList();
+
+ return new WarehouseParcelOutput
{
Id = parcel.Id,
CustomerId = parcel.CustomerId,
@@ -302,17 +337,10 @@ public partial class WarehouseService
ArrivalTime = parcel.ArrivalTime,
Remark = parcel.Remark,
CreateTime = parcel.CreateTime,
- Items = items.Select(item => new WarehouseParcelItemOutput
- {
- Id = item.Id,
- ProductName = item.ProductName,
- AttributeText = item.AttributeText,
- ImageUrl = item.ImageUrl,
- Quantity = item.Quantity,
- SkuId = item.SkuId,
- SkuCode = item.SkuCode,
- BinLocationId = item.BinLocationId,
- Remark = item.Remark
- }).ToList()
+ TotalQty = mappedItems.Sum(x => x.Quantity),
+ FrozenQty = mappedItems.Sum(x => x.FrozenQty),
+ AvailableQty = mappedItems.Sum(x => x.AvailableQty),
+ Items = mappedItems
};
+ }
}
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
index c785b9a..ecd753e 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
@@ -49,9 +49,25 @@ public partial class WarehouseService
.GroupBy(x => x.SkuId)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Qty));
+ var customerIds = entities.Select(x => x.CustomerId).Distinct().ToArray();
+ var customers = customerIds.Length == 0
+ ? []
+ : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
+ var customerMap = customers.ToDictionary(x => x.Id);
+
return new OmsPageResult
{
- Items = entities.Select(x => MapSkuOutput(x, qtyMap.GetValueOrDefault(x.Id))).ToList(),
+ Items = entities.Select(x =>
+ {
+ var output = MapSkuOutput(x, qtyMap.GetValueOrDefault(x.Id));
+ if (customerMap.TryGetValue(x.CustomerId, out var customer))
+ {
+ output.CustomerName = customer.CustomerName;
+ output.CustomerCode = customer.CustomerCode;
+ }
+
+ return output;
+ }).ToList(),
Total = total
};
}
diff --git a/src/Oms.Application/Shared/OmsSessionHelper.cs b/src/Oms.Application/Shared/OmsSessionHelper.cs
index aaf8ba8..3b603fd 100644
--- a/src/Oms.Application/Shared/OmsSessionHelper.cs
+++ b/src/Oms.Application/Shared/OmsSessionHelper.cs
@@ -1,3 +1,4 @@
+using System.Security.Claims;
using Fake.Application;
using Fake.Users;
using Oms.Domain.CustomersAggregate.Entities;
@@ -15,16 +16,32 @@ public static class OmsSessionHelper
///
public const string LoginTypeClaim = "loginType";
+ ///
+ /// JWT 中 RBAC mapId Claim 名称(与 FakeClaimTypes.UserId 对齐)。
+ ///
+ public const string MapIdClaim = "mapId";
+
private const string CustomerLoginType = "1";
+ private const string CustomerRoleName = "Customer";
///
/// 当前会话是否为客户门户登录。
///
- public static bool IsCustomerLogin(ICurrentUser currentUser) =>
- string.Equals(
- currentUser.FindClaimOrNull(LoginTypeClaim)?.Value,
- CustomerLoginType,
- StringComparison.Ordinal);
+ public static bool IsCustomerLogin(ICurrentUser currentUser)
+ {
+ if (string.Equals(
+ currentUser.FindClaimOrNull(LoginTypeClaim)?.Value,
+ CustomerLoginType,
+ StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ // 兼容 loginType 丢失但 role=Customer 仍在的令牌
+ var role = currentUser.FindClaimOrNull(ClaimTypes.Role)?.Value
+ ?? currentUser.FindClaimOrNull("role")?.Value;
+ return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase);
+ }
///
/// 客户登录时返回 mapId(对应 t_customer.UserId / 订单 CreateUserId);管理员返回 null。
@@ -37,7 +54,19 @@ public static class OmsSessionHelper
}
var userId = currentUser.GetId();
- return userId > 0 ? userId : null;
+ if (userId > 0)
+ {
+ return userId;
+ }
+
+ // GetId 未绑到 mapId 时,直接读 claim,避免客户会话被当成管理员(不过滤)
+ var mapIdRaw = currentUser.FindClaimOrNull(MapIdClaim)?.Value;
+ if (int.TryParse(mapIdRaw, out var mapId) && mapId > 0)
+ {
+ return mapId;
+ }
+
+ return null;
}
///
diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs
index 62d034b..d60c922 100644
--- a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs
+++ b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs
@@ -19,6 +19,11 @@ public class SalesProduct : OmsAuditedAggregateRoot
public string? CustomerWaybill { get; set; }
+ ///
+ /// 客户面单绑定的标签模板 Id。
+ ///
+ public int? LabelTemplateId { get; set; }
+
public int? BlacklistAreaId { get; set; }
public int? RemoteAreaId { get; set; }
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/LabelGenerateStatusCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/LabelGenerateStatusCodes.cs
new file mode 100644
index 0000000..067a23e
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Consts/LabelGenerateStatusCodes.cs
@@ -0,0 +1,11 @@
+namespace Oms.Domain.OrdersAggregate.Consts;
+
+///
+/// 订单面单 PDF 生成状态。
+///
+public static class LabelGenerateStatusCodes
+{
+ public const string Pending = "Pending";
+ public const string Ready = "Ready";
+ public const string Failed = "Failed";
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs
index a403872..ec1ac4c 100644
--- a/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs
+++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs
@@ -31,6 +31,7 @@ public static class OrderBatchActionCodes
public const string MarkClaimed = "mark-claimed";
public const string HandleStatusUpdate = "handle-status-update";
public const string Print = "print";
+ public const string GenerateLabelPdf = "generate-label-pdf";
public const string Export = "export";
public const string ParcelCreate = "parcel-create";
public const string ImportRow = "import-row";
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderStockBindCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderStockBindCodes.cs
new file mode 100644
index 0000000..cd4dfce
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderStockBindCodes.cs
@@ -0,0 +1,14 @@
+namespace Oms.Domain.OrdersAggregate.Consts;
+
+public static class OrderStockBindTypeCodes
+{
+ public const string Inventory = "inventory";
+ public const string ParcelItem = "parcelItem";
+}
+
+public static class OrderStockBindStatusCodes
+{
+ public const string Bound = "bound";
+ public const string Deducted = "deducted";
+ public const string Released = "released";
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
index 80e6fff..196abb8 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
@@ -140,4 +140,27 @@ public class Order : OmsAuditedAggregateRoot
public byte IsIntercept { get; set; }
public byte IsRemote { get; set; }
+
+ ///
+ /// 面单 PDF 相对路径(如 /uploads/prints/orders/xxx.pdf)。
+ ///
+ [SugarColumn(Length = 500, IsNullable = true)]
+ public string? LabelUrl { get; set; }
+
+ ///
+ /// 出单时实际使用的标签模板 Id。
+ ///
+ public int? LabelTemplateId { get; set; }
+
+ ///
+ /// 面单生成状态:Pending / Ready / Failed。
+ ///
+ [SugarColumn(Length = 32, IsNullable = true)]
+ public string? LabelGenerateStatus { get; set; }
+
+ ///
+ /// 面单生成失败原因。
+ ///
+ [SugarColumn(Length = 500, IsNullable = true)]
+ public string? LabelGenerateError { get; set; }
}
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderStockBind.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderStockBind.cs
new file mode 100644
index 0000000..e086c66
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderStockBind.cs
@@ -0,0 +1,56 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.OrdersAggregate.Entities;
+
+///
+/// 物流单仓储绑定明细(包裹行 / 库存)。
+///
+[SugarTable("t_order_stock_bind")]
+public class OrderStockBind : OmsAuditedAggregateRoot
+{
+ public int OrderId { get; set; }
+
+ public int CustomerId { get; set; }
+
+ public int WarehouseId { get; set; }
+
+ /// inventory / parcelItem
+ public string BindType { get; set; } = string.Empty;
+
+ public int? SkuId { get; set; }
+
+ public int? InventoryId { get; set; }
+
+ public int? BinLocationId { get; set; }
+
+ public int? ParcelId { get; set; }
+
+ public int? ParcelItemId { get; set; }
+
+ public int BindQty { get; set; }
+
+ /// bound / deducted / released
+ public string Status { get; set; } = "bound";
+
+ [SugarColumn(Length = 64, IsNullable = true)]
+ public string? SkuCode { get; set; }
+
+ [SugarColumn(Length = 128, IsNullable = true)]
+ public string? SkuName { get; set; }
+
+ [SugarColumn(Length = 64, IsNullable = true)]
+ public string? TrackingNo { get; set; }
+
+ [SugarColumn(Length = 256, IsNullable = true)]
+ public string? ProductName { get; set; }
+
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? ImageUrl { get; set; }
+
+ [SugarColumn(Length = 256, IsNullable = true)]
+ public string? AttributeText { get; set; }
+
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? Remark { get; set; }
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderStockBindRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderStockBindRepository.cs
new file mode 100644
index 0000000..55f8303
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderStockBindRepository.cs
@@ -0,0 +1,6 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Domain.OrdersAggregate.Repositories;
+
+public interface IOrderStockBindRepository : IRepository;
diff --git a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs
index 35ae9b6..0d76177 100644
--- a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs
+++ b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs
@@ -39,4 +39,9 @@ public static class SystemOtherConfigCodes
/// Temu(AppKey/AppSecret,BaseUrl=HostAuthUrl)。
///
public const string PlatformShopTemu = "platform_shop_temu";
+
+ ///
+ /// 全局默认面单模板(AppKey=模板 Id,Enabled 控制是否生效)。
+ ///
+ public const string LabelDefaultTemplate = "label_default_template";
}
diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CInventory.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CInventory.cs
index 7bdd3de..91aa6d1 100644
--- a/src/Oms.Domain/WarehouseAggregate/Entities/CInventory.cs
+++ b/src/Oms.Domain/WarehouseAggregate/Entities/CInventory.cs
@@ -15,4 +15,10 @@ public class CInventory : OmsAuditedAggregateRoot
public int BinLocationId { get; set; }
public int Qty { get; set; }
+
+ /// 已绑定未出库冻结量。
+ public int FrozenQty { get; set; }
+
+ /// 可用 = Qty - FrozenQty。
+ public int AvailableQty { get; set; }
}
diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CParcelItem.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CParcelItem.cs
index 06e870a..0cb75e1 100644
--- a/src/Oms.Domain/WarehouseAggregate/Entities/CParcelItem.cs
+++ b/src/Oms.Domain/WarehouseAggregate/Entities/CParcelItem.cs
@@ -18,6 +18,12 @@ public class CParcelItem : OmsAuditedAggregateRoot
public int Quantity { get; set; }
+ /// 已被物流单占用(冻结)数量。
+ public int FrozenQty { get; set; }
+
+ /// 可用 = Quantity - FrozenQty。
+ public int AvailableQty { get; set; }
+
public int? SkuId { get; set; }
public string? SkuCode { get; set; }
diff --git a/src/Oms.SqlSugarCore/Orders/OrderStockBindRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderStockBindRepository.cs
new file mode 100644
index 0000000..f479c3f
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Orders/OrderStockBindRepository.cs
@@ -0,0 +1,8 @@
+using Fake.DependencyInjection;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+using Oms.SqlSugarCore.Repositories;
+
+namespace Oms.SqlSugarCore.Orders;
+
+public class OrderStockBindRepository : OmsSqlSugarRepository, IOrderStockBindRepository, IScopedDependency;
diff --git a/src/Oms.Tests/Fixtures/LabelTemplates/sample_100x150.json b/src/Oms.Tests/Fixtures/LabelTemplates/sample_100x150.json
new file mode 100644
index 0000000..45a29f6
--- /dev/null
+++ b/src/Oms.Tests/Fixtures/LabelTemplates/sample_100x150.json
@@ -0,0 +1,138 @@
+{
+ "panels": [
+ {
+ "index": 0,
+ "name": "panel_100x150",
+ "width": 100,
+ "height": 150,
+ "paperHeader": 0,
+ "paperFooter": 420,
+ "printElements": [
+ {
+ "options": {
+ "left": 14.17,
+ "top": 14.17,
+ "height": 18,
+ "width": 255,
+ "title": "OMS Label Sample",
+ "fontSize": 14,
+ "fontWeight": "bold",
+ "textAlign": "left",
+ "hideTitle": true
+ },
+ "printElementType": { "title": "文本", "type": "text" }
+ },
+ {
+ "options": {
+ "left": 14.17,
+ "top": 42.52,
+ "height": 16,
+ "width": 255,
+ "field": "serviceNo",
+ "title": "运单号",
+ "fontSize": 11,
+ "hideTitle": false
+ },
+ "printElementType": { "title": "文本", "type": "text" }
+ },
+ {
+ "options": {
+ "left": 14.17,
+ "top": 70.87,
+ "height": 40,
+ "width": 255,
+ "field": "serviceNo",
+ "textType": "barcode",
+ "hideTitle": true
+ },
+ "printElementType": { "title": "文本", "type": "text" }
+ },
+ {
+ "options": {
+ "left": 14.17,
+ "top": 127.56,
+ "height": 50,
+ "width": 50,
+ "field": "serviceNo",
+ "textType": "qrcode",
+ "hideTitle": true
+ },
+ "printElementType": { "title": "文本", "type": "text" }
+ },
+ {
+ "options": {
+ "left": 70,
+ "top": 127.56,
+ "height": 48,
+ "width": 200,
+ "field": "recipientAddressFull",
+ "title": "地址",
+ "fontSize": 9,
+ "hideTitle": true
+ },
+ "printElementType": { "title": "长文", "type": "longText" }
+ },
+ {
+ "options": {
+ "left": 14.17,
+ "top": 190,
+ "height": 1.5,
+ "width": 255,
+ "borderWidth": 1,
+ "color": "#000000"
+ },
+ "printElementType": { "title": "横线", "type": "hline" }
+ },
+ {
+ "options": {
+ "left": 14.17,
+ "top": 205,
+ "height": 16,
+ "width": 120,
+ "field": "recipientName",
+ "title": "收件人",
+ "fontSize": 10,
+ "hideTitle": false
+ },
+ "printElementType": { "title": "文本", "type": "text" }
+ },
+ {
+ "options": {
+ "left": 14.17,
+ "top": 230,
+ "height": 16,
+ "width": 80,
+ "field": "actualWeight",
+ "title": "实重",
+ "fontSize": 10,
+ "hideTitle": false
+ },
+ "printElementType": { "title": "文本", "type": "text" }
+ },
+ {
+ "options": {
+ "left": 14.17,
+ "top": 260,
+ "height": 60,
+ "width": 255,
+ "borderWidth": 1,
+ "color": "#333333"
+ },
+ "printElementType": { "title": "矩形", "type": "rect" }
+ },
+ {
+ "options": {
+ "left": 20,
+ "top": 270,
+ "height": 40,
+ "width": 240,
+ "title": "Static note inside rect",
+ "fontSize": 9,
+ "hideTitle": true
+ },
+ "printElementType": { "title": "文本", "type": "text" }
+ }
+ ]
+ }
+ ]
+}
diff --git a/src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs b/src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs
new file mode 100644
index 0000000..bcff18c
--- /dev/null
+++ b/src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs
@@ -0,0 +1,64 @@
+using System.Reflection;
+using System.Text;
+using FluentAssertions;
+using Oms.Application.Labeling.Hiprint;
+
+namespace Oms.Tests.Labeling;
+
+public class HiprintTemplateParserTests
+{
+ [Fact]
+ public void Parse_SampleTemplate_ShouldMapUnitsAndElements()
+ {
+ var json = LoadFixture("sample_100x150.json");
+ var printData = new Dictionary
+ {
+ ["serviceNo"] = "S202607180001",
+ ["recipientName"] = "John Lee",
+ ["recipientAddressFull"] = "xxx Street, City, CA 90001",
+ ["actualWeight"] = 1.25
+ };
+
+ var parser = new HiprintTemplateParser();
+ var page = parser.Parse(json, printData);
+
+ page.WidthMm.Should().Be(100f);
+ page.HeightMm.Should().Be(150f);
+ page.Ops.Should().NotBeEmpty();
+
+ page.Ops.OfType().Should().Contain(x =>
+ x.Text.Contains("S202607180001", StringComparison.Ordinal) &&
+ x.LeftPt > 0 &&
+ x.FontSizePt == 11f);
+
+ page.Ops.OfType().Should().Contain(x => !x.QrCode && x.Content == "S202607180001");
+ page.Ops.OfType().Should().Contain(x => x.QrCode && x.Content == "S202607180001");
+ page.Ops.OfType().Should().Contain(x => !x.Vertical);
+ page.Ops.OfType().Should().ContainSingle();
+
+ // 纸张 mm → pt:100mm ≈ 283.46pt
+ var pageWidthPt = page.WidthMm * 72f / 25.4f;
+ pageWidthPt.Should().BeApproximately(283.46f, 0.1f);
+ }
+
+ [Fact]
+ public void Parse_MissingPanels_ShouldThrow()
+ {
+ var parser = new HiprintTemplateParser();
+ var act = () => parser.Parse("""{"panels":[]}""", new Dictionary());
+ act.Should().Throw();
+ }
+
+ internal static string LoadFixture(string fileName)
+ {
+ var dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
+ var path = Path.Combine(dir, "Fixtures", "LabelTemplates", fileName);
+ if (!File.Exists(path))
+ {
+ // 开发态:从源码目录相对查找
+ path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Fixtures", "LabelTemplates", fileName));
+ }
+
+ return File.ReadAllText(path, Encoding.UTF8);
+ }
+}
diff --git a/src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs b/src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs
new file mode 100644
index 0000000..e201f8d
--- /dev/null
+++ b/src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs
@@ -0,0 +1,29 @@
+using System.Text;
+using FluentAssertions;
+using Oms.Application.Labeling.Hiprint;
+
+namespace Oms.Tests.Labeling;
+
+public class QuestPdfLabelRendererTests
+{
+ [Fact]
+ public async Task RenderAsync_SampleTemplate_ShouldProducePdfBytes()
+ {
+ var json = HiprintTemplateParserTests.LoadFixture("sample_100x150.json");
+ var printData = new Dictionary
+ {
+ ["serviceNo"] = "S202607180001",
+ ["recipientName"] = "John Lee",
+ ["recipientAddressFull"] = "xxx Street, City, CA 90001",
+ ["actualWeight"] = 1.25
+ };
+
+ var renderer = new QuestPdfLabelRenderer(new HiprintTemplateParser());
+ var bytes = await renderer.RenderAsync(json, printData);
+
+ bytes.Should().NotBeNullOrEmpty();
+ // %PDF
+ Encoding.ASCII.GetString(bytes.AsSpan(0, 4)).Should().Be("%PDF");
+ bytes.Length.Should().BeGreaterThan(500);
+ }
+}
diff --git a/src/Oms.Tests/Oms.Tests.csproj b/src/Oms.Tests/Oms.Tests.csproj
index 07d7250..19f3343 100644
--- a/src/Oms.Tests/Oms.Tests.csproj
+++ b/src/Oms.Tests/Oms.Tests.csproj
@@ -29,4 +29,8 @@
+
+
+
+