Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
885e320432
5
.gitignore
vendored
5
.gitignore
vendored
@ -9,8 +9,13 @@ riderModule.iml
|
|||||||
.idea/
|
.idea/
|
||||||
/.vs
|
/.vs
|
||||||
/Fake.sln.DotSettings.user
|
/Fake.sln.DotSettings.user
|
||||||
|
*.DotSettings.user
|
||||||
|
SimpleAdmin.sln.DotSettings.user
|
||||||
|
|
||||||
Logs/
|
Logs/
|
||||||
|
*.log
|
||||||
|
api-*.log
|
||||||
|
build-warnings.txt
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClient_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fc7d66b24252bb52da52d1262d99fd654ffc18aedde818f147c3daf6aee3e63_003FClient_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityMaintenance_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003F49_003Fee705f67_003FEntityMaintenance_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIAggregateRoot_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6015bad29db142679ead208ae4ecd55c7800_003F0e_003F66feb813_003FIAggregateRoot_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIAggregateRoot_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6015bad29db142679ead208ae4ecd55c7800_003F0e_003F66feb813_003FIAggregateRoot_002Ecs_002Fz_003A2_002D0/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIAggregateRoot_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa1494e50f25d476db4fff59a546c2b857800_003F4e_003Fb27c424d_003FIAggregateRoot_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIAggregateRoot_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa1494e50f25d476db4fff59a546c2b857800_003F4e_003Fb27c424d_003FIAggregateRoot_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIAggregateRoot_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6015bad29db142679ead208ae4ecd55c7800_003F9a_003Fea4337c7_003FIAggregateRoot_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIAggregateRoot_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa1494e50f25d476db4fff59a546c2b857800_003F7b_003Fe45647d2_003FIAggregateRoot_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIAggregateRoot_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa1494e50f25d476db4fff59a546c2b857800_003F7b_003Fe45647d2_003FIAggregateRoot_00601_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIEntity_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa1494e50f25d476db4fff59a546c2b857800_003F59_003F8ca0c929_003FIEntity_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIRepository_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6015bad29db142679ead208ae4ecd55c7800_003F85_003Fa6db724e_003FIRepository_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIRepository_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F98ce6bfbfce34b1eabb7b03e820f0eea7800_003F66_003F56b27e93_003FIRepository_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIRepository_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa1494e50f25d476db4fff59a546c2b857800_003Fcc_003F73f30804_003FIRepository_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AObjectMethodExecutorAwaitable_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F61426bb2f5bd47ebb048d0e7051942f91de910_003Fe1_003F409ba3a2_003FObjectMethodExecutorAwaitable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryBuilder_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003Fc7_003F431a0b15_003FQueryBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReflectionInoCacheService_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003Fbb_003F446201ec_003FReflectionInoCacheService_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReflectionInoCore_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003Fc1_003F8f35c8ed_003FReflectionInoCore_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASqlBuilderProvider_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003F71_003F77c8fbe4_003FSqlBuilderProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASqlSugarClient_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003Fe8_003Ffcf6435d_003FSqlSugarClient_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASqlSugarProvider_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003F26_003Fce9a7a77_003FSqlSugarProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnauthorizedAccessException_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3e10648195244ebfa276c71cb5b3f877c90750_003F74_003F8418b441_003FUnauthorizedAccessException_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=9c7e285e_002D68dc_002D4e8e_002D88cb_002De8df37de5da5/@EntryIndexedValue"><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></s:String></wpf:ResourceDictionary>
|
|
||||||
63
sql/fix_concurrency_stamp_missing.sql
Normal file
63
sql/fix_concurrency_stamp_missing.sql
Normal file
@ -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;
|
||||||
87
sql/migrate_label_pdf_fields.sql
Normal file
87
sql/migrate_label_pdf_fields.sql
Normal file
@ -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'
|
||||||
|
);
|
||||||
106
sql/migrate_t_order_stock_bind.sql
Normal file
106
sql/migrate_t_order_stock_bind.sql
Normal file
@ -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);
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
namespace Oms.Application.Dtos.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签模板列表项(不含 TemplateJson,列表更轻)。
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
namespace Oms.Application.Dtos.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签模板预览入参。
|
||||||
|
/// </summary>
|
||||||
|
public class LabelTemplatePreviewInput
|
||||||
|
{
|
||||||
|
/// <summary>已保存模板 Id(与 TemplateJson 二选一,优先 Id)。</summary>
|
||||||
|
public int? TemplateId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>未保存时可直接传设计器 JSON。</summary>
|
||||||
|
public object? TemplateJson { get; set; }
|
||||||
|
|
||||||
|
/// <summary>可选 PrintData;空则用 Mock。</summary>
|
||||||
|
public Dictionary<string, object?>? PrintData { get; set; }
|
||||||
|
}
|
||||||
@ -40,6 +40,11 @@ public class SalesProductDetailOutput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CustomerWaybill { get; set; }
|
public string? CustomerWaybill { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户面单绑定的标签模板 Id。
|
||||||
|
/// </summary>
|
||||||
|
public int? LabelTemplateId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 黑名单分区主键。
|
/// 黑名单分区主键。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -40,6 +40,11 @@ public class SalesProductListOutput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CustomerWaybill { get; set; }
|
public string? CustomerWaybill { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户面单绑定的标签模板 Id。
|
||||||
|
/// </summary>
|
||||||
|
public int? LabelTemplateId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 黑名单分区主键。
|
/// 黑名单分区主键。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -38,10 +38,15 @@ public class SalesProductSaveInput
|
|||||||
public int? MaxTimeliness { get; set; }
|
public int? MaxTimeliness { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 客户面单类型。
|
/// 客户面单类型(旧枚举,兼容展示)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CustomerWaybill { get; set; }
|
public string? CustomerWaybill { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户面单绑定的标签模板 Id。
|
||||||
|
/// </summary>
|
||||||
|
public int? LabelTemplateId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 黑名单分区主键。
|
/// 黑名单分区主键。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -62,6 +62,11 @@ public class LabelPrintRecordOutput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? OperatorName { get; set; }
|
public string? OperatorName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面单 PDF 地址。
|
||||||
|
/// </summary>
|
||||||
|
public string? PrintUrl { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 创建时间。
|
/// 创建时间。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
109
src/Oms.Application/Dtos/Operations/PrintLabelLookupOutput.cs
Normal file
109
src/Oms.Application/Dtos/Operations/PrintLabelLookupOutput.cs
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
namespace Oms.Application.Dtos.Operations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打印标签扫码查单入参(Query)。
|
||||||
|
/// </summary>
|
||||||
|
public class PrintLabelLookupInput
|
||||||
|
{
|
||||||
|
/// <summary>扫描号码。</summary>
|
||||||
|
public string Number { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 号码类型:auto / waybill / customerOrder / tracking / other / parcel / sku。
|
||||||
|
/// 空视为 auto。
|
||||||
|
/// </summary>
|
||||||
|
public string? NumberType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打印标签查单结果:订单头 + 绑定明细,供操作员核对后打印。
|
||||||
|
/// </summary>
|
||||||
|
public class PrintLabelLookupOutput
|
||||||
|
{
|
||||||
|
/// <summary>是否唯一命中订单。</summary>
|
||||||
|
public bool Matched { get; set; }
|
||||||
|
|
||||||
|
/// <summary>实际命中的号码类型。</summary>
|
||||||
|
public string? MatchedNumberType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>告警:noneBind / multiMatch / alreadyPrinted 等。</summary>
|
||||||
|
public List<string> 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; }
|
||||||
|
|
||||||
|
/// <summary>none / bound / deducted / released</summary>
|
||||||
|
public string BindStatus { get; set; } = "none";
|
||||||
|
|
||||||
|
/// <summary>绑定件数合计。</summary>
|
||||||
|
public int BindQtyTotal { get; set; }
|
||||||
|
|
||||||
|
/// <summary>绑定行数。</summary>
|
||||||
|
public int BindLineCount { get; set; }
|
||||||
|
|
||||||
|
public List<PrintLabelBindItemOutput> BindItems { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>SKU/模糊扫到多单时的候选列表。</summary>
|
||||||
|
public List<PrintLabelLookupCandidateOutput> 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; }
|
||||||
|
}
|
||||||
@ -95,6 +95,16 @@ public class OrderOutput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int? CustomerId { get; set; }
|
public int? CustomerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户名称。
|
||||||
|
/// </summary>
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户编码。
|
||||||
|
/// </summary>
|
||||||
|
public string? CustomerCode { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 订单来源。
|
/// 订单来源。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -6,7 +6,17 @@ namespace Oms.Application.Dtos.Orders;
|
|||||||
public class OrderPrintOutput
|
public class OrderPrintOutput
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 打印地址。
|
/// 打印地址(PDF)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string PrintUrl { get; set; } = string.Empty;
|
public string PrintUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面单生成状态:Pending / Ready / Failed。
|
||||||
|
/// </summary>
|
||||||
|
public string? LabelGenerateStatus { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面单生成失败原因。
|
||||||
|
/// </summary>
|
||||||
|
public string? LabelGenerateError { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
159
src/Oms.Application/Dtos/Orders/OrderStockBindDtos.cs
Normal file
159
src/Oms.Application/Dtos/Orders/OrderStockBindDtos.cs
Normal file
@ -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; }
|
||||||
|
|
||||||
|
/// <summary>none / bound / deducted</summary>
|
||||||
|
public string BindStatus { get; set; } = "none";
|
||||||
|
|
||||||
|
public List<OrderStockBindItemOutput> 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; }
|
||||||
|
|
||||||
|
/// <summary>包裹候选:空/all=全部生命周期。</summary>
|
||||||
|
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<OrderStockBindParcelItemCandidateOutput> 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<OrderStockBindSaveItemInput> 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; }
|
||||||
|
}
|
||||||
@ -24,6 +24,10 @@ public class PlatformStoreOutput
|
|||||||
|
|
||||||
public int CustomerId { get; set; }
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerCode { get; set; }
|
||||||
|
|
||||||
public string PlatformCode { get; set; } = string.Empty;
|
public string PlatformCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string PlatformName { get; set; } = string.Empty;
|
public string PlatformName { get; set; } = string.Empty;
|
||||||
@ -186,6 +190,10 @@ public class PlatformStoreOrderOutput
|
|||||||
|
|
||||||
public int CustomerId { get; set; }
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerCode { get; set; }
|
||||||
|
|
||||||
public int StoreId { get; set; }
|
public int StoreId { get; set; }
|
||||||
|
|
||||||
public string StoreNickname { get; set; } = string.Empty;
|
public string StoreNickname { get; set; } = string.Empty;
|
||||||
|
|||||||
@ -24,6 +24,10 @@ public class WarehouseSkuOutput
|
|||||||
|
|
||||||
public int CustomerId { get; set; }
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerCode { get; set; }
|
||||||
|
|
||||||
public string SkuCode { get; set; } = string.Empty;
|
public string SkuCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string SkuName { get; set; } = string.Empty;
|
public string SkuName { get; set; } = string.Empty;
|
||||||
@ -166,6 +170,10 @@ public class WarehouseInboundOutput
|
|||||||
|
|
||||||
public int CustomerId { get; set; }
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerCode { get; set; }
|
||||||
|
|
||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
|
|
||||||
public string NoticeNo { get; set; } = string.Empty;
|
public string NoticeNo { get; set; } = string.Empty;
|
||||||
@ -354,6 +362,10 @@ public class WarehouseParcelItemOutput
|
|||||||
|
|
||||||
public int Quantity { get; set; }
|
public int Quantity { get; set; }
|
||||||
|
|
||||||
|
public int FrozenQty { get; set; }
|
||||||
|
|
||||||
|
public int AvailableQty { get; set; }
|
||||||
|
|
||||||
public int? SkuId { get; set; }
|
public int? SkuId { get; set; }
|
||||||
|
|
||||||
public string? SkuCode { get; set; }
|
public string? SkuCode { get; set; }
|
||||||
@ -369,6 +381,10 @@ public class WarehouseParcelOutput
|
|||||||
|
|
||||||
public int CustomerId { get; set; }
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerCode { get; set; }
|
||||||
|
|
||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
|
|
||||||
public string TrackingNo { get; set; } = string.Empty;
|
public string TrackingNo { get; set; } = string.Empty;
|
||||||
@ -381,6 +397,15 @@ public class WarehouseParcelOutput
|
|||||||
|
|
||||||
public string? Remark { get; set; }
|
public string? Remark { get; set; }
|
||||||
|
|
||||||
|
/// <summary>商品总数量。</summary>
|
||||||
|
public int TotalQty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>已冻结数量(订单绑定占用)。</summary>
|
||||||
|
public int FrozenQty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>可用数量 = 总量 - 冻结。</summary>
|
||||||
|
public int AvailableQty { get; set; }
|
||||||
|
|
||||||
public List<WarehouseParcelItemOutput> Items { get; set; } = [];
|
public List<WarehouseParcelItemOutput> Items { get; set; } = [];
|
||||||
|
|
||||||
public DateTime CreateTime { get; set; }
|
public DateTime CreateTime { get; set; }
|
||||||
@ -437,6 +462,12 @@ public class WarehouseInventoryListInput : WarehousePageInput
|
|||||||
|
|
||||||
public class WarehouseInventoryOutput
|
public class WarehouseInventoryOutput
|
||||||
{
|
{
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerCode { get; set; }
|
||||||
|
|
||||||
public int SkuId { get; set; }
|
public int SkuId { get; set; }
|
||||||
|
|
||||||
public string SkuCode { get; set; } = string.Empty;
|
public string SkuCode { get; set; } = string.Empty;
|
||||||
@ -447,6 +478,10 @@ public class WarehouseInventoryOutput
|
|||||||
|
|
||||||
public int TotalQty { get; set; }
|
public int TotalQty { get; set; }
|
||||||
|
|
||||||
|
public int FrozenQty { get; set; }
|
||||||
|
|
||||||
|
public int AvailableQty { get; set; }
|
||||||
|
|
||||||
public int BinCount { get; set; }
|
public int BinCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -461,6 +496,10 @@ public class WarehouseInventoryBinDetailOutput
|
|||||||
public string? BinName { get; set; }
|
public string? BinName { get; set; }
|
||||||
|
|
||||||
public int Qty { get; set; }
|
public int Qty { get; set; }
|
||||||
|
|
||||||
|
public int FrozenQty { get; set; }
|
||||||
|
|
||||||
|
public int AvailableQty { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WarehouseInventoryRelocateInput
|
public class WarehouseInventoryRelocateInput
|
||||||
@ -483,6 +522,36 @@ public class WarehouseInventoryRelocateInput
|
|||||||
public int Qty { get; set; }
|
public int Qty { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class WarehouseInventoryHistoryInput : WarehousePageInput
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public int SkuId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 流水类型:all / inbound / deduct;空视为 all。
|
||||||
|
/// </summary>
|
||||||
|
public string? MovementType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WarehouseInventoryHistoryOutput
|
||||||
|
{
|
||||||
|
/// <summary>inbound 入库 / deduct 扣减。</summary>
|
||||||
|
public string MovementType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>变动数量(入库为正,扣减为正数,前端按类型展示符号)。</summary>
|
||||||
|
public int Qty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>关联单号:入库预报号 / 物流单号。</summary>
|
||||||
|
public string? RefNo { get; set; }
|
||||||
|
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
|
||||||
|
public DateTime OccurredAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
// --- Parcel arrival(快递到仓)---
|
// --- Parcel arrival(快递到仓)---
|
||||||
|
|
||||||
public class WarehouseParcelArrivalListInput : WarehousePageInput
|
public class WarehouseParcelArrivalListInput : WarehousePageInput
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 条码 / 二维码 PNG 生成。
|
||||||
|
/// </summary>
|
||||||
|
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<Rgba32>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/Oms.Application/Labeling/Hiprint/HiprintModels.cs
Normal file
95
src/Oms.Application/Labeling/Hiprint/HiprintModels.cs
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Oms.Application.Labeling.Hiprint;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// vg-print / hiprint 导出的模板根节点(只映射渲染所需字段)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HiprintTemplateDocument
|
||||||
|
{
|
||||||
|
[JsonPropertyName("panels")]
|
||||||
|
public List<HiprintPanel> Panels { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HiprintPanel
|
||||||
|
{
|
||||||
|
[JsonPropertyName("index")]
|
||||||
|
public int Index { get; set; }
|
||||||
|
|
||||||
|
/// <summary>纸宽,单位 mm。</summary>
|
||||||
|
[JsonPropertyName("width")]
|
||||||
|
public float Width { get; set; } = 100f;
|
||||||
|
|
||||||
|
/// <summary>纸高,单位 mm。</summary>
|
||||||
|
[JsonPropertyName("height")]
|
||||||
|
public float Height { get; set; } = 150f;
|
||||||
|
|
||||||
|
[JsonPropertyName("printElements")]
|
||||||
|
public List<HiprintPrintElement> 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; }
|
||||||
|
}
|
||||||
201
src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs
Normal file
201
src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Fake.Application;
|
||||||
|
using Fake.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Oms.Application.Labeling.Hiprint;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 hiprint TemplateJson 转为中间绘制 IR。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HiprintTemplateParser : ITransientDependency
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public LabelPageIr Parse(string templateJson, IReadOnlyDictionary<string, object?> printData)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(templateJson))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("模板 JSON 为空", nameof(templateJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc = JsonSerializer.Deserialize<HiprintTemplateDocument>(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<LabelDrawOp>();
|
||||||
|
|
||||||
|
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<string, object?> 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<string, object?> 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<string, object?> printData)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(field) && printData.TryGetValue(field, out var raw) && raw != null)
|
||||||
|
{
|
||||||
|
return Convert.ToString(raw, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Oms.Application/Labeling/Hiprint/LabelDrawOps.cs
Normal file
56
src/Oms.Application/Labeling/Hiprint/LabelDrawOps.cs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
namespace Oms.Application.Labeling.Hiprint;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 中间绘制指令(与具体 PDF 引擎解耦)。
|
||||||
|
/// 坐标单位:pt;页面尺寸:mm(再换算)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LabelPageIr
|
||||||
|
{
|
||||||
|
public float WidthMm { get; init; } = 100f;
|
||||||
|
|
||||||
|
public float HeightMm { get; init; } = 150f;
|
||||||
|
|
||||||
|
public List<LabelDrawOp> 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; }
|
||||||
|
}
|
||||||
170
src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs
Normal file
170
src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
using Fake.DependencyInjection;
|
||||||
|
using QuestPDF.Fluent;
|
||||||
|
using QuestPDF.Helpers;
|
||||||
|
using QuestPDF.Infrastructure;
|
||||||
|
|
||||||
|
namespace Oms.Application.Labeling.Hiprint;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// QuestPDF 实现的面单渲染器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabelPdfRenderer, ITransientDependency
|
||||||
|
{
|
||||||
|
private static int _licenseConfigured;
|
||||||
|
|
||||||
|
public Task<byte[]> RenderAsync(
|
||||||
|
string templateJson,
|
||||||
|
IReadOnlyDictionary<string, object?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Oms.Application/Labeling/ILabelPdfRenderer.cs
Normal file
12
src/Oms.Application/Labeling/ILabelPdfRenderer.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
namespace Oms.Application.Labeling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面单 PDF 渲染器:TemplateJson + PrintData → PDF bytes。
|
||||||
|
/// </summary>
|
||||||
|
public interface ILabelPdfRenderer
|
||||||
|
{
|
||||||
|
Task<byte[]> RenderAsync(
|
||||||
|
string templateJson,
|
||||||
|
IReadOnlyDictionary<string, object?> printData,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
120
src/Oms.Application/Labeling/LabelFieldDictionary.cs
Normal file
120
src/Oms.Application/Labeling/LabelFieldDictionary.cs
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
namespace Oms.Application.Labeling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设计器字段字典(与 PrintData camelCase 对齐)。
|
||||||
|
/// </summary>
|
||||||
|
public static class LabelFieldDictionary
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<LabelFieldGroup> 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<LabelFieldItem> Fields { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LabelFieldItem
|
||||||
|
{
|
||||||
|
public string Field { get; init; } = string.Empty;
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
183
src/Oms.Application/Labeling/LabelPdfGenerationService.cs
Normal file
183
src/Oms.Application/Labeling/LabelPdfGenerationService.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面单 PDF 生成:解析模板 → 渲染 → 落盘 → 回写订单。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LabelPdfGenerationService(
|
||||||
|
ILabelPdfRenderer renderer,
|
||||||
|
ILabelTemplateRepository labelTemplateRepository,
|
||||||
|
ISalesProductRepository salesProductRepository,
|
||||||
|
ISystemOtherConfigRepository systemOtherConfigRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
IWebHostEnvironment webHostEnvironment,
|
||||||
|
ILogger<LabelPdfGenerationService> logger) : ITransientDependency
|
||||||
|
{
|
||||||
|
public async Task<LabelPdfGenerateResult> 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<byte[]> PreviewAsync(
|
||||||
|
string templateJson,
|
||||||
|
IReadOnlyDictionary<string, object?>? 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<int?> 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<int?> 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<LabelPdfGenerateResult> 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<string> 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; }
|
||||||
|
}
|
||||||
199
src/Oms.Application/Labeling/LabelPrintDataBuilder.cs
Normal file
199
src/Oms.Application/Labeling/LabelPrintDataBuilder.cs
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Oms.Application.Dtos.Orders;
|
||||||
|
|
||||||
|
namespace Oms.Application.Labeling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将订单出参映射为 hiprint PrintData(camelCase)。
|
||||||
|
/// </summary>
|
||||||
|
public static class LabelPrintDataBuilder
|
||||||
|
{
|
||||||
|
public static IReadOnlyDictionary<string, object?> 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<string, object?>(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<string, object?>
|
||||||
|
{
|
||||||
|
["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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设计预览用的 Mock PrintData。
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyDictionary<string, object?> BuildMock() =>
|
||||||
|
new Dictionary<string, object?>(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<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
["skuCode"] = "SKU001",
|
||||||
|
["skuName"] = "T-Shirt",
|
||||||
|
["qty"] = 2,
|
||||||
|
["weight"] = 0.2m,
|
||||||
|
["declareNameEn"] = "T-Shirt",
|
||||||
|
["declareValue"] = 9.9m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static OrderWeightOutput? PickWeight(IReadOnlyList<OrderWeightOutput> 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));
|
||||||
|
}
|
||||||
@ -41,6 +41,7 @@ public static class OrderBatchQueueTopology
|
|||||||
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.MarkProblematic => RoutingExceptionPrefix + "mark-problematic",
|
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.MarkProblematic => RoutingExceptionPrefix + "mark-problematic",
|
||||||
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.MarkClaimed => RoutingExceptionPrefix + "mark-claimed",
|
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.MarkClaimed => RoutingExceptionPrefix + "mark-claimed",
|
||||||
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.Print => RoutingDocumentPrefix + "print",
|
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.Print => RoutingDocumentPrefix + "print",
|
||||||
|
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.GenerateLabelPdf => RoutingDocumentPrefix + "generate-label-pdf",
|
||||||
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.Export => RoutingDocumentPrefix + "export",
|
Domain.OrdersAggregate.Consts.OrderBatchActionCodes.Export => RoutingDocumentPrefix + "export",
|
||||||
_ => "unknown." + action
|
_ => "unknown." + action
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,7 +26,10 @@
|
|||||||
<PackageReference Include="FreeRedis" Version="1.5.5" />
|
<PackageReference Include="FreeRedis" Version="1.5.5" />
|
||||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||||
<PackageReference Include="MiniExcel" Version="1.43.1" />
|
<PackageReference Include="MiniExcel" Version="1.43.1" />
|
||||||
|
<PackageReference Include="QuestPDF" Version="2025.7.0" />
|
||||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
|
<PackageReference Include="ZXing.Net" Version="0.16.10" />
|
||||||
|
<PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.15" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Oms.Application.Dtos.Channels;
|
using Oms.Application.Dtos.Channels;
|
||||||
|
using Oms.Application.Dtos.Shared;
|
||||||
|
using Oms.Application.Labeling;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Domain.ChannelsAggregate.Entities;
|
using Oms.Domain.ChannelsAggregate.Entities;
|
||||||
using Oms.Domain.ChannelsAggregate.Repositories;
|
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||||
@ -14,12 +16,105 @@ namespace Oms.Application.Services.Channels;
|
|||||||
public class LabelTemplateService : ApplicationService
|
public class LabelTemplateService : ApplicationService
|
||||||
{
|
{
|
||||||
private ILabelTemplateRepository LabelTemplateRepository => LazyServiceProvider.GetRequiredService<ILabelTemplateRepository>();
|
private ILabelTemplateRepository LabelTemplateRepository => LazyServiceProvider.GetRequiredService<ILabelTemplateRepository>();
|
||||||
|
private LabelPdfGenerationService LabelPdfGenerationService =>
|
||||||
|
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签模板列表(不含 JSON,供浏览/下拉)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("/channel/label-template/list")]
|
||||||
|
public async Task<OmsItemsResult<LabelTemplateListItemOutput>> 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<LabelTemplateListItemOutput> { Items = items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签模板详情(含 TemplateJson,编辑用)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("/channel/label-template/{id:int}")]
|
||||||
|
public async Task<LabelTemplateSaveOutput> GetAsync(int id)
|
||||||
|
{
|
||||||
|
var entity = await GetEntityOrThrowAsync(id);
|
||||||
|
return MapOutput(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设计器字段字典(可拖字段分组)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("/channel/label-template/field-dictionary")]
|
||||||
|
public Task<OmsItemsResult<LabelFieldGroup>> GetFieldDictionaryAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(new OmsItemsResult<LabelFieldGroup>
|
||||||
|
{
|
||||||
|
Items = LabelFieldDictionary.GetGroups().ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 预览 PDF(同一套 ILabelPdfRenderer)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/channel/label-template/preview-pdf")]
|
||||||
|
public async Task<FileContentResult> 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<string, object?>? 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 保存标签模板。
|
/// 保存标签模板。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">入参:标签模板保存参数。</param>
|
|
||||||
/// <returns>标签模板保存结果。</returns>
|
|
||||||
[HttpPost("/channel/label-template/save")]
|
[HttpPost("/channel/label-template/save")]
|
||||||
public async Task<LabelTemplateSaveOutput> SaveAsync(LabelTemplateSaveInput input)
|
public async Task<LabelTemplateSaveOutput> SaveAsync(LabelTemplateSaveInput input)
|
||||||
{
|
{
|
||||||
@ -48,6 +143,16 @@ public class LabelTemplateService : ApplicationService
|
|||||||
return MapOutput(entity);
|
return MapOutput(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除标签模板。
|
||||||
|
/// </summary>
|
||||||
|
[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)
|
private async Task ValidateUniqueNameAsync(string name, string? templateType, int? id)
|
||||||
{
|
{
|
||||||
var existed = await LabelTemplateRepository.GetByNameAndTypeAsync(name, templateType);
|
var existed = await LabelTemplateRepository.GetByNameAndTypeAsync(name, templateType);
|
||||||
|
|||||||
@ -207,6 +207,7 @@ public partial class SalesProductService
|
|||||||
MinTimeliness = product.MinTimeliness,
|
MinTimeliness = product.MinTimeliness,
|
||||||
MaxTimeliness = product.MaxTimeliness,
|
MaxTimeliness = product.MaxTimeliness,
|
||||||
CustomerWaybill = product.CustomerWaybill,
|
CustomerWaybill = product.CustomerWaybill,
|
||||||
|
LabelTemplateId = product.LabelTemplateId,
|
||||||
BlacklistAreaId = product.BlacklistAreaId,
|
BlacklistAreaId = product.BlacklistAreaId,
|
||||||
RemoteAreaId = product.RemoteAreaId,
|
RemoteAreaId = product.RemoteAreaId,
|
||||||
PriceAreaId = product.PriceAreaId,
|
PriceAreaId = product.PriceAreaId,
|
||||||
@ -311,6 +312,7 @@ public partial class SalesProductService
|
|||||||
entity.MinTimeliness = input.MinTimeliness;
|
entity.MinTimeliness = input.MinTimeliness;
|
||||||
entity.MaxTimeliness = input.MaxTimeliness;
|
entity.MaxTimeliness = input.MaxTimeliness;
|
||||||
entity.CustomerWaybill = input.CustomerWaybill;
|
entity.CustomerWaybill = input.CustomerWaybill;
|
||||||
|
entity.LabelTemplateId = input.LabelTemplateId;
|
||||||
entity.BlacklistAreaId = input.BlacklistAreaId;
|
entity.BlacklistAreaId = input.BlacklistAreaId;
|
||||||
entity.RemoteAreaId = input.RemoteAreaId;
|
entity.RemoteAreaId = input.RemoteAreaId;
|
||||||
entity.PriceAreaId = input.PriceAreaId;
|
entity.PriceAreaId = input.PriceAreaId;
|
||||||
@ -334,6 +336,7 @@ public partial class SalesProductService
|
|||||||
MinTimeliness = entity.MinTimeliness,
|
MinTimeliness = entity.MinTimeliness,
|
||||||
MaxTimeliness = entity.MaxTimeliness,
|
MaxTimeliness = entity.MaxTimeliness,
|
||||||
CustomerWaybill = entity.CustomerWaybill,
|
CustomerWaybill = entity.CustomerWaybill,
|
||||||
|
LabelTemplateId = entity.LabelTemplateId,
|
||||||
BlacklistAreaId = entity.BlacklistAreaId,
|
BlacklistAreaId = entity.BlacklistAreaId,
|
||||||
RemoteAreaId = entity.RemoteAreaId,
|
RemoteAreaId = entity.RemoteAreaId,
|
||||||
PriceAreaId = entity.PriceAreaId,
|
PriceAreaId = entity.PriceAreaId,
|
||||||
@ -360,6 +363,7 @@ public partial class SalesProductService
|
|||||||
MinTimeliness = entity.MinTimeliness,
|
MinTimeliness = entity.MinTimeliness,
|
||||||
MaxTimeliness = entity.MaxTimeliness,
|
MaxTimeliness = entity.MaxTimeliness,
|
||||||
CustomerWaybill = entity.CustomerWaybill,
|
CustomerWaybill = entity.CustomerWaybill,
|
||||||
|
LabelTemplateId = entity.LabelTemplateId,
|
||||||
BlacklistAreaId = entity.BlacklistAreaId,
|
BlacklistAreaId = entity.BlacklistAreaId,
|
||||||
RemoteAreaId = entity.RemoteAreaId,
|
RemoteAreaId = entity.RemoteAreaId,
|
||||||
PriceAreaId = entity.PriceAreaId,
|
PriceAreaId = entity.PriceAreaId,
|
||||||
|
|||||||
@ -1,12 +1,20 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Oms.Application.Dtos.Operations;
|
using Oms.Application.Dtos.Operations;
|
||||||
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Dtos.Shared;
|
using Oms.Application.Dtos.Shared;
|
||||||
|
using Oms.Application.Services.Orders;
|
||||||
|
using Oms.Application.Shared;
|
||||||
using Oms.Domain.ChannelsAggregate.Entities;
|
using Oms.Domain.ChannelsAggregate.Entities;
|
||||||
using Oms.Domain.ChannelsAggregate.Repositories;
|
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||||
using Oms.Domain.OperationsAggregate.Entities;
|
using Oms.Domain.OperationsAggregate.Entities;
|
||||||
using Oms.Domain.OperationsAggregate.Repositories;
|
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.Shared.Enums;
|
||||||
|
using Oms.Domain.SystemAggregate.Repositories;
|
||||||
|
using Oms.Domain.WarehouseAggregate.Repositories;
|
||||||
|
|
||||||
namespace Oms.Application.Services.Operations;
|
namespace Oms.Application.Services.Operations;
|
||||||
|
|
||||||
@ -18,12 +26,71 @@ public class LabelPrintService : ApplicationService
|
|||||||
{
|
{
|
||||||
private ILabelPrintRecordRepository LabelPrintRecordRepository => LazyServiceProvider.GetRequiredService<ILabelPrintRecordRepository>();
|
private ILabelPrintRecordRepository LabelPrintRecordRepository => LazyServiceProvider.GetRequiredService<ILabelPrintRecordRepository>();
|
||||||
private ILabelTemplateRepository LabelTemplateRepository => LazyServiceProvider.GetRequiredService<ILabelTemplateRepository>();
|
private ILabelTemplateRepository LabelTemplateRepository => LazyServiceProvider.GetRequiredService<ILabelTemplateRepository>();
|
||||||
|
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService<IOrderRepository>();
|
||||||
|
private IOrderStockBindRepository OrderStockBindRepository => LazyServiceProvider.GetRequiredService<IOrderStockBindRepository>();
|
||||||
|
private ICParcelRepository ParcelRepository => LazyServiceProvider.GetRequiredService<ICParcelRepository>();
|
||||||
|
private ICSkuRepository SkuRepository => LazyServiceProvider.GetRequiredService<ICSkuRepository>();
|
||||||
|
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
|
||||||
|
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
|
||||||
|
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
|
||||||
|
private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 扫码查单:运单/客户单/跟踪号/其他单号/包裹快递单号/SKU → 订单 + 绑定明细。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("/operation/print-label/lookup")]
|
||||||
|
public async Task<PrintLabelLookupOutput> 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<PrintLabelLookupCandidateOutput>? 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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 执行面单打印。
|
/// 执行面单打印。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">入参:面单打印参数。</param>
|
|
||||||
/// <returns>面单打印记录。</returns>
|
|
||||||
[HttpPost("/operation/print-label/print")]
|
[HttpPost("/operation/print-label/print")]
|
||||||
public async Task<LabelPrintRecordOutput> PrintAsync(PrintLabelInput input)
|
public async Task<LabelPrintRecordOutput> PrintAsync(PrintLabelInput input)
|
||||||
{
|
{
|
||||||
@ -43,6 +110,32 @@ public class LabelPrintService : ApplicationService
|
|||||||
labelTemplate = list.OrderByDescending(x => x.UpdateTime).ThenByDescending(x => x.Id).FirstOrDefault();
|
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 now = DateTime.Now;
|
||||||
var operatorId = CurrentUser.GetId<int>();
|
var operatorId = CurrentUser.GetId<int>();
|
||||||
var waybillNo = string.Empty;
|
var waybillNo = string.Empty;
|
||||||
@ -63,17 +156,17 @@ public class LabelPrintService : ApplicationService
|
|||||||
otherNumber = input.Number;
|
otherNumber = input.Number;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
waybillNo = input.Number;
|
waybillNo = string.IsNullOrWhiteSpace(input.Number) ? order.ServiceNo : input.Number;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var entity = new LabelPrintRecord
|
var entity = new LabelPrintRecord
|
||||||
{
|
{
|
||||||
WaybillNo = waybillNo,
|
WaybillNo = string.IsNullOrWhiteSpace(waybillNo) ? order.ServiceNo : waybillNo,
|
||||||
CustomerOrderNo = customerOrderNo,
|
CustomerOrderNo = customerOrderNo ?? order.ClientNo,
|
||||||
TrackingNo = trackingNo,
|
TrackingNo = trackingNo ?? order.TrackNo,
|
||||||
OtherNumber = otherNumber,
|
OtherNumber = otherNumber,
|
||||||
LabelTemplateId = labelTemplate?.Id ?? input.LabelTemplateId,
|
LabelTemplateId = labelTemplate?.Id ?? order.LabelTemplateId ?? input.LabelTemplateId,
|
||||||
PrintSuccess = EnableStatus.Enabled,
|
PrintSuccess = EnableStatus.Enabled,
|
||||||
PrintTime = now,
|
PrintTime = now,
|
||||||
OperatorId = operatorId
|
OperatorId = operatorId
|
||||||
@ -83,17 +176,17 @@ public class LabelPrintService : ApplicationService
|
|||||||
var output = ObjectMapper.Map<LabelPrintRecord, LabelPrintRecordOutput>(savedEntity);
|
var output = ObjectMapper.Map<LabelPrintRecord, LabelPrintRecordOutput>(savedEntity);
|
||||||
output.LabelTemplateName = labelTemplate?.Name;
|
output.LabelTemplateName = labelTemplate?.Name;
|
||||||
output.OperatorName = CurrentUser.UserName;
|
output.OperatorName = CurrentUser.UserName;
|
||||||
|
output.PrintUrl = print.PrintUrl;
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询最近面单打印记录列表。
|
/// 查询最近面单打印记录列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="limit">入参:返回记录条数上限。</param>
|
|
||||||
/// <param name="labelTemplateId">入参:标签模板主键,可选。</param>
|
|
||||||
/// <returns>面单打印记录列表结果。</returns>
|
|
||||||
[HttpGet("/operation/print-label/recent-list")]
|
[HttpGet("/operation/print-label/recent-list")]
|
||||||
public async Task<OmsItemsResult<LabelPrintRecordOutput>> GetRecentListAsync([FromQuery] int limit = 20, [FromQuery] int? labelTemplateId = null)
|
public async Task<OmsItemsResult<LabelPrintRecordOutput>> GetRecentListAsync(
|
||||||
|
[FromQuery] int limit = 20,
|
||||||
|
[FromQuery] int? labelTemplateId = null)
|
||||||
{
|
{
|
||||||
var hasTemplateFilter = labelTemplateId.HasValue;
|
var hasTemplateFilter = labelTemplateId.HasValue;
|
||||||
var labelTemplateIdValue = labelTemplateId.GetValueOrDefault();
|
var labelTemplateIdValue = labelTemplateId.GetValueOrDefault();
|
||||||
@ -127,10 +220,9 @@ public class LabelPrintService : ApplicationService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询标签模板列表。
|
/// 查询标签模板列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="templateType">入参:模板类型,可选。</param>
|
|
||||||
/// <returns>标签模板列表结果。</returns>
|
|
||||||
[HttpGet("/operation/print-label/template-list")]
|
[HttpGet("/operation/print-label/template-list")]
|
||||||
public async Task<OmsItemsResult<LabelTemplateDropdownOutput>> GetTemplateDropdownListAsync([FromQuery] string? templateType = null)
|
public async Task<OmsItemsResult<LabelTemplateDropdownOutput>> GetTemplateDropdownListAsync(
|
||||||
|
[FromQuery] string? templateType = null)
|
||||||
{
|
{
|
||||||
var entities = await LabelTemplateRepository.GetListAsync(
|
var entities = await LabelTemplateRepository.GetListAsync(
|
||||||
x => string.IsNullOrWhiteSpace(templateType) || x.TemplateType == templateType);
|
x => string.IsNullOrWhiteSpace(templateType) || x.TemplateType == templateType);
|
||||||
@ -140,4 +232,244 @@ public class LabelPrintService : ApplicationService
|
|||||||
.ToList();
|
.ToList();
|
||||||
return new OmsItemsResult<LabelTemplateDropdownOutput> { Items = items };
|
return new OmsItemsResult<LabelTemplateDropdownOutput> { 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<PrintLabelLookupCandidateOutput>? 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<PrintLabelLookupCandidateOutput>? 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<PrintLabelLookupCandidateOutput>? 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<PrintLabelLookupCandidateOutput>? 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<List<PrintLabelLookupCandidateOutput>> 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<PrintLabelLookupOutput> 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<string>();
|
||||||
|
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<int, string>();
|
||||||
|
if (binIds.Length > 0)
|
||||||
|
{
|
||||||
|
var binRepo = LazyServiceProvider.GetRequiredService<ICBinLocationRepository>();
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ public sealed class OrderBatchActionExecutor(
|
|||||||
OrderBatchActionCodes.CancelIntercept => await orderActionHandler.ExecuteAsync(message, cancellationToken),
|
OrderBatchActionCodes.CancelIntercept => await orderActionHandler.ExecuteAsync(message, cancellationToken),
|
||||||
OrderBatchActionCodes.Delete => await orderActionHandler.ExecuteAsync(message, cancellationToken),
|
OrderBatchActionCodes.Delete => await orderActionHandler.ExecuteAsync(message, cancellationToken),
|
||||||
OrderBatchActionCodes.Print => 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.MarkProblematic => await orderActionHandler.ExecuteAsync(message, cancellationToken),
|
||||||
OrderBatchActionCodes.MarkClaimed => await orderActionHandler.ExecuteAsync(message, cancellationToken),
|
OrderBatchActionCodes.MarkClaimed => await orderActionHandler.ExecuteAsync(message, cancellationToken),
|
||||||
OrderBatchActionCodes.HandleStatusUpdate => await orderActionHandler.ExecuteAsync(message, cancellationToken),
|
OrderBatchActionCodes.HandleStatusUpdate => await orderActionHandler.ExecuteAsync(message, cancellationToken),
|
||||||
|
|||||||
@ -51,6 +51,18 @@ public sealed class OrderBatchOrderActionHandler(IServiceScopeFactory scopeFacto
|
|||||||
resultJson: JsonConvert.SerializeObject(new { printUrl = print.PrintUrl }));
|
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:
|
case OrderBatchActionCodes.MarkProblematic:
|
||||||
await orderService.MarkProblematicForBatchAsync(
|
await orderService.MarkProblematicForBatchAsync(
|
||||||
message.OrderId,
|
message.OrderId,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
|
using Oms.Application.Labeling;
|
||||||
using Oms.Application.Services.Orders.Issues;
|
using Oms.Application.Services.Orders.Issues;
|
||||||
using Oms.Domain.OrdersAggregate.Consts;
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
using Oms.Domain.OrdersAggregate.Entities;
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
@ -27,6 +28,20 @@ public partial class OrderService
|
|||||||
public Task<OrderPrintOutput> PrintForBatchAsync(int orderId, CancellationToken cancellationToken = default) =>
|
public Task<OrderPrintOutput> PrintForBatchAsync(int orderId, CancellationToken cancellationToken = default) =>
|
||||||
PrintAsync(new OrderActionInput { Id = orderId });
|
PrintAsync(new OrderActionInput { Id = orderId });
|
||||||
|
|
||||||
|
public async Task<LabelPdfGenerateResult> 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(
|
public async Task MarkProblematicForBatchAsync(
|
||||||
int orderId,
|
int orderId,
|
||||||
string? payloadJson,
|
string? payloadJson,
|
||||||
|
|||||||
@ -79,6 +79,16 @@ public partial class OrderService
|
|||||||
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
|
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
|
||||||
orders.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId }));
|
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 =>
|
return orders.Select(order =>
|
||||||
{
|
{
|
||||||
senderMap.TryGetValue(order.Id, out var sender);
|
senderMap.TryGetValue(order.Id, out var sender);
|
||||||
@ -91,6 +101,14 @@ public partial class OrderService
|
|||||||
var receivableAmount = feeOutputs
|
var receivableAmount = feeOutputs
|
||||||
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
|
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
|
||||||
.Sum(x => x.Amount);
|
.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
|
return new OrderOutput
|
||||||
{
|
{
|
||||||
Id = order.Id,
|
Id = order.Id,
|
||||||
@ -119,6 +137,8 @@ public partial class OrderService
|
|||||||
? warehouse.WarehouseName
|
? warehouse.WarehouseName
|
||||||
: null,
|
: null,
|
||||||
CustomerId = order.CustomerId,
|
CustomerId = order.CustomerId,
|
||||||
|
CustomerName = customerName,
|
||||||
|
CustomerCode = customerCode,
|
||||||
OrderSource = string.IsNullOrWhiteSpace(order.OrderSource)
|
OrderSource = string.IsNullOrWhiteSpace(order.OrderSource)
|
||||||
? OrderSourceCodes.Manual
|
? OrderSourceCodes.Manual
|
||||||
: order.OrderSource,
|
: order.OrderSource,
|
||||||
|
|||||||
@ -4,6 +4,8 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Oms.Application.Dtos.Channels;
|
using Oms.Application.Dtos.Channels;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Integrations.SmallBagTrack;
|
using Oms.Application.Integrations.SmallBagTrack;
|
||||||
|
using Oms.Application.Labeling;
|
||||||
|
using Oms.Application.Messaging;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Application.Services.Orders.Issues;
|
using Oms.Application.Services.Orders.Issues;
|
||||||
using Oms.Domain.ChannelsAggregate.Consts;
|
using Oms.Domain.ChannelsAggregate.Consts;
|
||||||
@ -166,6 +168,7 @@ public partial class OrderService
|
|||||||
await OrderRepository.UpdateAsync(order);
|
await OrderRepository.UpdateAsync(order);
|
||||||
|
|
||||||
await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver);
|
await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver);
|
||||||
|
await EnqueueGenerateLabelPdfAsync(order);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -1128,6 +1131,8 @@ public partial class OrderService
|
|||||||
PredeclareFailReason = string.Empty,
|
PredeclareFailReason = string.Empty,
|
||||||
SalesProductRulesVersion = snapshotBinding.RulesVersion,
|
SalesProductRulesVersion = snapshotBinding.RulesVersion,
|
||||||
SalesProductPriceVersion = snapshotBinding.PriceVersion,
|
SalesProductPriceVersion = snapshotBinding.PriceVersion,
|
||||||
|
LabelTemplateId = product.LabelTemplateId,
|
||||||
|
LabelGenerateStatus = LabelGenerateStatusCodes.Pending,
|
||||||
OrderStatus = OrderStatusCodes.Draft
|
OrderStatus = OrderStatusCodes.Draft
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1482,7 +1487,9 @@ public partial class OrderService
|
|||||||
TrackNo = order.TrackNo,
|
TrackNo = order.TrackNo,
|
||||||
ConsigneeCountry = order.ConsigneeCountry,
|
ConsigneeCountry = order.ConsigneeCountry,
|
||||||
ConsigneeCountryName = order.ConsigneeCountryName,
|
ConsigneeCountryName = order.ConsigneeCountryName,
|
||||||
LabelUrl = order.OrderStatus == OrderStatusCodes.Predeclared
|
LabelUrl = !string.IsNullOrWhiteSpace(order.LabelUrl)
|
||||||
|
? order.LabelUrl!
|
||||||
|
: order.OrderStatus == OrderStatusCodes.Predeclared
|
||||||
? await CreateOrderPrintFileAsync(mapped)
|
? await CreateOrderPrintFileAsync(mapped)
|
||||||
: string.Empty
|
: string.Empty
|
||||||
};
|
};
|
||||||
@ -1491,6 +1498,34 @@ public partial class OrderService
|
|||||||
private ISmallBagTrackOrderNotifier SmallBagTrackOrderNotifier =>
|
private ISmallBagTrackOrderNotifier SmallBagTrackOrderNotifier =>
|
||||||
LazyServiceProvider.GetRequiredService<ISmallBagTrackOrderNotifier>();
|
LazyServiceProvider.GetRequiredService<ISmallBagTrackOrderNotifier>();
|
||||||
|
|
||||||
|
private async Task EnqueueGenerateLabelPdfAsync(Order order)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>();
|
||||||
|
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<int>() : 0);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// 投递失败不阻断创单;打印时会补生成
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 小包预报成功后异步同步轨迹订单(POST /OrderV2/UpsertSmallBag)。
|
/// 小包预报成功后异步同步轨迹订单(POST /OrderV2/UpsertSmallBag)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
774
src/Oms.Application/Services/Orders/OrderService.StockBind.cs
Normal file
774
src/Oms.Application/Services/Orders/OrderService.StockBind.cs
Normal file
@ -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<IOrderStockBindRepository>();
|
||||||
|
|
||||||
|
private ICInventoryRepository StockBindInventoryRepository =>
|
||||||
|
LazyServiceProvider.GetRequiredService<ICInventoryRepository>();
|
||||||
|
|
||||||
|
private ICParcelRepository StockBindParcelRepository =>
|
||||||
|
LazyServiceProvider.GetRequiredService<ICParcelRepository>();
|
||||||
|
|
||||||
|
private ICParcelItemRepository StockBindParcelItemRepository =>
|
||||||
|
LazyServiceProvider.GetRequiredService<ICParcelItemRepository>();
|
||||||
|
|
||||||
|
private ICSkuRepository StockBindSkuRepository =>
|
||||||
|
LazyServiceProvider.GetRequiredService<ICSkuRepository>();
|
||||||
|
|
||||||
|
private ICBinLocationRepository StockBindBinLocationRepository =>
|
||||||
|
LazyServiceProvider.GetRequiredService<ICBinLocationRepository>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询物流单当前仓储绑定明细。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("/order/stock-bind/detail")]
|
||||||
|
public async Task<OrderStockBindDetailOutput> GetStockBindDetailAsync([FromQuery] int orderId)
|
||||||
|
{
|
||||||
|
var order = await GetOrderOrThrowAsync(orderId);
|
||||||
|
return await BuildStockBindDetailAsync(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 库存候选(左侧库存 Tab)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/order/stock-bind/inventory-candidates")]
|
||||||
|
public async Task<OmsPageResult<OrderStockBindInventoryCandidateOutput>> 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<OrderStockBindInventoryCandidateOutput>
|
||||||
|
{
|
||||||
|
Items = grouped.Skip(skip).Take(pageSize).ToList(),
|
||||||
|
Total = grouped.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 包裹候选(左侧包裹 Tab,允许全部生命周期)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/order/stock-bind/parcel-candidates")]
|
||||||
|
public async Task<OmsPageResult<OrderStockBindParcelCandidateOutput>> 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<OrderStockBindParcelCandidateOutput>();
|
||||||
|
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<OrderStockBindParcelCandidateOutput>
|
||||||
|
{
|
||||||
|
Items = outputs,
|
||||||
|
Total = parcels.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存绑定(全量覆盖 + 冻结)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/order/stock-bind/save")]
|
||||||
|
public async Task<OrderStockBindDetailOutput> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清空绑定并释放冻结。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/order/stock-bind/clear")]
|
||||||
|
public async Task<OrderStockBindDetailOutput> ClearStockBindAsync([FromBody] OrderStockBindOrderInput input)
|
||||||
|
{
|
||||||
|
var order = await GetOrderOrThrowAsync(input.OrderId);
|
||||||
|
EnsureOrderCanBindStock(order);
|
||||||
|
await EnsureNoDeductedBindsAsync(order.Id);
|
||||||
|
await ReleaseBoundBindsAsync(order.Id);
|
||||||
|
return await BuildStockBindDetailAsync(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 确认扣减:将 bound 明细转为 deducted,扣减实物并释放冻结。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/order/stock-bind/confirm-deduct")]
|
||||||
|
public async Task<OrderStockBindDetailOutput> 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<List<(int InventoryId, int BinLocationId, int Qty)>> FreezeInventoryAsync(
|
||||||
|
int customerId,
|
||||||
|
int warehouseId,
|
||||||
|
int skuId,
|
||||||
|
int requiredQty,
|
||||||
|
int? inventoryId)
|
||||||
|
{
|
||||||
|
List<CInventory> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 绑定客户上下文:客户登录与仓储模块一致(会话客户);管理端按订单客户。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> 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<OrderStockBindDetailOutput> 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<OrderStockBindItemOutput>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ using MiniExcelLibs;
|
|||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Dtos.Shared;
|
using Oms.Application.Dtos.Shared;
|
||||||
using Oms.Application.DataPerm;
|
using Oms.Application.DataPerm;
|
||||||
|
using Oms.Application.Labeling;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Oms.Application.Services.Orders.Batch;
|
using Oms.Application.Services.Orders.Batch;
|
||||||
@ -33,6 +34,8 @@ public partial class OrderService : ApplicationService
|
|||||||
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
|
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
|
||||||
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
|
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
|
||||||
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
|
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
|
||||||
|
private LabelPdfGenerationService LabelPdfGenerationService =>
|
||||||
|
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
|
||||||
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
|
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
|
||||||
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();
|
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();
|
||||||
|
|
||||||
@ -223,8 +226,44 @@ public partial class OrderService : ApplicationService
|
|||||||
public async Task<OrderPrintOutput> PrintAsync(OrderActionInput input)
|
public async Task<OrderPrintOutput> PrintAsync(OrderActionInput input)
|
||||||
{
|
{
|
||||||
var order = await GetOrderOrThrowAsync(input.Id);
|
var order = await GetOrderOrThrowAsync(input.Id);
|
||||||
var printUrl = await CreateOrderPrintFileAsync((await MapOrderOutputsAsync([order])).First());
|
var mapped = (await MapOrderOutputsAsync([order])).First();
|
||||||
return new OrderPrintOutput { PrintUrl = printUrl };
|
|
||||||
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -483,10 +522,19 @@ public partial class OrderService : ApplicationService
|
|||||||
return;
|
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<Order> GetOrderOrThrowAsync(int id)
|
private async Task<Order> GetOrderOrThrowAsync(int id)
|
||||||
|
|||||||
@ -71,6 +71,12 @@ public class PlatformStoreOrderService : ApplicationService
|
|||||||
: await OrderRepository.GetListAsync(x => linkedOrderIds.Contains(x.Id));
|
: await OrderRepository.GetListAsync(x => linkedOrderIds.Contains(x.Id));
|
||||||
var linkedOrderMap = linkedOrders.ToDictionary(x => 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<PlatformStoreOrderOutput>
|
return new OmsPageResult<PlatformStoreOrderOutput>
|
||||||
{
|
{
|
||||||
Items = entities.Select(x =>
|
Items = entities.Select(x =>
|
||||||
@ -82,7 +88,14 @@ public class PlatformStoreOrderService : ApplicationService
|
|||||||
linkedServiceNo = linkedOrder.ServiceNo;
|
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(),
|
}).ToList(),
|
||||||
Total = total
|
Total = total
|
||||||
};
|
};
|
||||||
@ -110,8 +123,16 @@ public class PlatformStoreOrderService : ApplicationService
|
|||||||
: await SkuRepository.GetListAsync(x => skuIds.Contains(x.Id));
|
: await SkuRepository.GetListAsync(x => skuIds.Contains(x.Id));
|
||||||
var skuMap = skus.ToDictionary(x => x.Id);
|
var skuMap = skus.ToDictionary(x => x.Id);
|
||||||
var linkedServiceNo = await ResolveLinkedServiceNoAsync(order.LinkedOrderId);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -487,8 +508,8 @@ public class PlatformStoreOrderService : ApplicationService
|
|||||||
x.CustomerId == customerId
|
x.CustomerId == customerId
|
||||||
&& x.WarehouseId == warehouseId
|
&& x.WarehouseId == warehouseId
|
||||||
&& x.SkuId == skuId
|
&& x.SkuId == skuId
|
||||||
&& x.Qty > 0);
|
&& (x.AvailableQty > 0 || x.Qty - x.FrozenQty > 0));
|
||||||
var available = total.Sum(x => x.Qty);
|
var available = total.Sum(x => Math.Max(0, x.Qty - x.FrozenQty));
|
||||||
if (available < qty)
|
if (available < qty)
|
||||||
{
|
{
|
||||||
throw new BusinessException($"SKU {skuId} 库存不足,需要 {qty},可用 {available}");
|
throw new BusinessException($"SKU {skuId} 库存不足,需要 {qty},可用 {available}");
|
||||||
@ -526,7 +547,7 @@ public class PlatformStoreOrderService : ApplicationService
|
|||||||
x.CustomerId == customerId
|
x.CustomerId == customerId
|
||||||
&& x.WarehouseId == warehouseId
|
&& x.WarehouseId == warehouseId
|
||||||
&& x.SkuId == skuId
|
&& x.SkuId == skuId
|
||||||
&& x.Qty > 0);
|
&& (x.AvailableQty > 0 || x.Qty > x.FrozenQty));
|
||||||
rows = rows.OrderBy(x => x.BinLocationId).ToList();
|
rows = rows.OrderBy(x => x.BinLocationId).ToList();
|
||||||
|
|
||||||
var deductions = new List<InventoryDeduction>();
|
var deductions = new List<InventoryDeduction>();
|
||||||
@ -538,8 +559,15 @@ public class PlatformStoreOrderService : ApplicationService
|
|||||||
break;
|
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.Qty -= deduct;
|
||||||
|
row.AvailableQty = Math.Max(0, row.Qty - row.FrozenQty);
|
||||||
remaining -= deduct;
|
remaining -= deduct;
|
||||||
await InventoryRepository.UpdateAsync(row);
|
await InventoryRepository.UpdateAsync(row);
|
||||||
deductions.Add(new InventoryDeduction(skuId, row.BinLocationId, deduct));
|
deductions.Add(new InventoryDeduction(skuId, row.BinLocationId, deduct));
|
||||||
@ -570,13 +598,16 @@ public class PlatformStoreOrderService : ApplicationService
|
|||||||
WarehouseId = warehouseId,
|
WarehouseId = warehouseId,
|
||||||
SkuId = item.SkuId,
|
SkuId = item.SkuId,
|
||||||
BinLocationId = item.BinLocationId,
|
BinLocationId = item.BinLocationId,
|
||||||
Qty = item.Qty
|
Qty = item.Qty,
|
||||||
|
FrozenQty = 0,
|
||||||
|
AvailableQty = item.Qty
|
||||||
};
|
};
|
||||||
await InventoryRepository.InsertAsync(inventory);
|
await InventoryRepository.InsertAsync(inventory);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
inventory.Qty += item.Qty;
|
inventory.Qty += item.Qty;
|
||||||
|
inventory.AvailableQty = Math.Max(0, inventory.Qty - inventory.FrozenQty);
|
||||||
await InventoryRepository.UpdateAsync(inventory);
|
await InventoryRepository.UpdateAsync(inventory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -682,11 +713,15 @@ public class PlatformStoreOrderService : ApplicationService
|
|||||||
string storeNickname,
|
string storeNickname,
|
||||||
IReadOnlyCollection<SalesStoreOrderLine> lines,
|
IReadOnlyCollection<SalesStoreOrderLine> lines,
|
||||||
IReadOnlyDictionary<int, Domain.WarehouseAggregate.Entities.CSku>? skuMap = null,
|
IReadOnlyDictionary<int, Domain.WarehouseAggregate.Entities.CSku>? skuMap = null,
|
||||||
string? linkedServiceNo = null) =>
|
string? linkedServiceNo = null,
|
||||||
|
string? customerName = null,
|
||||||
|
string? customerCode = null) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = order.Id,
|
Id = order.Id,
|
||||||
CustomerId = order.CustomerId,
|
CustomerId = order.CustomerId,
|
||||||
|
CustomerName = customerName,
|
||||||
|
CustomerCode = customerCode,
|
||||||
StoreId = order.StoreId,
|
StoreId = order.StoreId,
|
||||||
StoreNickname = storeNickname,
|
StoreNickname = storeNickname,
|
||||||
PlatformCode = order.PlatformCode,
|
PlatformCode = order.PlatformCode,
|
||||||
|
|||||||
@ -43,9 +43,15 @@ public class PlatformStoreService : ApplicationService
|
|||||||
new Dictionary<string, bool> { [nameof(SalesStore.UpdateTime)] = false, [nameof(SalesStore.Id)] = false });
|
new Dictionary<string, bool> { [nameof(SalesStore.UpdateTime)] = false, [nameof(SalesStore.Id)] = false });
|
||||||
var total = await StoreRepository.CountAsync(predicate);
|
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<PlatformStoreOutput>
|
return new OmsPageResult<PlatformStoreOutput>
|
||||||
{
|
{
|
||||||
Items = entities.Select(MapOutput).ToList(),
|
Items = entities.Select(x => MapOutput(x, customerMap)).ToList(),
|
||||||
Total = total
|
Total = total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -445,11 +451,24 @@ public class PlatformStoreService : ApplicationService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PlatformStoreOutput MapOutput(SalesStore entity) =>
|
private static PlatformStoreOutput MapOutput(
|
||||||
new()
|
SalesStore entity,
|
||||||
|
IReadOnlyDictionary<int, Domain.CustomersAggregate.Entities.Customer>? 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,
|
Id = entity.Id,
|
||||||
CustomerId = entity.CustomerId,
|
CustomerId = entity.CustomerId,
|
||||||
|
CustomerName = customerName,
|
||||||
|
CustomerCode = customerCode,
|
||||||
PlatformCode = entity.PlatformCode,
|
PlatformCode = entity.PlatformCode,
|
||||||
PlatformName = ResolvePlatformName(entity.PlatformCode),
|
PlatformName = ResolvePlatformName(entity.PlatformCode),
|
||||||
StoreNickname = entity.StoreNickname,
|
StoreNickname = entity.StoreNickname,
|
||||||
@ -462,6 +481,7 @@ public class PlatformStoreService : ApplicationService
|
|||||||
Remark = entity.Remark,
|
Remark = entity.Remark,
|
||||||
CreateTime = entity.CreateTime
|
CreateTime = entity.CreateTime
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static string ResolvePlatformName(string platformCode) => platformCode.ToLowerInvariant() switch
|
private static string ResolvePlatformName(string platformCode) => platformCode.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
|
|||||||
@ -64,7 +64,9 @@ public partial class WarehouseService
|
|||||||
WarehouseId = warehouseId,
|
WarehouseId = warehouseId,
|
||||||
SkuId = skuId,
|
SkuId = skuId,
|
||||||
BinLocationId = binLocationId,
|
BinLocationId = binLocationId,
|
||||||
Qty = deltaQty
|
Qty = deltaQty,
|
||||||
|
FrozenQty = 0,
|
||||||
|
AvailableQty = deltaQty
|
||||||
};
|
};
|
||||||
await InventoryRepository.InsertAsync(inventory);
|
await InventoryRepository.InsertAsync(inventory);
|
||||||
return;
|
return;
|
||||||
@ -76,7 +78,13 @@ public partial class WarehouseService
|
|||||||
throw new BusinessException("库存不足");
|
throw new BusinessException("库存不足");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deltaQty < 0 && inventory.AvailableQty < -deltaQty)
|
||||||
|
{
|
||||||
|
throw new BusinessException("库存可用不足");
|
||||||
|
}
|
||||||
|
|
||||||
inventory.Qty = newQty;
|
inventory.Qty = newQty;
|
||||||
|
inventory.AvailableQty = Math.Max(0, inventory.Qty - inventory.FrozenQty);
|
||||||
await InventoryRepository.UpdateAsync(inventory);
|
await InventoryRepository.UpdateAsync(inventory);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +103,7 @@ public partial class WarehouseService
|
|||||||
x.CustomerId == customerId
|
x.CustomerId == customerId
|
||||||
&& x.WarehouseId == warehouseId
|
&& x.WarehouseId == warehouseId
|
||||||
&& x.SkuId == skuId
|
&& x.SkuId == skuId
|
||||||
&& x.Qty > 0);
|
&& x.AvailableQty > 0);
|
||||||
rows = rows.OrderBy(x => x.BinLocationId).ToList();
|
rows = rows.OrderBy(x => x.BinLocationId).ToList();
|
||||||
|
|
||||||
var remaining = requiredQty;
|
var remaining = requiredQty;
|
||||||
@ -106,8 +114,15 @@ public partial class WarehouseService
|
|||||||
break;
|
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.Qty -= deduct;
|
||||||
|
row.AvailableQty = Math.Max(0, row.Qty - row.FrozenQty);
|
||||||
remaining -= deduct;
|
remaining -= deduct;
|
||||||
await InventoryRepository.UpdateAsync(row);
|
await InventoryRepository.UpdateAsync(row);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,9 +58,25 @@ public partial class WarehouseService
|
|||||||
? []
|
? []
|
||||||
: await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId));
|
: 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<WarehouseInboundOutput>
|
return new OmsPageResult<WarehouseInboundOutput>
|
||||||
{
|
{
|
||||||
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
|
Total = total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,19 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Oms.Application.Dtos.Shared;
|
using Oms.Application.Dtos.Shared;
|
||||||
using Oms.Application.Dtos.Warehouse;
|
using Oms.Application.Dtos.Warehouse;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
|
using Oms.Domain.OrdersAggregate.Repositories;
|
||||||
|
|
||||||
namespace Oms.Application.Services.Warehouse;
|
namespace Oms.Application.Services.Warehouse;
|
||||||
|
|
||||||
public partial class WarehouseService
|
public partial class WarehouseService
|
||||||
{
|
{
|
||||||
|
private IOrderStockBindRepository OrderStockBindRepository =>
|
||||||
|
LazyServiceProvider.GetRequiredService<IOrderStockBindRepository>();
|
||||||
|
|
||||||
|
private IOrderRepository OrderRepository =>
|
||||||
|
LazyServiceProvider.GetRequiredService<IOrderRepository>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询库存列表。
|
/// 查询库存列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -20,6 +28,7 @@ public partial class WarehouseService
|
|||||||
var (hasCustomerFilter, customerIdValue) = CustomerFilter(customerId);
|
var (hasCustomerFilter, customerIdValue) = CustomerFilter(customerId);
|
||||||
var hasWarehouseFilter = input.WarehouseId.HasValue;
|
var hasWarehouseFilter = input.WarehouseId.HasValue;
|
||||||
var warehouseIdValue = input.WarehouseId.GetValueOrDefault();
|
var warehouseIdValue = input.WarehouseId.GetValueOrDefault();
|
||||||
|
// 有总量即展示(含全部冻结、可用为 0 的行),避免客户看不到被占用库存
|
||||||
var inventories = await InventoryRepository.GetListAsync(x =>
|
var inventories = await InventoryRepository.GetListAsync(x =>
|
||||||
(!hasCustomerFilter || x.CustomerId == customerIdValue)
|
(!hasCustomerFilter || x.CustomerId == customerIdValue)
|
||||||
&& x.Qty > 0
|
&& x.Qty > 0
|
||||||
@ -32,27 +41,42 @@ public partial class WarehouseService
|
|||||||
(!hasCustomerFilter || x.CustomerId == customerIdValue) && skuIds.Contains(x.Id));
|
(!hasCustomerFilter || x.CustomerId == customerIdValue) && skuIds.Contains(x.Id));
|
||||||
var skuMap = skus.ToDictionary(x => 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 keyword = input.Keyword?.Trim();
|
||||||
var grouped = inventories
|
var grouped = inventories
|
||||||
.GroupBy(x => new { x.SkuId, x.WarehouseId })
|
.GroupBy(x => new { x.CustomerId, x.SkuId, x.WarehouseId })
|
||||||
.Select(g =>
|
.Select(g =>
|
||||||
{
|
{
|
||||||
skuMap.TryGetValue(g.Key.SkuId, out var sku);
|
skuMap.TryGetValue(g.Key.SkuId, out var sku);
|
||||||
|
customerMap.TryGetValue(g.Key.CustomerId, out var customer);
|
||||||
return new WarehouseInventoryOutput
|
return new WarehouseInventoryOutput
|
||||||
{
|
{
|
||||||
|
CustomerId = g.Key.CustomerId,
|
||||||
|
CustomerName = customer?.CustomerName,
|
||||||
|
CustomerCode = customer?.CustomerCode,
|
||||||
SkuId = g.Key.SkuId,
|
SkuId = g.Key.SkuId,
|
||||||
SkuCode = sku?.SkuCode ?? string.Empty,
|
SkuCode = sku?.SkuCode ?? string.Empty,
|
||||||
SkuName = sku?.SkuName ?? string.Empty,
|
SkuName = sku?.SkuName ?? string.Empty,
|
||||||
WarehouseId = g.Key.WarehouseId,
|
WarehouseId = g.Key.WarehouseId,
|
||||||
TotalQty = g.Sum(x => x.Qty),
|
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()
|
BinCount = g.Count()
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
string.IsNullOrWhiteSpace(keyword)
|
string.IsNullOrWhiteSpace(keyword)
|
||||||
|| x.SkuCode.Contains(keyword!, StringComparison.OrdinalIgnoreCase)
|
|| x.SkuCode.Contains(keyword!, StringComparison.OrdinalIgnoreCase)
|
||||||
|| x.SkuName.Contains(keyword!, StringComparison.OrdinalIgnoreCase))
|
|| x.SkuName.Contains(keyword!, StringComparison.OrdinalIgnoreCase)
|
||||||
.OrderBy(x => x.SkuCode)
|
|| (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)
|
.ThenBy(x => x.WarehouseId)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@ -113,7 +137,9 @@ public partial class WarehouseService
|
|||||||
BinLocationId = x.BinLocationId,
|
BinLocationId = x.BinLocationId,
|
||||||
BinCode = bin?.BinCode ?? string.Empty,
|
BinCode = bin?.BinCode ?? string.Empty,
|
||||||
BinName = bin?.BinName,
|
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()
|
.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.SourceBinLocationId, -input.Qty);
|
||||||
await UpsertInventoryQtyAsync(customerId, input.WarehouseId, input.SkuId, input.TargetBinLocationId, input.Qty);
|
await UpsertInventoryQtyAsync(customerId, input.WarehouseId, input.SkuId, input.TargetBinLocationId, input.Qty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询 SKU 库存变动历史:入库收货 + 订单扣减。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/warehouse/inventory/history")]
|
||||||
|
public async Task<OmsPageResult<WarehouseInventoryHistoryOutput>> 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<WarehouseInventoryHistoryOutput>();
|
||||||
|
|
||||||
|
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<WarehouseInventoryHistoryOutput>
|
||||||
|
{
|
||||||
|
Items = ordered.Skip(skip).Take(pageSize).ToList(),
|
||||||
|
Total = ordered.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,9 +55,25 @@ public partial class WarehouseService
|
|||||||
? []
|
? []
|
||||||
: await ParcelItemRepository.GetListAsync(x => parcelIds.Contains(x.ParcelId));
|
: 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<WarehouseParcelOutput>
|
return new OmsPageResult<WarehouseParcelOutput>
|
||||||
{
|
{
|
||||||
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
|
Total = total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -176,6 +192,8 @@ public partial class WarehouseService
|
|||||||
: itemInput.AttributeText.Trim(),
|
: itemInput.AttributeText.Trim(),
|
||||||
ImageUrl = imageUrl,
|
ImageUrl = imageUrl,
|
||||||
Quantity = itemInput.Quantity,
|
Quantity = itemInput.Quantity,
|
||||||
|
FrozenQty = 0,
|
||||||
|
AvailableQty = itemInput.Quantity,
|
||||||
Remark = string.IsNullOrWhiteSpace(itemInput.Remark) ? null : itemInput.Remark.Trim()
|
Remark = string.IsNullOrWhiteSpace(itemInput.Remark) ? null : itemInput.Remark.Trim()
|
||||||
};
|
};
|
||||||
await ParcelItemRepository.InsertAsync(item);
|
await ParcelItemRepository.InsertAsync(item);
|
||||||
@ -290,8 +308,25 @@ public partial class WarehouseService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WarehouseParcelOutput MapParcelOutput(CParcel parcel, IEnumerable<CParcelItem> items) =>
|
private static WarehouseParcelOutput MapParcelOutput(CParcel parcel, IEnumerable<CParcelItem> items)
|
||||||
new()
|
{
|
||||||
|
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,
|
Id = parcel.Id,
|
||||||
CustomerId = parcel.CustomerId,
|
CustomerId = parcel.CustomerId,
|
||||||
@ -302,17 +337,10 @@ public partial class WarehouseService
|
|||||||
ArrivalTime = parcel.ArrivalTime,
|
ArrivalTime = parcel.ArrivalTime,
|
||||||
Remark = parcel.Remark,
|
Remark = parcel.Remark,
|
||||||
CreateTime = parcel.CreateTime,
|
CreateTime = parcel.CreateTime,
|
||||||
Items = items.Select(item => new WarehouseParcelItemOutput
|
TotalQty = mappedItems.Sum(x => x.Quantity),
|
||||||
{
|
FrozenQty = mappedItems.Sum(x => x.FrozenQty),
|
||||||
Id = item.Id,
|
AvailableQty = mappedItems.Sum(x => x.AvailableQty),
|
||||||
ProductName = item.ProductName,
|
Items = mappedItems
|
||||||
AttributeText = item.AttributeText,
|
|
||||||
ImageUrl = item.ImageUrl,
|
|
||||||
Quantity = item.Quantity,
|
|
||||||
SkuId = item.SkuId,
|
|
||||||
SkuCode = item.SkuCode,
|
|
||||||
BinLocationId = item.BinLocationId,
|
|
||||||
Remark = item.Remark
|
|
||||||
}).ToList()
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,9 +49,25 @@ public partial class WarehouseService
|
|||||||
.GroupBy(x => x.SkuId)
|
.GroupBy(x => x.SkuId)
|
||||||
.ToDictionary(g => g.Key, g => g.Sum(x => x.Qty));
|
.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<WarehouseSkuOutput>
|
return new OmsPageResult<WarehouseSkuOutput>
|
||||||
{
|
{
|
||||||
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
|
Total = total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Fake.Users;
|
using Fake.Users;
|
||||||
using Oms.Domain.CustomersAggregate.Entities;
|
using Oms.Domain.CustomersAggregate.Entities;
|
||||||
@ -15,16 +16,32 @@ public static class OmsSessionHelper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string LoginTypeClaim = "loginType";
|
public const string LoginTypeClaim = "loginType";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JWT 中 RBAC mapId Claim 名称(与 FakeClaimTypes.UserId 对齐)。
|
||||||
|
/// </summary>
|
||||||
|
public const string MapIdClaim = "mapId";
|
||||||
|
|
||||||
private const string CustomerLoginType = "1";
|
private const string CustomerLoginType = "1";
|
||||||
|
private const string CustomerRoleName = "Customer";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 当前会话是否为客户门户登录。
|
/// 当前会话是否为客户门户登录。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsCustomerLogin(ICurrentUser currentUser) =>
|
public static bool IsCustomerLogin(ICurrentUser currentUser)
|
||||||
string.Equals(
|
{
|
||||||
|
if (string.Equals(
|
||||||
currentUser.FindClaimOrNull(LoginTypeClaim)?.Value,
|
currentUser.FindClaimOrNull(LoginTypeClaim)?.Value,
|
||||||
CustomerLoginType,
|
CustomerLoginType,
|
||||||
StringComparison.Ordinal);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 客户登录时返回 mapId(对应 t_customer.UserId / 订单 CreateUserId);管理员返回 null。
|
/// 客户登录时返回 mapId(对应 t_customer.UserId / 订单 CreateUserId);管理员返回 null。
|
||||||
@ -37,7 +54,19 @@ public static class OmsSessionHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
var userId = currentUser.GetId<int>();
|
var userId = currentUser.GetId<int>();
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -19,6 +19,11 @@ public class SalesProduct : OmsAuditedAggregateRoot
|
|||||||
|
|
||||||
public string? CustomerWaybill { get; set; }
|
public string? CustomerWaybill { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户面单绑定的标签模板 Id。
|
||||||
|
/// </summary>
|
||||||
|
public int? LabelTemplateId { get; set; }
|
||||||
|
|
||||||
public int? BlacklistAreaId { get; set; }
|
public int? BlacklistAreaId { get; set; }
|
||||||
|
|
||||||
public int? RemoteAreaId { get; set; }
|
public int? RemoteAreaId { get; set; }
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
namespace Oms.Domain.OrdersAggregate.Consts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单面单 PDF 生成状态。
|
||||||
|
/// </summary>
|
||||||
|
public static class LabelGenerateStatusCodes
|
||||||
|
{
|
||||||
|
public const string Pending = "Pending";
|
||||||
|
public const string Ready = "Ready";
|
||||||
|
public const string Failed = "Failed";
|
||||||
|
}
|
||||||
@ -31,6 +31,7 @@ public static class OrderBatchActionCodes
|
|||||||
public const string MarkClaimed = "mark-claimed";
|
public const string MarkClaimed = "mark-claimed";
|
||||||
public const string HandleStatusUpdate = "handle-status-update";
|
public const string HandleStatusUpdate = "handle-status-update";
|
||||||
public const string Print = "print";
|
public const string Print = "print";
|
||||||
|
public const string GenerateLabelPdf = "generate-label-pdf";
|
||||||
public const string Export = "export";
|
public const string Export = "export";
|
||||||
public const string ParcelCreate = "parcel-create";
|
public const string ParcelCreate = "parcel-create";
|
||||||
public const string ImportRow = "import-row";
|
public const string ImportRow = "import-row";
|
||||||
|
|||||||
14
src/Oms.Domain/OrdersAggregate/Consts/OrderStockBindCodes.cs
Normal file
14
src/Oms.Domain/OrdersAggregate/Consts/OrderStockBindCodes.cs
Normal file
@ -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";
|
||||||
|
}
|
||||||
@ -140,4 +140,27 @@ public class Order : OmsAuditedAggregateRoot
|
|||||||
public byte IsIntercept { get; set; }
|
public byte IsIntercept { get; set; }
|
||||||
|
|
||||||
public byte IsRemote { get; set; }
|
public byte IsRemote { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面单 PDF 相对路径(如 /uploads/prints/orders/xxx.pdf)。
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(Length = 500, IsNullable = true)]
|
||||||
|
public string? LabelUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 出单时实际使用的标签模板 Id。
|
||||||
|
/// </summary>
|
||||||
|
public int? LabelTemplateId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面单生成状态:Pending / Ready / Failed。
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(Length = 32, IsNullable = true)]
|
||||||
|
public string? LabelGenerateStatus { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面单生成失败原因。
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(Length = 500, IsNullable = true)]
|
||||||
|
public string? LabelGenerateError { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/Oms.Domain/OrdersAggregate/Entities/OrderStockBind.cs
Normal file
56
src/Oms.Domain/OrdersAggregate/Entities/OrderStockBind.cs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
using Oms.Domain.Shared;
|
||||||
|
using SqlSugar;
|
||||||
|
|
||||||
|
namespace Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 物流单仓储绑定明细(包裹行 / 库存)。
|
||||||
|
/// </summary>
|
||||||
|
[SugarTable("t_order_stock_bind")]
|
||||||
|
public class OrderStockBind : OmsAuditedAggregateRoot
|
||||||
|
{
|
||||||
|
public int OrderId { get; set; }
|
||||||
|
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>inventory / parcelItem</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>bound / deducted / released</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
using Fake.Domain.Repositories;
|
||||||
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
|
||||||
|
namespace Oms.Domain.OrdersAggregate.Repositories;
|
||||||
|
|
||||||
|
public interface IOrderStockBindRepository : IRepository<OrderStockBind>;
|
||||||
@ -39,4 +39,9 @@ public static class SystemOtherConfigCodes
|
|||||||
/// Temu(AppKey/AppSecret,BaseUrl=HostAuthUrl)。
|
/// Temu(AppKey/AppSecret,BaseUrl=HostAuthUrl)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string PlatformShopTemu = "platform_shop_temu";
|
public const string PlatformShopTemu = "platform_shop_temu";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 全局默认面单模板(AppKey=模板 Id,Enabled 控制是否生效)。
|
||||||
|
/// </summary>
|
||||||
|
public const string LabelDefaultTemplate = "label_default_template";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,4 +15,10 @@ public class CInventory : OmsAuditedAggregateRoot
|
|||||||
public int BinLocationId { get; set; }
|
public int BinLocationId { get; set; }
|
||||||
|
|
||||||
public int Qty { get; set; }
|
public int Qty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>已绑定未出库冻结量。</summary>
|
||||||
|
public int FrozenQty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>可用 = Qty - FrozenQty。</summary>
|
||||||
|
public int AvailableQty { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,12 @@ public class CParcelItem : OmsAuditedAggregateRoot
|
|||||||
|
|
||||||
public int Quantity { get; set; }
|
public int Quantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>已被物流单占用(冻结)数量。</summary>
|
||||||
|
public int FrozenQty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>可用 = Quantity - FrozenQty。</summary>
|
||||||
|
public int AvailableQty { get; set; }
|
||||||
|
|
||||||
public int? SkuId { get; set; }
|
public int? SkuId { get; set; }
|
||||||
|
|
||||||
public string? SkuCode { get; set; }
|
public string? SkuCode { get; set; }
|
||||||
|
|||||||
8
src/Oms.SqlSugarCore/Orders/OrderStockBindRepository.cs
Normal file
8
src/Oms.SqlSugarCore/Orders/OrderStockBindRepository.cs
Normal file
@ -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<OrderStockBind>, IOrderStockBindRepository, IScopedDependency;
|
||||||
138
src/Oms.Tests/Fixtures/LabelTemplates/sample_100x150.json
Normal file
138
src/Oms.Tests/Fixtures/LabelTemplates/sample_100x150.json
Normal file
@ -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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
64
src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs
Normal file
64
src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs
Normal file
@ -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<string, object?>
|
||||||
|
{
|
||||||
|
["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<LabelTextOp>().Should().Contain(x =>
|
||||||
|
x.Text.Contains("S202607180001", StringComparison.Ordinal) &&
|
||||||
|
x.LeftPt > 0 &&
|
||||||
|
x.FontSizePt == 11f);
|
||||||
|
|
||||||
|
page.Ops.OfType<LabelBarcodeOp>().Should().Contain(x => !x.QrCode && x.Content == "S202607180001");
|
||||||
|
page.Ops.OfType<LabelBarcodeOp>().Should().Contain(x => x.QrCode && x.Content == "S202607180001");
|
||||||
|
page.Ops.OfType<LabelLineOp>().Should().Contain(x => !x.Vertical);
|
||||||
|
page.Ops.OfType<LabelRectOp>().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<string, object?>());
|
||||||
|
act.Should().Throw<Exception>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs
Normal file
29
src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs
Normal file
@ -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<string, object?>
|
||||||
|
{
|
||||||
|
["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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,4 +29,8 @@
|
|||||||
<ProjectReference Include="..\SimpleAdmin.Api\SimpleAdmin.Api.csproj" />
|
<ProjectReference Include="..\SimpleAdmin.Api\SimpleAdmin.Api.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Fixtures\LabelTemplates\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user