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 &lt;src&gt;\&lt;SimpleAdmin.Api.IntegrationTests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Project Location="D:\my\itfx\admin\src\SimpleAdmin.Api.IntegrationTests" Presentation="&lt;src&gt;\&lt;SimpleAdmin.Api.IntegrationTests&gt;" /> -</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 @@ + + + +