From 7dd92f1c8445845c88419c020e43e54dfd192a77 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 11:49:51 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 666 ++++++++++++++++++ .../InternalLoadingEvidenceController.java | 51 ++ .../PlatformLoadingEvidenceController.java | 54 ++ .../file/service/LoadingEvidenceService.java | 74 ++ .../file/service/MeasurementImageStorage.java | 10 +- .../service/LoadingEvidenceServiceTest.java | 51 ++ .../client/LoadingEvidenceFileClient.java | 19 + ...ormInboundMeasurementReviewController.java | 10 +- .../PlatformLoadingDeliveryController.java | 102 +++ .../loading/domain/LoadingEvidenceType.java | 17 + .../loading/domain/PickingStatus.java | 8 + .../firstleg/loading/dto/LoadingRequests.java | 32 + .../loading/dto/LoadingResponses.java | 19 + .../loading/entity/LoadingEvidenceEntity.java | 31 + .../entity/LoadingOperationEntity.java | 27 + .../loading/entity/LoadingPickTaskEntity.java | 27 + .../loading/mapper/LoadingEvidenceMapper.java | 20 + .../loading/mapper/LoadingInboundMapper.java | 25 + .../mapper/LoadingOperationMapper.java | 25 + .../loading/mapper/LoadingPickTaskMapper.java | 21 + .../loading/repository/LoadingRepository.java | 89 +++ .../service/LoadingDeliveryService.java | 245 +++++++ .../firstleg/mapper/InboundOrderMapper.java | 3 +- .../service/InboundMeasurementService.java | 71 +- .../service/MeasurementBatchService.java | 16 +- .../service/MeasurementReviewService.java | 5 +- .../service/MeasurementSubmissionService.java | 5 +- .../PlatformSplitPreloadController.java | 82 +++ .../firstleg/split/domain/PreloadStatus.java | 12 + .../firstleg/split/dto/SplitRequests.java | 29 + .../firstleg/split/dto/SplitResponses.java | 15 + .../split/entity/PreloadPlanEntity.java | 29 + .../split/entity/SplitOperationEntity.java | 25 + .../split/mapper/PreloadPlanMapper.java | 27 + .../split/mapper/SplitInboundMapper.java | 21 + .../split/mapper/SplitOperationMapper.java | 25 + .../split/repository/SplitRepository.java | 71 ++ .../split/service/SplitPreloadService.java | 213 ++++++ .../db/phase3-loading-delivery-schema.sql | 62 ++ .../db/phase3-split-preload-schema.sql | 39 + .../service/LoadingDeliveryServiceTest.java | 66 ++ .../InboundMeasurementServiceTest.java | 42 +- .../service/MeasurementBatchServiceTest.java | 25 + .../service/MeasurementReviewServiceTest.java | 8 +- .../MeasurementSubmissionServiceTest.java | 12 +- ...ormSplitPreloadControllerContractTest.java | 30 + .../service/SplitPreloadServiceTest.java | 71 ++ .../PlatformFirstLegOrderController.java | 6 +- .../service/OrderApplicationService.java | 25 +- .../OrderApplicationServiceStateTest.java | 42 ++ print/AGENTS.md | 16 +- print/README.md | 168 ++++- .../controller/InternalPdfController.java | 33 + .../controller/PrintRequestSizeFilter.java | 6 +- .../baiyue/print/dto/FreightA4PdfDtos.java | 47 ++ .../print/renderer/FreightA4Renderer.java | 333 +++++++++ .../print/service/FreightA4PdfService.java | 257 +++++++ .../resources/openapi/print-internal-v1.yaml | 108 +++ .../controller/InternalPdfControllerTest.java | 52 ++ .../PrintRequestSizeFilterTest.java | 25 +- .../FreightA4PdfServiceGenerationTest.java | 134 ++++ .../FreightA4PdfServiceValidationTest.java | 64 ++ .../controller/MerchantPartnerController.java | 9 +- .../MerchantWarehouseController.java | 16 +- .../PlatformLocationController.java | 14 +- .../controller/PlatformPalletController.java | 6 +- .../PlatformWarehouseController.java | 99 ++- .../warehouse/dto/WarehouseImportDtos.java | 51 ++ .../warehouse/dto/WarehouseRequests.java | 34 +- .../dto/WarehouseSummaryResponse.java | 65 ++ .../warehouse/entity/WarehouseEntity.java | 2 +- .../repository/WarehouseRepository.java | 74 +- .../service/WarehouseApplicationService.java | 25 +- .../service/WarehouseImportService.java | 342 +++++++++ .../service/WarehouseSaveNormalizer.java | 162 +++++ .../fba-public-addresses.accepted.csv | 253 +++++++ .../fba-public-addresses.rejected.csv | 55 ++ .../master-data/platform-fixed-warehouses.csv | 5 + .../db/phase1-warehouse-schema-migration.sql | 2 +- .../db/warehouse-public-master-data.md | 45 ++ .../db/warehouse-public-master-data.sql | 663 +++++++++++++++++ .../src/main/resources/db/warehouse-seed.sql | 4 +- warehouse/src/main/resources/db/warehouse.sql | 2 +- ...erchantControllerIdentityContractTest.java | 14 +- ...tformMasterDataControllerContractTest.java | 21 + .../repository/WarehouseRepositoryTest.java | 83 +++ .../WarehouseApplicationServiceTest.java | 72 +- .../service/WarehouseImportServiceTest.java | 180 +++++ .../verify-warehouse-public-master-data.sql | 48 ++ ...house-public-master-data-contract.test.mjs | 116 +++ 91 files changed, 6233 insertions(+), 174 deletions(-) create mode 100644 file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java create mode 100644 file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java create mode 100644 file/src/main/java/com/baiyue/file/service/LoadingEvidenceService.java create mode 100644 file/src/test/java/com/baiyue/file/service/LoadingEvidenceServiceTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingEvidenceEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingOperationEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingPickTaskEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingEvidenceMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingOperationMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitResponses.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/split/entity/PreloadPlanEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/split/entity/SplitOperationEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitOperationMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java create mode 100644 firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql create mode 100644 firstleg/src/main/resources/db/phase3-split-preload-schema.sql create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java create mode 100644 print/src/main/java/com/baiyue/print/dto/FreightA4PdfDtos.java create mode 100644 print/src/main/java/com/baiyue/print/renderer/FreightA4Renderer.java create mode 100644 print/src/main/java/com/baiyue/print/service/FreightA4PdfService.java create mode 100644 print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java create mode 100644 print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceValidationTest.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseImportDtos.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java create mode 100644 warehouse/src/main/resources/db/master-data/fba-public-addresses.accepted.csv create mode 100644 warehouse/src/main/resources/db/master-data/fba-public-addresses.rejected.csv create mode 100644 warehouse/src/main/resources/db/master-data/platform-fixed-warehouses.csv create mode 100644 warehouse/src/main/resources/db/warehouse-public-master-data.md create mode 100644 warehouse/src/main/resources/db/warehouse-public-master-data.sql create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java create mode 100644 warehouse/src/test/resources/db/verify-warehouse-public-master-data.sql create mode 100644 warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 56bb1d6b9..6e38c9018 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -6,7 +6,7 @@ - 该脚本汇总一期、二期当前全部数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 后续数据库变更只允许在该文件末尾增加带日期和业务说明的幂等增量区块,不再创建新的部署入口 SQL。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 8 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`。Gateway、Print、Integration Service 无数据库。 - 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 493b5412f..848d1f45d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3762,6 +3762,672 @@ CREATE TABLE IF NOT EXISTS sys_audit_dead_letter ( ) ENGINE=InnoDB COMMENT='审计消息死信补偿表'; -- ============================================================ + +-- 2026-08-06 平台公共仓库正式主数据:固定作业仓 + FBA 地址库 +-- 来源:warehouse/src/main/resources/db/warehouse-public-master-data.sql。必须位于最终 PASS 校验之前,可重复执行。 +USE baiyue_warehouse; + +-- 正式平台公共仓库主数据:固定作业仓 + FBA 地址库。 +-- 来源:C:\Users\BY-OY\Downloads\FBA地址表.xls,只读清洗后生成。 +-- 接受 252 条,拒绝 54 条;拒绝清单见 master-data/fba-public-addresses.rejected.csv。 +-- 四个固定作业仓按通用占位地址写入;清单见 master-data/platform-fixed-warehouses.csv。 +-- FBA 是目的地址主数据,不创建 RECEIVING、STORAGE、PICK_PACK、RETURN 等平台作业能力。 + +DROP TEMPORARY TABLE IF EXISTS tmp_wh_fixed_platform_warehouse; +CREATE TEMPORARY TABLE tmp_wh_fixed_platform_warehouse ( + warehouse_id_seed BIGINT UNSIGNED NOT NULL, + address_id_seed BIGINT UNSIGNED NOT NULL, + warehouse_code VARCHAR(50) NOT NULL, + warehouse_name VARCHAR(120) NOT NULL, + warehouse_type VARCHAR(32) NOT NULL, + country_code CHAR(2) NOT NULL, + timezone VARCHAR(64) NOT NULL, + state_province VARCHAR(100) NOT NULL, + city VARCHAR(100) NOT NULL, + postal_code VARCHAR(20) NOT NULL, + address_line1 VARCHAR(255) NOT NULL, + PRIMARY KEY (warehouse_code), + UNIQUE KEY uk_tmp_fixed_warehouse_id_seed (warehouse_id_seed), + UNIQUE KEY uk_tmp_fixed_address_id_seed (address_id_seed) +) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO tmp_wh_fixed_platform_warehouse + (warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,state_province,city,postal_code,address_line1) +VALUES +(193000000000200001,193000000000300001,'CN-YIWU-01','义乌仓','DOMESTIC','CN','UTC+8','浙江省','义乌市','322000','义乌仓通用地址待维护'), +(193000000000200002,193000000000300002,'CN-DONGGUAN-01','东莞仓','DOMESTIC','CN','UTC+8','广东省','东莞市','523000','东莞仓通用地址待维护'), +(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','CA','Los Angeles','90001','Los Angeles warehouse generic address pending maintenance'), +(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','NY','New York','10001','New York warehouse generic address pending maintenance'); + +DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed; +DELIMITER // +CREATE PROCEDURE assert_wh_fixed_platform_warehouse_seed() +BEGIN + IF (SELECT COUNT(*) FROM tmp_wh_fixed_platform_warehouse) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓源数量不是4'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM tmp_wh_fixed_platform_warehouse + WHERE warehouse_code <> UPPER(TRIM(warehouse_code)) + OR warehouse_type NOT IN ('DOMESTIC', 'OVERSEAS') + OR country_code NOT IN ('CN', 'US') + OR TRIM(timezone) = '' + OR TRIM(city) = '' + OR TRIM(state_province) = '' + OR TRIM(address_line1) = '' + OR TRIM(postal_code) = '' + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓主数据存在未清洗或必填缺失记录'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse existing + JOIN tmp_wh_fixed_platform_warehouse source + ON existing.id = source.warehouse_id_seed + AND (existing.tenant_id <> 0 OR existing.warehouse_code <> source.warehouse_code OR existing.deleted <> 0) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓仓库种子ID与既有数据冲突'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse_address existing + JOIN tmp_wh_fixed_platform_warehouse source + ON existing.id = source.address_id_seed + LEFT JOIN wh_warehouse parent + ON parent.id = existing.warehouse_id + AND parent.tenant_id = existing.tenant_id + WHERE existing.tenant_id <> 0 + OR existing.deleted <> 0 + OR parent.warehouse_code IS NULL + OR parent.warehouse_code <> source.warehouse_code + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址种子ID与既有数据冲突'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse existing + JOIN tmp_wh_fixed_platform_warehouse source + ON existing.tenant_id = 0 + AND existing.warehouse_code = source.warehouse_code + AND existing.deleted = 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓存在同编码已删除仓库,需人工确认后恢复或清理'; + END IF; +END// +DELIMITER ; +CALL assert_wh_fixed_platform_warehouse_seed(); +DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed; + +INSERT INTO wh_warehouse ( + id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, + contact_name, contact_phone, contact_email, status, remark, + created_by, created_at, updated_by, updated_at, deleted, version +) +SELECT + source.warehouse_id_seed, + 0, + source.warehouse_code, + source.warehouse_name, + source.warehouse_type, + source.country_code, + source.timezone, + NULL, + NULL, + NULL, + 'ACTIVE', + NULL, + NULL, + CURRENT_TIMESTAMP(3), + NULL, + CURRENT_TIMESTAMP(3), + 0, + 0 +FROM tmp_wh_fixed_platform_warehouse source +ON DUPLICATE KEY UPDATE + warehouse_name = VALUES(warehouse_name), + warehouse_type = VALUES(warehouse_type), + country_code = VALUES(country_code), + timezone = VALUES(timezone), + updated_at = CURRENT_TIMESTAMP(3); + +INSERT INTO wh_warehouse_address ( + id, tenant_id, warehouse_id, address_type, country_code, state_province, city, + district, postal_code, address_line1, address_line2, latitude, longitude, + created_by, created_at, updated_by, updated_at, deleted, version +) +SELECT + source.address_id_seed, + 0, + warehouse.id, + 'OPERATING', + source.country_code, + source.state_province, + source.city, + NULL, + source.postal_code, + source.address_line1, + NULL, + NULL, + NULL, + NULL, + CURRENT_TIMESTAMP(3), + NULL, + CURRENT_TIMESTAMP(3), + 0, + 0 +FROM tmp_wh_fixed_platform_warehouse source +JOIN wh_warehouse warehouse + ON warehouse.tenant_id = 0 + AND warehouse.warehouse_code = source.warehouse_code + AND warehouse.deleted = 0 +ON DUPLICATE KEY UPDATE + tenant_id = VALUES(tenant_id); + +DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data; +CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data ( + warehouse_id_seed BIGINT UNSIGNED NOT NULL, + address_id_seed BIGINT UNSIGNED NOT NULL, + warehouse_code VARCHAR(50) NOT NULL, + warehouse_name VARCHAR(120) NOT NULL, + state_province VARCHAR(100) NOT NULL, + city VARCHAR(100) NOT NULL, + postal_code VARCHAR(20) NOT NULL, + timezone VARCHAR(64) NOT NULL, + address_line1 VARCHAR(255) NOT NULL, + source_row INT NOT NULL, + PRIMARY KEY (warehouse_code), + UNIQUE KEY uk_tmp_fba_warehouse_id_seed (warehouse_id_seed), + UNIQUE KEY uk_tmp_fba_address_id_seed (address_id_seed) +) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO tmp_wh_fba_public_master_data + (warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,state_province,city,postal_code,timezone,address_line1,source_row) +VALUES +(193000000000000001,193000000000100001,'ABE2','ABE2','PA','Breinigsville','18031','UTC-5','705 Boulder Drive',238), +(193000000000000002,193000000000100002,'ABE3','ABE3','PA','Breinigsville','18031','UTC-5','650 Boulder Drive',237), +(193000000000000003,193000000000100003,'ABE4','ABE4','PA','Easton','18045','UTC-5','1610 Van Buren Road',134), +(193000000000000004,193000000000100004,'ABE5','ABE5','PA','Harrisburg','17112','UTC-5','6455 Allentown Boulevard',231), +(193000000000000005,193000000000100005,'ABE8','ABE8','NJ','Florence','08518','UTC-5','401 Independence Road 08518-2200',125), +(193000000000000006,193000000000100006,'ABQ2','ABQ2','NM','LOS LUNAS','87031','UTC-7','6251 Pioneer Trail NW',99), +(193000000000000007,193000000000100007,'ACY2','ACY2','NJ','BURLINGTON','08016','UTC-5','1101 E PEARL ST',84), +(193000000000000008,193000000000100008,'AFW1','AFW1','TX','FORT WORTH','76131','UTC-6','1851 NE LOOP 820',141), +(193000000000000009,193000000000100009,'AFW5','AFW5','TX','BALCH SPRINGS','75180-1636','UTC-6','12374 LAKE JUNE RD',27), +(193000000000000010,193000000000100010,'AKR1','AKR1','OH','Canton','44705','UTC-5','4747 Rebar Ave NE',148), +(193000000000000011,193000000000100011,'ALB1','ALB1','NY','Castleton','12033-9634','UTC-5','1835 US Route 9',174), +(193000000000000012,193000000000100012,'AMA1','AMA1','TX','Amarillo','79108','UTC-6','8590 NE 24th Avenue',129), +(193000000000000013,193000000000100013,'ATL2N','ATL2N','GA','Carrollton','30117','UTC-5','3101 N. Highway 27',31), +(193000000000000014,193000000000100014,'ATL3','ATL3','GA','Pendergrass','30567','UTC-5','445 Valentine Industrial Parkway',147), +(193000000000000015,193000000000100015,'ATL6','ATL6','GA','East Point','30344','UTC-5','4200 North Commerce',202), +(193000000000000016,193000000000100016,'AVP1','AVP1','PA','Hazle Township','18202-9361','UTC-5','550 Oak Ridge Road',233), +(193000000000000017,193000000000100017,'AVP3','AVP3','PA','GOULDSBORO','18424-9492','UTC-5','298 1ST AVE',232), +(193000000000000018,193000000000100018,'BDL1','BDL1','CT','Windsor','06095','UTC-5','801 Day Hill Rd.',289), +(193000000000000019,193000000000100019,'BFI1','BFI1','WA','Sumner','98390','UTC-8','1800 140th Avenue E.',208), +(193000000000000020,193000000000100020,'BFI3','BFI3','WA','Dupont','98327-9607','UTC-8','2700 Center Drive',207), +(193000000000000021,193000000000100021,'BFI7','BFI7','WA','Sumner','98390','UTC-8','1901 140th Ave E',206), +(193000000000000022,193000000000100022,'BJC1','BJC1','CO','AURORA','80019','UTC-7','6300 N Powhaton Road',11), +(193000000000000023,193000000000100023,'BNA1','BNA1','TN','Lebanon','37090','UTC-6','14840 Central Pike Suite 190',221), +(193000000000000024,193000000000100024,'BNA2','BNA2','TN','Lebanon','37090','UTC-6','500 Duke DR',131), +(193000000000000025,193000000000100025,'BNA3','BNA3','TN','Murfreesboro','37127','UTC-6','2020 Joe B Jackson Pkwy',140), +(193000000000000026,193000000000100026,'BNA4','BNA4','TN','Nashville','37217','UTC-6','50 airways Blvd',220), +(193000000000000027,193000000000100027,'BNA6','BNA6','TN','CLARKSVILLE','37040-5502','UTC-6','3875 GUTHRIE HWY',110), +(193000000000000028,193000000000100028,'BOS1','BOS1','NH','Nashua','03063','UTC-5','10 State St.',243), +(193000000000000029,193000000000100029,'BOS2','BOS2','MA','Drive Stoughton','02072','UTC-5','1000 Tech Centre',193), +(193000000000000030,193000000000100030,'BOS7','BOS7','MA','Fall River','02720','UTC-5','1180 Innovation Way',192), +(193000000000000031,193000000000100031,'BWI1','BWI1','VA','Sterling','20166-2058','UTC-5','45121 Global Plaza',242), +(193000000000000032,193000000000100032,'BWI2','BWI2','MD','Baltimore','21224','UTC-5','2010 Broening Hwy',241), +(193000000000000033,193000000000100033,'BWI4','BWI4','VA','CLEARBROOK','22624','UTC-5','165 Business Blvd',102), +(193000000000000034,193000000000100034,'CAE1','CAE1','SC','West Columbia','29172','UTC-5','4400 12TH STREET EXT',225), +(193000000000000035,193000000000100035,'CHA1','CHA1','TN','Chattanooga','37416','UTC-5','7200 Discovery Drive',223), +(193000000000000036,193000000000100036,'CHA2','CHA2','TN','Charleston','37310','UTC-5','225 Infinity Dr NW',222), +(193000000000000037,193000000000100037,'CHO1','CHO1','VA','FISHERSVILLE','22939','UTC-5','32 TRADER RD',75), +(193000000000000038,193000000000100038,'CLT2','CLT2','NC','Charlotte','28214-8082','UTC-5','10240 Old Dowd Rd',191), +(193000000000000039,193000000000100039,'CLT3','CLT3','NC','CONCORD','28027','UTC-5','6500 Davidson Hwy',114), +(193000000000000040,193000000000100040,'CMH2','CMH2','OH','Groveport','43125-9016','UTC-5','6050 Gateway Court',240), +(193000000000000041,193000000000100041,'CMH3','CMH3','OH','Monroe','45050-1848','UTC-5','700 GATEWAY BLVD',181), +(193000000000000042,193000000000100042,'CVG1','CVG1','KY','Hebron','41048','UTC-5','1155 Worldwide Blvd',262), +(193000000000000043,193000000000100043,'CVG2','CVG2','KY','Hebron','41048','UTC-5','1600 Worldwide Blvd',261), +(193000000000000044,193000000000100044,'CVG3','CVG3','KY','Hebron','41048','UTC-5','3680 Langley Dr.',260), +(193000000000000045,193000000000100045,'CVG5','CVG5','KY','Hebron','41048','UTC-5','2285 Litton Lane',259), +(193000000000000046,193000000000100046,'DCA6','DCA6','MD','BALTIMORE','21219','UTC-5','6001 Bethlehem Blvd',135), +(193000000000000047,193000000000100047,'DEN2','DEN2','CO','Aurora','80019','UTC-7','22205 East 19th Ave',123), +(193000000000000048,193000000000100048,'DEN8','DEN8','CO','Aurora','80018','UTC-7','21000 E 13th Ave',120), +(193000000000000049,193000000000100049,'DET1','DET1','MI','Livonia','48150-1543','UTC-5','39000 Amrhein Road',189), +(193000000000000050,193000000000100050,'DET2','DET2','MI','Shelby Township','48317-1318','UTC-5','50500 Mound Rd',182), +(193000000000000051,193000000000100051,'DFW5S','DFW5S','TX','Lancaster','75146','UTC-6','2500 E Belt Line Rd,',143), +(193000000000000052,193000000000100052,'DFW6','DFW6','TX','Coppell','75019','UTC-6','940 W Bethel Road',219), +(193000000000000053,193000000000100053,'DFW8','DFW8','TX','DFW Airport','75261','UTC-6','2700 Regent Blvd',217), +(193000000000000054,193000000000100054,'EWR4','EWR4','NJ','Robbinsville','08691','UTC-5','50 New Canton Way',256), +(193000000000000055,193000000000100055,'EWR5','EWR5','NJ','Avenel','07001','UTC-5','301 Blair Rd.',255), +(193000000000000056,193000000000100056,'EWR7','EWR7','NJ','Avenel','07001','UTC-5','301 Blair Rd.',254), +(193000000000000057,193000000000100057,'EWR9','EWR9','NJ','Carteret','07008','UTC-5','8003 Industrial Parkway',253), +(193000000000000058,193000000000100058,'FAT2','FAT2','CA','VISALIA','93291-1882','UTC-8','3315 N KELSEYST',117), +(193000000000000059,193000000000100059,'FOE1','FOE1','KS','KANSAS CITY','66109-3665','UTC-6','9400 LEAVENWORTH RD',176), +(193000000000000060,193000000000100060,'FTW1','FTW1','TX','Dallas','75241','UTC-6','33333 Lyndon B Johnson FREEWAY',212), +(193000000000000061,193000000000100061,'FTW2','FTW2','TX','Coppell','75019','UTC-6','2701 West Bethel Road',211), +(193000000000000062,193000000000100062,'FTW3','FTW3','TX','Fort Worth','76177-2517','UTC-6','15201 Heritage Pkwy',89), +(193000000000000063,193000000000100063,'FTW5','FTW5','TX','Forney','75126','UTC-6','1475 AKRON WAY',121), +(193000000000000064,193000000000100064,'FWA4','FWA4','IN','FORT WAYNE','46809-9771','UTC-5','9798 Smith Road',171), +(193000000000000065,193000000000100065,'GEG2','GEG2','WA','SPOKANE','99216','UTC-8','18007 E Garland Ave',151), +(193000000000000066,193000000000100066,'GEU2','GEU2','AZ','Glendale','85307','UTC-7','15301 West Northern Avenue',67), +(193000000000000067,193000000000100067,'GEU3','GEU3','AZ','BUCKEYE','85396','UTC-7','18900 W McDowell Road',78), +(193000000000000068,193000000000100068,'GEU5','GEU5','AZ','GOODYEAR','85395','UTC-7','4660 North Cotton Lane',68), +(193000000000000069,193000000000100069,'GSO1','GSO1','NC','KERNERSVILLE','27284','UTC-5','1656 OLD GREENSBORO RD',164), +(193000000000000070,193000000000100070,'GSP1','GSP1','SC','Spartanburg','29303','UTC-5','402 John Dodd Rd',224), +(193000000000000071,193000000000100071,'GYR2','GYR2','AZ','GOODYEAR','85395-7672','UTC-7','17341 W MINNEZONA AVE',188), +(193000000000000072,193000000000100072,'GYR3','GYR3','AZ','PHOENIX','85043-2356','UTC-7','8181 W ROOSEVELT ST',187), +(193000000000000073,193000000000100073,'HGR6','HGR6','MD','HAGERSTOWN','21740-7301','UTC-5','55 West Oak Ridge Drive',80), +(193000000000000074,193000000000100074,'HIA1','HIA1','PA','MIDDLETOWN','17057','UTC-5','3327 E Harrisburg Pik',87), +(193000000000000075,193000000000100075,'HLI2','HLI2','CA','HOLLISTER','95023','UTC-8','600 Ernie Drive',44), +(193000000000000076,193000000000100076,'HOU1','HOU1','TX','Humble','77338','UTC-6','8120 Humble Westfield Rd',216), +(193000000000000077,193000000000100077,'HOU2','HOU2','TX','Houston','77038-2324','UTC-6','10550 Ella Blvd.',215), +(193000000000000078,193000000000100078,'HOU3','HOU3','TX','Brookshire','77423','UTC-6','31555 Highway 90 E',214), +(193000000000000079,193000000000100079,'HOU8','HOU8','TX','FRESNO','77545','UTC-6','2303 Hurricane Lane',154), +(193000000000000080,193000000000100080,'HSV1','HSV1','AL','Madison','35756','UTC-6','7817 Greenbrier Road',142), +(193000000000000081,193000000000100081,'HSV2','HSV2','AL','MADISON','35756','UTC-6','28869 Fanning Dr NW',126), +(193000000000000082,193000000000100082,'IAH3','IAH3','TX','Houston','77032','UTC-6','15525 Milner Road',155), +(193000000000000083,193000000000100083,'IAZ1','IAZ1','AZ','GOODYEAR','85395-4515','UTC-7','4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States',3), +(193000000000000084,193000000000100084,'ICT2','ICT2','KS','PARK CITY','67219','UTC-6','7130 N BROADWAY AVE',136), +(193000000000000085,193000000000100085,'IGA3','IGA3','GA','MACON','31216-6427','UTC-5','7001 Skipper Rd 31216-6427 MACON, GA - United States',2), +(193000000000000086,193000000000100086,'IGQ2','IGQ2','IL','University park','60484','UTC-6','23257 S. Central Avenue',152), +(193000000000000087,193000000000100087,'ILG1','ILG1','DE','NEW CASTLE','19720','UTC-5','780 S. DuPont Highway',119), +(193000000000000088,193000000000100088,'IMO1','IMO1','MO','LIBERTY','64068-8933','UTC-6','2361 S Withers RD',10), +(193000000000000089,193000000000100089,'IMS1','IMS1','MS','OLIVE BRANCH','38654','UTC-6','IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US',6), +(193000000000000090,193000000000100090,'INC1','INC1','NC','FAYETTEVILLE','28303-2215','UTC-5','6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States',5), +(193000000000000091,193000000000100091,'IND2','IND2','IN','Plainfield','46168-7442','UTC-5','715 Airtech Pkwy',88), +(193000000000000092,193000000000100092,'IND4','IND4','IN','Indianapolis','46231','UTC-5','710 S. Girls School Rd',276), +(193000000000000093,193000000000100093,'IND5','IND5','IN','Plainfield','46168','UTC-5','800 Perry Road',275), +(193000000000000094,193000000000100094,'IND6','IND6','IN','Jeffersonville','47130','UTC-5','1453 E 10th St',274), +(193000000000000095,193000000000100095,'IND9','IND9','IN','GREENWOOD','46143-7830','UTC-5','1151 S GRAHAM RD',173), +(193000000000000096,193000000000100096,'ITX3','ITX3','TX','AMARILLO','79108-7749','UTC-6','ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US',9), +(193000000000000097,193000000000100097,'IUSF','IUSF','TX','DALLAS','75241','UTC-6','3351 Balmorhea Dr',22), +(193000000000000098,193000000000100098,'IUSJ','IUSJ','CA','BEAUMONT','92223','UTC-8','36900 West 4th Street',69), +(193000000000000099,193000000000100099,'IUSP','IUSP','CA','HESPERIA,','92344','UTC-8','8140 Caliente Rd',74), +(193000000000000100,193000000000100100,'IUSQ','IUSQ','CA','REDLANDS','92374','UTC-8','2125 W San Bernardino Ave',70), +(193000000000000101,193000000000100101,'IUSR','IUSR','SC','COWPENS','29330','UTC-5','1120 Mt Olive Rd',40), +(193000000000000102,193000000000100102,'IUST','IUST','PA','GREENCASTLE','17225','UTC-5','3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States',39), +(193000000000000103,193000000000100103,'IUSW','IUSW','CA','Hesperia','92344','UTC-8','8130 Caliente Rd 92344 - Hesperia, CA - United States',33), +(193000000000000104,193000000000100104,'IUTE','IUTE','AZ','GOODYEAR','85338','UTC-7','16155 W Elwood St',21), +(193000000000000105,193000000000100105,'IUTJ','IUTJ','CA','Fontana','92335','UTC-8','9253 Dreamland Drive 92335 - Fontana, CA - United',8), +(193000000000000106,193000000000100106,'IUTL','IUTL','CA','Moreno Valley','92551','UTC-8','24405 Krameria Ave 92551 - Moreno Valley, CA - United States',7), +(193000000000000107,193000000000100107,'IWA6','IWA6','WA','PASCO','99301-6161','UTC-8','1202 S Road 40 E 99301-6161 - PASCO, WA - United States',4), +(193000000000000108,193000000000100108,'JOT1','JOT1','CA','SAN BERNARDINO','92408','UTC-8','1494 S WATERMAN AVE',72), +(193000000000000109,193000000000100109,'JVL1','JVL1','WI','BELOIT','53511','UTC-6','1255 Gateway Blvd',150), +(193000000000000110,193000000000100110,'KRB4','KRB4','CA','PERRIS','92571-9722','UTC-8','4501 PATTERSON AVE',178), +(193000000000000111,193000000000100111,'KY1','KY1','KY','Shepherdsville','40165','UTC-5','120 Velocity Way.,',161), +(193000000000000112,193000000000100112,'LAN2','LAN2','MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',100), +(193000000000000113,193000000000100113,'LAS1','LAS1','NV','HENDERSON','89044','UTC-8','12300 Bermuda Road',247), +(193000000000000114,193000000000100114,'LAS2','LAS2','NV','Las Vegas','89030','UTC-8','3837 Bay Lake Trail, Suite 111 North',246), +(193000000000000115,193000000000100115,'LAS6','LAS6','NV','Las Vegas','89115','UTC-8','4550 Nexus Way',132), +(193000000000000116,193000000000100116,'LAX2T','LAX2T','CA','CHINO','91708','UTC-8','6730 Kimball Ave',162), +(193000000000000117,193000000000100117,'LAX9','LAX9','CA','FONTANA','92337','UTC-8','11263 Oleander Ave',183), +(193000000000000118,193000000000100118,'LBE1','LBE1','PA','Greensburg','15605','UTC-5','165 Glen Fox Road',86), +(193000000000000119,193000000000100119,'LEX1','LEX1','KY','Lexington','40511','UTC-5','1850 Mercer RD',268), +(193000000000000120,193000000000100120,'LEX2','LEX2','KY','Lexington','40511','UTC-5','172 Trade St.',267), +(193000000000000121,193000000000100121,'LFT1','LFT1','LA','CARENCRO','70520-5976','UTC-6','3550 NE EVANGELINE TRWY',107), +(193000000000000122,193000000000100122,'LGB3','LGB3','CA','Eastvale','91752','UTC-8','4950 Goodman Way',291), +(193000000000000123,193000000000100123,'LGB4','LGB4','CA','Redlands','92374','UTC-8','27517 Pioneer Avenue',292), +(193000000000000124,193000000000100124,'LGB6','LGB6','CA','Riverside','92518-1513','UTC-8','20901 Krameria Ave',293), +(193000000000000125,193000000000100125,'LGB8','LGB8','CA','Rialto','92376','UTC-8','1568 N Linden Ave',294), +(193000000000000126,193000000000100126,'LIT2','LIT2','AR','NORTH LITTLE ROCK','72117','UTC-6','13001 HIGHWAY 70',106), +(193000000000000127,193000000000100127,'MCC1','MCC1','CA','RANCHO CORDOVA','95742','UTC-8','3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States',32), +(193000000000000128,193000000000100128,'MCE1','MCE1','CA','TURLOCK','95380','UTC-8','FULKERTH RD',157), +(193000000000000129,193000000000100129,'MCI1','MCI1','KS','Lenexa','66219','UTC-6','113th St & Renner Blvd.',271), +(193000000000000130,193000000000100130,'MCI4','MCI4','MO','KANSAS CITY','64163','UTC-6','MCI4 - 10501 NW 136th Street',12), +(193000000000000131,193000000000100131,'MCO2','MCO2','FL','Deltona','32725-2610','UTC-5','2600 N Normandy Blvd',115), +(193000000000000132,193000000000100132,'MDT1','MDT1','PA','Carlisle','17015','UTC-5','2Ames Drive',230), +(193000000000000133,193000000000100133,'MDT4','MDT4','PA','York','17408','UTC-5','5125 Commerce Drive',128), +(193000000000000134,193000000000100134,'MDW2','MDW2','IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',200), +(193000000000000135,193000000000100135,'MDW6','MDW6','IL','ROMEOVILLE','60446-6529','UTC-6','1125 W REMINGTON BLVD ROMEOVILLE',199), +(193000000000000136,193000000000100136,'MDW7','MDW7','IL','Monee','60449','UTC-6','6605 Monee Manhattan Road',198), +(193000000000000137,193000000000100137,'MDW8','MDW8','IL','Waukegan','60085','UTC-6','1750 Bridge Drive',197), +(193000000000000138,193000000000100138,'MDW9','MDW9','IL','Aurora','60502','UTC-6','2865 Duke Parkway',196), +(193000000000000139,193000000000100139,'MEM1','MEM1','TN','Memphis','38118','UTC-6','3292 E Holmes Rd',163), +(193000000000000140,193000000000100140,'MEM6','MEM6','MS','OLIVE BRANCH','38654','UTC-6','11505 Progress Way',118), +(193000000000000141,193000000000100141,'MGE3','MGE3','GA','Jefferson','30549-2010','UTC-5','808 Hog Mountain Road',201), +(193000000000000142,193000000000100142,'MIT2','MIT2','CA','SHAFTER','93263','UTC-8','5408 Express Avenue',77), +(193000000000000143,193000000000100143,'MKC4','MKC4','KS','Edgerton','66021-9588','UTC-6','19645 Waverly Rd',270), +(193000000000000144,193000000000100144,'MKC6','MKC6','KS','Kansas City','66102','UTC-6','6925 Riverview Avenue',269), +(193000000000000145,193000000000100145,'MKE1','MKE1','WI','Kenosha','53144','UTC-6','3501 120th Ave',203), +(193000000000000146,193000000000100146,'MQJ1','MQJ1','IN','GREENFIELD','46140','UTC-5','4412 W 300 RD N',94), +(193000000000000147,193000000000100147,'MSP1','MSP1','MN','Shakopee','55379','UTC-6','2601 4th Avenue East',190), +(193000000000000148,193000000000100148,'NJ3','NJ3','NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',17), +(193000000000000149,193000000000100149,'OAK3','OAK3','CA','Patterson','95363-8876','UTC-8','255 Park Center Drive',299), +(193000000000000150,193000000000100150,'OAK4','OAK4','CA','Tracy','95304-9370','UTC-8','1555 N. Chrisman Rd',300), +(193000000000000151,193000000000100151,'OKC2','OKC2','OK','OKLAHOMA CITY','73159','UTC-6','8991 S PORTLAND AVENUE',133), +(193000000000000152,193000000000100152,'OLM1','OLM1','WA','OLYMPIA','98516','UTC-8','3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States',19), +(193000000000000153,193000000000100153,'ONT2','ONT2','CA','San Bernardino','92408','UTC-8','1910 E Central Ave',303), +(193000000000000154,193000000000100154,'ONT6','ONT6','CA','Moreno Valley','92551-9561','UTC-8','24208 San Michele Rd',302), +(193000000000000155,193000000000100155,'ONT8','ONT8','CA','Moreno Valley','92551-9534','UTC-8','24300 Nandina Ave',301), +(193000000000000156,193000000000100156,'ONT9','ONT9','CA','Redlands','92374','UTC-8','2125 W. San Bernandino Ave',298), +(193000000000000157,193000000000100157,'ORD2','ORD2','IL','CHANNAHON','60410-8758','UTC-6','23714 W Amoco Rd',103), +(193000000000000158,193000000000100158,'ORD6','ORD6','IL','Wood Dale','60191','UTC-6','1250 N Mittel Blvd',195), +(193000000000000159,193000000000100159,'ORF2','ORF2','VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',113), +(193000000000000160,193000000000100160,'PBI3','PBI3','FL','Jacksonville','32206','UTC-5','21 Jump Street',81), +(193000000000000161,193000000000100161,'PDX7','PDX7','OR','Salem','97317','UTC-8','4775 Depot Ct SE',127), +(193000000000000162,193000000000100162,'PHL1','PHL1','DE','New Castle','19720','UTC-5','1 Centerpoint Blvd.',288), +(193000000000000163,193000000000100163,'PHL3','PHL3','DE','New Castle','19720','UTC-5','1600 Johnson Way',287), +(193000000000000164,193000000000100164,'PHL4','PHL4','PA','Carlisle','17015','UTC-5','21 Roadway Dr',236), +(193000000000000165,193000000000100165,'PHL5','PHL5','PA','Lewisberry','17339','UTC-5','500 McCarthy Dr',235), +(193000000000000166,193000000000100166,'PHL5S','PHL5S','PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct, Greencastle, PA 17225',20), +(193000000000000167,193000000000100167,'PHL6','PHL6','PA','Carlisle','17015','UTC-5','675 Allen Rd.',234), +(193000000000000168,193000000000100168,'PHL7','PHL7','DE','Middletown','19709','UTC-5','560 Merrimac Ave',286), +(193000000000000169,193000000000100169,'PHL8','PHL8','DE','Middletown','19709','UTC-5','727 N Broad St',285), +(193000000000000170,193000000000100170,'PHX3','PHX3','AZ','Phoenix','85043','UTC-7','6835 West Buckeye Road',307), +(193000000000000171,193000000000100171,'PHX5','PHX5','AZ','Goodyear','85338','UTC-7','16920 W Commerce Drive',306), +(193000000000000172,193000000000100172,'PHX6','PHX6','AZ','Phoenix','85043','UTC-7','4750 West Mohave St',305), +(193000000000000173,193000000000100173,'PHX7','PHX7','AZ','Phoenix','85043','UTC-7','800 N 75th Ave',304), +(193000000000000174,193000000000100174,'PIT1','PIT1','PA','Pittsburgh','15205','UTC-5','2250 Roswell Drive',229), +(193000000000000175,193000000000100175,'PIT2','PIT2','PA','IMPERIAL','15126-1002','UTC-5','1200 Westport Rd',158), +(193000000000000176,193000000000100176,'POC1','POC1','CA','FONTANA','92336-1123','UTC-8','4121 LYTLE CREEK RD',46), +(193000000000000177,193000000000100177,'POC2','POC2','CA','ONTARIO','91761','UTC-8','4000 South Hamner Avenue',38), +(193000000000000178,193000000000100178,'POC3','POC3','CA','JURUPA VALLEY','92509','UTC-8','6120 Clinker Drive',45), +(193000000000000179,193000000000100179,'PPO4','PPO4','IN','MERRILLVILLE','46410','UTC-6','9850 Mississippi Street',49), +(193000000000000180,193000000000100180,'PSC2','PSC2','WA','PASCO','99301','UTC-8','1351 S Road 40 E',93), +(193000000000000181,193000000000100181,'PSP3','PSP3','CA','DESERT HOT SPRINGS','92240','UTC-8','64165 19th AVE',28), +(193000000000000182,193000000000100182,'QXY5','QXY5','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',71), +(193000000000000183,193000000000100183,'QXY8','QXY8','CA','FRESNO','93725-1962','UTC-8','3611 S NORTHPOINTE DR',35), +(193000000000000184,193000000000100184,'QZZ7','QZZ7','AZ','MESA','85212-9608','UTC-7','8560 E ELLIOT RD',14), +(193000000000000185,193000000000100185,'RDU2','RDU2','NC','Smithfield','27577','UTC-5','2150 US HWY 70 Business West',146), +(193000000000000186,193000000000100186,'RDU4','RDU4','NC','FAYETTEVILLE','28303','UTC-5','6309 Bragg Blvd',95), +(193000000000000187,193000000000100187,'RFD2','RFD2','IL','Huntley','60142','UTC-6','11500 Freeman Road',145), +(193000000000000188,193000000000100188,'RFD4','RFD4','IL','HUNTLEY','60142','UTC-6','11400 Venture Court 60142-8075 - HUNTLEY, IL - United States',16), +(193000000000000189,193000000000100189,'RIC1','RIC1','VA','Petersburg','23803','UTC-5','5000 Commerce Way',210), +(193000000000000190,193000000000100190,'RIC2','RIC2','VA','Chester','23836','UTC-5','1901 Meadowville Technology Pkwy',209), +(193000000000000191,193000000000100191,'RMN3','RMN3','VA','Fredericksburg','22406','UTC-5','220 Centreport Parkway',144), +(193000000000000192,193000000000100192,'RNO1','RNO1','NV','Fernley','89408','UTC-8','1600 East Newlands RD',248), +(193000000000000193,193000000000100193,'RNO2','RNO2','NV','Reno','85906','UTC-8','8000 North Virginia Street',245), +(193000000000000194,193000000000100194,'RNO4','RNO4','NV','Reno','89506','UTC-8','8000 North Virginia Street',244), +(193000000000000195,193000000000100195,'RYY2','RYY2','GA','White','30184','UTC-5','1250 Cassville White Road,',73), +(193000000000000196,193000000000100196,'SAT1','SAT1','TX','Schertz','78154-1461','UTC-6','6000 Enterprise Avenue',218), +(193000000000000197,193000000000100197,'SAT4','SAT4','TX','SAN ANTONIO','78245','UTC-6','10384 W US HIGHWAY 90',149), +(193000000000000198,193000000000100198,'SAT6','SAT6','TX','SAN MARCOS','78666','UTC-6','1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States',15), +(193000000000000199,193000000000100199,'SAV3','SAV3','GA','Macon','31216-6427','UTC-5','7001 Skipper Rd',98), +(193000000000000200,193000000000100200,'SBD1','SBD1','CA','BLOOMINGTON','92316-3819','UTC-8','3388 S Cactus Ave',185), +(193000000000000201,193000000000100201,'SBD2','SBD2','CA','SAN BERNARDINO','92408-2805','UTC-8','1494 S WATERMAN AVE',179), +(193000000000000202,193000000000100202,'SCK1','SCK1','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',97), +(193000000000000203,193000000000100203,'SCK3','SCK3','CA','MANTECA','95336','UTC-8','3565 N AIRPORT WAY MANTECA',60), +(193000000000000204,193000000000100204,'SCK4','SCK4','CA','STOCKTON','95215-8354','UTC-8','6001 S AUSTIN RD',186), +(193000000000000205,193000000000100205,'SCK8','SCK8','CA','OAKLEY','94561-2677','UTC-8','4700 WILBUR AVE',104), +(193000000000000206,193000000000100206,'SDF1','SDF1','KY','Campbellsville','42718','UTC-5','1050 South Columbia AVE',266), +(193000000000000207,193000000000100207,'SDF2','SDF2','KY','Watterson Park','40218','UTC-5','4360 Robards Ln',265), +(193000000000000208,193000000000100208,'SDF4','SDF4','KY','Shepherdsville','40165','UTC-5','376 Zappos.com Blvd',264), +(193000000000000209,193000000000100209,'SDF6','SDF6','KY','Shepherdsville','40165','UTC-5','271 Omega Pkwy',263), +(193000000000000210,193000000000100210,'SDF7','SDF7','KY','Shepherdsville','40165','UTC-5','300 Omicron Court',258), +(193000000000000211,193000000000100211,'SDF8','SDF8','IN','Jeffersonville','47130','UTC-5','900 Patrol Rd',273), +(193000000000000212,193000000000100212,'SDF9','SDF9','KY','Shepherdsville','40165','UTC-5','100 W. Thomas P. Echols Lane',257), +(193000000000000213,193000000000100213,'SEA6','SEA6','WA','South Seattle','98144','UTC-8','2646 Rainier Ave.',205), +(193000000000000214,193000000000100214,'SEA8','SEA8','WA','Northeast Bellevue','98005','UTC-8','1475 124th Avenue',204), +(193000000000000215,193000000000100215,'SJC7','SJC7','CA','Tracy','95377-8906','UTC-8','188 Mountain House Parkway',295), +(193000000000000216,193000000000100216,'SLC2','SLC2','UT','West Jordan','84081','UTC-7','7148 W. Old Bingham Hwy',153), +(193000000000000217,193000000000100217,'SLC3','SLC3','UT','SALT LAKE CITY','84116-4413','UTC-7','355 N JOHN GLENN RD',13), +(193000000000000218,193000000000100218,'SMF3','SMF3','CA','Stockton','95206-8202','UTC-8','3923 S B ST',184), +(193000000000000219,193000000000100219,'SMF6','SMF6','CA','SACRAMENTO','95837-9109','UTC-8','4930 ALLBAUGH DR',105), +(193000000000000220,193000000000100220,'SNA4','SNA4','CA','Rialto','92376-3009','UTC-8','2496 W Walnut Ave',296), +(193000000000000221,193000000000100221,'STL3','STL3','MO','REPUBLIC','65738-2779','UTC-6','3200 E SAWYER RD',109), +(193000000000000222,193000000000100222,'STL4','STL4','IL','Edwardsville','62025-2815','UTC-6','3050 GATEWAY COMMERCE CENTER DR S',194), +(193000000000000223,193000000000100223,'SWF1','SWF1','NY','ROCK TAVERN','12575','UTC-5','635 INTERNATIONAL BLVD',122), +(193000000000000224,193000000000100224,'SWF2','SWF2','NY','Hopewell Junction','12533','UTC-5','76 Patriot Way Hopewell Junction',76), +(193000000000000225,193000000000100225,'TCY1','TCY1','CA','STOCKTON','95206','UTC-8','2690 East Arch Airport Road',41), +(193000000000000226,193000000000100226,'TCY2','TCY2','CA','STOCKTON','95215','UTC-8','6201 Newcastle Rd',43), +(193000000000000227,193000000000100227,'TEB3','TEB3','NJ','Logan Township','08085','UTC-5','2651 Oldmans Creek Rd',250), +(193000000000000228,193000000000100228,'TEB4','TEB4','NJ','PENNS GROVE','08069','UTC-5','747 COURSES LANDING RD',130), +(193000000000000229,193000000000100229,'TEB6','TEB6','NJ','Cranbury','08512','UTC-5','22 Hightstown Cranbury State Road',249), +(193000000000000230,193000000000100230,'TEB9','TEB9','NJ','Somerset','08873','UTC-5','601 Randolph Road',156), +(193000000000000231,193000000000100231,'TMB8','TMB8','FL','HOMESTEAD','33032-8597','UTC-5','27505 SW 132nd AVE',65), +(193000000000000232,193000000000100232,'TOL3','TOL3','OH','PERRYSBURG','43551','UTC-5','9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States',18), +(193000000000000233,193000000000100233,'TPA1','TPA1','FL','Ruskin','33570','UTC-5','3350 Laurel Ridge Ave.',284), +(193000000000000234,193000000000100234,'TPA2','TPA2','FL','Lakeland','33811','UTC-5','1760 County Line Rd',283), +(193000000000000235,193000000000100235,'TPA3','TPA3','FL','Auburndale','33823','UTC-5','676 C. Fred Jones Boulevard',124), +(193000000000000236,193000000000100236,'TUL1','TUL1','KS','Coffeyville','67337','UTC-6','2654 North Highway 169',272), +(193000000000000237,193000000000100237,'VGT2','VGT2','NV','LAS VEGAS','89115-2013','UTC-8','6401 Howdy Wells Ave',172), +(193000000000000238,193000000000100238,'VUBA','VUBA','PA','Scranton','18512','UTC-5','1000 Keystone Industrial Park',228), +(193000000000000239,193000000000100239,'VUGA','VUGA','PA','Pittston','18643','UTC-5','508 Delaware Avenue West',227), +(193000000000000240,193000000000100240,'WBW2','WBW2','PA','OLYPHANT','18447','UTC-5','1300 Corporate Way',82), +(193000000000000241,193000000000100241,'XAV3','XAV3','GA','PORT WENTWORTH','31407','UTC-5','XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States',36), +(193000000000000242,193000000000100242,'XEW5','XEW5','NJ','Cinnaminson','08077-2015','UTC-5','995 Taylors Ln 08077-2015',37), +(193000000000000243,193000000000100243,'XIN5','XIN5','IN','Avon','46123-1700','UTC-5','8838 E COUNTY ROAD 100 S',25), +(193000000000000244,193000000000100244,'XLX1','XLX1','MD','HAGERSTOWN','21740','UTC-5','1115 Wesel Blvd HAGERSTOWN,MD 21740,USA',34), +(193000000000000245,193000000000100245,'XLX6','XLX6','NC','PINEVILLE','28134','UTC-5','12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA',23), +(193000000000000246,193000000000100246,'XLX7','XLX7','CA','VICTORVILLE','92394','UTC-8','18580 Gateway Dr,Victorville,CA 92394,USA',63), +(193000000000000247,193000000000100247,'XPB2','XPB2','FL','APOPKA','32712','UTC-5','XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States',26), +(193000000000000248,193000000000100248,'XRI3','XRI3','VA','Richmond','23234','UTC-5','4949 Commerce Rd, Richmond, VA 23234',24), +(193000000000000249,193000000000100249,'XUSB','XUSB','TX','Fort Worth','76155','UTC-6','14900 Frye Rd.',213), +(193000000000000250,193000000000100250,'XUSC','XUSC','PA','Carlisle','17013','UTC-5','40 Logistics Drive',226), +(193000000000000251,193000000000100251,'XUSD','XUSD','CA','Stockton','95206','UTC-8','1909 Zephyr St',297), +(193000000000000252,193000000000100252,'XUSE','XUSE','IN','Whitestown','46075','UTC-5','5100 S Indianapolis Rd',280); + +DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed; +DELIMITER // +CREATE PROCEDURE assert_wh_fba_public_master_data_seed() +BEGIN + IF (SELECT COUNT(*) FROM tmp_wh_fba_public_master_data) <> 252 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据源数量不是252'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM tmp_wh_fba_public_master_data + WHERE warehouse_code <> UPPER(TRIM(warehouse_code)) + OR warehouse_code NOT REGEXP '^[A-Z0-9]{3,5}$' + OR TRIM(city) = '' + OR TRIM(state_province) = '' + OR TRIM(address_line1) = '' + OR TRIM(postal_code) = '' + OR TRIM(timezone) = '' + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在未清洗或必填缺失记录'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse existing + JOIN tmp_wh_fba_public_master_data source + ON existing.id = source.warehouse_id_seed + AND (existing.tenant_id <> 0 OR existing.warehouse_code <> source.warehouse_code OR existing.deleted <> 0) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据仓库种子ID与既有数据冲突'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse_address existing + JOIN tmp_wh_fba_public_master_data source + ON existing.id = source.address_id_seed + LEFT JOIN wh_warehouse parent + ON parent.id = existing.warehouse_id + AND parent.tenant_id = existing.tenant_id + WHERE existing.tenant_id <> 0 + OR existing.deleted <> 0 + OR parent.warehouse_code IS NULL + OR parent.warehouse_code <> source.warehouse_code + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据地址种子ID与既有数据冲突'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse existing + JOIN tmp_wh_fba_public_master_data source + ON existing.tenant_id = 0 + AND existing.warehouse_code = source.warehouse_code + AND existing.deleted = 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在同编码已删除仓库,需人工确认后恢复或清理'; + END IF; +END// +DELIMITER ; +CALL assert_wh_fba_public_master_data_seed(); +DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed; + +INSERT INTO wh_warehouse ( + id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, + contact_name, contact_phone, contact_email, status, remark, + created_by, created_at, updated_by, updated_at, deleted, version +) +SELECT + source.warehouse_id_seed, + 0, + source.warehouse_code, + source.warehouse_name, + 'FBA', + 'US', + source.timezone, + NULL, + NULL, + NULL, + 'ACTIVE', + NULL, + NULL, + CURRENT_TIMESTAMP(3), + NULL, + CURRENT_TIMESTAMP(3), + 0, + 0 +FROM tmp_wh_fba_public_master_data source +ON DUPLICATE KEY UPDATE + warehouse_type = VALUES(warehouse_type), + country_code = VALUES(country_code), + timezone = VALUES(timezone), + updated_at = CURRENT_TIMESTAMP(3); + +INSERT INTO wh_warehouse_address ( + id, tenant_id, warehouse_id, address_type, country_code, state_province, city, + district, postal_code, address_line1, address_line2, latitude, longitude, + created_by, created_at, updated_by, updated_at, deleted, version +) +SELECT + source.address_id_seed, + 0, + warehouse.id, + 'OPERATING', + 'US', + source.state_province, + source.city, + NULL, + source.postal_code, + source.address_line1, + NULL, + NULL, + NULL, + NULL, + CURRENT_TIMESTAMP(3), + NULL, + CURRENT_TIMESTAMP(3), + 0, + 0 +FROM tmp_wh_fba_public_master_data source +JOIN wh_warehouse warehouse + ON warehouse.tenant_id = 0 + AND warehouse.warehouse_code = source.warehouse_code + AND warehouse.deleted = 0 +ON DUPLICATE KEY UPDATE + tenant_id = VALUES(tenant_id), + country_code = VALUES(country_code), + state_province = VALUES(state_province), + city = VALUES(city), + postal_code = VALUES(postal_code), + address_line1 = VALUES(address_line1), + updated_at = CURRENT_TIMESTAMP(3); + +DROP PROCEDURE IF EXISTS verify_wh_fba_public_master_data; +DELIMITER // +CREATE PROCEDURE verify_wh_fba_public_master_data() +BEGIN + IF (SELECT COUNT(*) + FROM wh_warehouse warehouse + JOIN tmp_wh_fba_public_master_data source + ON source.warehouse_code = warehouse.warehouse_code + WHERE warehouse.tenant_id = 0 + AND warehouse.warehouse_type = 'FBA' + AND warehouse.country_code = 'US' + AND warehouse.deleted = 0) <> 252 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共仓库写入数量校验失败'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM tmp_wh_fba_public_master_data source + LEFT JOIN wh_warehouse warehouse + ON warehouse.tenant_id = 0 + AND warehouse.warehouse_code = source.warehouse_code + AND warehouse.deleted = 0 + LEFT JOIN wh_warehouse_address address + ON address.tenant_id = 0 + AND address.warehouse_id = warehouse.id + AND address.address_type = 'OPERATING' + AND address.deleted = 0 + WHERE warehouse.id IS NULL + OR address.id IS NULL + OR address.country_code <> 'US' + OR address.state_province <> source.state_province + OR address.city <> source.city + OR address.postal_code <> source.postal_code + OR address.address_line1 <> source.address_line1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共地址父子解析或地址字段校验失败'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_service_capability capability + JOIN wh_warehouse warehouse + ON warehouse.id = capability.warehouse_id + AND warehouse.tenant_id = capability.tenant_id + WHERE warehouse.tenant_id = 0 + AND warehouse.warehouse_type = 'FBA' + AND warehouse.warehouse_code IN (SELECT warehouse_code FROM tmp_wh_fba_public_master_data) + AND capability.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共地址不得创建平台作业能力'; + END IF; +END// +DELIMITER ; +CALL verify_wh_fba_public_master_data(); +DROP PROCEDURE IF EXISTS verify_wh_fba_public_master_data; +DROP PROCEDURE IF EXISTS verify_wh_fixed_platform_warehouse; +DELIMITER // +CREATE PROCEDURE verify_wh_fixed_platform_warehouse() +BEGIN + IF (SELECT COUNT(*) + FROM wh_warehouse warehouse + JOIN tmp_wh_fixed_platform_warehouse source + ON source.warehouse_code = warehouse.warehouse_code + WHERE warehouse.tenant_id = 0 + AND warehouse.warehouse_type = source.warehouse_type + AND warehouse.country_code = source.country_code + AND warehouse.deleted = 0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓写入数量校验失败'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM tmp_wh_fixed_platform_warehouse source + LEFT JOIN wh_warehouse warehouse + ON warehouse.tenant_id = 0 + AND warehouse.warehouse_code = source.warehouse_code + AND warehouse.deleted = 0 + LEFT JOIN wh_warehouse_address address + ON address.tenant_id = 0 + AND address.warehouse_id = warehouse.id + AND address.address_type = 'OPERATING' + AND address.deleted = 0 + WHERE warehouse.id IS NULL + OR address.id IS NULL + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址父子解析失败'; + END IF; +END// +DELIMITER ; +CALL verify_wh_fixed_platform_warehouse(); +DROP PROCEDURE IF EXISTS verify_wh_fixed_platform_warehouse; +DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data; +DROP TEMPORARY TABLE IF EXISTS tmp_wh_fixed_platform_warehouse; + -- 全部增量完成后的最终结构强校验;只有通过此段才输出唯一 PASS -- ============================================================ DROP PROCEDURE IF EXISTS unified_assert_final_extensions; diff --git a/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java b/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java new file mode 100644 index 000000000..d40d27952 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java @@ -0,0 +1,51 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.LoadingEvidenceService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** Firstleg 使用的装柜证据文件内部接口。 */ +@RestController +@RequestMapping("/internal/file/v1/loading-evidence") +public class InternalLoadingEvidenceController { + @Resource private LoadingEvidenceService service; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + private byte[] expectedToken; + + @PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } + + @PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse upload( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, @PathVariable long tenantId, @PathVariable long inboundId, + @RequestParam String evidenceType, @RequestPart("file") MultipartFile file) { + requireToken(token); + if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0"); + return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file)); + } + + @GetMapping("/{tenantId}/{inboundId}/{fileId}") + public ApiResponse validate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) { + requireToken(token); + if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0"); + return ApiResponse.success(service.validate(tenantId, inboundId, fileId)); + } + + private void requireToken(String token) { + if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java b/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java new file mode 100644 index 000000000..71b5261ef --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java @@ -0,0 +1,54 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.LoadingEvidenceService; +import jakarta.annotation.Resource; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; + +/** 平台及作业端装柜证据上传和读取。 */ +@RestController +@RequestMapping("/api/platform/v1/files/loading-evidence") +public class PlatformLoadingEvidenceController { + @Resource private LoadingEvidenceService service; + + @PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')") + public ApiResponse upload( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable long inboundId, + @RequestParam String evidenceType, @RequestPart("file") MultipartFile file) { + long operatorId = requirePlatform(user); requirePositive(tenantId, inboundId); + return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file)); + } + + @GetMapping("/{tenantId}/{inboundId}/{fileId}") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')") + public ResponseEntity download(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) { + requirePlatform(user); requirePositive(tenantId, inboundId, fileId); + var value = service.download(tenantId, inboundId, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(value.contentType())).contentLength(value.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() + .filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content()); + } + + private long requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) || user.tenantId() == null + || user.tenantId() != 0L || user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份"); + } + return user.userId(); + } + private static void requirePositive(long... ids) { + for (long id : ids) if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0"); + } +} diff --git a/file/src/main/java/com/baiyue/file/service/LoadingEvidenceService.java b/file/src/main/java/com/baiyue/file/service/LoadingEvidenceService.java new file mode 100644 index 000000000..7c87b214a --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/LoadingEvidenceService.java @@ -0,0 +1,74 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; + +/** 装柜四步照片和铅封证据的受控文件入口。 */ +@Service +public class LoadingEvidenceService { + @Resource private MeasurementImageStorage storage; + @Resource private FileObjectMapper files; + + @Transactional + public UploadResult upload(long tenantId, long inboundId, long operatorId, String evidenceType, + MultipartFile file) { + validateType(evidenceType); + var stored = storage.storeLoading(tenantId, inboundId, file); + FileObjectEntity object = new FileObjectEntity(); + object.setTenantId(tenantId); + object.setBusinessType("LOADING_" + evidenceType); + object.setBusinessId(Long.toString(inboundId)); + object.setOriginalName(stored.originalName()); + object.setObjectKey(stored.objectKey()); + object.setStorageProvider("LOCAL"); + object.setBucketName("loading-evidence"); + object.setContentType(stored.contentType()); + object.setSizeBytes(stored.size()); + object.setSha256(stored.sha256()); + object.setStatus("ACTIVE"); + object.setCreatedBy(operatorId); object.setUpdatedBy(operatorId); + object.setCreatedAt(LocalDateTime.now()); object.setUpdatedAt(LocalDateTime.now()); + object.setDeleted(false); object.setVersion(0); + files.insert(object); + return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), object.getSizeBytes(), object.getBusinessType()); + } + + public FileMetadata validate(long tenantId, long inboundId, long fileId) { + FileObjectEntity object = requireObject(tenantId, inboundId, fileId); + return new FileMetadata(object.getId(), object.getOriginalName(), object.getBusinessType(), object.getContentType()); + } + + public Download download(long tenantId, long inboundId, long fileId) { + FileObjectEntity object = requireObject(tenantId, inboundId, fileId); + return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey())); + } + + private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) { + FileObjectEntity object = files.selectOne(new LambdaQueryWrapper() + .eq(FileObjectEntity::getTenantId, tenantId).eq(FileObjectEntity::getId, fileId) + .eq(FileObjectEntity::getBusinessId, Long.toString(inboundId)) + .likeRight(FileObjectEntity::getBusinessType, "LOADING_") + .eq(FileObjectEntity::getDeleted, false)); + if (object == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在"); + return object; + } + + private static void validateType(String type) { + if (type == null || !(type.matches("PHOTO_STEP_[1-4]") || "SEAL".equals(type))) { + throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型"); + } + } + + public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes, String businessType) {} + public record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {} + public record Download(String fileName, String contentType, byte[] content) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java index 92ac9620b..df40cb857 100644 --- a/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java +++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java @@ -30,13 +30,21 @@ public class MeasurementImageStorage { void initializeRoot() { root = configuredRoot.toAbsolutePath().normalize(); } public Stored store(long tenantId, long inboundId, MultipartFile file) { + return store(tenantId, inboundId, file, "measurements"); + } + + public Stored storeLoading(long tenantId, long inboundId, MultipartFile file) { + return store(tenantId, inboundId, file, "loading-evidence"); + } + + private Stored store(long tenantId, long inboundId, MultipartFile file, String directory) { if (file == null || file.isEmpty()) throw bad("称重图片不能为空"); if (file.getSize() > MAX_SIZE) throw bad("称重图片不能超过5MB"); String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); if (!TYPES.contains(type)) throw bad("称重图片仅支持JPG、PNG、BMP"); verifyImage(file); String extension = "image/png".equals(type) ? ".png" : ("image/bmp".equals(type) ? ".bmp" : ".jpg"); - String objectKey = "measurements/" + tenantId + "/" + inboundId + "/" + UUID.randomUUID() + extension; + String objectKey = directory + "/" + tenantId + "/" + inboundId + "/" + UUID.randomUUID() + extension; Path target = root.resolve(objectKey).normalize(); if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); try { diff --git a/file/src/test/java/com/baiyue/file/service/LoadingEvidenceServiceTest.java b/file/src/test/java/com/baiyue/file/service/LoadingEvidenceServiceTest.java new file mode 100644 index 000000000..b8283d848 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/LoadingEvidenceServiceTest.java @@ -0,0 +1,51 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LoadingEvidenceServiceTest { + @Mock MeasurementImageStorage storage; + @Mock FileObjectMapper files; + @InjectMocks LoadingEvidenceService service; + + @Test + void uploadsEvidenceWithDedicatedBusinessType() { + var multipart = new MockMultipartFile("file", "step.jpg", "image/jpeg", new byte[]{1}); + when(storage.storeLoading(10, 20, multipart)).thenReturn(new MeasurementImageStorage.Stored( + "loading-evidence/10/20/a.jpg", Path.of("a.jpg"), "image/jpeg", 1, "hash", "step.jpg")); + var result = service.upload(10, 20, 7, "PHOTO_STEP_2", multipart); + assertThat(result.businessType()).isEqualTo("LOADING_PHOTO_STEP_2"); + verify(files).insert(any(FileObjectEntity.class)); + } + + @Test + void rejectsUnknownEvidenceType() { + var multipart = new MockMultipartFile("file", "x.jpg", "image/jpeg", new byte[]{1}); + assertThatThrownBy(() -> service.upload(10, 20, 7, "UNKNOWN", multipart)).isInstanceOf(BizException.class); + } + + @Test + void validatesTenantBoundFile() { + FileObjectEntity object = new FileObjectEntity(); + object.setId(30L); object.setOriginalName("seal.jpg"); object.setBusinessType("LOADING_SEAL"); object.setContentType("image/jpeg"); + when(files.selectOne(any(Wrapper.class))).thenReturn(object); + assertThat(service.validate(10, 20, 30).fileId()).isEqualTo(30L); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java new file mode 100644 index 000000000..ec120df69 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java @@ -0,0 +1,19 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; + +/** 仅用于确认证据文件属于当前客户和入库单,不在 firstleg 复制文件元数据。 */ +@FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient", + url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/loading-evidence") +public interface LoadingEvidenceFileClient { + @GetMapping("/{tenantId}/{inboundId}/{fileId}") + ApiResponse validate(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, @PathVariable long inboundId, + @PathVariable long fileId); + + record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java index eddba69aa..8ddd133a3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java @@ -10,7 +10,6 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; import com.baiyue.firstleg.service.MeasurementReviewService; -import com.baiyue.firstleg.service.AutomaticInboundCompletionService; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import io.swagger.v3.oas.annotations.Operation; @@ -36,12 +35,11 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/platform/v1/inbounds") public class PlatformInboundMeasurementReviewController { @Resource private MeasurementReviewService service; - @Resource private AutomaticInboundCompletionService automaticCompletion; @BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "测量审核通过") @Operation(summary = "测量审核通过", - description = "客服审核通过后自动计费、发布费用给客户并完成入库;客户无需确认费用。") + description = "客服确认测量数据后直接进入待分柜;费用和其他资料可在后续继续维护。") @PostMapping("/{inboundId}/measurements/approve") @PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')") public ApiResponse approve( @@ -53,9 +51,9 @@ public class PlatformInboundMeasurementReviewController { @Valid @RequestBody MeasurementReviewRequest request) { validateScope(user, tenantId); service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); - return ApiResponse.success(automaticCompletion.completeApproved( - tenantId, inboundId, user.userId(), user.username(), request.measurementId(), - request.expectedVersion() + 1)); + // 统一订单主线要求测量审核后直接进入 WAIT_SPLIT,不把计费、库位或其他资料 + // 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。 + return ApiResponse.success(null); } @BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java new file mode 100644 index 000000000..0c882e571 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java @@ -0,0 +1,102 @@ +package com.baiyue.firstleg.loading.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteDeliveryRequest; +import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest; +import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; +import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest; +import com.baiyue.firstleg.loading.dto.LoadingResponses.EvidenceListResponse; +import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse; +import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse; +import com.baiyue.firstleg.loading.service.LoadingDeliveryService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 平台端装柜证据、装柜完成和派送完成操作。 */ +@Tag(name = "订单装柜与派送") +@RestController +@RequestMapping("/api/platform/v1/inbounds") +public class PlatformLoadingDeliveryController { + @Resource private LoadingDeliveryService service; + + @Operation(summary = "新增或修改装柜证据") + @PostMapping("/{inboundId}/loading/evidence") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')") + @BusinessAudit(event = "INBOUND_LOADING_EVIDENCE_UPSERT", resourceType = "INBOUND", resourceId = "#inboundId", description = "维护装柜证据") + public ApiResponse evidence( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpsertEvidenceRequest request) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.upsertEvidence(tenantId, inboundId, user.userId(), user.username(), key, request)); + } + + @Operation(summary = "查询装柜证据") + @GetMapping("/{inboundId}/loading/evidence") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')") + public ApiResponse evidence( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.evidence(tenantId, inboundId)); + } + + @Operation(summary = "完成装柜") + @PostMapping("/{inboundId}/loading/complete") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:complete')") + @BusinessAudit(event = "INBOUND_COMPLETE_LOADING", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认装柜完成") + public ApiResponse completeLoading( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CompleteLoadingRequest request) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.completeLoading(tenantId, inboundId, user.userId(), user.username(), key, request)); + } + + @Operation(summary = "确认派送完成") + @PostMapping("/{inboundId}/delivery/complete") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:delivery:complete')") + @BusinessAudit(event = "INBOUND_COMPLETE_DELIVERY", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认派送完成") + public ApiResponse completeDelivery( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CompleteDeliveryRequest request) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.completeDelivery(tenantId, inboundId, user.userId(), user.username(), key, request)); + } + + @Operation(summary = "PDA开始拣货") + @PostMapping("/{inboundId}/loading/picking/start") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:edit')") + @BusinessAudit(event = "INBOUND_START_PICKING", resourceType = "INBOUND", resourceId = "#inboundId", description = "开始PDA拣货") + public ApiResponse startPicking( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody PickingRequest request) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.startPicking(tenantId, inboundId, user.userId(), user.username(), key, request)); + } + + @Operation(summary = "PDA完成拣货") + @PostMapping("/{inboundId}/loading/picking/complete") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:edit')") + @BusinessAudit(event = "INBOUND_COMPLETE_PICKING", resourceType = "INBOUND", resourceId = "#inboundId", description = "完成PDA拣货") + public ApiResponse completePicking( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody PickingRequest request) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.completePicking(tenantId, inboundId, user.userId(), user.username(), key, request)); + } + + @Operation(summary = "查询PDA拣货任务") + @GetMapping("/{inboundId}/loading/picking") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:view')") + public ApiResponse picking( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.picking(tenantId, inboundId)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java new file mode 100644 index 000000000..7f3f4f017 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java @@ -0,0 +1,17 @@ +package com.baiyue.firstleg.loading.domain; + +import java.util.Set; + +/** + * 装柜证据类型。照片语义尚待产品确认,当前只固定四个步骤和一个铅封证据槽位。 + */ +public final class LoadingEvidenceType { + public static final String PHOTO_STEP_1 = "PHOTO_STEP_1"; + public static final String PHOTO_STEP_2 = "PHOTO_STEP_2"; + public static final String PHOTO_STEP_3 = "PHOTO_STEP_3"; + public static final String PHOTO_STEP_4 = "PHOTO_STEP_4"; + public static final String SEAL = "SEAL"; + public static final Set VALUES = Set.of(PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL); + + private LoadingEvidenceType() {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java new file mode 100644 index 000000000..b0b9fb351 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java @@ -0,0 +1,8 @@ +package com.baiyue.firstleg.loading.domain; + +public final class PickingStatus { + public static final String PENDING = "PENDING"; + public static final String IN_PROGRESS = "IN_PROGRESS"; + public static final String COMPLETED = "COMPLETED"; + private PickingStatus() {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java new file mode 100644 index 000000000..8920e531a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.loading.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class LoadingRequests { + private LoadingRequests() {} + + public record UpsertEvidenceRequest( + @NotNull @PositiveOrZero Integer expectedVersion, + @NotBlank @Size(max = 32) String evidenceType, + @Positive Long fileId, + @Size(max = 255) String fileName, + @Size(max = 64) String sealNo, + @Size(max = 1000) String remark) {} + + public record CompleteLoadingRequest( + @NotNull @PositiveOrZero Integer expectedVersion, + @Size(max = 1000) String remark) {} + + public record CompleteDeliveryRequest( + @NotNull @PositiveOrZero Integer expectedVersion, + @Size(max = 1000) String remark) {} + + public record PickingRequest(@NotNull @PositiveOrZero Integer expectedVersion, + @PositiveOrZero Integer expectedCount, + @PositiveOrZero Integer pickedCount, + @Size(max = 1000) String remark) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java new file mode 100644 index 000000000..9976283bb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java @@ -0,0 +1,19 @@ +package com.baiyue.firstleg.loading.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class LoadingResponses { + private LoadingResponses() {} + + public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType, + Long fileId, String fileName, String sealNo, String remark, + Integer version, LocalDateTime createdAt, LocalDateTime updatedAt) {} + + public record EvidenceListResponse(Long inboundId, List evidences) {} + + public record LoadingActionResponse(String mainStatus, EvidenceListResponse evidences) {} + + public record PickingResponse(Long id, Long inboundId, String status, Integer version, + Integer expectedCount, Integer pickedCount, String remark) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingEvidenceEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingEvidenceEntity.java new file mode 100644 index 000000000..7096c2f10 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingEvidenceEntity.java @@ -0,0 +1,31 @@ +package com.baiyue.firstleg.loading.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_loading_evidence") +public class LoadingEvidenceEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private Long inboundId; + private String evidenceType; + private Long fileId; + private String fileName; + private String sealNo; + private String remark; + private Integer version; + private Long createdBy; + private String createdByName; + private Long updatedBy; + private String updatedByName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingOperationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingOperationEntity.java new file mode 100644 index 000000000..6589379c0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingOperationEntity.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.loading.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_loading_operation") +public class LoadingOperationEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private Long inboundId; + private Long operatorId; + private String operation; + private String idempotencyKey; + private String requestHash; + private String status; + private String resultJson; + private LocalDateTime createdAt; + private LocalDateTime completedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingPickTaskEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingPickTaskEntity.java new file mode 100644 index 000000000..08d58558c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingPickTaskEntity.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.loading.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_loading_pick_task") +public class LoadingPickTaskEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long tenantId; + private Long inboundId; + private String status; + private Integer version; + private Integer expectedCount; + private Integer pickedCount; + private String remark; + private Long updatedBy; + private String updatedByName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingEvidenceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingEvidenceMapper.java new file mode 100644 index 000000000..b6efa3937 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingEvidenceMapper.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.loading.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface LoadingEvidenceMapper extends BaseMapper { + @Update("UPDATE fl_loading_evidence SET file_id=#{fileId}, file_name=#{fileName}, seal_no=#{sealNo}, " + + "remark=#{remark}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, version=version+1 " + + "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND evidence_type=#{evidenceType} " + + "AND version=#{expectedVersion}") + int updateEvidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("evidenceType") String evidenceType, @Param("fileId") Long fileId, + @Param("fileName") String fileName, @Param("sealNo") String sealNo, + @Param("remark") String remark, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName, @Param("expectedVersion") int expectedVersion); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java new file mode 100644 index 000000000..a659459ac --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.firstleg.loading.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface LoadingInboundMapper { + @Select("SELECT id, tenant_id AS tenantId, process_status AS processStatus, version " + + "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") + Snapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Update("UPDATE fl_inbound_order SET process_status='LOADED', updated_by=#{operatorId}, version=version+1 " + + "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADING' AND version=#{expectedVersion}") + int completeLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); + + @Update("UPDATE fl_inbound_order SET process_status='DELIVERED', updated_by=#{operatorId}, version=version+1 " + + "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADED' AND version=#{expectedVersion}") + int completeDelivery(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); + + record Snapshot(Long id, Long tenantId, String processStatus, Integer version) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingOperationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingOperationMapper.java new file mode 100644 index 000000000..8527434ef --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingOperationMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.firstleg.loading.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.loading.entity.LoadingOperationEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface LoadingOperationMapper extends BaseMapper { + @Insert("INSERT IGNORE INTO fl_loading_operation " + + "(id,tenant_id,inbound_id,operator_id,operation,idempotency_key,request_hash,status,created_at) " + + "VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{operation},#{key},#{requestHash},'PROCESSING',CURRENT_TIMESTAMP(3))") + int claim(@Param("id") long id, @Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("operatorId") long operatorId, @Param("operation") String operation, + @Param("key") String key, @Param("requestHash") String requestHash); + + @Update("UPDATE fl_loading_operation SET status='SUCCESS', result_json=#{resultJson}, completed_at=CURRENT_TIMESTAMP(3) " + + "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND operator_id=#{operatorId} " + + "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'") + int complete(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("operatorId") long operatorId, @Param("operation") String operation, + @Param("key") String key, @Param("resultJson") String resultJson); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java new file mode 100644 index 000000000..dba3437f8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.loading.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface LoadingPickTaskMapper extends BaseMapper { + @Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, " + + "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), " + + "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} " + + "AND status=#{fromStatus} AND version=#{expectedVersion}") + int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName, + @Param("remark") String remark, @Param("expectedCount") Integer expectedCount, + @Param("pickedCount") Integer pickedCount, + @Param("expectedVersion") int expectedVersion); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java new file mode 100644 index 000000000..60151b079 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java @@ -0,0 +1,89 @@ +package com.baiyue.firstleg.loading.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; +import com.baiyue.firstleg.loading.entity.LoadingOperationEntity; +import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; +import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper; +import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper; +import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class LoadingRepository { + @Resource private LoadingInboundMapper inbounds; + @Resource private LoadingEvidenceMapper evidences; + @Resource private LoadingOperationMapper operations; + @Resource private LoadingPickTaskMapper picks; + + public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) { + return inbounds.find(tenantId, inboundId); + } + + public int completeLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) { + return inbounds.completeLoading(tenantId, inboundId, operatorId, expectedVersion); + } + + public int completeDelivery(long tenantId, long inboundId, long operatorId, int expectedVersion) { + return inbounds.completeDelivery(tenantId, inboundId, operatorId, expectedVersion); + } + + public LoadingEvidenceEntity findEvidence(long tenantId, long inboundId, String type) { + return evidences.selectOne(new LambdaQueryWrapper() + .eq(LoadingEvidenceEntity::getTenantId, tenantId) + .eq(LoadingEvidenceEntity::getInboundId, inboundId) + .eq(LoadingEvidenceEntity::getEvidenceType, type)); + } + + public List listEvidence(long tenantId, long inboundId) { + return evidences.selectList(new LambdaQueryWrapper() + .eq(LoadingEvidenceEntity::getTenantId, tenantId) + .eq(LoadingEvidenceEntity::getInboundId, inboundId) + .orderByAsc(LoadingEvidenceEntity::getEvidenceType)); + } + + public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); } + + public int updateEvidence(long tenantId, long inboundId, String type, Long fileId, String fileName, + String sealNo, String remark, long operatorId, String operatorName, int version) { + return evidences.updateEvidence(tenantId, inboundId, type, fileId, fileName, sealNo, remark, + operatorId, operatorName, version); + } + + public LoadingOperationEntity findOperation(long tenantId, long inboundId, long operatorId, + String operation, String key) { + return operations.selectOne(new LambdaQueryWrapper() + .eq(LoadingOperationEntity::getTenantId, tenantId) + .eq(LoadingOperationEntity::getInboundId, inboundId) + .eq(LoadingOperationEntity::getOperatorId, operatorId) + .eq(LoadingOperationEntity::getOperation, operation) + .eq(LoadingOperationEntity::getIdempotencyKey, key)); + } + + public boolean claimOperation(long tenantId, long inboundId, long operatorId, String operation, + String key, String requestHash) { + return operations.claim(IdWorker.getId(), tenantId, inboundId, operatorId, operation, key, requestHash) == 1; + } + + public void completeOperation(long tenantId, long inboundId, long operatorId, String operation, + String key, String resultJson) { + operations.complete(tenantId, inboundId, operatorId, operation, key, resultJson); + } + + public LoadingPickTaskEntity findPickTask(long tenantId, long inboundId) { + return picks.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickTaskEntity::getTenantId, tenantId).eq(LoadingPickTaskEntity::getInboundId, inboundId)); + } + public void createPickTask(LoadingPickTaskEntity task) { picks.insert(task); } + public int changePickStatus(long tenantId, long inboundId, String from, String to, long operatorId, + String operatorName, String remark, Integer expectedCount, + Integer pickedCount, int version) { + return picks.changeStatus(tenantId, inboundId, from, to, operatorId, operatorName, remark, + expectedCount, pickedCount, version); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java new file mode 100644 index 000000000..0d6755382 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -0,0 +1,245 @@ +package com.baiyue.firstleg.loading.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.LoadingEvidenceFileClient; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.loading.domain.LoadingEvidenceType; +import com.baiyue.firstleg.loading.dto.LoadingRequests.*; +import com.baiyue.firstleg.loading.dto.LoadingResponses.*; +import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; +import com.baiyue.firstleg.loading.entity.LoadingOperationEntity; +import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.loading.domain.PickingStatus; +import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; +import com.baiyue.firstleg.loading.repository.LoadingRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; + +/** 装柜证据、装柜完成和派送完成;不检查证据完整度,不阻断业务流转。 */ +@Service +public class LoadingDeliveryService { + private static final String UPSERT_EVIDENCE = "UPSERT_LOADING_EVIDENCE"; + private static final String COMPLETE_LOADING = "COMPLETE_LOADING"; + private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY"; + + @Resource private LoadingRepository repository; + @Resource private InboundStatusHistoryMapper histories; + @Resource private LoadingEvidenceFileClient files; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + + @Transactional + public LoadingActionResponse upsertEvidence(long tenantId, long inboundId, long operatorId, + String operatorName, String key, UpsertEvidenceRequest request) { + String hash = hash(request); + LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, hash); + if (replay != null) return replay; + requireLoadingStage(tenantId, inboundId); + if (!LoadingEvidenceType.VALUES.contains(request.evidenceType())) { + throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型"); + } + var file = request.fileId() == null ? null : validateFile(tenantId, inboundId, request.fileId(), request.evidenceType()); + String fileName = file == null ? request.fileName() : file.fileName(); + LoadingEvidenceEntity existing = repository.findEvidence(tenantId, inboundId, request.evidenceType()); + if (existing == null) { + if (request.expectedVersion() != 0) throw concurrent(); + LoadingEvidenceEntity entity = new LoadingEvidenceEntity(); + entity.setTenantId(tenantId); entity.setInboundId(inboundId); + entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId()); + entity.setFileName(fileName); entity.setSealNo(request.sealNo()); entity.setRemark(request.remark()); + entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName); + entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName); + repository.createEvidence(entity); + } else if (repository.updateEvidence(tenantId, inboundId, request.evidenceType(), request.fileId(), fileName, + request.sealNo(), request.remark(), operatorId, operatorName, request.expectedVersion()) != 1) { + throw concurrent(); + } + LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId)); + return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result); + } + + @Transactional + public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId, + String operatorName, String key, CompleteLoadingRequest request) { + String hash = hash(request); + LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash); + if (replay != null) return replay; + Snapshot inbound = requireInbound(tenantId, inboundId); + if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { + return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, + new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId))); + } + if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中"); + if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); + addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName); + return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, + new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId))); + } + + @Transactional + public LoadingActionResponse completeDelivery(long tenantId, long inboundId, long operatorId, + String operatorName, String key, CompleteDeliveryRequest request) { + String hash = hash(request); + LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key, hash); + if (replay != null) return replay; + Snapshot inbound = requireInbound(tenantId, inboundId); + if ("DELIVERED".equals(inbound.processStatus())) { + return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key, + new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId))); + } + if (!"LOADED".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜完成"); + if (repository.completeDelivery(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); + addHistory(tenantId, inboundId, "LOADED", "DELIVERED", COMPLETE_DELIVERY, request.remark(), operatorId, operatorName); + return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key, + new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId))); + } + + public EvidenceListResponse evidence(long tenantId, long inboundId) { + requireLoadingStage(tenantId, inboundId); + return evidenceResponse(tenantId, inboundId); + } + + @Transactional + public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName, + String key, PickingRequest request) { + String hash = hash(request); + LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash); + if (replay != null) return pickResponse(tenantId, inboundId); + requireLoadingStage(tenantId, inboundId); + LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); + if (task == null) { + if (request.expectedVersion() != 0) throw concurrent(); + task = new LoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId); + task.setStatus(PickingStatus.IN_PROGRESS); task.setVersion(0); task.setExpectedCount(request.expectedCount()); + task.setPickedCount(request.pickedCount()); + task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName); + repository.createPickTask(task); + } else if (repository.changePickStatus(tenantId, inboundId, task.getStatus(), PickingStatus.IN_PROGRESS, + operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent(); + PickingResponse result = pickResponse(tenantId, inboundId); + complete(tenantId, inboundId, operatorId, "START_PICKING", key, + new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId))); + return result; + } + + @Transactional + public PickingResponse completePicking(long tenantId, long inboundId, long operatorId, String operatorName, + String key, PickingRequest request) { + String hash = hash(request); + LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash); + if (replay != null) return pickResponse(tenantId, inboundId); + requireLoadingStage(tenantId, inboundId); + LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); + if (task == null) { + task = new LoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId); + task.setStatus(PickingStatus.COMPLETED); task.setVersion(0); task.setExpectedCount(request.expectedCount()); + task.setPickedCount(request.pickedCount()); + task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName); + repository.createPickTask(task); + } else if (repository.changePickStatus(tenantId, inboundId, + task.getStatus(), PickingStatus.COMPLETED, operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent(); + PickingResponse result = pickResponse(tenantId, inboundId); + complete(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, + new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId))); + return result; + } + + public PickingResponse picking(long tenantId, long inboundId) { + requireInbound(tenantId, inboundId); + return pickResponse(tenantId, inboundId); + } + + private PickingResponse pickResponse(long tenantId, long inboundId) { + LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); + if (task == null) return null; + return new PickingResponse(task.getId(), task.getInboundId(), task.getStatus(), task.getVersion(), + task.getExpectedCount(), task.getPickedCount(), task.getRemark()); + } + + private LoadingEvidenceFileClient.FileMetadata validateFile(long tenantId, long inboundId, long fileId, String evidenceType) { + try { + var response = files.validate(internalToken, tenantId, inboundId, fileId); + if (response == null || !response.successful() || response.data() == null + || !("LOADING_" + evidenceType).equals(response.data().businessType())) { + throw new BizException(ResultCode.BAD_REQUEST, "证据文件不存在或不属于当前订单"); + } + return response.data(); + } catch (FeignException ex) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } + + private Snapshot requireInbound(long tenantId, long inboundId) { + Snapshot value = repository.findInbound(tenantId, inboundId); + if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在"); + return value; + } + + private Snapshot requireLoadingStage(long tenantId, long inboundId) { + Snapshot value = requireInbound(tenantId, inboundId); + if (!("LOADING".equals(value.processStatus()) || "LOADED".equals(value.processStatus()) + || "DELIVERED".equals(value.processStatus()))) { + throw new BizException(ResultCode.CONFLICT, "订单尚未进入装柜阶段"); + } + return value; + } + + private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) { + List values = repository.listEvidence(tenantId, inboundId).stream().map(e -> + new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(), + e.getFileName(), e.getSealNo(), e.getRemark(), e.getVersion(), e.getCreatedAt(), e.getUpdatedAt())).toList(); + return new EvidenceListResponse(inboundId, values); + } + + private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId, + String operation, String key, String hash) { + validateKey(key); + if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key, hash)) return null; + LoadingOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key); + if (existing == null || !hash.equals(existing.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求"); + if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试"); + try { return objectMapper.readValue(existing.getResultJson(), LoadingActionResponse.class); } + catch (JsonProcessingException e) { throw new IllegalStateException("无法读取装柜幂等结果", e); } + } + + private LoadingActionResponse complete(long tenantId, long inboundId, long operatorId, String operation, + String key, LoadingActionResponse result) { + try { repository.completeOperation(tenantId, inboundId, operatorId, operation, key, objectMapper.writeValueAsString(result)); } + catch (JsonProcessingException e) { throw new IllegalStateException("无法保存装柜幂等结果", e); } + return result; + } + + private void addHistory(long tenantId, long inboundId, String from, String to, String action, String reason, + long operatorId, String operatorName) { + InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); + history.setTenantId(tenantId); history.setInboundId(inboundId); history.setFromStatus(from); history.setToStatus(to); + history.setAction(action); history.setReason(reason); history.setOperatorId(operatorId); history.setOperatorName(operatorName); + histories.insert(history); + } + + private String hash(Object request) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (Exception e) { throw new IllegalStateException("无法计算幂等请求摘要", e); } + } + + private static void validateKey(String key) { + if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且不能超过128个字符"); + } + private static BizException concurrent() { return new BizException(ResultCode.CONFLICT, "数据已被其他人修改,请刷新后重试"); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java index c34ba8f50..9e727b3fe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java @@ -11,7 +11,8 @@ import org.apache.ibatis.annotations.Update; public interface InboundOrderMapper extends BaseMapper { @Update(""" UPDATE fl_inbound_order - SET process_status = 'WAIT_PRICING', measure_review_status = 'APPROVED', + SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED', + inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)), updated_by = #{operatorId}, version = version + 1 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0 AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING' diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index a7251ef8b..b5193dfee 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -34,8 +34,6 @@ public class InboundMeasurementService { private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL"; /** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */ private static final String RECOMMENDED_PALLET_TYPE = "STANDARD"; - private static final Set EDITABLE_INBOUND_STATUSES = - Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237"); @Resource @@ -58,7 +56,6 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SaveDraftRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); - requireEditable(inbound); MeasurementBatchCommand command = new MeasurementBatchCommand( tenantId, inboundId, request.measurementId(), request.version(), MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null, @@ -78,35 +75,42 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SubmitRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); - requireEditable(inbound); InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId()); requireVersion(request.version(), measurement.getVersion()); - if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED) + boolean alreadyAdvanced = !Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED") + .contains(inbound.getProcessStatus()); + if (!alreadyAdvanced && !Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED) .contains(measurement.getStatus())) { throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核"); } List items = repository.listItems(tenantId, measurement.getId()); - if (items.isEmpty()) { - throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细不能为空"); - } - if (items.stream().noneMatch(item -> item.getSourceImageFileId() != null)) { - throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张测量图片"); - } List weights = repository.listWeights(tenantId, inboundId); BigDecimal confirmedWeight = confirmedWeightForSubmission(weights); - measurement.setStatus(STATUS_SUBMITTED); - if (!repository.updateMeasurement(measurement)) { - throw concurrentModification(); + if (!alreadyAdvanced) { + measurement.setStatus(STATUS_SUBMITTED); + if (!repository.updateMeasurement(measurement)) { + throw concurrentModification(); + } } - inbound.setProcessStatus("MEASURE_PENDING"); - inbound.setMeasureReviewStatus("PENDING"); - inbound.setTotalPackageCount(measurement.getTotalPackageCount()); - inbound.setActualGrossWeightKg(confirmedWeight); - inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); - inbound.setVolumeWeightKg(measurement.getVolumeWeightKg()); - inbound.setMeasureCompletedAt(LocalDateTime.now()); + if (!alreadyAdvanced) { + inbound.setProcessStatus("MEASURE_PENDING"); + inbound.setMeasureReviewStatus("PENDING"); + inbound.setMeasureCompletedAt(LocalDateTime.now()); + } + if (measurement.getTotalPackageCount() != null) { + inbound.setTotalPackageCount(measurement.getTotalPackageCount()); + } + if (confirmedWeight != null) { + inbound.setActualGrossWeightKg(confirmedWeight); + } + if (measurement.getTotalVolumeCbm() != null) { + inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); + } + if (measurement.getVolumeWeightKg() != null) { + inbound.setVolumeWeightKg(measurement.getVolumeWeightKg()); + } inbound.setUpdatedBy(operatorId); if (!repository.updateInbound(inbound)) { throw concurrentModification(); @@ -121,7 +125,6 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.CreateWeightResultRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); - requireEditable(inbound); if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) { throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重"); } @@ -150,7 +153,6 @@ public class InboundMeasurementService { long tenantId, long inboundId, long resultId, long operatorId, String operatorName, InboundMeasurementRequests.CorrectWeightResultRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); - requireEditable(inbound); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); requireVersion(request.version(), weight.getVersion()); String adjustedUnit = normalizeUnit(request.adjustedWeightUnit()); @@ -181,7 +183,6 @@ public class InboundMeasurementService { long tenantId, long inboundId, long resultId, long operatorId, String operatorName, InboundMeasurementRequests.ConfirmWeightResultRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); - requireEditable(inbound); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); requireVersion(request.version(), weight.getVersion()); if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) { @@ -225,18 +226,12 @@ public class InboundMeasurementService { } private BigDecimal confirmedWeightForSubmission(List weights) { - if (weights.isEmpty()) { - throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张已确认的称重图片"); - } - if (weights.stream().anyMatch(weight -> !WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))) { - throw new BizException(ResultCode.VALIDATION_FAILED, "所有有效称重图片必须先完成确认"); - } - BigDecimal total = weights.stream().map(InboundWeightResultEntity::getFinalWeightKg) + BigDecimal total = weights.stream() + .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) + .map(InboundWeightResultEntity::getFinalWeightKg) + .filter(java.util.Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP); - if (!isPositive(total)) { - throw new BizException(ResultCode.VALIDATION_FAILED, "确认后的实际总重量必须大于 0"); - } - return total; + return isPositive(total) ? total : null; } private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action, @@ -328,12 +323,6 @@ public class InboundMeasurementService { return weight; } - private void requireEditable(InboundOrderEntity inbound) { - if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) { - throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许维护测量数据"); - } - } - private void requireVersion(Integer expected, Integer actual) { if (expected == null || !expected.equals(actual)) { throw concurrentModification(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index 81356891e..f586c0416 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -38,7 +38,8 @@ public class MeasurementBatchService { @Transactional public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) { - if (repository.findInbound(command.tenantId(), command.inboundId()) == null) { + var inbound = repository.findInbound(command.tenantId(), command.inboundId()); + if (inbound == null) { throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); } validateSource(command); @@ -50,7 +51,13 @@ public class MeasurementBatchService { applyTotals(measurement, calculatedItems); repository.insertMeasurement(measurement); } else { - measurement = requireEditableManualBatch(command); + measurement = requireEditableBatch(command); + // 修正待测量或待审核资料时重新形成可提交的内部草稿;已入库订单只更新资料, + // 不回退其主状态,也不把已经审核的批次重新标成待审核。 + if (Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED") + .contains(inbound.getProcessStatus())) { + measurement.setStatus(STATUS_DRAFT); + } applyHeader(measurement, command); applyTotals(measurement, calculatedItems); if (!repository.updateMeasurement(measurement)) { @@ -90,15 +97,12 @@ public class MeasurementBatchService { return measurement; } - private InboundMeasurementEntity requireEditableManualBatch(MeasurementBatchCommand command) { + private InboundMeasurementEntity requireEditableBatch(MeasurementBatchCommand command) { InboundMeasurementEntity measurement = repository.findMeasurement( command.tenantId(), command.inboundId(), command.measurementId()); if (measurement == null) { throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在"); } - if (!SOURCE_MANUAL.equals(measurement.getSourceType()) || !STATUS_DRAFT.equals(measurement.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "仅人工草稿允许直接修改"); - } if (!Integer.valueOf(command.expectedVersion()).equals(measurement.getVersion())) { throw concurrentModification(); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index bb9ffeeb0..15b8b5a46 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -20,7 +20,8 @@ import org.springframework.util.StringUtils; import java.time.LocalDateTime; /** - * 测量审核应用服务。审核是 MEASURE_PENDING 到计费阶段的唯一正常推进关口。 + * 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 WAIT_SPLIT; + * 计费、库位和其他资料不作为本版本的强制门禁。 */ @Service public class MeasurementReviewService { @@ -79,7 +80,7 @@ public class MeasurementReviewService { repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result, request.reason(), false, null, false, operatorId, operatorName)); repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING", - approved ? "WAIT_PRICING" : "MEASURE_REJECTED", + approved ? "WAIT_SPLIT" : "MEASURE_REJECTED", approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName)); idempotency.complete(context, inboundId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java index 6a2730250..6ce5a0f76 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java @@ -11,15 +11,14 @@ import org.springframework.transaction.annotation.Transactional; public class MeasurementSubmissionService { @Resource private InboundMeasurementService measurements; - @Resource - private MeasurementLocationOccupationService locations; @Transactional public InboundMeasurementResponses.MeasurementSubmitted submit( long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SubmitRequest request) { var result = measurements.submit(tenantId, inboundId, operatorId, request); - locations.occupy(tenantId, inboundId, operatorId, result.measurementId(), result.version()); + // 当前版本不把托盘/库位占用作为测量提交的强制门禁;入库主状态由客服确认测量时推进, + // 仓储资源可在后续作业阶段继续维护。 return result; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java b/firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java new file mode 100644 index 000000000..1864e0339 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java @@ -0,0 +1,82 @@ +package com.baiyue.firstleg.split.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; +import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; +import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse; +import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse; +import com.baiyue.firstleg.split.service.SplitPreloadService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** 平台端分柜、预装清单和装柜中主状态推进接口。 */ +@Tag(name = "订单分柜与预装") +@RestController +@RequestMapping("/api/platform/v1/inbounds") +public class PlatformSplitPreloadController { + @Resource private SplitPreloadService service; + + @Operation(summary = "智能分柜并生成或覆盖预装清单") + @PostMapping("/{inboundId}/split/smart") + @PreAuthorize("hasAuthority('firstleg:inbound:split')") + @BusinessAudit(event = "INBOUND_SMART_SPLIT", resourceType = "INBOUND", resourceId = "#inboundId", + description = "智能分柜") + public ApiResponse smart( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, + @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody SmartSplitRequest request) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.smartSplit(tenantId, inboundId, user.userId(), user.username(), key, request)); + } + + @Operation(summary = "提交预装清单确认") + @PostMapping("/{inboundId}/preload/confirming") + @PreAuthorize("hasAuthority('firstleg:inbound:preload')") + @BusinessAudit(event = "INBOUND_PRELOAD_CONFIRMING", resourceType = "INBOUND", resourceId = "#inboundId", + description = "提交预装清单确认") + public ApiResponse confirming( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, + @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ConfirmPreloadRequest request) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.startConfirming(tenantId, inboundId, user.userId(), user.username(), key, request)); + } + + @Operation(summary = "确认预装清单并进入装柜中") + @PostMapping("/{inboundId}/preload/confirm") + @PreAuthorize("hasAuthority('firstleg:inbound:preload')") + @BusinessAudit(event = "INBOUND_CONFIRM_PRELOAD", resourceType = "INBOUND", resourceId = "#inboundId", + description = "确认预装清单") + public ApiResponse confirm( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, + @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ConfirmPreloadRequest request) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.confirmPreload(tenantId, inboundId, user.userId(), user.username(), key, request)); + } + + @Operation(summary = "查询预装清单") + @GetMapping("/{inboundId}/preload") + @PreAuthorize("hasAuthority('firstleg:inbound:preload-view')") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, + @PathVariable long inboundId) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.detail(tenantId, inboundId)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java new file mode 100644 index 000000000..3d9675b82 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java @@ -0,0 +1,12 @@ +package com.baiyue.firstleg.split.domain; + +import java.util.Set; + +/** 预装清单子状态,固定为三态。 */ +public final class PreloadStatus { + public static final String PENDING = "PENDING"; + public static final String CONFIRMING = "CONFIRMING"; + public static final String CONFIRMED = "CONFIRMED"; + public static final Set VALUES = Set.of(PENDING, CONFIRMING, CONFIRMED); + private PreloadStatus() {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java new file mode 100644 index 000000000..2caf3352b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.split.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.util.List; + +/** 分柜与预装清单写入请求。资料字段本版本不做完整性门禁。 */ +public final class SplitRequests { + private SplitRequests() {} + + public record SmartSplitRequest( + @NotNull @PositiveOrZero Integer expectedVersion, + List<@Valid PreloadItem> items, + @Size(max = 1000) String remark) {} + + public record ConfirmPreloadRequest(@NotNull @PositiveOrZero Integer expectedVersion) {} + + public record PreloadItem( + @Size(max = 64) String containerCode, + @Size(max = 128) String cargoReference, + @Positive Integer packageCount, + @PositiveOrZero BigDecimal volumeCbm, + @Size(max = 500) String remark) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitResponses.java new file mode 100644 index 000000000..3cf30465e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitResponses.java @@ -0,0 +1,15 @@ +package com.baiyue.firstleg.split.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class SplitResponses { + private SplitResponses() {} + + public record PreloadPlanResponse(Long id, Long inboundId, Long tenantId, String status, + Integer version, List items, + String remark, LocalDateTime createdAt, + LocalDateTime updatedAt) {} + + public record SplitActionResponse(String mainStatus, PreloadPlanResponse preloadPlan) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/entity/PreloadPlanEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/split/entity/PreloadPlanEntity.java new file mode 100644 index 000000000..f1785f0bb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/entity/PreloadPlanEntity.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.split.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_preload_plan") +public class PreloadPlanEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private Long inboundId; + private String status; + private Integer version; + private String itemsJson; + private String remark; + private Long createdBy; + private String createdByName; + private Long updatedBy; + private String updatedByName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/entity/SplitOperationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/split/entity/SplitOperationEntity.java new file mode 100644 index 000000000..fe99a57bb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/entity/SplitOperationEntity.java @@ -0,0 +1,25 @@ +package com.baiyue.firstleg.split.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_split_operation") +public class SplitOperationEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private Long inboundId; + private Long operatorId; + private String operation; + private String idempotencyKey; + private String status; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java new file mode 100644 index 000000000..efe37bd23 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.split.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.split.entity.PreloadPlanEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface PreloadPlanMapper extends BaseMapper { + @Update("UPDATE fl_preload_plan SET items_json=#{itemsJson}, remark=#{remark}, status='PENDING', " + + "updated_by=#{operatorId}, updated_by_name=#{operatorName}, version=version+1 " + + "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND version=#{expectedVersion}") + int replace(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("itemsJson") String itemsJson, @Param("remark") String remark, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName, + @Param("expectedVersion") int expectedVersion); + + @Update("UPDATE fl_preload_plan SET status=#{toStatus}, updated_by=#{operatorId}, " + + "updated_by_name=#{operatorName}, version=version+1 " + + "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status=#{fromStatus} " + + "AND version=#{expectedVersion}") + int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName, + @Param("expectedVersion") int expectedVersion); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java new file mode 100644 index 000000000..c608e8c06 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.split.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface SplitInboundMapper { + @Select("SELECT id, tenant_id AS tenantId, process_status AS processStatus, version FROM fl_inbound_order " + + "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") + InboundSnapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, " + + "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 " + + "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}") + int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); + + record InboundSnapshot(Long id, Long tenantId, String processStatus, Integer version) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitOperationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitOperationMapper.java new file mode 100644 index 000000000..09de4e0a9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitOperationMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.firstleg.split.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.split.entity.SplitOperationEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface SplitOperationMapper extends BaseMapper { + @Insert("INSERT IGNORE INTO fl_split_operation " + + "(id,tenant_id,inbound_id,operator_id,operation,idempotency_key,status,created_at) " + + "VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{operation},#{key},'PROCESSING',CURRENT_TIMESTAMP(3))") + int claim(@Param("id") long id, @Param("tenantId") long tenantId, + @Param("inboundId") long inboundId, @Param("operatorId") long operatorId, + @Param("operation") String operation, @Param("key") String key); + + @Update("UPDATE fl_split_operation SET status='SUCCESS', result_json=#{resultJson} " + + "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND operator_id=#{operatorId} " + + "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'") + int complete(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("operatorId") long operatorId, @Param("operation") String operation, + @Param("key") String key, @Param("resultJson") String resultJson); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java new file mode 100644 index 000000000..fc0d5a2bf --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java @@ -0,0 +1,71 @@ +package com.baiyue.firstleg.split.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.firstleg.split.entity.PreloadPlanEntity; +import com.baiyue.firstleg.split.entity.SplitOperationEntity; +import com.baiyue.firstleg.split.mapper.PreloadPlanMapper; +import com.baiyue.firstleg.split.mapper.SplitInboundMapper; +import com.baiyue.firstleg.split.mapper.SplitOperationMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; + +@Repository +public class SplitRepository { + @Resource private SplitInboundMapper inbounds; + @Resource private PreloadPlanMapper plans; + @Resource private SplitOperationMapper operations; + + public SplitInboundMapper.InboundSnapshot findInbound(long tenantId, long inboundId) { + return inbounds.find(tenantId, inboundId); + } + + public int beginLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) { + return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion); + } + + public PreloadPlanEntity findPlan(long tenantId, long inboundId) { + return plans.selectOne(new LambdaQueryWrapper() + .eq(PreloadPlanEntity::getTenantId, tenantId) + .eq(PreloadPlanEntity::getInboundId, inboundId)); + } + + public void createPlan(PreloadPlanEntity plan) { + plans.insert(plan); + } + + public int replacePlan(long tenantId, long inboundId, String itemsJson, String remark, + long operatorId, String operatorName, int expectedVersion) { + return plans.replace(tenantId, inboundId, itemsJson, remark, operatorId, operatorName, expectedVersion); + } + + public int changePlanStatus(long tenantId, long inboundId, String fromStatus, String toStatus, + long operatorId, String operatorName, int expectedVersion) { + return plans.changeStatus(tenantId, inboundId, fromStatus, toStatus, + operatorId, operatorName, expectedVersion); + } + + public SplitOperationEntity findOperation(long tenantId, long inboundId, long operatorId, + String operation, String key) { + return operations.selectOne(new LambdaQueryWrapper() + .eq(SplitOperationEntity::getTenantId, tenantId) + .eq(SplitOperationEntity::getInboundId, inboundId) + .eq(SplitOperationEntity::getOperatorId, operatorId) + .eq(SplitOperationEntity::getOperation, operation) + .eq(SplitOperationEntity::getIdempotencyKey, key)); + } + + public void saveOperation(SplitOperationEntity operation) { + operations.insert(operation); + } + + public boolean claimOperation(long tenantId, long inboundId, long operatorId, + String operation, String key) { + return operations.claim(IdWorker.getId(), tenantId, inboundId, operatorId, operation, key) == 1; + } + + public void completeOperation(long tenantId, long inboundId, long operatorId, + String operation, String key, String resultJson) { + operations.complete(tenantId, inboundId, operatorId, operation, key, resultJson); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java new file mode 100644 index 000000000..4b4b66672 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -0,0 +1,213 @@ +package com.baiyue.firstleg.split.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.split.domain.PreloadStatus; +import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; +import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem; +import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; +import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse; +import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse; +import com.baiyue.firstleg.split.entity.PreloadPlanEntity; +import com.baiyue.firstleg.split.entity.SplitOperationEntity; +import com.baiyue.firstleg.split.mapper.SplitInboundMapper.InboundSnapshot; +import com.baiyue.firstleg.split.repository.SplitRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.List; + +/** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */ +@Service +public class SplitPreloadService { + private static final String SMART_SPLIT = "SMART_SPLIT"; + private static final String START_CONFIRMING = "START_PRELOAD_CONFIRMING"; + private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD"; + + @Resource private SplitRepository repository; + @Resource private InboundStatusHistoryMapper histories; + @Resource private ObjectMapper objectMapper; + + @Transactional + public SplitActionResponse smartSplit(long tenantId, long inboundId, long operatorId, + String operatorName, String key, SmartSplitRequest request) { + SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, SMART_SPLIT, key); + if (replay != null) return replay; + InboundSnapshot inbound = requireInbound(tenantId, inboundId); + if (!"WAIT_SPLIT".equals(inbound.processStatus())) { + throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以生成预装清单"); + } + PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); + String itemsJson = write(request.items() == null ? List.of() : request.items()); + if (plan == null) { + if (request.expectedVersion() != 0) throw concurrent(); + plan = new PreloadPlanEntity(); + plan.setTenantId(tenantId); + plan.setInboundId(inboundId); + plan.setStatus(PreloadStatus.PENDING); + plan.setVersion(0); + plan.setItemsJson(itemsJson); + plan.setRemark(request.remark()); + plan.setCreatedBy(operatorId); + plan.setCreatedByName(operatorName); + plan.setUpdatedBy(operatorId); + plan.setUpdatedByName(operatorName); + repository.createPlan(plan); + } else if (repository.replacePlan(tenantId, inboundId, itemsJson, request.remark(), + operatorId, operatorName, request.expectedVersion()) != 1) { + throw concurrent(); + } + PreloadPlanResponse result = response(repository.findPlan(tenantId, inboundId)); + return complete(tenantId, inboundId, operatorId, SMART_SPLIT, key, + new SplitActionResponse("WAIT_SPLIT", result)); + } + + @Transactional + public SplitActionResponse startConfirming(long tenantId, long inboundId, long operatorId, + String operatorName, String key, + ConfirmPreloadRequest request) { + SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key); + if (replay != null) return replay; + InboundSnapshot inbound = requireInbound(tenantId, inboundId); + requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以提交预装清单确认"); + PreloadPlanEntity plan = requirePlan(tenantId, inboundId); + if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) { + return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, + new SplitActionResponse("WAIT_SPLIT", response(plan))); + } + if (!PreloadStatus.PENDING.equals(plan.getStatus()) + || repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING, + PreloadStatus.CONFIRMING, operatorId, operatorName, request.expectedVersion()) != 1) { + throw concurrent(); + } + return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, + new SplitActionResponse("WAIT_SPLIT", response(repository.findPlan(tenantId, inboundId)))); + } + + @Transactional + public SplitActionResponse confirmPreload(long tenantId, long inboundId, long operatorId, + String operatorName, String key, + ConfirmPreloadRequest request) { + SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key); + if (replay != null) return replay; + InboundSnapshot inbound = requireInbound(tenantId, inboundId); + if ("LOADING".equals(inbound.processStatus())) { + return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, + new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); + } + requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以确认预装清单"); + PreloadPlanEntity plan = requirePlan(tenantId, inboundId); + if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试"); + } + if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED, + operatorId, operatorName, request.expectedVersion()) != 1) { + throw concurrent(); + } + if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) { + throw concurrent(); + } + addHistory(tenantId, inboundId, "WAIT_SPLIT", "LOADING", CONFIRM_PRELOAD, + operatorId, operatorName); + return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, + new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); + } + + public PreloadPlanResponse detail(long tenantId, long inboundId) { + requireInbound(tenantId, inboundId); + return response(requirePlan(tenantId, inboundId)); + } + + private SplitActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId, + String operation, String key) { + validateKey(key); + if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key)) return null; + SplitOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key); + if (existing == null || !"SUCCESS".equals(existing.getStatus()) + || !StringUtils.hasText(existing.getResultJson())) { + throw new BizException(ResultCode.CONFLICT, "相同幂等请求正在处理中,请稍后重试"); + } + try { + return objectMapper.readValue(existing.getResultJson(), SplitActionResponse.class); + } catch (JsonProcessingException e) { + throw new IllegalStateException("无法读取分柜幂等结果", e); + } + } + + private SplitActionResponse complete(long tenantId, long inboundId, long operatorId, + String operation, String key, SplitActionResponse result) { + repository.completeOperation(tenantId, inboundId, operatorId, operation, key, write(result)); + return result; + } + + private InboundSnapshot requireInbound(long tenantId, long inboundId) { + InboundSnapshot inbound = repository.findInbound(tenantId, inboundId); + if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在"); + return inbound; + } + + private PreloadPlanEntity requirePlan(long tenantId, long inboundId) { + PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); + if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "预装清单不存在"); + return plan; + } + + private static void requireStatus(InboundSnapshot inbound, String expected, String message) { + if (!expected.equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, message); + } + + private void addHistory(long tenantId, long inboundId, String from, String to, String action, + long operatorId, String operatorName) { + InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); + history.setTenantId(tenantId); + history.setInboundId(inboundId); + history.setFromStatus(from); + history.setToStatus(to); + history.setAction(action); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + histories.insert(history); + } + + private PreloadPlanResponse response(PreloadPlanEntity plan) { + if (plan == null) return null; + return new PreloadPlanResponse(plan.getId(), plan.getInboundId(), plan.getTenantId(), + plan.getStatus(), plan.getVersion(), readItems(plan.getItemsJson()), plan.getRemark(), + plan.getCreatedAt(), plan.getUpdatedAt()); + } + + private List readItems(String json) { + if (!StringUtils.hasText(json)) return List.of(); + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (JsonProcessingException e) { + throw new IllegalStateException("无法读取预装清单", e); + } + } + + private String write(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("无法保存预装清单", e); + } + } + + private static void validateKey(String key) { + if (!StringUtils.hasText(key) || key.length() > 128) { + throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且不能超过128个字符"); + } + } + + private static BizException concurrent() { + return new BizException(ResultCode.CONFLICT, "数据已被其他人修改,请刷新后重试"); + } +} diff --git a/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql b/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql new file mode 100644 index 000000000..5c61dada4 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql @@ -0,0 +1,62 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +/* 装柜证据和派送完成;不设置证据完整度门禁,允许后续覆盖编辑。 */ +CREATE TABLE IF NOT EXISTS fl_loading_evidence ( + id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1..4/SEAL', + file_id BIGINT UNSIGNED NULL COMMENT 'File 服务逻辑文件ID', + file_name VARCHAR(255) NULL, + seal_no VARCHAR(64) NULL, + remark VARCHAR(1000) NULL, + version INT NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + updated_by BIGINT UNSIGNED NOT NULL, + updated_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type), + KEY idx_loading_evidence_time (tenant_id, inbound_id, updated_at), + CONSTRAINT fk_loading_evidence_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='订单装柜四步照片和铅封证据'; + +CREATE TABLE IF NOT EXISTS fl_loading_operation ( + id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operation VARCHAR(48) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING', + result_json JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + completed_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_loading_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key), + KEY idx_loading_operation_time (tenant_id, inbound_id, created_at), + CONSTRAINT fk_loading_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='装柜与派送写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( + id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/IN_PROGRESS/COMPLETED', + version INT NOT NULL DEFAULT 0, + expected_count INT UNSIGNED NULL, + picked_count INT UNSIGNED NULL, + remark VARCHAR(1000) NULL, + updated_by BIGINT UNSIGNED NOT NULL, + updated_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id, inbound_id), + KEY idx_loading_pick_status (tenant_id, status, updated_at), + CONSTRAINT fk_loading_pick_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='PDA拣货任务及结果'; diff --git a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql new file mode 100644 index 000000000..c521497b7 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql @@ -0,0 +1,39 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +/* 三期分柜与预装清单;独立于现有测量、计费表,可重复部署。 */ +CREATE TABLE IF NOT EXISTS fl_preload_plan ( + id BIGINT UNSIGNED NOT NULL COMMENT '预装清单ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单/订单ID', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CONFIRMING/CONFIRMED', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + items_json JSON NOT NULL COMMENT '可编辑分柜明细快照', + remark VARCHAR(1000) NULL COMMENT '预装备注', + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + updated_by BIGINT UNSIGNED NOT NULL, + updated_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_preload_tenant_inbound (tenant_id, inbound_id), + KEY idx_preload_status (tenant_id, status, updated_at), + CONSTRAINT fk_preload_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='订单分柜预装清单'; + +CREATE TABLE IF NOT EXISTS fl_split_operation ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + inbound_id BIGINT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operation VARCHAR(40) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING/SUCCESS', + result_json JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_split_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key), + KEY idx_split_operation_time (tenant_id, inbound_id, created_at), + CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='分柜操作幂等与审计索引'; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java new file mode 100644 index 000000000..c723060fb --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -0,0 +1,66 @@ +package com.baiyue.firstleg.loading.service; + +import com.baiyue.firstleg.client.LoadingEvidenceFileClient; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest; +import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; +import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse; +import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; +import com.baiyue.firstleg.loading.repository.LoadingRepository; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LoadingDeliveryServiceTest { + @Mock LoadingRepository repository; + @Mock InboundStatusHistoryMapper histories; + @Mock LoadingEvidenceFileClient files; + @InjectMocks LoadingDeliveryService service; + + @BeforeEach + void injectObjectMapper() throws Exception { + var field = LoadingDeliveryService.class.getDeclaredField("objectMapper"); + field.setAccessible(true); + field.set(service, new ObjectMapper()); + var token = LoadingDeliveryService.class.getDeclaredField("internalToken"); + token.setAccessible(true); + token.set(service, "token"); + } + + @Test + void completesLoadingWithoutEvidenceGate() { + when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); + when(repository.completeLoading(10, 20, 7, 3)).thenReturn(1); + when(repository.listEvidence(10, 20)).thenReturn(List.of()); + LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null)); + assertThat(result.mainStatus()).isEqualTo("LOADED"); + verify(histories).insert(any(InboundStatusHistoryEntity.class)); + } + + @Test + void evidenceCanBeEditedAndDoesNotChangeMainStatus() { + when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); + when(repository.findEvidence(10, 20, "PHOTO_STEP_1")).thenReturn(null); + when(repository.listEvidence(10, 20)).thenReturn(List.of()); + LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "k2", + new UpsertEvidenceRequest(0, "PHOTO_STEP_1", null, "photo.jpg", null, "updated")); + assertThat(result.mainStatus()).isEqualTo("LOADING"); + verify(repository).createEvidence(any(LoadingEvidenceEntity.class)); + verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 187430ca7..b234b81e7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -138,7 +138,7 @@ class InboundMeasurementServiceTest { } @Test - void submitRejectsAnyUnconfirmedWeightWithoutChangingState() { + void submitDoesNotBlockOnUnconfirmedWeight() { InboundOrderEntity inbound = inbound("MEASURING"); InboundMeasurementEntity measurement = new InboundMeasurementEntity(); measurement.setId(30L); @@ -151,13 +151,43 @@ class InboundMeasurementServiceTest { when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); when(repository.listWeights(1L, 10L)).thenReturn(List.of( weight(20L, "20", "KG", "PENDING_CONFIRM", 0))); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); - assertThatThrownBy(() -> service.submit(1L, 10L, 9L, - new InboundMeasurementRequests.SubmitRequest(30L, 0))) - .isInstanceOf(BizException.class) - .hasMessageContaining("必须先完成确认"); + InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L, + new InboundMeasurementRequests.SubmitRequest(30L, 0)); + + assertThat(result.processStatus()).isEqualTo("MEASURE_PENDING"); + assertThat(inbound.getActualGrossWeightKg()).isNull(); + verify(repository).updateMeasurement(measurement); + verify(repository).updateInbound(inbound); + } + + @Test + void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT"); + inbound.setMeasureReviewStatus("APPROVED"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setStatus("APPROVED"); + measurement.setVersion(2); + measurement.setTotalPackageCount(4); + measurement.setTotalVolumeCbm(new BigDecimal("2.0000")); + measurement.setVolumeWeightKg(new BigDecimal("334.000")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of()); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(repository.updateInbound(inbound)).thenReturn(true); + + InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L, + new InboundMeasurementRequests.SubmitRequest(30L, 2)); + + assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(result.measureReviewStatus()).isEqualTo("APPROVED"); + assertThat(measurement.getStatus()).isEqualTo("APPROVED"); verify(repository, never()).updateMeasurement(any()); - verify(repository, never()).updateInbound(any()); + verify(repository).updateInbound(inbound); } private InboundOrderEntity inbound(String status) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index 7e4e16e21..20e4d530f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -104,6 +104,31 @@ class MeasurementBatchServiceTest { org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any()); } + @Test + void approvedManualMeasurementCanBeEditedAsDraftBeforeReviewCompletes() { + InboundMeasurementEntity existing = new InboundMeasurementEntity(); + existing.setId(100L); + existing.setTenantId(1L); + existing.setInboundId(10L); + existing.setSourceType("MANUAL"); + existing.setStatus("APPROVED"); + existing.setVersion(3); + when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing); + when(repository.updateMeasurement(existing)).thenReturn(true); + + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, 100L, 3, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, "CKW-2", new BigDecimal("21"), + new BigDecimal("0.2"), "服装", 9L, List.of(item(BigDecimal.ONE))); + + InboundMeasurementEntity saved = service.saveBatch(command); + + assertThat(saved.getStatus()).isEqualTo("DRAFT"); + verify(repository).updateMeasurement(existing); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), any()); + } + private MeasurementBatchCommand.Item item(BigDecimal unitWeight) { return new MeasurementBatchCommand.Item( "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 056322888..cd1a3c8c9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -7,10 +7,12 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.repository.InboundWorkflowRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -70,7 +72,11 @@ class MeasurementReviewServiceTest { verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true); verify(fixture.repository).transitionMeasurement(10001L, 10L, 20L, "APPROVED"); verify(fixture.repository).insertReview(any()); - verify(fixture.repository).insertHistory(any()); + ArgumentCaptor history = + ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); + verify(fixture.repository).insertHistory(history.capture()); + assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING"); + assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java index 9bf0ca80f..1e2716801 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java @@ -9,10 +9,9 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @SpringJUnitConfig(MeasurementSubmissionService.class) @@ -38,7 +37,7 @@ class MeasurementSubmissionServiceTest { assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted); - verify(locations).occupy(10001L, 88L, 9L, 30L, 3); + verifyNoInteractions(locations); } @Test @@ -47,10 +46,7 @@ class MeasurementSubmissionServiceTest { var submitted = new InboundMeasurementResponses.MeasurementSubmitted( 30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null); when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted); - doThrow(new IllegalStateException("warehouse unavailable")) - .when(locations).occupy(10001L, 88L, 9L, 30L, 3); - - assertThatThrownBy(() -> service.submit(10001L, 88L, 9L, request)) - .isInstanceOf(IllegalStateException.class); + assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted); + verifyNoInteractions(locations); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java new file mode 100644 index 000000000..716e160eb --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.split.controller; + +import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; +import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformSplitPreloadControllerContractTest { + @Test + void shouldDeclareStableOperationsAndButtonPermissions() throws Exception { + assertAuthority("smart", "hasAuthority('firstleg:inbound:split')", SmartSplitRequest.class); + assertAuthority("confirming", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class); + assertAuthority("confirm", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class); + assertThat(PlatformSplitPreloadController.class + .getDeclaredMethod("detail", com.baiyue.common.security.AuthenticatedUser.class, + long.class, long.class).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('firstleg:inbound:preload-view')"); + } + + private void assertAuthority(String name, String expected, Class requestType) throws Exception { + Method method = PlatformSplitPreloadController.class.getDeclaredMethod(name, + com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class, + String.class, requestType); + assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java new file mode 100644 index 000000000..30b98bb0c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -0,0 +1,71 @@ +package com.baiyue.firstleg.split.service; + +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; +import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem; +import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; +import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse; +import com.baiyue.firstleg.split.entity.PreloadPlanEntity; +import com.baiyue.firstleg.split.mapper.SplitInboundMapper.InboundSnapshot; +import com.baiyue.firstleg.split.repository.SplitRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class SplitPreloadServiceTest { + @Mock SplitRepository repository; + @Mock InboundStatusHistoryMapper histories; + @InjectMocks SplitPreloadService service; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void objectMapper() throws Exception { + var field = SplitPreloadService.class.getDeclaredField("objectMapper"); + field.setAccessible(true); + field.set(service, objectMapper); + } + + @Test + void smartSplitCreatesEditablePendingPlan() { + when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + when(repository.findPlan(10, 20)).thenReturn(null); + SmartSplitRequest request = new SmartSplitRequest(0, + List.of(new PreloadItem("C1", "A", 2, BigDecimal.ONE, null)), "r"); + SplitActionResponse result = service.smartSplit(10, 20, 7, "admin", "k2", request); + assertThat(result.mainStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository).createPlan(any(PreloadPlanEntity.class)); + } + + @Test + void confirmsPlanAndMovesInboundToLoading() { + when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1); + plan.setItemsJson("[]"); + when(repository.findPlan(10, 20)).thenReturn(plan); + when(repository.changePlanStatus(10, 20, "CONFIRMING", "CONFIRMED", 7, "admin", 1)).thenReturn(1); + when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1); + SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3", + new ConfirmPreloadRequest(1)); + assertThat(result.mainStatus()).isEqualTo("LOADING"); + verify(repository).beginLoading(10, 20, 7, 4); + } +} diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 5e99b8065..481e3cc3b 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -61,8 +61,8 @@ public class PlatformFirstLegOrderController { return ApiResponse.success(service.createDraft(scopedTenant, user.userId(), request, idempotencyKey)); } - @BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑草稿") - @Operation(summary = "平台编辑草稿", description = "在明确指定的目标租户内编辑草稿或驳回订单。") + @BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑订单资料") + @Operation(summary = "平台编辑订单", description = "在待审核阶段编辑订单资料;编辑不会将订单退回草稿状态。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "编辑成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台编辑权限"), @@ -75,7 +75,7 @@ public class PlatformFirstLegOrderController { @Parameter(description = "订单 ID", required = true) @PathVariable long id, @Valid @RequestBody OrderRequests.SaveRequest request) { long scopedTenant = OrderScopeGuard.platform(user, tenantId); - service.updateDraft(scopedTenant, user.userId(), id, request); + service.updateSubmittedOrder(scopedTenant, user.userId(), id, request); return ApiResponse.success(null); } diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 14154e49f..9c036b27e 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -60,16 +60,37 @@ public class OrderApplicationService { @Transactional public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) { + updateOrderData(tenantId, userId, id, request, false); + } + + /** + * 平台审核队列中的订单仍允许编辑。编辑只更新订单资料,不回退已提交订单的主状态。 + * 商户端继续使用 {@link #updateDraft(long, long, long, OrderRequests.SaveRequest)}, + * 因此不会因为该兼容能力意外获得已提交订单的编辑权限。 + */ + @Transactional + public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.SaveRequest request) { + updateOrderData(tenantId, userId, id, request, true); + } + + private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request, + boolean allowSubmitted) { OrderEntity order = require(tenantId, id); validatePrototypeFields(request); var snapshots = masterDataGuard.validate(tenantId, request); - if (order.getStatus() != OrderStatus.DRAFT && order.getStatus() != OrderStatus.REJECTED) { - throw new BizException("Only draft or rejected orders can be edited"); + OrderStatus before = order.getStatus(); + if (before != OrderStatus.DRAFT && before != OrderStatus.REJECTED + && !(allowSubmitted && before == OrderStatus.SUBMITTED)) { + throw new BizException("当前订单状态不允许编辑订单资料"); } apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); + if (before == OrderStatus.SUBMITTED) { + order.setStatus(OrderStatus.SUBMITTED); + } if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); repository.replaceDraftChildren(tenantId, id); saveChildren(tenantId, userId, id, request); + record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null); } @Transactional diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 0fe4215ec..d04e34ebc 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -78,6 +78,48 @@ class OrderApplicationServiceStateTest { verify(repository).saveOrder(eq(10001L), argThat(saved -> saved.getStatus() == OrderStatus.DRAFT)); } + @Test + void platformCanEditSubmittedOrderWithoutRollingBackReviewStatus() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderRequests.SaveRequest request = request(); + OrderEntity order = new OrderEntity(); + order.setId(1L); + order.setTenantId(10001L); + order.setStatus(OrderStatus.SUBMITTED); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + when(masterData.validate(10001L, request)).thenReturn(snapshots()); + when(repository.saveOrder(10001L, order)).thenReturn(1); + + service(repository, mock(OrderEligibilityGuard.class), masterData) + .updateSubmittedOrder(10001L, 2L, 1L, request); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); + verify(repository).replaceDraftChildren(10001L, 1L); + verify(repository).insertHistory(argThat(history -> history.getFromStatus() == OrderStatus.SUBMITTED + && history.getToStatus() == OrderStatus.SUBMITTED + && "UPDATE".equals(history.getAction()))); + } + + @Test + void merchantDraftEditorStillCannotEditSubmittedOrder() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = new OrderEntity(); + order.setId(1L); + order.setTenantId(10001L); + order.setStatus(OrderStatus.SUBMITTED); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + when(masterData.validate(eq(10001L), any(OrderRequests.SaveRequest.class))).thenReturn(snapshots()); + + assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData) + .updateDraft(10001L, 2L, 1L, request())) + .isInstanceOf(BizException.class) + .hasMessageContaining("不允许编辑"); + + verify(repository, never()).saveOrder(anyLong(), any()); + } + @Test void unapprovedTenantStillCannotSubmitDraft() { OrderRepository repository = mock(OrderRepository.class); diff --git a/print/AGENTS.md b/print/AGENTS.md index d0276dc6c..efe020bb0 100644 --- a/print/AGENTS.md +++ b/print/AGENTS.md @@ -2,9 +2,9 @@ ## 职责边界 -- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]`,模块在内存中生成一次性多页 PDF 并同步返回。 +- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。 - 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。 -- 首版只支持 `INBOUND_MARK_V1` 和 `100mm x 100mm`;新尺寸或布局必须使用新模板编码,不得静默改变 V1。 +- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(均为 `210mm x 297mm`);新尺寸或布局必须使用新模板编码,不得静默改变既有 V1。 ## 快速索引 @@ -13,20 +13,22 @@ | 调用方接入指南 | `README.md` | | 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` | | 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) | -| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`) | +| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/freight-a4-documents`) | | 内部令牌校验 | `controller/InternalServiceTokenGuard.java` | -| 请求与响应模型 | `dto/InboundMarkPdfDtos.java` | -| 固定模板与二维码渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` | -| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/PdfGenerationLimiter.java` | +| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java` | +| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` | +| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.java`、`service/PdfGenerationLimiter.java` | | PDF 参数配置 | `config/PrintPdfProperties.java` | | 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` | | 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` | | 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/print-service.yml` | -| 端到端与架构约束测试 | `src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java`、`src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java` | +| 端到端与架构约束测试 | `src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java`、`src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java`、`src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java` | ## 修改与验证 - 内部 PDF 接口不加入 Gateway 公网路由;调用方必须先完成业务权限和数据范围校验,再携带共享内部令牌调用。 +- 其他模块接入时先读 `README.md` 和 OpenAPI,在调用模块内声明 DTO 并从既有业务数据自动映射;不得依赖 Print 的 Java DTO,不得新增要求用户重复填写打印字段的表单。 +- A4 V1 每个 `document` 的 `cargoRows` 只允许 0 或 1 项;当前渲染器只消费首行,调用方不得发送会被静默忽略的后续行。 - 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。 - 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。 - 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。 diff --git a/print/README.md b/print/README.md index b760b6449..b240f8704 100644 --- a/print/README.md +++ b/print/README.md @@ -1,22 +1,25 @@ -# Print PDF 服务接入指南 +# Print PDF 内部调用契约 -本文面向需要生成入仓标签 PDF 的业务服务。Print 是无数据库的内部渲染服务:调用方提交完整标签数据,Print 同步返回一次性 PDF,不保存任务、文件或业务数据。 +本文是其他 Java 业务模块及代码生成 AI 接入 Print 的实现约束,不是终端用户操作手册。Print 是无数据库的内部渲染服务:调用方提交完整打印数据,Print 同步返回一次性 PDF,不保存任务、文件或业务数据。 -完整机器可读契约见 [`src/main/resources/openapi/print-internal-v1.yaml`](src/main/resources/openapi/print-internal-v1.yaml),版式与技术决策见 [`../docs/2026-08-04-无状态PDF打印服务设计.md`](../docs/2026-08-04-无状态PDF打印服务设计.md)。 +机器可读 HTTP 契约以 [`src/main/resources/openapi/print-internal-v1.yaml`](src/main/resources/openapi/print-internal-v1.yaml) 为准;本文件补充调用方必须实现、但 OpenAPI 无法完整表达的权限、字段映射、响应分支和日志约束。调用方必须自行声明 DTO,不得依赖 Print 模块的 Java 实现类。 ## 1. 调用边界 - 服务发现名:`print-service`。 - 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。 +- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。 - 本地直连:`http://127.0.0.1:9207`。 - 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。 - 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。 -- Admin 请求业务服务;业务服务完成 JWT、客户归属、权限和数据范围校验,组装完整 `labels[]` 后调用 Print,再把 PDF 响应转发给浏览器。JWT 中的 `tenant_id` 当前是客户隔离兼容字段,Print 不解析或引入真实租户层。 +- Admin 请求业务服务;业务服务完成 JWT、客户归属、权限和数据范围校验,组装完整 `labels[]` 或 `documents[]` 后调用 Print,再把 PDF 响应转发给浏览器。JWT 中的 `tenant_id` 当前是客户隔离兼容字段,Print 不解析或引入真实租户层。 -调用方不得要求 Print 查询订单、补箱号、排序或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。 +调用方必须从本模块拥有的数据自动组装打印字段,不得把 Print 请求 DTO 暴露成要求用户逐项填写的表单。调用方不得要求 Print 查询订单、补箱号、排序、推导或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。 ## 2. 请求契约 +### 2.1 入仓标签 + ```http POST /internal/print/v1/pdfs/inbound-marks HTTP/1.1 Content-Type: application/json @@ -69,6 +72,57 @@ X-Request-Id: 未传 `downloadFileName` 时,服务使用 `inbound-marks-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。 +### 2.2 海运 A4 单据 + +```json +{ + "templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1", + "pageSpec": { + "widthMm": 210, + "heightMm": 297 + }, + "downloadFileName": "BYSE2607044-托运书.pdf", + "documents": [ + { + "fields": { + "toCompany": "宁波胜泽远航国际物流有限公司", + "shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613...", + "consignee": "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS", + "jobNumber": "BYSE2607044" + }, + "cargoRows": [ + { + "marksAndNos": "N/M", + "quantity": "700", + "packaging": "PACKAGES", + "descriptionOfGoods": "CAR ACCESSORIES\nACID: 4120661061019510011", + "grossWeight": "28000.00 KGS", + "measurement": "28.000 CBM" + } + ] + } + ] +} +``` + +| 字段 | 必填 | 规则 | +|---|---|---| +| `templateCode` | 是 | `SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 或 `BILL_OF_LADING_CONFIRMATION_A4_V1` | +| `pageSpec.widthMm` | 是 | 固定数值 `210` | +| `pageSpec.heightMm` | 是 | 固定数值 `297` | +| `downloadFileName` | 否 | 最多 120 字符,必须以 `.pdf` 结尾,不得含路径或文件系统保留字符 | +| `documents` | 是 | 1-2000 项;一个元素对应一页,按数组顺序生成 | +| `documents[].fields` | 否 | 字符串字典,字段值允许换行,单值最多 4000 字符 | +| `documents[].cargoRows` | 否 | 0-1 项;当前 V1 模板只渲染一行货物,调用方不得发送第二行 | + +托运书模板支持的完整 `fields` 键:`toCompany`、`issuerNameCn`、`issuerNameEn`、`issuerAddress`、`issuerPostalCode`、`issuerTel`、`issuerFax`、`shipper`、`consignee`、`notifyParty`、`picName`、`picTel`、`picFax`、`documentName`、`documentTel`、`documentFax`、`jobNumber`、`etdDate`、`serviceTerm`、`freightTerm`、`apCode`、`apName`、`cargoQuantity`、`transshipment`、`partialShipment`、`oceanVesselVoyage`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`totalPackagesInWords`、`remark`、`carrier`、`bookingAgent`、`printDate`。 + +提单确认件模板支持的完整 `fields` 键:`shipper`、`consignee`、`notifyParty`、`bookingNumber`、`ensAmsCutoffDate`、`customsCutoffDate`、`expectedDepartureDate`、`oceanVesselVoyage`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`finalDestination`、`serviceTerm`、`freightTerm`、`shipperLoadCountSeal`、`containerSpec`、`shippedOnBoard`、`draftText`、`totalPackagesInWords`、`containerSummary`、`notes`、`issuePlace`、`issueDate`。 + +调用方 MUST 只发送所选模板支持的键。当前服务会忽略未知键而不报错,因此调用方必须通过单元测试固定字段映射,避免拼写错误生成缺字段 PDF。空值字段可以省略;不得为了填满模板要求用户重复录入订单、客户、航线或货物中已经存在的数据。 + +未传 `downloadFileName` 时,服务使用 `freight-a4-documents-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。 + ## 3. 响应处理 调用方必须同时判断 HTTP 状态和 `Content-Type`,不能只判断 HTTP 200: @@ -114,6 +168,12 @@ public interface PrintPdfClient { @RequestHeader(value = "X-Request-Id", required = false) String requestId, @RequestBody InboundMarkPdfRequest request); + @PostMapping(value = "/freight-a4-documents", consumes = "application/json") + ResponseEntity generateFreightA4Documents( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody FreightA4PdfRequest request); + record InboundMarkPdfRequest( String templateCode, PageSpec pageSpec, @@ -128,6 +188,22 @@ public interface PrintPdfClient { String cartonText, String routeText, String qrContent) {} + + record FreightA4PdfRequest( + String templateCode, + PageSpec pageSpec, + String downloadFileName, + List documents) {} + + record FreightDocument(Map fields, List cargoRows) {} + + record CargoRow( + String marksAndNos, + String quantity, + String packaging, + String descriptionOfGoods, + String grossWeight, + String measurement) {} } ``` @@ -176,13 +252,91 @@ spring: - 不建议对该 POST 做无限或无退避自动重试;超时或 `A0429` 最多有限重试,并加入退避和抖动。 - 单次最多 2000 页。大批量请求应设置不低于 30 秒的读取超时,并避免调用方自身重复复制 PDF 字节。 - 浏览器下载场景由业务服务原样转发 PDF,不要把 PDF Base64 塞入 JSON,也不要为了“一次性下载”写入数据库。 -- 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、二维码内容或 PDF 二进制。 +- 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、完整 `documents[]`、二维码内容或 PDF 二进制。 ## 6. 联调检查清单 1. `GET /actuator/health` 返回 `UP`。 2. 使用正确内部令牌提交 1 页,响应为 `application/pdf`。 -3. 检查页面为严格 `100mm x 100mm`,`X-Print-Page-Count` 与 `labels.length` 一致。 +3. 检查入仓标签页面为严格 `100mm x 100mm`,A4 单据页面为严格 `210mm x 297mm`,`X-Print-Page-Count` 与数组长度一致。 4. 提交多页并检查页序与数组顺序一致。 5. 分别验证错误令牌、非法尺寸、空数组、超过 2000 页和超过 2 MiB 请求体。 6. Admin 下载链路不得在浏览器请求或日志中出现 `X-Internal-Service-Token`。 + +## 7. AI 实施与验证清单 + +修改其他业务模块接入 Print 时,按以下顺序实施,不得从 Admin 浏览器直连 Print: + +1. 从 OpenAPI 和本文件在调用模块内声明请求 DTO 与 Feign Client;不得新增对 `print-service` Java 包的编译依赖。 +2. 在调用模块的 Service 中完成权限、客户归属和数据范围校验,再从已有订单、客户、航线及货物数据自动映射打印字段。 +3. `labels[]`、`documents[]` 和 `cargoRows` 顺序必须由调用模块明确决定;A4 V1 的 `cargoRows` 只能包含 0 或 1 项。 +4. 同时处理 `application/pdf`、HTTP 200 的 `application/json` 业务错误及 HTTP 413;不得把任意 HTTP 200 当作成功 PDF。 +5. 只向浏览器转发安全响应头,不得转发或记录 `X-Internal-Service-Token`,不得记录完整打印请求或 PDF 二进制。 +6. 为字段映射、PDF/JSON 响应分支、`A0403`、`A0422`、`A0429`、HTTP 413 和超时增加调用模块测试。 + +只验证 Print 本身时,从 `baiyue-firstleg/` 构建并启动单个服务;不需要启动业务服务、Nacos 或数据库: + +```powershell +mvn -pl print -am package -DskipTests +$env:PRINT_PORT="9207" +$env:SPRING_PROFILES_ACTIVE="dev" +$env:INTERNAL_SERVICE_TOKEN="print-local-contract-test-token-20260806" +$env:SPRING_CLOUD_NACOS_CONFIG_ENABLED="false" +$env:NACOS_DISCOVERY_ENABLED="false" +$env:NACOS_REGISTER_ENABLED="false" +java -jar print\target\print-service-1.0.0-SNAPSHOT.jar +``` + +启动后向 `POST http://127.0.0.1:9207/internal/print/v1/pdfs/freight-a4-documents` 发送 `Content-Type: application/json`、`Accept: application/pdf, application/json`、上述内部令牌及任意 `X-Request-Id`。托运书验证请求体: + +```json +{ + "templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1", + "pageSpec": { + "widthMm": 210, + "heightMm": 297 + }, + "downloadFileName": "BYSE2607044-托运书.pdf", + "documents": [ + { + "fields": { + "toCompany": "宁波胜泽远航国际物流有限公司", + "issuerNameCn": "浙江柏悦贸易有限公司(香港)", + "issuerNameEn": "ZHEJIANG BAIYUE TRADE CO.,LIMITED", + "issuerAddress": "地址:浙江省义乌市稠州北路 1399 号新光国际 8613 室", + "issuerPostalCode": "322000", + "issuerTel": "0579-85875557", + "issuerFax": "0579-85601277", + "shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN STREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557", + "consignee": "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354", + "notifyParty": "SAME AS CONSIGNEE", + "picName": "苏天翔", + "jobNumber": "BYSE2607044", + "etdDate": "2026/07/24", + "serviceTerm": "CY-CY", + "freightTerm": "FREIGHT PREPAID", + "cargoQuantity": "1*20GP", + "portOfLoading": "NINGBO", + "portOfDischarge": "EL DEKHEILA", + "placeOfDelivery": "EL DEKHEILA", + "totalPackagesInWords": "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY.", + "carrier": "万海航运(香港)股份有限公司", + "bookingAgent": "宁波胜泽远航国际物流有限公司", + "printDate": "2026 年 07 月 16 日" + }, + "cargoRows": [ + { + "marksAndNos": "N/M", + "quantity": "700", + "packaging": "PACKAGES", + "descriptionOfGoods": "CAR ACCESSORIES\nACID: 4120661061019510011\n申请目的港 21 天免用箱", + "grossWeight": "28000.00 KGS", + "measurement": "28.000 CBM" + } + ] + } + ] +} +``` + +提单确认件必须把 `templateCode` 改为 `BILL_OF_LADING_CONFIRMATION_A4_V1`,并仅传上一节列出的提单确认件字段。成功响应必须是 `Content-Type: application/pdf`;`application/json` 必须按响应体 `code/message` 作为失败处理。 diff --git a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java index 22a485506..17fdf30e0 100644 --- a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java +++ b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java @@ -3,7 +3,9 @@ package com.baiyue.print.controller; import jakarta.annotation.Resource; import com.baiyue.common.web.CorrelationContext; +import com.baiyue.print.dto.FreightA4PdfDtos; import com.baiyue.print.dto.InboundMarkPdfDtos; +import com.baiyue.print.service.FreightA4PdfService; import com.baiyue.print.service.InboundMarkPdfService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -27,6 +29,7 @@ public class InternalPdfController { static final String PAGE_COUNT_HEADER = "X-Print-Page-Count"; @Resource private InboundMarkPdfService pdfService; + @Resource private FreightA4PdfService freightA4PdfService; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE) @@ -58,4 +61,34 @@ public class InternalPdfController { return response.body(content); } + + @PostMapping(value = "/freight-a4-documents", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity generateFreightA4Documents( + @RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken, + @Valid @RequestBody FreightA4PdfDtos.Request request, + HttpServletRequest servletRequest) { + tokenGuard.verify(internalToken); + String requestId = CorrelationContext.requestId(servletRequest); + FreightA4PdfDtos.Request effectiveRequest = request; + if (request.downloadFileName() == null && requestId != null && !requestId.isBlank()) { + effectiveRequest = new FreightA4PdfDtos.Request(request.templateCode(), request.pageSpec(), + "freight-a4-documents-" + requestId + ".pdf", request.documents()); + } + FreightA4PdfService.GeneratedPdf pdf = freightA4PdfService.generate(effectiveRequest); + byte[] content = pdf.content(); + + ContentDisposition disposition = ContentDisposition.attachment() + .filename(pdf.fileName(), StandardCharsets.UTF_8) + .build(); + ResponseEntity.BodyBuilder response = ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.PRAGMA, "no-cache") + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .header("X-Content-Type-Options", "nosniff") + .header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount())) + .contentType(MediaType.APPLICATION_PDF) + .contentLength(content.length); + + return response.body(content); + } } diff --git a/print/src/main/java/com/baiyue/print/controller/PrintRequestSizeFilter.java b/print/src/main/java/com/baiyue/print/controller/PrintRequestSizeFilter.java index 9a01129df..e55f33db9 100644 --- a/print/src/main/java/com/baiyue/print/controller/PrintRequestSizeFilter.java +++ b/print/src/main/java/com/baiyue/print/controller/PrintRequestSizeFilter.java @@ -28,7 +28,9 @@ import org.springframework.web.filter.OncePerRequestFilter; @Component @Order(Ordered.HIGHEST_PRECEDENCE + 10) public class PrintRequestSizeFilter extends OncePerRequestFilter { - private static final String PATH = "/internal/print/v1/pdfs/inbound-marks"; + private static final java.util.Set PATHS = java.util.Set.of( + "/internal/print/v1/pdfs/inbound-marks", + "/internal/print/v1/pdfs/freight-a4-documents"); @Resource private PrintPdfProperties properties; @Resource private ObjectMapper objectMapper; @@ -36,7 +38,7 @@ public class PrintRequestSizeFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATH.equals(request.getRequestURI())) { + if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATHS.contains(request.getRequestURI())) { filterChain.doFilter(request, response); return; } diff --git a/print/src/main/java/com/baiyue/print/dto/FreightA4PdfDtos.java b/print/src/main/java/com/baiyue/print/dto/FreightA4PdfDtos.java new file mode 100644 index 000000000..3c2b4b266 --- /dev/null +++ b/print/src/main/java/com/baiyue/print/dto/FreightA4PdfDtos.java @@ -0,0 +1,47 @@ +package com.baiyue.print.dto; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** A4 sea-freight document PDF request contract. */ +public final class FreightA4PdfDtos { + public static final String SHIPPING_INSTRUCTION_TEMPLATE = "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1"; + public static final String BILL_CONFIRMATION_TEMPLATE = "BILL_OF_LADING_CONFIRMATION_A4_V1"; + + private FreightA4PdfDtos() { + } + + public record Request( + String templateCode, + PageSpec pageSpec, + String downloadFileName, + List documents + ) { + } + + public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) { + } + + public record Document( + Map fields, + List cargoRows + ) { + public String field(String key) { + if (fields == null) { + return ""; + } + return fields.getOrDefault(key, ""); + } + } + + public record CargoRow( + String marksAndNos, + String quantity, + String packaging, + String descriptionOfGoods, + String grossWeight, + String measurement + ) { + } +} diff --git a/print/src/main/java/com/baiyue/print/renderer/FreightA4Renderer.java b/print/src/main/java/com/baiyue/print/renderer/FreightA4Renderer.java new file mode 100644 index 000000000..db94122b1 --- /dev/null +++ b/print/src/main/java/com/baiyue/print/renderer/FreightA4Renderer.java @@ -0,0 +1,333 @@ +package com.baiyue.print.renderer; + +import com.baiyue.print.dto.FreightA4PdfDtos; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.springframework.stereotype.Component; + +/** Fixed A4 renderers for sea-freight documents. */ +@Component +public class FreightA4Renderer { + public static final double PAGE_WIDTH_MM = 210d; + public static final double PAGE_HEIGHT_MM = 297d; + + private static final float PAGE_WIDTH_PT = PdfUnits.mmToPt(PAGE_WIDTH_MM); + private static final float PAGE_HEIGHT_PT = PdfUnits.mmToPt(PAGE_HEIGHT_MM); + private static final float SHIPPING_TEXT = 6.2f; + private static final float SHIPPING_SMALL = 5.8f; + private static final float BILL_TEXT = 7.2f; + private static final float BILL_SMALL = 6.8f; + + public void renderPage(PDDocument document, PDFont font, String templateCode, + FreightA4PdfDtos.Document data) throws IOException { + PDPage page = new PDPage(new PDRectangle(PAGE_WIDTH_PT, PAGE_HEIGHT_PT)); + page.setRotation(0); + document.addPage(page); + PdfUnits units = new PdfUnits(PAGE_HEIGHT_MM); + try (PDPageContentStream stream = new PDPageContentStream(document, page)) { + stream.setNonStrokingColor(0, 0, 0); + stream.setStrokingColor(0, 0, 0); + if (FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode)) { + renderShippingInstruction(stream, units, font, data); + } else if (FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE.equals(templateCode)) { + renderBillConfirmation(stream, units, font, data); + } else { + throw new IllegalArgumentException("unsupported A4 freight template"); + } + } + } + + private void renderShippingInstruction(PDPageContentStream stream, PdfUnits units, PDFont font, + FreightA4PdfDtos.Document data) throws IOException { + text(stream, units, font, "Sea Freight Shipping Instructions", 12, 15, 18); + text(stream, units, font, "To: " + data.field("toCompany"), SHIPPING_TEXT, 15, 24); + + rect(stream, 15, 29, 180, 250); + line(stream, 113, 29, 113, 155); + line(stream, 15, 64, 113, 64); + line(stream, 15, 99, 113, 99); + line(stream, 15, 134, 113, 134); + line(stream, 15, 147, 113, 147); + line(stream, 64, 134, 64, 155); + + wrapped(stream, units, font, "Shipper(Full Name & Address)(托运人):", SHIPPING_TEXT, 17, 33, 92, 4, 5); + wrapped(stream, units, font, data.field("shipper"), SHIPPING_TEXT, 17, 39, 92, 5, 5); + wrapped(stream, units, font, "Consignee(收货人):", SHIPPING_TEXT, 17, 68, 92, 4, 5); + wrapped(stream, units, font, data.field("consignee"), SHIPPING_TEXT, 17, 74, 92, 5, 5); + wrapped(stream, units, font, "Notify Party(通知人):", SHIPPING_TEXT, 17, 103, 92, 4, 5); + wrapped(stream, units, font, data.field("notifyParty"), SHIPPING_TEXT, 17, 109, 92, 4, 5); + + text(stream, units, font, "Ocean Vessel/Voy(船名航次):", SHIPPING_TEXT, 17, 139); + text(stream, units, font, data.field("oceanVesselVoyage"), SHIPPING_TEXT, 17, 145.5); + text(stream, units, font, "Port of Loading(起运港):", SHIPPING_TEXT, 66, 139); + text(stream, units, font, data.field("portOfLoading"), SHIPPING_TEXT, 66, 145.5); + text(stream, units, font, "Part of Discharge(卸货港):", SHIPPING_TEXT, 17, 150); + text(stream, units, font, data.field("portOfDischarge"), SHIPPING_TEXT, 17, 154); + text(stream, units, font, "Place of Delivery(交货地):", SHIPPING_TEXT, 66, 150); + text(stream, units, font, data.field("placeOfDelivery"), SHIPPING_TEXT, 66, 154); + + renderShippingRightPanel(stream, units, font, data); + renderShippingCargo(stream, units, font, data); + } + + private void renderShippingRightPanel(PDPageContentStream stream, PdfUnits units, PDFont font, + FreightA4PdfDtos.Document data) throws IOException { + line(stream, 113, 76, 195, 76); + line(stream, 113, 89, 195, 89); + line(stream, 113, 102, 195, 102); + for (double y : new double[]{109, 116, 123, 130, 137, 144, 151}) { + line(stream, 113, y, 195, y); + } + line(stream, 135, 76, 135, 102); + line(stream, 135, 82.5, 195, 82.5); + line(stream, 135, 95.5, 195, 95.5); + + centered(stream, units, font, data.field("issuerNameCn"), 12, 154, 37); + centered(stream, units, font, data.field("issuerNameEn"), SHIPPING_TEXT, 154, 45); + wrapped(stream, units, font, data.field("issuerAddress"), SHIPPING_TEXT, 114, 52, 78, 2, 4.5); + text(stream, units, font, "邮编:" + data.field("issuerPostalCode"), SHIPPING_TEXT, 114, 59); + text(stream, units, font, "TEL:" + data.field("issuerTel") + " | FAX:" + data.field("issuerFax"), + 5.2f, 114, 63.5); + centered(stream, units, font, "出口货物托运单", 12, 154, 72); + + text(stream, units, font, "PIC(负责人):", SHIPPING_TEXT, 114, 80); + text(stream, units, font, data.field("picName"), SHIPPING_TEXT, 114, 86); + text(stream, units, font, "TEL(电话): " + data.field("picTel"), SHIPPING_TEXT, 136, 80); + text(stream, units, font, "FAX(传真): " + data.field("picFax"), SHIPPING_TEXT, 136, 87); + text(stream, units, font, "Document:", SHIPPING_TEXT, 114, 94); + text(stream, units, font, data.field("documentName"), SHIPPING_TEXT, 114, 100); + text(stream, units, font, "TEL(电话): " + data.field("documentTel"), SHIPPING_TEXT, 136, 94); + text(stream, units, font, "FAX(传真): " + data.field("documentFax"), SHIPPING_TEXT, 136, 101); + + text(stream, units, font, "Job Nbr(委托单号): " + data.field("jobNumber"), SHIPPING_TEXT, 114, 107); + text(stream, units, font, "ETD(开航日期): " + data.field("etdDate"), SHIPPING_TEXT, 114, 114); + text(stream, units, font, "Service Term(运输条款):" + data.field("serviceTerm"), SHIPPING_TEXT, 114, 121); + text(stream, units, font, "Freight Term(运费条款):" + data.field("freightTerm"), SHIPPING_TEXT, 114, 128); + text(stream, units, font, "AP Code: " + data.field("apCode"), SHIPPING_TEXT, 114, 135); + text(stream, units, font, "AP Name: " + data.field("apName"), SHIPPING_TEXT, 114, 142); + text(stream, units, font, "Cargo Quantity(货量描述): " + data.field("cargoQuantity"), SHIPPING_TEXT, 114, 149); + text(stream, units, font, "T/S(可否转船): " + data.field("transshipment") + + " Batch(可否分批): " + data.field("partialShipment"), SHIPPING_TEXT, 114, 153.5); + } + + private void renderShippingCargo(PDPageContentStream stream, PdfUnits units, PDFont font, + FreightA4PdfDtos.Document data) throws IOException { + double y = 155; + line(stream, 15, y, 195, y); + line(stream, 15, 166, 195, 166); + line(stream, 15, 229, 195, 229); + line(stream, 15, 237, 195, 237); + line(stream, 130, 237, 130, 279); + line(stream, 130, 259, 195, 259); + for (double x : new double[]{50, 88, 137, 166}) { + line(stream, x, 155, x, 166); + } + centered(stream, units, font, "Marks & Nos\n(标志和号码)", SHIPPING_TEXT, 32.5, 160); + centered(stream, units, font, "Quantity & Packaging\n(件数及包装)", SHIPPING_TEXT, 69, 160); + centered(stream, units, font, "Description of Goods(品名)", SHIPPING_TEXT, 112.5, 161); + centered(stream, units, font, "Gross Weight(Kilos)\n毛重(公斤)", SHIPPING_SMALL, 151.5, 160); + centered(stream, units, font, "Measurement(CBM)\n体积(立方米)", SHIPPING_SMALL, 180.5, 160); + + FreightA4PdfDtos.CargoRow row = firstCargoRow(data); + text(stream, units, font, value(row.marksAndNos()), SHIPPING_TEXT, 17, 171); + text(stream, units, font, value(row.quantity()), SHIPPING_TEXT, 55, 171); + text(stream, units, font, value(row.packaging()), SHIPPING_TEXT, 64, 171); + wrapped(stream, units, font, value(row.descriptionOfGoods()), SHIPPING_TEXT, 88, 171, 47, 5, 5); + text(stream, units, font, value(row.grossWeight()), SHIPPING_TEXT, 147, 171); + text(stream, units, font, value(row.measurement()), SHIPPING_TEXT, 177, 171); + + text(stream, units, font, "Total Packages(in words)(件数大写):" + data.field("totalPackagesInWords"), + SHIPPING_TEXT, 17, 234); + text(stream, units, font, "Ramark(备注):", SHIPPING_TEXT, 20, 245); + wrapped(stream, units, font, data.field("remark"), SHIPPING_TEXT, 20, 252, 105, 5, 5); + text(stream, units, font, "Carrier(船公司):", SHIPPING_TEXT, 135, 247); + wrapped(stream, units, font, data.field("carrier"), SHIPPING_TEXT, 135, 254, 55, 2, 5); + text(stream, units, font, "Booking Agent(订舱代理):", SHIPPING_TEXT, 135, 266); + wrapped(stream, units, font, data.field("bookingAgent"), SHIPPING_TEXT, 135, 273, 55, 2, 5); + text(stream, units, font, "打印于: " + data.field("printDate"), SHIPPING_TEXT, 149, 287); + } + + private void renderBillConfirmation(PDPageContentStream stream, PdfUnits units, PDFont font, + FreightA4PdfDtos.Document data) throws IOException { + centered(stream, units, font, "提单确认件", 15, 105, 40); + wrapped(stream, units, font, data.field("shipper"), BILL_TEXT, 10, 64, 72, 5, 5); + wrapped(stream, units, font, data.field("consignee"), BILL_TEXT, 10, 95, 84, 5, 5); + wrapped(stream, units, font, data.field("notifyParty"), BILL_TEXT, 10, 126, 72, 3, 5); + + text(stream, units, font, data.field("bookingNumber"), BILL_TEXT, 148, 64); + text(stream, units, font, "ENS/AMS 截单: " + data.field("ensAmsCutoffDate"), BILL_TEXT, 148, 73); + text(stream, units, font, "截关日期: " + data.field("customsCutoffDate"), BILL_TEXT, 148, 82); + text(stream, units, font, "预计开航日期: " + data.field("expectedDepartureDate"), BILL_TEXT, 148, 91); + + text(stream, units, font, data.field("oceanVesselVoyage"), BILL_TEXT, 10, 155); + text(stream, units, font, data.field("portOfLoading"), BILL_TEXT, 61, 161); + text(stream, units, font, data.field("portOfDischarge"), BILL_TEXT, 10, 170); + text(stream, units, font, data.field("placeOfDelivery"), BILL_TEXT, 61, 170); + text(stream, units, font, data.field("finalDestination"), BILL_TEXT, 114, 155); + text(stream, units, font, data.field("serviceTerm"), BILL_TEXT, 128, 170); + + FreightA4PdfDtos.CargoRow row = firstCargoRow(data); + text(stream, units, font, value(row.quantity()) + " " + value(row.packaging()), BILL_TEXT, 44, 179); + text(stream, units, font, "SAID TO CONTAIN(S)", BILL_TEXT, 74, 179); + text(stream, units, font, value(row.grossWeight()), BILL_TEXT, 156, 179); + text(stream, units, font, value(row.measurement()), BILL_TEXT, 183, 179); + text(stream, units, font, value(row.marksAndNos()), BILL_TEXT, 9, 187); + wrapped(stream, units, font, value(row.descriptionOfGoods()), BILL_SMALL, 73, 186, 83, 8, 4.25); + + text(stream, units, font, data.field("freightTerm"), BILL_SMALL, 87, 221); + text(stream, units, font, data.field("shipperLoadCountSeal"), BILL_SMALL, 72, 227); + text(stream, units, font, data.field("containerSpec"), BILL_SMALL, 72, 232); + text(stream, units, font, "SHIPPED ON BOARD:", BILL_SMALL, 163, 195); + text(stream, units, font, data.field("shippedOnBoard"), BILL_SMALL, 169, 202); + centered(stream, units, font, data.field("draftText"), 16, 158, 225); + centered(stream, units, font, data.field("totalPackagesInWords"), BILL_SMALL, 105, 238); + text(stream, units, font, data.field("containerSummary"), BILL_SMALL, 10, 247); + wrapped(stream, units, font, data.field("notes"), BILL_SMALL, 10, 278, 130, 3, 5); + text(stream, units, font, data.field("issuePlace"), BILL_SMALL, 140, 288); + text(stream, units, font, data.field("issueDate"), BILL_SMALL, 168, 288); + } + + private FreightA4PdfDtos.CargoRow firstCargoRow(FreightA4PdfDtos.Document data) { + if (data.cargoRows() == null || data.cargoRows().isEmpty() || data.cargoRows().get(0) == null) { + return new FreightA4PdfDtos.CargoRow("", "", "", "", "", ""); + } + return data.cargoRows().get(0); + } + + private void text(PDPageContentStream stream, PdfUnits units, PDFont font, String text, + float fontSize, double xMm, double baselineMmFromTop) throws IOException { + String value = value(text).replace('\n', ' '); + if (value.isEmpty()) { + return; + } + stream.beginText(); + stream.setFont(font, fontSize); + stream.newLineAtOffset(PdfUnits.mmToPt(xMm), units.topDownMmToPdfY(baselineMmFromTop)); + stream.showText(value); + stream.endText(); + } + + private void centered(PDPageContentStream stream, PdfUnits units, PDFont font, String text, + float fontSize, double centerXmm, double baselineMmFromTop) throws IOException { + String value = value(text); + if (value.isEmpty()) { + return; + } + String[] lines = value.split("\\n", -1); + double y = baselineMmFromTop - (lines.length - 1) * 2.2; + for (String line : lines) { + float width = font.getStringWidth(line) / 1000f * fontSize; + float x = PdfUnits.mmToPt(centerXmm) - width / 2f; + stream.beginText(); + stream.setFont(font, fontSize); + stream.newLineAtOffset(x, units.topDownMmToPdfY(y)); + stream.showText(line); + stream.endText(); + y += 5; + } + } + + private void wrapped(PDPageContentStream stream, PdfUnits units, PDFont font, String text, float fontSize, + double xMm, double firstBaselineMmFromTop, double widthMm, int maxLines, + double lineHeightMm) throws IOException { + List lines = wrap(font, fontSize, value(text), PdfUnits.mmToPt(widthMm), maxLines); + double y = firstBaselineMmFromTop; + for (String line : lines) { + text(stream, units, font, line, fontSize, xMm, y); + y += lineHeightMm; + } + } + + private List wrap(PDFont font, float fontSize, String text, float maxWidthPt, int maxLines) + throws IOException { + List lines = new ArrayList<>(); + if (text.isEmpty() || maxLines < 1) { + return lines; + } + for (String sourceLine : text.split("\\n", -1)) { + StringBuilder current = new StringBuilder(); + for (String token : sourceLine.split("(?<=\\s)|(?=\\s)")) { + if (token.isEmpty()) { + continue; + } + String candidate = current + token; + if (!current.isEmpty() && width(font, fontSize, candidate) > maxWidthPt) { + lines.add(current.toString().stripTrailing()); + if (lines.size() >= maxLines) { + return lines; + } + current.setLength(0); + candidate = token.stripLeading(); + } + if (width(font, fontSize, candidate) > maxWidthPt && current.isEmpty()) { + for (String broken : breakLongToken(font, fontSize, candidate, maxWidthPt)) { + lines.add(broken); + if (lines.size() >= maxLines) { + return lines; + } + } + } else { + current.setLength(0); + current.append(candidate); + } + } + if (!current.isEmpty()) { + lines.add(current.toString().stripTrailing()); + if (lines.size() >= maxLines) { + return lines; + } + } + } + return lines; + } + + private List breakLongToken(PDFont font, float fontSize, String token, float maxWidthPt) + throws IOException { + List lines = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + for (int offset = 0; offset < token.length(); ) { + int codePoint = token.codePointAt(offset); + String next = current + new String(Character.toChars(codePoint)); + if (!current.isEmpty() && width(font, fontSize, next) > maxWidthPt) { + lines.add(current.toString()); + current.setLength(0); + } + current.appendCodePoint(codePoint); + offset += Character.charCount(codePoint); + } + if (!current.isEmpty()) { + lines.add(current.toString()); + } + return lines; + } + + private float width(PDFont font, float fontSize, String text) throws IOException { + return font.getStringWidth(text) / 1000f * fontSize; + } + + private void rect(PDPageContentStream stream, double xMm, double yMm, double widthMm, double heightMm) + throws IOException { + stream.setLineWidth(0.5f); + stream.addRect(PdfUnits.mmToPt(xMm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - yMm - heightMm), + PdfUnits.mmToPt(widthMm), PdfUnits.mmToPt(heightMm)); + stream.stroke(); + } + + private void line(PDPageContentStream stream, double x1Mm, double y1Mm, double x2Mm, double y2Mm) + throws IOException { + stream.setLineWidth(0.45f); + stream.moveTo(PdfUnits.mmToPt(x1Mm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - y1Mm)); + stream.lineTo(PdfUnits.mmToPt(x2Mm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - y2Mm)); + stream.stroke(); + } + + private String value(String text) { + return text == null ? "" : text; + } +} diff --git a/print/src/main/java/com/baiyue/print/service/FreightA4PdfService.java b/print/src/main/java/com/baiyue/print/service/FreightA4PdfService.java new file mode 100644 index 000000000..53da17c5f --- /dev/null +++ b/print/src/main/java/com/baiyue/print/service/FreightA4PdfService.java @@ -0,0 +1,257 @@ +package com.baiyue.print.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.print.config.PrintPdfProperties; +import com.baiyue.print.dto.FreightA4PdfDtos; +import com.baiyue.print.renderer.FreightA4Renderer; +import jakarta.annotation.Resource; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +/** Validates A4 freight documents and renders one A4 page per request document. */ +@Service +public class FreightA4PdfService { + private static final int MAX_FILE_NAME_CODE_POINTS = 120; + private static final int MAX_FIELD_KEY_CODE_POINTS = 80; + private static final int MAX_FIELD_VALUE_CODE_POINTS = 4000; + private static final int MAX_CARGO_ROWS = 50; + private static final BigDecimal A4_WIDTH_MM = new BigDecimal("210"); + private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297"); + private static final Set SUPPORTED_TEMPLATES = Set.of( + FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, + FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE); + private static final Set FORBIDDEN_FILE_NAME_CHARACTERS = + Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|'); + + @Resource private PrintPdfProperties properties; + @Resource private PdfGenerationLimiter limiter; + @Resource private FreightA4Renderer renderer; + + public GeneratedPdf generate(FreightA4PdfDtos.Request request) { + NormalizedRequest normalized = validateAndNormalize(request); + try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) { + return render(normalized); + } + } + + private GeneratedPdf render(NormalizedRequest request) { + try (PDDocument document = new PDDocument(); + InputStream fontStream = openFont(); + LimitedByteArrayOutputStream output = + new LimitedByteArrayOutputStream(properties.getMaxOutputBytes())) { + PDType0Font font = PDType0Font.load(document, fontStream, true); + for (FreightA4PdfDtos.Document documentData : request.documents()) { + renderer.renderPage(document, font, request.templateCode(), documentData); + } + document.save(output); + byte[] content = output.toByteArray(); + return new GeneratedPdf(content, request.fileName(), request.documents().size()); + } catch (OutputLimitExceededException exception) { + throw new BizException(ResultCode.INTERNAL_ERROR, "生成的 PDF 超过输出大小上限"); + } catch (IOException | RuntimeException exception) { + if (exception instanceof BizException bizException) { + throw bizException; + } + throw new BizException(ResultCode.INTERNAL_ERROR, "PDF 生成失败"); + } + } + + private InputStream openFont() throws IOException { + ClassPathResource resource = new ClassPathResource(properties.getFontResource()); + if (!resource.exists()) { + throw new IOException("configured PDF font resource does not exist"); + } + return resource.getInputStream(); + } + + private NormalizedRequest validateAndNormalize(FreightA4PdfDtos.Request request) { + if (request == null) { + invalid("request is required"); + } + String templateCode = normalizeRequiredLine(request.templateCode(), "templateCode"); + if (!SUPPORTED_TEMPLATES.contains(templateCode)) { + invalid("templateCode is not supported"); + } + if (request.pageSpec() == null) { + invalid("pageSpec is required"); + } + requireSupportedDimension(request.pageSpec().widthMm(), A4_WIDTH_MM, "pageSpec.widthMm"); + requireSupportedDimension(request.pageSpec().heightMm(), A4_HEIGHT_MM, "pageSpec.heightMm"); + if (request.documents() == null || request.documents().isEmpty()) { + invalid("documents must contain at least one item"); + } + if (request.documents().size() > properties.getMaxPages()) { + invalid("documents must not contain more than " + properties.getMaxPages() + " items"); + } + + List documents = new ArrayList<>(request.documents().size()); + for (int index = 0; index < request.documents().size(); index++) { + documents.add(normalizeDocument(request.documents().get(index), index)); + } + return new NormalizedRequest(templateCode, normalizeFileName(request.downloadFileName()), + List.copyOf(documents)); + } + + private FreightA4PdfDtos.Document normalizeDocument(FreightA4PdfDtos.Document document, int index) { + if (document == null) { + invalid("documents[" + index + "] is required"); + } + Map fields = new LinkedHashMap<>(); + if (document.fields() != null) { + for (Map.Entry entry : document.fields().entrySet()) { + String key = normalizeRequiredLine(entry.getKey(), field(index, "fields key")); + if (key.codePointCount(0, key.length()) > MAX_FIELD_KEY_CODE_POINTS) { + invalid(field(index, "fields." + key) + " key is too long"); + } + fields.put(key, normalizeOptionalBlock(entry.getValue(), field(index, "fields." + key))); + } + } + List rows = new ArrayList<>(); + if (document.cargoRows() != null) { + if (document.cargoRows().size() > MAX_CARGO_ROWS) { + invalid(field(index, "cargoRows") + " must not contain more than " + MAX_CARGO_ROWS + " items"); + } + for (int rowIndex = 0; rowIndex < document.cargoRows().size(); rowIndex++) { + rows.add(normalizeCargoRow(document.cargoRows().get(rowIndex), index, rowIndex)); + } + } + return new FreightA4PdfDtos.Document(Map.copyOf(fields), List.copyOf(rows)); + } + + private FreightA4PdfDtos.CargoRow normalizeCargoRow(FreightA4PdfDtos.CargoRow row, int documentIndex, + int rowIndex) { + if (row == null) { + invalid("documents[" + documentIndex + "].cargoRows[" + rowIndex + "] is required"); + } + String prefix = "documents[" + documentIndex + "].cargoRows[" + rowIndex + "]."; + return new FreightA4PdfDtos.CargoRow( + normalizeOptionalBlock(row.marksAndNos(), prefix + "marksAndNos"), + normalizeOptionalBlock(row.quantity(), prefix + "quantity"), + normalizeOptionalBlock(row.packaging(), prefix + "packaging"), + normalizeOptionalBlock(row.descriptionOfGoods(), prefix + "descriptionOfGoods"), + normalizeOptionalBlock(row.grossWeight(), prefix + "grossWeight"), + normalizeOptionalBlock(row.measurement(), prefix + "measurement")); + } + + private String normalizeFileName(String value) { + if (value == null) { + return "freight-a4-documents.pdf"; + } + String normalized = normalizeRequiredLine(value, "downloadFileName"); + if (normalized.codePointCount(0, normalized.length()) > MAX_FILE_NAME_CODE_POINTS) { + invalid("downloadFileName must not exceed 120 characters"); + } + if (!normalized.toLowerCase(java.util.Locale.ROOT).endsWith(".pdf")) { + invalid("downloadFileName must end with .pdf"); + } + for (int offset = 0; offset < normalized.length(); offset++) { + if (FORBIDDEN_FILE_NAME_CHARACTERS.contains(normalized.charAt(offset))) { + invalid("downloadFileName contains a reserved character"); + } + } + return normalized; + } + + private String normalizeRequiredLine(String value, String field) { + String normalized = normalizeOptionalBlock(value, field); + if (normalized.isBlank()) { + invalid(field + " must not be blank"); + } + if (normalized.contains("\n")) { + invalid(field + " must be a single line"); + } + return normalized; + } + + private String normalizeOptionalBlock(String value, String field) { + if (value == null) { + return ""; + } + String normalized = Normalizer.normalize(value, Normalizer.Form.NFC) + .replace("\r\n", "\n") + .replace('\r', '\n'); + if (normalized.codePointCount(0, normalized.length()) > MAX_FIELD_VALUE_CODE_POINTS) { + invalid(field + " must not exceed " + MAX_FIELD_VALUE_CODE_POINTS + " characters"); + } + normalized.codePoints().forEach(codePoint -> { + if (isForbiddenControl(codePoint)) { + invalid(field + " contains a control character"); + } + }); + return normalized.strip(); + } + + private boolean isForbiddenControl(int codePoint) { + int type = Character.getType(codePoint); + return codePoint != '\n' && codePoint != '\t' + && (Character.isISOControl(codePoint) + || type == Character.FORMAT + || type == Character.LINE_SEPARATOR + || type == Character.PARAGRAPH_SEPARATOR); + } + + private void requireSupportedDimension(BigDecimal actual, BigDecimal expected, String field) { + if (actual == null || actual.compareTo(expected) != 0) { + invalid(field + " is not supported"); + } + } + + private String field(int index, String name) { + return "documents[" + index + "]." + name; + } + + private void invalid(String message) { + throw new BizException(ResultCode.VALIDATION_FAILED, message); + } + + public record GeneratedPdf(byte[] content, String fileName, int pageCount) { + } + + private record NormalizedRequest(String templateCode, String fileName, List documents) { + } + + private static final class LimitedByteArrayOutputStream extends ByteArrayOutputStream { + private final long maxBytes; + + private LimitedByteArrayOutputStream(long maxBytes) { + if (maxBytes < 1 || maxBytes > Integer.MAX_VALUE) { + throw new IllegalArgumentException("max-output-bytes must be between 1 and Integer.MAX_VALUE"); + } + this.maxBytes = maxBytes; + } + + @Override + public synchronized void write(int value) { + ensureCapacityWithinLimit(1); + super.write(value); + } + + @Override + public synchronized void write(byte[] bytes, int offset, int length) { + ensureCapacityWithinLimit(length); + super.write(bytes, offset, length); + } + + private void ensureCapacityWithinLimit(int addedBytes) { + if ((long) count + addedBytes > maxBytes) { + throw new OutputLimitExceededException(); + } + } + } + + private static final class OutputLimitExceededException extends RuntimeException { + } +} diff --git a/print/src/main/resources/openapi/print-internal-v1.yaml b/print/src/main/resources/openapi/print-internal-v1.yaml index 5013c171c..30b6c5e83 100644 --- a/print/src/main/resources/openapi/print-internal-v1.yaml +++ b/print/src/main/resources/openapi/print-internal-v1.yaml @@ -52,6 +52,54 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiError' + /internal/print/v1/pdfs/freight-a4-documents: + post: + summary: Generate an A4 sea-freight document PDF + operationId: generateFreightA4DocumentPdf + security: + - internalServiceToken: [] + parameters: + - $ref: '#/components/parameters/RequestId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FreightA4PdfRequest' + responses: + '200': + description: PDF generation succeeded, or a business error was returned as ApiResponse JSON. + headers: + Content-Disposition: + description: UTF-8 attachment filename. Present only for a successful PDF. + schema: + type: string + Cache-Control: + schema: + type: string + example: no-store + X-Print-Page-Count: + schema: + type: integer + minimum: 1 + maximum: 2000 + X-Request-Id: + schema: + type: string + content: + application/pdf: + schema: + type: string + format: binary + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '413': + description: Request body exceeds the configured limit. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' components: securitySchemes: internalServiceToken: @@ -112,6 +160,66 @@ components: minLength: 1 maxLength: 34 pattern: '^[\\x20-\\x7E]+$' + FreightA4PdfRequest: + type: object + required: [templateCode, pageSpec, documents] + properties: + templateCode: + type: string + enum: [SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1, BILL_OF_LADING_CONFIRMATION_A4_V1] + pageSpec: + $ref: '#/components/schemas/FreightA4PageSpec' + downloadFileName: + type: string + maxLength: 120 + example: BYSE2607044-托运书.pdf + documents: + type: array + minItems: 1 + maxItems: 2000 + description: One document item renders one A4 page in request order. + items: + $ref: '#/components/schemas/FreightA4Document' + FreightA4PageSpec: + type: object + required: [widthMm, heightMm] + properties: + widthMm: + type: number + enum: [210] + heightMm: + type: number + enum: [297] + FreightA4Document: + type: object + properties: + fields: + type: object + additionalProperties: + type: string + maxLength: 4000 + description: Template field map. Known keys are documented in the print module README. + cargoRows: + type: array + maxItems: 1 + description: V1 templates render at most one cargo row. Callers must not send additional rows. + items: + $ref: '#/components/schemas/FreightA4CargoRow' + FreightA4CargoRow: + type: object + properties: + marksAndNos: + type: string + quantity: + type: string + packaging: + type: string + descriptionOfGoods: + type: string + grossWeight: + type: string + measurement: + type: string ApiError: type: object required: [code, message] diff --git a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java index e6188a3b5..cd8ea1512 100644 --- a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java +++ b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java @@ -3,7 +3,9 @@ package com.baiyue.print.controller; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.web.GlobalExceptionHandler; +import com.baiyue.print.dto.FreightA4PdfDtos; import com.baiyue.print.dto.InboundMarkPdfDtos; +import com.baiyue.print.service.FreightA4PdfService; import com.baiyue.print.service.InboundMarkPdfService; import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; @@ -38,6 +40,8 @@ class InternalPdfControllerTest { private static final String TOKEN = "0123456789abcdef0123456789abcdef"; @MockBean private InboundMarkPdfService service; + @MockBean + private FreightA4PdfService freightA4PdfService; @Resource private InternalPdfController controller; private MockMvc mockMvc; @@ -127,6 +131,28 @@ class InternalPdfControllerTest { assertThat(captor.getValue().downloadFileName()).isEqualTo("inbound-marks-request-123.pdf"); } + @Test + void returnsFreightA4PdfWithDownloadHeaders() throws Exception { + byte[] pdf = "%PDF-a4-test".getBytes(StandardCharsets.US_ASCII); + when(freightA4PdfService.generate(any())).thenReturn(new FreightA4PdfService.GeneratedPdf( + pdf, "BYSE2607044-托运书.pdf", 1)); + + mockMvc.perform(post("/internal/print/v1/pdfs/freight-a4-documents") + .header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON) + .content(validFreightA4RequestJson())) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_PDF)) + .andExpect(content().bytes(pdf)) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(header().string("X-Print-Page-Count", "1")) + .andExpect(header().string("Content-Disposition", + org.hamcrest.Matchers.containsString("filename*="))); + + verify(freightA4PdfService).generate(any(FreightA4PdfDtos.Request.class)); + } + @Test void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception { PostMapping mapping = InternalPdfController.class @@ -142,6 +168,7 @@ class InternalPdfControllerTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(contract) .contains("/internal/print/v1/pdfs/inbound-marks:") + .contains("/internal/print/v1/pdfs/freight-a4-documents:") .contains("application/pdf:") .contains("application/json:") .contains("name: X-Internal-Service-Token") @@ -165,4 +192,29 @@ class InternalPdfControllerTest { """; } + private String validFreightA4RequestJson() { + return """ + { + "templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1", + "pageSpec": {"widthMm": 210, "heightMm": 297}, + "downloadFileName": "BYSE2607044-托运书.pdf", + "documents": [{ + "fields": { + "toCompany": "宁波胜泽远航国际物流有限公司", + "shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED", + "jobNumber": "BYSE2607044" + }, + "cargoRows": [{ + "marksAndNos": "N/M", + "quantity": "700", + "packaging": "PACKAGES", + "descriptionOfGoods": "CAR ACCESSORIES", + "grossWeight": "28000.00 KGS", + "measurement": "28.000 CBM" + }] + }] + } + """; + } + } diff --git a/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java b/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java index 96c2ab6a3..16ed3c315 100644 --- a/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java +++ b/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java @@ -39,6 +39,19 @@ class PrintRequestSizeFilterTest { assertThat(response.getStatus()).isEqualTo(200); } + @Test + void alsoCapsFreightA4DocumentRequests() throws Exception { + MockHttpServletRequest request = request("12345678901", true, + "/internal/print/v1/pdfs/freight-a4-documents"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference called = new AtomicReference<>(false); + + filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> called.set(true)); + + assertThat(called).hasValue(false); + assertTooLarge(response); + } + @Test void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest() { @@ -68,17 +81,25 @@ class PrintRequestSizeFilterTest { } private MockHttpServletRequest request(String content, boolean unknownLength) { + return request(content, unknownLength, "/internal/print/v1/pdfs/inbound-marks"); + } + + private MockHttpServletRequest request(String content, boolean unknownLength, String path) { MockHttpServletRequest request = unknownLength ? new MockHttpServletRequest() { @Override public int getContentLength() { return -1; } @Override public long getContentLengthLong() { return -1; } } : new MockHttpServletRequest(); - initialize(request, content); + initialize(request, content, path); return request; } private void initialize(MockHttpServletRequest request, String content) { + initialize(request, content, "/internal/print/v1/pdfs/inbound-marks"); + } + + private void initialize(MockHttpServletRequest request, String content, String path) { request.setMethod("POST"); - request.setRequestURI("/internal/print/v1/pdfs/inbound-marks"); + request.setRequestURI(path); request.setContent(content.getBytes(StandardCharsets.UTF_8)); request.setAttribute("requestId", "request-123"); request.setAttribute("traceId", "trace-123"); diff --git a/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java new file mode 100644 index 000000000..8fa2c3648 --- /dev/null +++ b/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java @@ -0,0 +1,134 @@ +package com.baiyue.print.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.baiyue.print.config.PrintPdfProperties; +import com.baiyue.print.dto.FreightA4PdfDtos; +import com.baiyue.print.renderer.FreightA4Renderer; +import com.baiyue.print.renderer.PdfUnits; +import jakarta.annotation.Resource; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig(classes = {FreightA4PdfService.class, PrintPdfProperties.class, + PdfGenerationLimiter.class, FreightA4Renderer.class}) +class FreightA4PdfServiceGenerationTest { + @Resource + private FreightA4PdfService service; + + @Test + void generatesShippingInstructionAsStrictA4Pdf() throws IOException { + FreightA4PdfService.GeneratedPdf generated = service.generate(request( + FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, + "BYSE2607044-shipping-instruction.pdf", + shippingDocument())); + + assertThat(generated.fileName()).isEqualTo("BYSE2607044-shipping-instruction.pdf"); + assertThat(generated.pageCount()).isEqualTo(1); + assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + try (PDDocument document = Loader.loadPDF(generated.content())) { + assertA4(document); + String text = new PDFTextStripper().getText(document); + assertThat(text).contains("Sea Freight Shipping Instructions", "Job Nbr", + "BYSE2607044", "CAR ACCESSORIES", "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY."); + } + } + + @Test + void generatesBillConfirmationAsStrictA4Pdf() throws IOException { + FreightA4PdfService.GeneratedPdf generated = service.generate(request( + FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE, + "BYSE2607044-bill-confirmation.pdf", + billConfirmationDocument())); + + assertThat(generated.pageCount()).isEqualTo(1); + try (PDDocument document = Loader.loadPDF(generated.content())) { + assertA4(document); + String text = new PDFTextStripper().getText(document); + assertThat(text).contains("WHY074G504054", "D R A F T", "SAID TO CONTAIN(S)", + "WHSU2884669/WHLX730243/20GP/1028PACKAGES/28000KGS/ 28CBM"); + } + } + + private FreightA4PdfDtos.Request request(String templateCode, String fileName, + FreightA4PdfDtos.Document document) { + return new FreightA4PdfDtos.Request(templateCode, + new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297.0")), + fileName, List.of(document)); + } + + private FreightA4PdfDtos.Document shippingDocument() { + return new FreightA4PdfDtos.Document(Map.ofEntries( + Map.entry("toCompany", "宁波胜泽远航国际物流有限公司"), + Map.entry("issuerNameCn", "浙江柏悦贸易有限公司(香港)"), + Map.entry("issuerNameEn", "ZHEJIANG BAIYUE TRADE CO.,LIMITED"), + Map.entry("issuerAddress", "地址:浙江省义乌市稠州北路 1399 号新光国际 8613 室"), + Map.entry("issuerPostalCode", "322000"), + Map.entry("issuerTel", "0579-85875557"), + Map.entry("issuerFax", "0579-85601277"), + Map.entry("shipper", "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN STREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557"), + Map.entry("consignee", "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354"), + Map.entry("notifyParty", "SAME AS CONSIGNEE"), + Map.entry("picName", "苏天翔"), + Map.entry("jobNumber", "BYSE2607044"), + Map.entry("etdDate", "2026/07/24"), + Map.entry("serviceTerm", "CY-CY"), + Map.entry("freightTerm", "FREIGHT PREPAID"), + Map.entry("cargoQuantity", "1*20GP"), + Map.entry("portOfLoading", "NINGBO"), + Map.entry("portOfDischarge", "EL DEKHEILA"), + Map.entry("placeOfDelivery", "EL DEKHEILA"), + Map.entry("totalPackagesInWords", "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY."), + Map.entry("carrier", "万海航运(香港)股份有限公司"), + Map.entry("bookingAgent", "宁波胜泽远航国际物流有限公司"), + Map.entry("printDate", "2026 年 07 月 16 日") + ), List.of(new FreightA4PdfDtos.CargoRow("N/M", "700", "PACKAGES", + "CAR ACCESSORIES\nACID: 4120661061019510011\n申请目的港 21 天免用箱", + "28000.00 KGS", "28.000 CBM"))); + } + + private FreightA4PdfDtos.Document billConfirmationDocument() { + return new FreightA4PdfDtos.Document(Map.ofEntries( + Map.entry("shipper", "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN\nSTREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557"), + Map.entry("consignee", "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354"), + Map.entry("notifyParty", "SAME AS CONSIGNEE"), + Map.entry("bookingNumber", "WHY074G504054"), + Map.entry("ensAmsCutoffDate", "2026/07/20"), + Map.entry("expectedDepartureDate", "2026/07/24"), + Map.entry("oceanVesselVoyage", "WAN HAI 622 W027"), + Map.entry("portOfLoading", "NINGBO"), + Map.entry("portOfDischarge", "EL DEKHEILA"), + Map.entry("placeOfDelivery", "EL DEKHEILA"), + Map.entry("finalDestination", "EL DEKHEILA"), + Map.entry("serviceTerm", "CY-CY"), + Map.entry("freightTerm", "FREIGHT PREPAID"), + Map.entry("shipperLoadCountSeal", "SHPR'S LOAD,COUNT AND SEAL"), + Map.entry("containerSpec", "1*20GP"), + Map.entry("shippedOnBoard", "24-JUL-2026"), + Map.entry("draftText", "D R A F T"), + Map.entry("totalPackagesInWords", "SAY ONE THOUSAND AND TWENTY EIGHT PACKAGES ONLY."), + Map.entry("containerSummary", "WHSU2884669/WHLX730243/20GP/1028PACKAGES/28000KGS/ 28CBM"), + Map.entry("notes", "备注:1.多个箱子请分别提供分箱数,分箱品名,若未指出默认品名混装.\n2.请在 ENS/AMS 规定时间内确认好提单内容,否则有上了不船的风险."), + Map.entry("issuePlace", "NINGBO"), + Map.entry("issueDate", "24-JUL-2026") + ), List.of(new FreightA4PdfDtos.CargoRow("N/M", "1028", "PACKAGES", + "AUTO SPARE PARTS\nHS CODE 870899\nACID: 4120661061019510011\nEGYPTIAN IMPORTER VAT NUMBER:412066106\nEXPORTER REGISTRATION\nNUMBER:CN-02-91330782MA2E62HK3Y\n申请目的港 14 天免用箱", + "28000.00 KGS", "28.000 CBM"))); + } + + private void assertA4(PDDocument document) { + assertThat(document.getNumberOfPages()).isEqualTo(1); + assertThat(document.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(210), + org.assertj.core.data.Offset.offset(0.002f)); + assertThat(document.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(297), + org.assertj.core.data.Offset.offset(0.002f)); + assertThat(document.getPage(0).getRotation()).isZero(); + } +} diff --git a/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceValidationTest.java b/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceValidationTest.java new file mode 100644 index 000000000..74cb9b1ca --- /dev/null +++ b/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceValidationTest.java @@ -0,0 +1,64 @@ +package com.baiyue.print.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.print.config.PrintPdfProperties; +import com.baiyue.print.dto.FreightA4PdfDtos; +import com.baiyue.print.renderer.FreightA4Renderer; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig(classes = {FreightA4PdfService.class, PrintPdfProperties.class}) +class FreightA4PdfServiceValidationTest { + @MockBean private PdfGenerationLimiter limiter; + @MockBean private FreightA4Renderer renderer; + @jakarta.annotation.Resource private FreightA4PdfService service; + + @Test + void rejectsUnsupportedTemplateCode() { + assertValidationFailure(new FreightA4PdfDtos.Request("UNKNOWN", + pageSpec(), "a4.pdf", List.of(document())), "templateCode"); + } + + @Test + void rejectsNonA4PageSize() { + assertValidationFailure(new FreightA4PdfDtos.Request( + FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, + new FreightA4PdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100")), + "a4.pdf", List.of(document())), "pageSpec.widthMm"); + } + + @Test + void allowsMultilineDocumentFields() { + FreightA4PdfDtos.Request request = new FreightA4PdfDtos.Request( + FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, + pageSpec(), "a4.pdf", List.of(document())); + + assertThatCode(() -> service.generate(request)).doesNotThrowAnyException(); + } + + private void assertValidationFailure(FreightA4PdfDtos.Request request, String messagePart) { + assertThatThrownBy(() -> service.generate(request)) + .isInstanceOfSatisfying(BizException.class, exception -> { + assertThat(exception.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + assertThat(exception.getMessage()).contains(messagePart); + }); + } + + private FreightA4PdfDtos.PageSpec pageSpec() { + return new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")); + } + + private FreightA4PdfDtos.Document document() { + return new FreightA4PdfDtos.Document(Map.of("shipper", "A\nB"), + List.of(new FreightA4PdfDtos.CargoRow("N/M", "1", "PACKAGES", "GOODS", "1 KGS", "1 CBM"))); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantPartnerController.java index 28af44d45..228200c30 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantPartnerController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantPartnerController.java @@ -9,27 +9,26 @@ import com.baiyue.warehouse.service.PartnerQueryService; import jakarta.annotation.Resource; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import org.springframework.beans.factory.annotation.Value; import java.util.List; @RestController @RequestMapping("/api/merchant/v1/partners") public class MerchantPartnerController { + private static final long PUBLIC_TENANT_ID = 0L; + @Resource private PartnerQueryService service; - @Value("${baiyue.warehouse.public-tenant-id:0}") - private long publicTenantId; @GetMapping("/candidates") public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required=false) String keyword,@RequestParam(required=false) String type) { requireMerchant(user); - return ApiResponse.success(service.candidates(publicTenantId,keyword,type)); + return ApiResponse.success(service.candidates(PUBLIC_TENANT_ID,keyword,type)); } @GetMapping("/{id}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) { requireMerchant(user); - return ApiResponse.success(service.active(publicTenantId,id)); + return ApiResponse.success(service.active(PUBLIC_TENANT_ID,id)); } private static void requireMerchant(AuthenticatedUser user) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java index bbcf1a31d..57d68bee8 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java @@ -15,7 +15,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -23,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** - * 商户端公共仓库查询接口。认证用户共享平台默认租户维护的仓库配置。 + * 商户端公共仓库查询接口。认证用户共享平台公共作用域维护的仓库配置。 */ @Tag( name = "Merchant warehouses / 商户仓库", @@ -32,15 +31,14 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/merchant/v1/warehouses") public class MerchantWarehouseController { + private static final long PUBLIC_TENANT_ID = 0L; + @Resource private WarehouseApplicationService service; - @Value("${baiyue.warehouse.public-tenant-id:0}") - private long publicTenantId; - @Operation( summary = "List available warehouses / 查询可用仓库", - description = "Returns active public warehouses maintained under the configured public tenant. / 返回平台默认租户维护的可用公共仓库。" + description = "Returns active public warehouses maintained under the platform public scope. / 返回平台公共作用域维护的可用公共仓库。" ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @@ -51,12 +49,12 @@ public class MerchantWarehouseController { @AuthenticationPrincipal AuthenticatedUser user, @Valid WarehouseRequests.QueryRequest query) { requireMerchant(user); - return ApiResponse.success(service.merchantPage(publicTenantId, query)); + return ApiResponse.success(service.merchantPage(PUBLIC_TENANT_ID, query)); } @Operation( summary = "Get warehouse details / 查询仓库详情", - description = "Returns a public warehouse maintained under the configured public tenant. / 返回平台默认租户维护的公共仓库详情。" + description = "Returns a public warehouse maintained under the platform public scope. / 返回平台公共作用域维护的公共仓库详情。" ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @@ -68,7 +66,7 @@ public class MerchantWarehouseController { @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) @PathVariable long id) { requireMerchant(user); - return ApiResponse.success(service.activeDetail(publicTenantId, id)); + return ApiResponse.success(service.activeDetail(PUBLIC_TENANT_ID, id)); } private static void requireMerchant(AuthenticatedUser user) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java index fc915c962..a78f4ec8d 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java @@ -36,11 +36,11 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; -/** 平台库位管理接口;分页支持跨企业查询,写操作仍以目标 tenantId 隔离。 */ +/** 平台库位管理接口;统一维护平台公共作用域下的库位资源。 */ @Validated @RestController @RequestMapping("/api/platform/v1/locations") -@Tag(name = "平台库位管理", description = "按企业租户和仓库数据范围管理库位基础资料") +@Tag(name = "平台库位管理", description = "按平台公共作用域和仓库管理库位基础资料") public class PlatformLocationController { @Resource private LocationApplicationService service; @@ -48,14 +48,14 @@ public class PlatformLocationController { @GetMapping @PreAuthorize("hasAuthority('platform:location:list')") @Operation(summary = "分页查询库位", - description = "默认分页查询全部企业库位;可按目标企业租户、仓库、库区、编码、类型和状态筛选") + description = "默认分页查询全部平台公共库位;可按仓库、库区、编码、类型和状态筛选") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限,且仓库须属于目标企业租户") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限,且仓库须属于平台公共作用域") }) public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "可选目标企业租户ID;不传时查询全部企业") + @Parameter(description = "兼容作用域;仅允许 0,不传时查询平台公共资源") @RequestParam(required = false) @Min(0) Long tenantId, @Valid @ParameterObject PageQuery query) { requirePlatform(user); @@ -65,7 +65,7 @@ public class PlatformLocationController { @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:location:list')") - @Operation(summary = "查询库位详情", description = "详情查询同时校验企业租户和仓库归属") + @Operation(summary = "查询库位详情", description = "详情查询同时校验平台公共作用域和仓库归属") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限或仓库超出数据范围"), @@ -98,7 +98,7 @@ public class PlatformLocationController { @PostMapping @PreAuthorize("hasAuthority('platform:location:create')") - @Operation(summary = "新增库位", description = "在目标企业租户的指定仓库内新增唯一编码库位") + @Operation(summary = "新增库位", description = "在平台公共仓库内新增唯一编码库位") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "新增成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:create 权限或仓库超出数据范围"), diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java index 76bb66ec4..41970d73a 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java @@ -32,17 +32,17 @@ import org.springframework.web.bind.annotation.RestController; @Validated @RestController @RequestMapping("/api/platform/v1/pallets") -@Tag(name = "平台托盘管理", description = "按租户和仓库维护托盘主数据") +@Tag(name = "平台托盘管理", description = "按平台公共作用域和仓库维护托盘主数据") public class PlatformPalletController { @Resource private PalletApplicationService service; @GetMapping @PreAuthorize("hasAuthority('platform:pallet:list')") - @Operation(summary = "分页查询托盘", description = "默认查询全部企业;可按企业、仓库、托盘编码和类型筛选") + @Operation(summary = "分页查询托盘", description = "默认查询全部平台公共托盘;可按仓库、托盘编码和类型筛选") @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:pallet:list 权限")) public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "目标企业租户ID;不传时查询全部企业") + @Parameter(description = "兼容作用域;仅允许 0,不传时查询平台公共资源") @RequestParam(required = false) @Min(0) Long tenantId, @Valid @ParameterObject PalletRequests.PageQuery query) { requirePlatform(user); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index b41e0b39c..8b210d66f 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -6,8 +6,11 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode; +import com.baiyue.warehouse.dto.WarehouseImportDtos.WarehouseImportResult; import com.baiyue.warehouse.dto.WarehouseRequests; -import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.dto.WarehouseSummaryResponse; +import com.baiyue.warehouse.service.WarehouseImportService; import com.baiyue.warehouse.service.WarehouseApplicationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -16,6 +19,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -27,25 +35,32 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; /** - * 平台端仓库管理接口,提供跨租户管理场景下的仓库创建、修改、查询和状态维护能力。 + * 平台端仓库管理接口,统一维护平台公共作用域下的仓库主数据。 */ @Validated @Tag( name = "Platform warehouse administration / 平台仓库管理", - description = "Platform APIs for managing warehouses across tenants. / 平台端按租户管理仓库的接口。" + description = "Platform APIs for managing public warehouses. / 平台端管理公共仓库的接口。" ) @RestController @RequestMapping("/api/platform/v1/warehouse-admin") public class PlatformWarehouseController { @Resource private WarehouseApplicationService service; + @Resource + private WarehouseImportService importService; @Operation( summary = "Create warehouse / 新建仓库", - description = "Creates a warehouse, address and service capabilities for the target tenant. / 为目标租户创建仓库、地址和服务能力。" + description = "Creates a public warehouse, address and service capabilities. / 创建平台公共仓库、地址和服务能力。" ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"), @@ -58,7 +73,7 @@ public class PlatformWarehouseController { public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody WarehouseRequests.SaveRequest request) { requirePlatform(user); - requireTenant(request.tenantId()); + if (request.tenantId() != null) requireTenant(request.tenantId()); return ApiResponse.success(service.create(request)); } @@ -69,7 +84,7 @@ public class PlatformWarehouseController { @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse edit permission required (platform:warehouse:edit), or tenant validation failed / 需要平台仓库编辑权限,或租户校验失败"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse edit permission required (platform:warehouse:edit), or scope validation failed / 需要平台仓库编辑权限,或作用域校验失败"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") }) @@ -77,7 +92,7 @@ public class PlatformWarehouseController { @PreAuthorize("hasAuthority('platform:warehouse:edit')") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0", required = true) @RequestParam @NotNull Long tenantId, @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) @PathVariable long id, @@ -90,7 +105,7 @@ public class PlatformWarehouseController { @Operation( summary = "List platform warehouses / 查询平台仓库", - description = "Returns all platform warehouses by default; customer ID is an optional compatibility filter. / 默认分页查询平台全部仓库,客户 ID 仅作为兼容筛选条件。" + description = "Returns public platform warehouses; tenantId is kept only for compatibility and must be 0. / 分页查询平台公共仓库;tenantId 仅保留兼容且必须为 0。" ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @@ -99,19 +114,64 @@ public class PlatformWarehouseController { }) @GetMapping @PreAuthorize("hasAuthority('platform:warehouse:list')") - public ApiResponse> page( + public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "Optional customer ID / 可选客户 ID", example = "10001") + @Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0") @RequestParam(required = false) Long tenantId, - @Valid WarehouseRequests.QueryRequest request) { + @Valid @ParameterObject WarehouseRequests.QueryRequest request) { requirePlatform(user); if (tenantId != null) requireTenant(tenantId); - return ApiResponse.success(service.page(0L, request)); + return ApiResponse.success(service.pageSummaries(0L, request)); + } + + @GetMapping("/import-template") + @PreAuthorize("hasAuthority('platform:warehouse:create')") + @Operation(summary = "Download warehouse import template / 下载仓库导入模板", + description = "Downloads the current XLSX template for warehouse and FBA warehouse import. / 下载仓库与 FBA 仓库导入的当前 XLSX 模板。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Template downloaded / 模板下载成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse create permission required (platform:warehouse:create) / 需要平台仓库新增权限") + }) + public ResponseEntity importTemplate(@AuthenticationPrincipal AuthenticatedUser user) { + requirePlatform(user); + HttpHeaders headers = new HttpHeaders(); + headers.setContentDisposition(ContentDisposition.attachment() + .filename("warehouse-import-template-v1.xlsx", StandardCharsets.UTF_8).build()); + headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")); + return ResponseEntity.ok().headers(headers).body(importService.template()); + } + + @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:warehouse:create')") + @Operation(summary = "Import warehouses / 批量导入仓库", + description = "Validates the whole XLSX file and atomically creates or optionally updates warehouses. Default mode is CREATE_ONLY. / 整表校验 XLSX 后原子新增或可选更新仓库,默认 CREATE_ONLY。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Import precheck completed / 导入预检完成"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid file or query / 文件或参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse create permission required; UPSERT also requires edit permission (platform:warehouse:create, platform:warehouse:edit) / 需要平台仓库新增权限;UPSERT 还需要编辑权限") + }) + public ApiResponse importWarehouses( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0") + @RequestParam(required = false) Long tenantId, + @Parameter(description = "Import mode, defaults to CREATE_ONLY / 导入模式,默认 CREATE_ONLY") + @RequestParam(required = false) ImportMode mode, + @Parameter(description = "Whether to only precheck without writing data / 是否只预检不写入", example = "true") + @RequestParam(defaultValue = "false") boolean dryRun, + @RequestPart("file") MultipartFile file) { + requirePlatform(user); + requireImportPermission(user, mode); + if (tenantId != null) requireTenant(tenantId); + try { + return ApiResponse.success(importService.importFile(0L, file.getOriginalFilename(), file.getBytes(), mode, dryRun)); + } catch (IOException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败"); + } } @Operation( - summary = "Get tenant warehouse details / 查询租户仓库详情", - description = "Returns the warehouse, addresses and service capabilities in the target tenant. / 返回目标租户下的仓库、地址及服务能力。" + summary = "Get public warehouse details / 查询公共仓库详情", + description = "Returns the public warehouse, addresses and service capabilities. / 返回平台公共仓库、地址及服务能力。" ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @@ -122,7 +182,7 @@ public class PlatformWarehouseController { @PreAuthorize("hasAuthority('platform:warehouse:list')") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0", required = true) @RequestParam @NotNull Long tenantId, @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) @PathVariable long id) { @@ -145,7 +205,7 @@ public class PlatformWarehouseController { @PreAuthorize("hasAuthority('platform:warehouse:status')") public ApiResponse status( @AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0", required = true) @RequestParam @NotNull Long tenantId, @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) @PathVariable long id, @@ -169,4 +229,11 @@ public class PlatformWarehouseController { throw new BizException(ResultCode.BAD_REQUEST, "Platform resource scope must be 0 / 平台公共资源作用域必须为0"); } } + + private static void requireImportPermission(AuthenticatedUser user, ImportMode mode) { + if (mode == ImportMode.UPSERT && !user.permissions().contains("platform:warehouse:edit")) { + throw new BizException(ResultCode.FORBIDDEN, + "UPSERT import requires platform:warehouse:edit / 新增并更新导入需要平台仓库编辑权限"); + } + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseImportDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseImportDtos.java new file mode 100644 index 000000000..8d70d9316 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseImportDtos.java @@ -0,0 +1,51 @@ +package com.baiyue.warehouse.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** 仓库与 FBA 仓库 XLSX 导入契约。 */ +public final class WarehouseImportDtos { + private WarehouseImportDtos() { + } + + @Schema(name = "WarehouseImportMode", description = "Warehouse import mode / 仓库导入模式") + public enum ImportMode { + @Schema(description = "Only create new rows; existing rows are reported but not modified / 仅新增,现有数据只预检分类不修改") + CREATE_ONLY, + @Schema(description = "Create new rows and update existing rows without overriding contact or remark / 新增并更新,更新不覆盖联系人和备注") + UPSERT + } + + @Schema(name = "WarehouseImportError", description = "Warehouse import row error / 仓库导入行错误") + public record WarehouseImportError( + @Schema(description = "Excel row number / Excel 行号") int row, + @Schema(description = "Field name / 字段") String field, + @Schema(description = "Error message / 错误原因") String message + ) { + } + + @Schema(name = "WarehouseImportPreviewRow", description = "Warehouse import preview row / 仓库导入预检行") + public record WarehouseImportPreviewRow( + @Schema(description = "Excel row number / Excel 行号") int row, + @Schema(description = "Warehouse code / 仓库编码") String warehouseCode, + @Schema(description = "Resolved action / 预检动作", allowableValues = {"CREATE", "UPDATE", "UNCHANGED", "ERROR"}) String action, + @Schema(description = "Reason / 原因") String reason + ) { + } + + @Schema(name = "WarehouseImportResult", description = "Warehouse import result / 仓库导入结果") + public record WarehouseImportResult( + @Schema(description = "Original file name / 原始文件名") String fileName, + @Schema(description = "Import mode / 导入模式") ImportMode mode, + @Schema(description = "Data row count / 数据行总数") int totalRows, + @Schema(description = "Created row count / 新增行数") int createdRows, + @Schema(description = "Updated row count / 更新行数") int updatedRows, + @Schema(description = "Unchanged row count / 无变化行数") int unchangedRows, + @Schema(description = "Whether the import passed validation and committed writable rows / 是否校验通过并写入可写行") boolean success, + @Schema(description = "Whether writable rows have been committed / 是否已经写入数据") boolean committed, + @Schema(description = "Per-row preview classifications / 逐行预检分类") List previewRows, + @Schema(description = "Per-row errors / 逐行错误") List errors + ) { + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java index 07b07f860..c48a3f81d 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -5,10 +5,10 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.NotBlank; import java.math.BigDecimal; import java.time.LocalTime; @@ -23,26 +23,26 @@ public final class WarehouseRequests { @Schema(name = "WarehouseSaveRequest", description = "Warehouse create or update request / 仓库新增或更新请求") public record SaveRequest( - @Schema(description = "Target tenant ID / 目标租户 ID", example = "10001", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull Long tenantId, + @Schema(description = "Compatibility tenant ID; platform APIs force it to 0 / 兼容租户字段,平台接口固定为 0", example = "0") + Long tenantId, @Schema(description = "Tenant-unique warehouse code / 租户内唯一仓库编码", example = "US-LAX-01", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank @Size(max = 50) String warehouseCode, @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank @Size(max = 120) String warehouseName, - @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}, requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType, - @Schema(description = "ISO 3166-1 alpha-2 country code / ISO 两位国家代码", example = "US", pattern = "[A-Z]{2}", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, - @Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank String timezone, + @Schema(description = "Warehouse type; defaults to DOMESTIC / 仓库类型,默认 DOMESTIC", example = "FBA", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}, defaultValue = "DOMESTIC") + @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType, + @Schema(description = "ISO 3166-1 alpha-2 country code; DOMESTIC defaults to CN / ISO 两位国家代码,DOMESTIC 默认 CN", example = "US", pattern = "[A-Z]{2}") + @Pattern(regexp = "[A-Z]{2}") String countryCode, + @Schema(description = "UTC offset; DOMESTIC defaults to UTC+8 / UTC 偏移量,DOMESTIC 默认 UTC+8", example = "UTC-8") + String timezone, @Schema(description = "Contact name / 联系人姓名", example = "Alex Chen", maxLength = 64) @Size(max = 64) String contactName, @Schema(description = "Contact phone / 联系电话", example = "+1-213-555-0100", maxLength = 32) @Size(max = 32) String contactPhone, @Schema(description = "Contact email / 联系邮箱", example = "warehouse@example.com") @Email String contactEmail, - @Schema(description = "Warehouse status / 仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank String status, + @Schema(description = "Warehouse status; defaults to ACTIVE / 仓库状态,默认 ACTIVE", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, defaultValue = "ACTIVE") + @Pattern(regexp = "ACTIVE|INACTIVE") String status, @Schema(description = "Remark / 备注", example = "Supports appointment receiving / 支持预约入库", maxLength = 500) @Size(max = 500) String remark, @Schema(description = "Primary warehouse address / 仓库主地址", requiredMode = Schema.RequiredMode.REQUIRED) @@ -54,10 +54,10 @@ public final class WarehouseRequests { @Schema(name = "WarehouseAddressRequest", description = "Warehouse address / 仓库地址") public record AddressRequest( - @Schema(description = "Address type / 地址类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank String addressType, - @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, + @Schema(description = "Address type; defaults to WAREHOUSE / 地址类型,默认 WAREHOUSE", example = "WAREHOUSE") + String addressType, + @Schema(description = "ISO two-letter country code; defaults from warehouse country / ISO 两位国家代码,默认跟随仓库国家", example = "US") + @Pattern(regexp = "[A-Z]{2}") String countryCode, @Schema(description = "State or province / 州或省", example = "California", maxLength = 100) @Size(max = 100) String stateProvince, @Schema(description = "City / 城市", example = "Los Angeles", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) @@ -98,6 +98,8 @@ public final class WarehouseRequests { @Min(1) long page, @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") @Min(1) @Max(200) long size, + @Schema(description = "Keyword across code, name, primary city, state and address / 关键字,覆盖编码、名称、主地址城市、州和地址", example = "Los Angeles") + @Size(max = 128) String keyword, @Schema(description = "Country filter / 国家筛选", example = "US") String countryCode, @Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE") @@ -105,7 +107,7 @@ public final class WarehouseRequests { @Schema(description = "Warehouse type filter / 仓库类型筛选", example = "DOMESTIC", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}) @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType, @Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) - String status + @Pattern(regexp = "ACTIVE|INACTIVE") String status ) { public QueryRequest { if (page == 0) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java new file mode 100644 index 000000000..f990c2322 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java @@ -0,0 +1,65 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +/** 仓库列表响应,带主地址摘要,避免列表页逐条请求详情。 */ +@Schema(name = "WarehouseSummaryResponse", description = "Warehouse list item with primary address summary / 仓库列表项及主地址摘要") +public record WarehouseSummaryResponse( + @Schema(description = "Warehouse ID / 仓库 ID") Long id, + @Schema(description = "Platform public scope ID, fixed to 0 / 平台公共作用域,固定为 0") Long tenantId, + @Schema(description = "Warehouse code / 仓库编码") String warehouseCode, + @Schema(description = "Warehouse name / 仓库名称") String warehouseName, + @Schema(description = "Warehouse type / 仓库类型", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}) String warehouseType, + @Schema(description = "ISO two-letter country code / 国家二字码") String countryCode, + @Schema(description = "UTC offset / UTC 偏移量") String timezone, + @Schema(description = "Contact name / 联系人") String contactName, + @Schema(description = "Contact phone / 联系电话") String contactPhone, + @Schema(description = "Contact email / 联系邮箱") String contactEmail, + @Schema(description = "Warehouse status / 仓库状态", allowableValues = {"ACTIVE", "INACTIVE"}) String status, + @Schema(description = "Remark / 备注") String remark, + @Schema(description = "Creation time / 创建时间") LocalDateTime createdAt, + @Schema(description = "Last update time / 更新时间") LocalDateTime updatedAt, + @Schema(description = "Primary address summary / 主地址摘要") AddressSummary primaryAddress +) { + public static WarehouseSummaryResponse of(WarehouseEntity warehouse, WarehouseAddressEntity address) { + return new WarehouseSummaryResponse( + warehouse.getId(), warehouse.getTenantId(), warehouse.getWarehouseCode(), warehouse.getWarehouseName(), + warehouse.getWarehouseType(), warehouse.getCountryCode(), warehouse.getTimezone(), + warehouse.getContactName(), warehouse.getContactPhone(), warehouse.getContactEmail(), + warehouse.getStatus(), warehouse.getRemark(), warehouse.getCreatedAt(), warehouse.getUpdatedAt(), + address == null ? null : AddressSummary.of(address) + ); + } + + @Schema(name = "WarehouseAddressSummary", description = "Primary warehouse address summary / 仓库主地址摘要") + public record AddressSummary( + @Schema(description = "Address ID / 地址 ID") Long id, + @Schema(description = "Address type / 地址类型") String addressType, + @Schema(description = "Country code / 国家二字码") String countryCode, + @Schema(description = "State or province / 州或省") String stateProvince, + @Schema(description = "City / 城市") String city, + @Schema(description = "District / 区县") String district, + @Schema(description = "Postal code / 邮政编码") String postalCode, + @Schema(description = "Address line 1 / 地址第一行") String addressLine1, + @Schema(description = "Address line 2 / 地址第二行") String addressLine2, + @Schema(description = "Display address / 展示地址") String displayAddress + ) { + static AddressSummary of(WarehouseAddressEntity address) { + return new AddressSummary(address.getId(), address.getAddressType(), address.getCountryCode(), + address.getStateProvince(), address.getCity(), address.getDistrict(), address.getPostalCode(), + address.getAddressLine1(), address.getAddressLine2(), displayAddress(address)); + } + + private static String displayAddress(WarehouseAddressEntity address) { + return java.util.stream.Stream.of(address.getStateProvince(), address.getCity(), address.getDistrict(), + address.getAddressLine1(), address.getAddressLine2()) + .filter(value -> value != null && !value.isBlank()) + .reduce((left, right) -> left + " " + right) + .orElse(""); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java index 48e32e6c7..e391ee0d4 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java @@ -31,7 +31,7 @@ public class WarehouseEntity extends BaseEntity { private String warehouseType; @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US") private String countryCode; - @Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles") + @Schema(description = "UTC offset / UTC 偏移量", example = "UTC-8") private String timezone; @Schema(description = "Contact name / 联系人", example = "Alex Chen") private String contactName; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 64249a398..9b7c1bff3 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.dto.WarehouseSummaryResponse; import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -14,7 +15,10 @@ import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; +import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * 仓库聚合持久化仓储,组合仓库、地址和服务能力 Mapper,并强制按租户范围执行查询与写入。 @@ -42,6 +46,12 @@ public class WarehouseRepository { .eq(WarehouseEntity::getId, id)); } + public WarehouseEntity findByCode(long tenantId, String code) { + return warehouses.selectOne(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getWarehouseCode, code)); + } + public WarehouseEntity findActiveOwned(long tenantId, long id) { return warehouses.selectOne(new LambdaQueryWrapper() .eq(WarehouseEntity::getTenantId, tenantId) @@ -57,11 +67,21 @@ public class WarehouseRepository { } public PageResult page(Long tenantId, WarehouseRequests.QueryRequest query) { + String keyword = StringUtils.hasText(query.keyword()) ? query.keyword().trim() : null; + List addressMatches = StringUtils.hasText(keyword) ? addressWarehouseIdsByKeyword(tenantId, keyword) : List.of(); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(tenantId != null, WarehouseEntity::getTenantId, tenantId) .eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status()) .eq(StringUtils.hasText(query.warehouseType()), WarehouseEntity::getWarehouseType, query.warehouseType()) .eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode()) + .and(StringUtils.hasText(keyword), condition -> { + condition.like(WarehouseEntity::getWarehouseCode, keyword) + .or() + .like(WarehouseEntity::getWarehouseName, keyword); + if (!addressMatches.isEmpty()) { + condition.or().in(WarehouseEntity::getId, addressMatches); + } + }) .orderByDesc(WarehouseEntity::getCreatedAt); if (StringUtils.hasText(query.capabilityCode())) { @@ -82,8 +102,19 @@ public class WarehouseRepository { return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper)); } + public PageResult pageSummaries(Long tenantId, WarehouseRequests.QueryRequest query) { + PageResult page = page(tenantId, query); + Map primaryAddresses = primaryAddresses(tenantId, + page.records().stream().map(WarehouseEntity::getId).toList()); + return new PageResult<>( + page.records().stream() + .map(warehouse -> WarehouseSummaryResponse.of(warehouse, primaryAddresses.get(warehouse.getId()))) + .toList(), + page.page(), page.size(), page.total(), page.pages()); + } + public PageResult activePage(long tenantId, WarehouseRequests.QueryRequest query) { - return page(tenantId, new WarehouseRequests.QueryRequest(query.page(), query.size(), + return page(tenantId, new WarehouseRequests.QueryRequest(query.page(), query.size(), query.keyword(), query.countryCode(), query.capabilityCode(), query.warehouseType(), "ACTIVE")); } @@ -104,6 +135,11 @@ public class WarehouseRepository { capabilities.physicallyDeleteByWarehouse(tenantId, warehouseId); } + public void replaceAddress(long tenantId, long warehouseId, WarehouseAddressEntity address) { + addresses.physicallyDeleteByWarehouse(tenantId, warehouseId); + addresses.insert(address); + } + public void insertAddress(WarehouseAddressEntity address) { addresses.insert(address); } @@ -111,4 +147,40 @@ public class WarehouseRepository { public void insertCapability(ServiceCapabilityEntity capability) { capabilities.insert(capability); } + + private List addressWarehouseIdsByKeyword(Long tenantId, String keyword) { + return addresses.selectList(new LambdaQueryWrapper() + .select(WarehouseAddressEntity::getWarehouseId) + .eq(tenantId != null, WarehouseAddressEntity::getTenantId, tenantId) + .and(condition -> condition + .like(WarehouseAddressEntity::getStateProvince, keyword) + .or() + .like(WarehouseAddressEntity::getCity, keyword) + .or() + .like(WarehouseAddressEntity::getAddressLine1, keyword) + .or() + .like(WarehouseAddressEntity::getAddressLine2, keyword)) + .orderByAsc(WarehouseAddressEntity::getWarehouseId)) + .stream() + .map(WarehouseAddressEntity::getWarehouseId) + .distinct() + .toList(); + } + + private Map primaryAddresses(Long tenantId, Collection warehouseIds) { + if (warehouseIds == null || warehouseIds.isEmpty()) { + return Map.of(); + } + List records = addresses.selectList(new LambdaQueryWrapper() + .eq(tenantId != null, WarehouseAddressEntity::getTenantId, tenantId) + .in(WarehouseAddressEntity::getWarehouseId, warehouseIds) + .orderByAsc(WarehouseAddressEntity::getWarehouseId) + .orderByDesc(WarehouseAddressEntity::getAddressType) + .orderByAsc(WarehouseAddressEntity::getId)); + Map grouped = new LinkedHashMap<>(); + for (WarehouseAddressEntity address : records) { + grouped.putIfAbsent(address.getWarehouseId(), address); + } + return grouped; + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index 808534c68..76456870f 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.dto.WarehouseSummaryResponse; import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -34,10 +35,12 @@ public class WarehouseApplicationService { captureBefore = false ) public Long create(WarehouseRequests.SaveRequest request) { - ensureCodeUnique(request.tenantId(), request.warehouseCode(), null); - WarehouseEntity warehouse = toEntity(request); + WarehouseSaveNormalizer.NormalizedSaveRequest normalized = WarehouseSaveNormalizer.normalize( + request, WarehouseSaveNormalizer.PUBLIC_TENANT_ID); + ensureCodeUnique(normalized.tenantId(), normalized.warehouseCode(), null); + WarehouseEntity warehouse = toEntity(normalized); repository.insertWarehouse(warehouse); - insertChildren(warehouse.getTenantId(), warehouse.getId(), request.address(), request.capabilities()); + insertChildren(warehouse.getTenantId(), warehouse.getId(), normalized.address(), normalized.capabilities()); return warehouse.getId(); } @@ -49,24 +52,29 @@ public class WarehouseApplicationService { description = "Update warehouse / 更新仓库" ) public void update(long tenantId, long id, WarehouseRequests.SaveRequest request) { - if (request.tenantId() == null || tenantId != request.tenantId()) { + if (request.tenantId() != null && tenantId != request.tenantId()) { throw new BizException(ResultCode.FORBIDDEN, "Tenant mismatch / 租户不匹配"); } + WarehouseSaveNormalizer.NormalizedSaveRequest normalized = WarehouseSaveNormalizer.normalize(request, tenantId); WarehouseEntity old = owned(tenantId, id); - ensureCodeUnique(tenantId, request.warehouseCode(), id); + ensureCodeUnique(tenantId, normalized.warehouseCode(), id); - WarehouseEntity warehouse = toEntity(request); + WarehouseEntity warehouse = toEntity(normalized); warehouse.setId(old.getId()); repository.saveWarehouse(warehouse); repository.physicallyDeleteChildren(tenantId, id); - insertChildren(tenantId, id, request.address(), request.capabilities()); + insertChildren(tenantId, id, normalized.address(), normalized.capabilities()); } public PageResult page(Long tenantId, WarehouseRequests.QueryRequest query) { return repository.page(tenantId, query); } + public PageResult pageSummaries(Long tenantId, WarehouseRequests.QueryRequest query) { + return repository.pageSummaries(tenantId, query); + } + public WarehouseDetailResponse detail(long tenantId, long id) { WarehouseEntity warehouse = owned(tenantId, id); List addresses = repository.findAddresses(tenantId, id); @@ -120,7 +128,7 @@ public class WarehouseApplicationService { } } - private WarehouseEntity toEntity(WarehouseRequests.SaveRequest request) { + private WarehouseEntity toEntity(WarehouseSaveNormalizer.NormalizedSaveRequest request) { WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setTenantId(request.tenantId()); warehouse.setWarehouseCode(request.warehouseCode()); @@ -169,4 +177,5 @@ public class WarehouseApplicationService { repository.insertCapability(entity); } } + } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java new file mode 100644 index 000000000..841ae7216 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java @@ -0,0 +1,342 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode; +import com.baiyue.warehouse.dto.WarehouseImportDtos.WarehouseImportError; +import com.baiyue.warehouse.dto.WarehouseImportDtos.WarehouseImportPreviewRow; +import com.baiyue.warehouse.dto.WarehouseImportDtos.WarehouseImportResult; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.repository.WarehouseRepository; +import jakarta.annotation.Resource; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.xssf.usermodel.XSSFSheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; +import org.springframework.util.StringUtils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** 仓库与 FBA 仓库标准模板生成、整表预检和原子导入。 */ +@Service +public class WarehouseImportService { + private static final int MAX_FILE_BYTES = 5 * 1024 * 1024; + private static final int MAX_ROWS = 1000; + private static final String TEMPLATE_VERSION = "1"; + private static final String[] HEADERS = { + "仓库编码*", "仓库名称", "仓库类型", "国家二字码", "省/州", "城市*", "区/县", + "详细地址*", "邮政编码", "时区", "状态", "联系人", "联系电话", "联系邮箱", "备注" + }; + + @Resource + private WarehouseRepository repository; + @Resource + private WarehouseApplicationService warehouseService; + + public byte[] template() { + try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + XSSFSheet sheet = workbook.createSheet("仓库"); + Row version = sheet.createRow(0); + version.createCell(0).setCellValue("模板版本"); + version.createCell(1).setCellValue(TEMPLATE_VERSION); + Row instruction = sheet.createRow(1); + instruction.createCell(0).setCellValue("说明"); + instruction.createCell(1).setCellValue( + "默认仅新增;仓库类型默认 DOMESTIC,状态默认 ACTIVE;DOMESTIC 默认 CN/UTC+8;FBA 名称可不填并默认使用编码,多时区州必须显式填写时区;更新不覆盖联系人和备注"); + Row header = sheet.createRow(2); + for (int i = 0; i < HEADERS.length; i++) { + header.createCell(i).setCellValue(HEADERS[i]); + sheet.setColumnWidth(i, Math.min(48, Math.max(14, HEADERS[i].length() * 2 + 4)) * 256); + } + workbook.write(output); + return output.toByteArray(); + } catch (IOException exception) { + throw new IllegalStateException("生成仓库导入模板失败", exception); + } + } + + @Transactional + @BusinessAudit(event = "WAREHOUSE_IMPORT", resourceType = "WAREHOUSE_IMPORT", description = "批量导入仓库", captureBefore = false) + public WarehouseImportResult importFile(long tenantId, String fileName, byte[] content, ImportMode mode) { + return importFile(tenantId, fileName, content, mode, false); + } + + @Transactional + @BusinessAudit(event = "WAREHOUSE_IMPORT", resourceType = "WAREHOUSE_IMPORT", description = "批量导入仓库", captureBefore = false) + public WarehouseImportResult importFile(long tenantId, String fileName, byte[] content, ImportMode mode, boolean dryRun) { + if (tenantId != WarehouseSaveNormalizer.PUBLIC_TENANT_ID) { + throw new BizException(ResultCode.BAD_REQUEST, "平台公共仓库作用域必须为0"); + } + ImportMode resolvedMode = mode == null ? ImportMode.CREATE_ONLY : mode; + String safeFileName = safeFileName(fileName); + if (content == null || content.length == 0) return failure(safeFileName, resolvedMode, 0, "文件", "文件不能为空"); + if (content.length > MAX_FILE_BYTES) return failure(safeFileName, resolvedMode, 0, "文件", "文件不能超过 5MB"); + if (!safeFileName.toLowerCase(Locale.ROOT).endsWith(".xlsx")) { + return failure(safeFileName, resolvedMode, 0, "文件", "只支持 .xlsx 文件"); + } + + List rows = new ArrayList<>(); + List errors = new ArrayList<>(); + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) { + if (workbook.getNumberOfSheets() == 0) return failure(safeFileName, resolvedMode, 0, "模板", "缺少仓库工作表"); + XSSFSheet sheet = workbook.getSheetAt(0); + DataFormatter formatter = new DataFormatter(); + validateTemplate(sheet, formatter, errors); + if (!errors.isEmpty()) return result(safeFileName, resolvedMode, 0, 0, 0, 0, List.of(), errors); + for (int index = 3; index <= sheet.getLastRowNum(); index++) { + Row row = sheet.getRow(index); + if (isBlank(row, formatter)) continue; + if (rows.size() >= MAX_ROWS) { + errors.add(new WarehouseImportError(index + 1, "文件", "单次最多导入 1000 行")); + break; + } + rows.add(new ImportRow(index + 1, + value(row, 0, formatter), value(row, 1, formatter), value(row, 2, formatter), + value(row, 3, formatter), value(row, 4, formatter), value(row, 5, formatter), + value(row, 6, formatter), value(row, 7, formatter), value(row, 8, formatter), + value(row, 9, formatter), value(row, 10, formatter), value(row, 11, formatter), + value(row, 12, formatter), value(row, 13, formatter), value(row, 14, formatter))); + } + } catch (IOException | RuntimeException exception) { + return failure(safeFileName, resolvedMode, rows.size(), "文件", "文件格式无法读取,请使用标准 XLSX 模板"); + } + if (rows.isEmpty() && errors.isEmpty()) { + errors.add(new WarehouseImportError(0, "文件", "文件中没有可导入的数据行")); + } + + List plan = precheck(rows, errors, resolvedMode); + if (!errors.isEmpty()) { + return result(safeFileName, resolvedMode, rows.size(), 0, 0, 0, preview(plan), errors); + } + + int plannedCreates = (int) plan.stream().filter(row -> "CREATE".equals(row.action)).count(); + int plannedUpdates = (int) plan.stream().filter(row -> "UPDATE".equals(row.action)).count(); + int plannedUnchanged = (int) plan.stream().filter(row -> "UNCHANGED".equals(row.action)).count(); + if (dryRun) { + return result(safeFileName, resolvedMode, rows.size(), plannedCreates, plannedUpdates, plannedUnchanged, + false, preview(plan), List.of()); + } + + int created = 0; + int updated = 0; + int unchanged = 0; + try { + for (PlanRow row : plan) { + if ("CREATE".equals(row.action)) { + warehouseService.create(row.createRequest()); + created++; + } else if ("UPDATE".equals(row.action) && resolvedMode == ImportMode.UPSERT) { + warehouseService.update(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, row.existing().getId(), row.updateRequest()); + updated++; + } else if ("UNCHANGED".equals(row.action)) { + unchanged++; + } + } + } catch (DataIntegrityViolationException exception) { + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + return failure(safeFileName, resolvedMode, rows.size(), "仓库编码", "与现有仓库重复,整批数据未写入"); + } + return result(safeFileName, resolvedMode, rows.size(), created, updated, unchanged, true, preview(plan), List.of()); + } + + private List precheck(List rows, List errors, ImportMode mode) { + List plan = new ArrayList<>(); + for (ImportRow row : rows) { + validateRequired(row, rows, errors); + WarehouseRequests.SaveRequest createRequest = row.toCreateRequest(); + WarehouseSaveNormalizer.NormalizedSaveRequest normalized = null; + try { + normalized = WarehouseSaveNormalizer.normalize(createRequest, WarehouseSaveNormalizer.PUBLIC_TENANT_ID); + } catch (BizException exception) { + errors.add(new WarehouseImportError(row.row(), "时区", exception.getMessage())); + } + WarehouseEntity existing = row.code().isBlank() + ? null + : repository.findByCode(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, row.code()); + String action = existing == null ? "CREATE" : "UPDATE"; + String reason = existing == null ? "新增仓库" : "现有仓库存在差异"; + if (existing != null && normalized != null) { + if (unchanged(existing, firstAddress(existing), normalized)) { + action = "UNCHANGED"; + reason = "与现有仓库无变化"; + } else if (mode == ImportMode.CREATE_ONLY) { + action = "UNCHANGED"; + reason = "仅新增模式,现有仓库不修改"; + } + } + plan.add(new PlanRow(row, existing, normalized, action, reason)); + } + return plan; + } + + private static void validateTemplate(XSSFSheet sheet, DataFormatter formatter, List errors) { + if (!"模板版本".equals(value(sheet.getRow(0), 0, formatter)) + || !TEMPLATE_VERSION.equals(value(sheet.getRow(0), 1, formatter))) { + errors.add(new WarehouseImportError(1, "模板版本", "模板版本不匹配,请下载最新模板")); + } + for (int i = 0; i < HEADERS.length; i++) { + if (!HEADERS[i].equals(value(sheet.getRow(2), i, formatter))) { + errors.add(new WarehouseImportError(3, "表头", "第 " + (i + 1) + " 列应为“" + HEADERS[i] + "”")); + } + } + } + + private void validateRequired(ImportRow row, List rows, List errors) { + required(row, errors, "仓库编码", row.code(), 50); + if (row.name().isBlank() && !"FBA".equalsIgnoreCase(row.type())) { + errors.add(new WarehouseImportError(row.row(), "仓库名称", "不能为空;FBA 仓可不填并默认使用仓库编码")); + } else { + optionalLength(row, errors, "仓库名称", row.name(), 120); + } + if (!row.type().isBlank() && !row.type().matches("(?i)DOMESTIC|FBA|OVERSEAS")) { + errors.add(new WarehouseImportError(row.row(), "仓库类型", "只能填写 DOMESTIC、FBA 或 OVERSEAS")); + } + if (!row.countryCode().isBlank() && !row.countryCode().matches("(?i)[A-Z]{2}")) { + errors.add(new WarehouseImportError(row.row(), "国家二字码", "必须是两个英文字母")); + } + optionalLength(row, errors, "省/州", row.stateProvince(), 100); + required(row, errors, "城市", row.city(), 100); + optionalLength(row, errors, "区/县", row.district(), 100); + required(row, errors, "详细地址", row.addressLine(), 255); + optionalLength(row, errors, "邮政编码", row.postalCode(), 20); + optionalLength(row, errors, "时区", row.timezone(), 64); + if (!row.status().isBlank() && !row.status().matches("(?i)ACTIVE|INACTIVE")) { + errors.add(new WarehouseImportError(row.row(), "状态", "只能填写 ACTIVE 或 INACTIVE")); + } + optionalLength(row, errors, "联系人", row.contactName(), 64); + optionalLength(row, errors, "联系电话", row.contactPhone(), 32); + optionalLength(row, errors, "联系邮箱", row.contactEmail(), 128); + optionalLength(row, errors, "备注", row.remark(), 500); + for (ImportRow previous : rows) { + if (previous == row) break; + if (!row.code().isBlank() && previous.code().equalsIgnoreCase(row.code())) { + errors.add(new WarehouseImportError(row.row(), "仓库编码", "与文件内其他行重复")); + } + } + } + + private WarehouseAddressEntity firstAddress(WarehouseEntity existing) { + return repository.findAddresses(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, existing.getId()).stream() + .findFirst() + .orElse(null); + } + + private static boolean unchanged(WarehouseEntity existing, WarehouseAddressEntity address, + WarehouseSaveNormalizer.NormalizedSaveRequest normalized) { + return Objects.equals(existing.getWarehouseName(), normalized.warehouseName()) + && Objects.equals(existing.getWarehouseType(), normalized.warehouseType()) + && Objects.equals(existing.getCountryCode(), normalized.countryCode()) + && Objects.equals(existing.getTimezone(), normalized.timezone()) + && Objects.equals(existing.getStatus(), normalized.status()) + && address != null + && Objects.equals(address.getCountryCode(), normalized.address().countryCode()) + && Objects.equals(address.getStateProvince(), normalized.address().stateProvince()) + && Objects.equals(address.getCity(), normalized.address().city()) + && Objects.equals(address.getDistrict(), normalized.address().district()) + && Objects.equals(address.getPostalCode(), normalized.address().postalCode()) + && Objects.equals(address.getAddressLine1(), normalized.address().addressLine1()) + && Objects.equals(address.getAddressLine2(), normalized.address().addressLine2()); + } + + private static List preview(List plan) { + return plan.stream() + .map(row -> new WarehouseImportPreviewRow(row.source().row(), row.source().code(), row.action(), row.reason())) + .toList(); + } + + private static WarehouseImportResult failure(String fileName, ImportMode mode, int total, String field, String message) { + return result(fileName, mode, total, 0, 0, 0, List.of(), List.of(new WarehouseImportError(0, field, message))); + } + + private static WarehouseImportResult result(String fileName, ImportMode mode, int total, int created, int updated, + int unchanged, List preview, + List errors) { + return result(fileName, mode, total, created, updated, unchanged, false, preview, errors); + } + + private static WarehouseImportResult result(String fileName, ImportMode mode, int total, int created, int updated, + int unchanged, boolean committed, List preview, + List errors) { + return new WarehouseImportResult(fileName, mode, total, created, updated, unchanged, errors.isEmpty(), committed, preview, errors); + } + + private static void required(ImportRow row, List errors, String field, String value, int max) { + if (value.isBlank()) errors.add(new WarehouseImportError(row.row(), field, "不能为空")); + else optionalLength(row, errors, field, value, max); + } + + private static void optionalLength(ImportRow row, List errors, String field, String value, int max) { + if (value.length() > max) errors.add(new WarehouseImportError(row.row(), field, "不能超过 " + max + " 个字符")); + } + + private static String value(Row row, int index, DataFormatter formatter) { + if (row == null) return ""; + Cell cell = row.getCell(index); + return cell == null ? "" : formatter.formatCellValue(cell).trim(); + } + + private static boolean isBlank(Row row, DataFormatter formatter) { + if (row == null) return true; + for (int i = 0; i < HEADERS.length; i++) if (!value(row, i, formatter).isBlank()) return false; + return true; + } + + private static String safeFileName(String value) { + if (value == null || value.isBlank()) return "未命名文件.xlsx"; + String normalized = value.replace('\\', '/'); + return normalized.substring(normalized.lastIndexOf('/') + 1); + } + + private record PlanRow(ImportRow source, WarehouseEntity existing, + WarehouseSaveNormalizer.NormalizedSaveRequest normalized, + String action, String reason) { + WarehouseRequests.SaveRequest createRequest() { + return source.toCreateRequest(); + } + + WarehouseRequests.SaveRequest updateRequest() { + String status = "INACTIVE".equals(existing.getStatus()) ? existing.getStatus() : source.status(); + return source.toRequest(existing.getContactName(), existing.getContactPhone(), existing.getContactEmail(), + existing.getRemark(), status); + } + } + + private record ImportRow(int row, String code, String name, String type, String countryCode, String stateProvince, + String city, String district, String addressLine, String postalCode, String timezone, + String status, String contactName, String contactPhone, String contactEmail, String remark) { + WarehouseRequests.SaveRequest toCreateRequest() { + return toRequest(contactName, contactPhone, contactEmail, remark, status); + } + + WarehouseRequests.SaveRequest toRequest(String resolvedContactName, String resolvedContactPhone, + String resolvedContactEmail, String resolvedRemark, String resolvedStatus) { + String warehouseType = StringUtils.hasText(type) ? type.trim().toUpperCase(Locale.ROOT) : null; + String resolvedCountry = StringUtils.hasText(countryCode) ? countryCode.trim().toUpperCase(Locale.ROOT) : null; + String resolvedName = StringUtils.hasText(name) ? name : code; + return new WarehouseRequests.SaveRequest(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, code, resolvedName, warehouseType, + resolvedCountry, WarehouseSaveNormalizer.trimToNull(timezone), + WarehouseSaveNormalizer.trimToNull(resolvedContactName), + WarehouseSaveNormalizer.trimToNull(resolvedContactPhone), + WarehouseSaveNormalizer.trimToNull(resolvedContactEmail), + StringUtils.hasText(resolvedStatus) ? resolvedStatus.trim().toUpperCase(Locale.ROOT) : null, + WarehouseSaveNormalizer.trimToNull(resolvedRemark), + new WarehouseRequests.AddressRequest(null, resolvedCountry, stateProvince, city, district, postalCode, + addressLine, null, null, null), + List.of()); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java new file mode 100644 index 000000000..fe901037a --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java @@ -0,0 +1,162 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseRequests; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** 仓库轻量新增/导入共用默认规则。 */ +final class WarehouseSaveNormalizer { + static final long PUBLIC_TENANT_ID = 0L; + static final String TYPE_DOMESTIC = "DOMESTIC"; + static final String TYPE_FBA = "FBA"; + static final String STATUS_ACTIVE = "ACTIVE"; + static final String DEFAULT_ADDRESS_TYPE = "WAREHOUSE"; + static final String DEFAULT_DOMESTIC_COUNTRY = "CN"; + static final String DEFAULT_DOMESTIC_TIMEZONE = "UTC+8"; + private static final Map US_SINGLE_TIMEZONE_STATES = Map.ofEntries( + Map.entry("AL", "UTC-6"), Map.entry("ALABAMA", "UTC-6"), + Map.entry("AK", "UTC-9"), Map.entry("ALASKA", "UTC-9"), + Map.entry("AZ", "UTC-7"), Map.entry("ARIZONA", "UTC-7"), + Map.entry("CA", "UTC-8"), Map.entry("CALIFORNIA", "UTC-8"), + Map.entry("CO", "UTC-7"), Map.entry("COLORADO", "UTC-7"), + Map.entry("CT", "UTC-5"), Map.entry("CONNECTICUT", "UTC-5"), + Map.entry("DE", "UTC-5"), Map.entry("DELAWARE", "UTC-5"), + Map.entry("GA", "UTC-5"), Map.entry("GEORGIA", "UTC-5"), + Map.entry("HI", "UTC-10"), Map.entry("HAWAII", "UTC-10"), + Map.entry("IA", "UTC-6"), Map.entry("IOWA", "UTC-6"), + Map.entry("IL", "UTC-6"), Map.entry("ILLINOIS", "UTC-6"), + Map.entry("LA", "UTC-6"), Map.entry("LOUISIANA", "UTC-6"), + Map.entry("MA", "UTC-5"), Map.entry("MASSACHUSETTS", "UTC-5"), + Map.entry("MD", "UTC-5"), Map.entry("MARYLAND", "UTC-5"), + Map.entry("ME", "UTC-5"), Map.entry("MAINE", "UTC-5"), + Map.entry("MN", "UTC-6"), Map.entry("MINNESOTA", "UTC-6"), + Map.entry("MO", "UTC-6"), Map.entry("MISSOURI", "UTC-6"), + Map.entry("MS", "UTC-6"), Map.entry("MISSISSIPPI", "UTC-6"), + Map.entry("MT", "UTC-7"), Map.entry("MONTANA", "UTC-7"), + Map.entry("NC", "UTC-5"), Map.entry("NORTH CAROLINA", "UTC-5"), + Map.entry("NH", "UTC-5"), Map.entry("NEW HAMPSHIRE", "UTC-5"), + Map.entry("NJ", "UTC-5"), Map.entry("NEW JERSEY", "UTC-5"), + Map.entry("NM", "UTC-7"), Map.entry("NEW MEXICO", "UTC-7"), + Map.entry("NV", "UTC-8"), Map.entry("NEVADA", "UTC-8"), + Map.entry("NY", "UTC-5"), Map.entry("NEW YORK", "UTC-5"), + Map.entry("OH", "UTC-5"), Map.entry("OHIO", "UTC-5"), + Map.entry("OK", "UTC-6"), Map.entry("OKLAHOMA", "UTC-6"), + Map.entry("PA", "UTC-5"), Map.entry("PENNSYLVANIA", "UTC-5"), + Map.entry("RI", "UTC-5"), Map.entry("RHODE ISLAND", "UTC-5"), + Map.entry("SC", "UTC-5"), Map.entry("SOUTH CAROLINA", "UTC-5"), + Map.entry("VA", "UTC-5"), Map.entry("VIRGINIA", "UTC-5"), + Map.entry("VT", "UTC-5"), Map.entry("VERMONT", "UTC-5"), + Map.entry("WA", "UTC-8"), Map.entry("WASHINGTON", "UTC-8"), + Map.entry("WI", "UTC-6"), Map.entry("WISCONSIN", "UTC-6"), + Map.entry("WV", "UTC-5"), Map.entry("WEST VIRGINIA", "UTC-5"), + Map.entry("WY", "UTC-7"), Map.entry("WYOMING", "UTC-7") + ); + private static final Set US_MULTI_TIMEZONE_STATES = Set.of( + "FL", "FLORIDA", "ID", "IDAHO", "IN", "INDIANA", "KS", "KANSAS", "KY", "KENTUCKY", + "MI", "MICHIGAN", "ND", "NORTH DAKOTA", "NE", "NEBRASKA", "OR", "OREGON", + "SD", "SOUTH DAKOTA", "TN", "TENNESSEE", "TX", "TEXAS" + ); + + private WarehouseSaveNormalizer() { + } + + static NormalizedSaveRequest normalize(WarehouseRequests.SaveRequest request, long tenantId) { + String warehouseType = upperOrDefault(request.warehouseType(), TYPE_DOMESTIC); + if (!TYPE_DOMESTIC.equals(warehouseType) && !TYPE_FBA.equals(warehouseType) && !"OVERSEAS".equals(warehouseType)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "Warehouse type must be DOMESTIC, FBA or OVERSEAS / 仓库类型只能为 DOMESTIC、FBA 或 OVERSEAS"); + } + String status = upperOrDefault(request.status(), STATUS_ACTIVE); + if (!STATUS_ACTIVE.equals(status) && !"INACTIVE".equals(status)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "Warehouse status must be ACTIVE or INACTIVE / 仓库状态只能为 ACTIVE 或 INACTIVE"); + } + WarehouseRequests.AddressRequest address = request.address(); + if (address == null) { + throw new BizException(ResultCode.VALIDATION_FAILED, "Primary address is required / 主地址不能为空"); + } + String countryCode = upperOrDefault(firstText(request.countryCode(), address.countryCode()), + TYPE_DOMESTIC.equals(warehouseType) ? DEFAULT_DOMESTIC_COUNTRY : null); + if (!StringUtils.hasText(countryCode)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "Country code is required / 国家二字码不能为空"); + } + if (!countryCode.matches("[A-Z]{2}")) { + throw new BizException(ResultCode.VALIDATION_FAILED, "Country code must be two uppercase letters / 国家二字码必须为两个大写字母"); + } + String timezone = resolveTimezone(warehouseType, countryCode, request.timezone(), address.stateProvince()); + WarehouseRequests.AddressRequest normalizedAddress = new WarehouseRequests.AddressRequest( + upperOrDefault(address.addressType(), DEFAULT_ADDRESS_TYPE), + upperOrDefault(address.countryCode(), countryCode), + trimToNull(address.stateProvince()), address.city(), trimToNull(address.district()), + trimToNull(address.postalCode()), address.addressLine1(), trimToNull(address.addressLine2()), + address.latitude(), address.longitude()); + List capabilities = TYPE_FBA.equals(warehouseType) + ? List.of() + : request.capabilities(); + return new NormalizedSaveRequest(tenantId, request.warehouseCode().trim(), request.warehouseName().trim(), + warehouseType, countryCode, timezone, trimToNull(request.contactName()), trimToNull(request.contactPhone()), + trimToNull(request.contactEmail()), status, trimToNull(request.remark()), normalizedAddress, capabilities); + } + + static String resolveTimezone(String warehouseType, String countryCode, String explicitTimezone, String stateProvince) { + if (StringUtils.hasText(explicitTimezone)) { + return explicitTimezone.trim(); + } + if (TYPE_DOMESTIC.equals(warehouseType) && DEFAULT_DOMESTIC_COUNTRY.equals(countryCode)) { + return DEFAULT_DOMESTIC_TIMEZONE; + } + if (TYPE_FBA.equals(warehouseType) && "US".equals(countryCode)) { + String stateKey = normalizeState(stateProvince); + if (US_MULTI_TIMEZONE_STATES.contains(stateKey)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "FBA warehouse in multi-timezone state requires explicit timezone / 多时区州的 FBA 仓需要显式确认时区"); + } + String timezone = US_SINGLE_TIMEZONE_STATES.get(stateKey); + if (timezone != null) { + return timezone; + } + } + throw new BizException(ResultCode.VALIDATION_FAILED, + "Timezone is required unless a confirmed default rule applies / 未命中明确默认规则时必须显式填写时区"); + } + + static String trimToNull(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + + private static String firstText(String left, String right) { + return StringUtils.hasText(left) ? left : right; + } + + private static String upperOrDefault(String value, String defaultValue) { + String resolved = StringUtils.hasText(value) ? value.trim() : defaultValue; + return resolved == null ? null : resolved.toUpperCase(Locale.ROOT); + } + + private static String normalizeState(String value) { + return StringUtils.hasText(value) + ? value.trim().replace(".", "").toUpperCase(Locale.ROOT) + : ""; + } + + record NormalizedSaveRequest( + long tenantId, + String warehouseCode, + String warehouseName, + String warehouseType, + String countryCode, + String timezone, + String contactName, + String contactPhone, + String contactEmail, + String status, + String remark, + WarehouseRequests.AddressRequest address, + List capabilities + ) { + } +} diff --git a/warehouse/src/main/resources/db/master-data/fba-public-addresses.accepted.csv b/warehouse/src/main/resources/db/master-data/fba-public-addresses.accepted.csv new file mode 100644 index 000000000..5f4846717 --- /dev/null +++ b/warehouse/src/main/resources/db/master-data/fba-public-addresses.accepted.csv @@ -0,0 +1,253 @@ +source_row,warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,country_code,state_province,city,postal_code,timezone,address_line1,source_internal_id +238,193000000000000001,193000000000100001,ABE2,ABE2,US,PA,Breinigsville,18031,UTC-5,705 Boulder Drive, +237,193000000000000002,193000000000100002,ABE3,ABE3,US,PA,Breinigsville,18031,UTC-5,650 Boulder Drive, +134,193000000000000003,193000000000100003,ABE4,ABE4,US,PA,Easton,18045,UTC-5,1610 Van Buren Road,126 +231,193000000000000004,193000000000100004,ABE5,ABE5,US,PA,Harrisburg,17112,UTC-5,6455 Allentown Boulevard, +125,193000000000000005,193000000000100005,ABE8,ABE8,US,NJ,Florence,08518,UTC-5,401 Independence Road 08518-2200,39 +99,193000000000000006,193000000000100006,ABQ2,ABQ2,US,NM,LOS LUNAS,87031,UTC-7,6251 Pioneer Trail NW,76 +84,193000000000000007,193000000000100007,ACY2,ACY2,US,NJ,BURLINGTON,08016,UTC-5,1101 E PEARL ST,101 +141,193000000000000008,193000000000100008,AFW1,AFW1,US,TX,FORT WORTH,76131,UTC-6,1851 NE LOOP 820, +27,193000000000000009,193000000000100009,AFW5,AFW5,US,TX,BALCH SPRINGS,75180-1636,UTC-6,12374 LAKE JUNE RD,181 +148,193000000000000010,193000000000100010,AKR1,AKR1,US,OH,Canton,44705,UTC-5,4747 Rebar Ave NE,153 +174,193000000000000011,193000000000100011,ALB1,ALB1,US,NY,Castleton,12033-9634,UTC-5,1835 US Route 9,102 +129,193000000000000012,193000000000100012,AMA1,AMA1,US,TX,Amarillo,79108,UTC-6,8590 NE 24th Avenue,132 +31,193000000000000013,193000000000100013,ATL2N,ATL2N,US,GA,Carrollton,30117,UTC-5,3101 N. Highway 27,157 +147,193000000000000014,193000000000100014,ATL3,ATL3,US,GA,Pendergrass,30567,UTC-5,445 Valentine Industrial Parkway, +202,193000000000000015,193000000000100015,ATL6,ATL6,US,GA,East Point,30344,UTC-5,4200 North Commerce, +233,193000000000000016,193000000000100016,AVP1,AVP1,US,PA,Hazle Township,18202-9361,UTC-5,550 Oak Ridge Road,70 +232,193000000000000017,193000000000100017,AVP3,AVP3,US,PA,GOULDSBORO,18424-9492,UTC-5,298 1ST AVE, +289,193000000000000018,193000000000100018,BDL1,BDL1,US,CT,Windsor,06095,UTC-5,801 Day Hill Rd., +208,193000000000000019,193000000000100019,BFI1,BFI1,US,WA,Sumner,98390,UTC-8,1800 140th Avenue E., +207,193000000000000020,193000000000100020,BFI3,BFI3,US,WA,Dupont,98327-9607,UTC-8,2700 Center Drive,34 +206,193000000000000021,193000000000100021,BFI7,BFI7,US,WA,Sumner,98390,UTC-8,1901 140th Ave E, +11,193000000000000022,193000000000100022,BJC1,BJC1,US,CO,AURORA,80019,UTC-7,6300 N Powhaton Road,186 +221,193000000000000023,193000000000100023,BNA1,BNA1,US,TN,Lebanon,37090,UTC-6,14840 Central Pike Suite 190, +131,193000000000000024,193000000000100024,BNA2,BNA2,US,TN,Lebanon,37090,UTC-6,500 Duke DR,37 +140,193000000000000025,193000000000100025,BNA3,BNA3,US,TN,Murfreesboro,37127,UTC-6,2020 Joe B Jackson Pkwy, +220,193000000000000026,193000000000100026,BNA4,BNA4,US,TN,Nashville,37217,UTC-6,50 airways Blvd, +110,193000000000000027,193000000000100027,BNA6,BNA6,US,TN,CLARKSVILLE,37040-5502,UTC-6,3875 GUTHRIE HWY,99 +243,193000000000000028,193000000000100028,BOS1,BOS1,US,NH,Nashua,03063,UTC-5,10 State St., +193,193000000000000029,193000000000100029,BOS2,BOS2,US,MA,Drive Stoughton,02072,UTC-5,1000 Tech Centre, +192,193000000000000030,193000000000100030,BOS7,BOS7,US,MA,Fall River,02720,UTC-5,1180 Innovation Way,103 +242,193000000000000031,193000000000100031,BWI1,BWI1,US,VA,Sterling,20166-2058,UTC-5,45121 Global Plaza, +241,193000000000000032,193000000000100032,BWI2,BWI2,US,MD,Baltimore,21224,UTC-5,2010 Broening Hwy, +102,193000000000000033,193000000000100033,BWI4,BWI4,US,VA,CLEARBROOK,22624,UTC-5,165 Business Blvd,150 +225,193000000000000034,193000000000100034,CAE1,CAE1,US,SC,West Columbia,29172,UTC-5,4400 12TH STREET EXT, +223,193000000000000035,193000000000100035,CHA1,CHA1,US,TN,Chattanooga,37416,UTC-5,7200 Discovery Drive, +222,193000000000000036,193000000000100036,CHA2,CHA2,US,TN,Charleston,37310,UTC-5,225 Infinity Dr NW,66 +75,193000000000000037,193000000000100037,CHO1,CHO1,US,VA,FISHERSVILLE,22939,UTC-5,32 TRADER RD,120 +191,193000000000000038,193000000000100038,CLT2,CLT2,US,NC,Charlotte,28214-8082,UTC-5,10240 Old Dowd Rd,59 +114,193000000000000039,193000000000100039,CLT3,CLT3,US,NC,CONCORD,28027,UTC-5,6500 Davidson Hwy,104 +240,193000000000000040,193000000000100040,CMH2,CMH2,US,OH,Groveport,43125-9016,UTC-5,6050 Gateway Court,123 +181,193000000000000041,193000000000100041,CMH3,CMH3,US,OH,Monroe,45050-1848,UTC-5,700 GATEWAY BLVD,32 +262,193000000000000042,193000000000100042,CVG1,CVG1,US,KY,Hebron,41048,UTC-5,1155 Worldwide Blvd, +261,193000000000000043,193000000000100043,CVG2,CVG2,US,KY,Hebron,41048,UTC-5,1600 Worldwide Blvd, +260,193000000000000044,193000000000100044,CVG3,CVG3,US,KY,Hebron,41048,UTC-5,3680 Langley Dr., +259,193000000000000045,193000000000100045,CVG5,CVG5,US,KY,Hebron,41048,UTC-5,2285 Litton Lane, +135,193000000000000046,193000000000100046,DCA6,DCA6,US,MD,BALTIMORE,21219,UTC-5,6001 Bethlehem Blvd,135 +123,193000000000000047,193000000000100047,DEN2,DEN2,US,CO,Aurora,80019,UTC-7,22205 East 19th Ave,107 +120,193000000000000048,193000000000100048,DEN8,DEN8,US,CO,Aurora,80018,UTC-7,21000 E 13th Ave,73 +189,193000000000000049,193000000000100049,DET1,DET1,US,MI,Livonia,48150-1543,UTC-5,39000 Amrhein Road,118 +182,193000000000000050,193000000000100050,DET2,DET2,US,MI,Shelby Township,48317-1318,UTC-5,50500 Mound Rd,136 +143,193000000000000051,193000000000100051,DFW5S,DFW5S,US,TX,Lancaster,75146,UTC-6,"2500 E Belt Line Rd,", +219,193000000000000052,193000000000100052,DFW6,DFW6,US,TX,Coppell,75019,UTC-6,940 W Bethel Road,105 +217,193000000000000053,193000000000100053,DFW8,DFW8,US,TX,DFW Airport,75261,UTC-6,2700 Regent Blvd, +256,193000000000000054,193000000000100054,EWR4,EWR4,US,NJ,Robbinsville,08691,UTC-5,50 New Canton Way, +255,193000000000000055,193000000000100055,EWR5,EWR5,US,NJ,Avenel,07001,UTC-5,301 Blair Rd., +254,193000000000000056,193000000000100056,EWR7,EWR7,US,NJ,Avenel,07001,UTC-5,301 Blair Rd., +253,193000000000000057,193000000000100057,EWR9,EWR9,US,NJ,Carteret,07008,UTC-5,8003 Industrial Parkway, +117,193000000000000058,193000000000100058,FAT2,FAT2,US,CA,VISALIA,93291-1882,UTC-8,3315 N KELSEYST,43 +176,193000000000000059,193000000000100059,FOE1,FOE1,US,KS,KANSAS CITY,66109-3665,UTC-6,9400 LEAVENWORTH RD,117 +212,193000000000000060,193000000000100060,FTW1,FTW1,US,TX,Dallas,75241,UTC-6,33333 Lyndon B Johnson FREEWAY,24 +211,193000000000000061,193000000000100061,FTW2,FTW2,US,TX,Coppell,75019,UTC-6,2701 West Bethel Road, +89,193000000000000062,193000000000100062,FTW3,FTW3,US,TX,Fort Worth,76177-2517,UTC-6,15201 Heritage Pkwy, +121,193000000000000063,193000000000100063,FTW5,FTW5,US,TX,Forney,75126,UTC-6,1475 AKRON WAY,48 +171,193000000000000064,193000000000100064,FWA4,FWA4,US,IN,FORT WAYNE,46809-9771,UTC-5,9798 Smith Road,58 +151,193000000000000065,193000000000100065,GEG2,GEG2,US,WA,SPOKANE,99216,UTC-8,18007 E Garland Ave,108 +67,193000000000000066,193000000000100066,GEU2,GEU2,US,AZ,Glendale,85307,UTC-7,15301 West Northern Avenue,97 +78,193000000000000067,193000000000100067,GEU3,GEU3,US,AZ,BUCKEYE,85396,UTC-7,18900 W McDowell Road,81 +68,193000000000000068,193000000000100068,GEU5,GEU5,US,AZ,GOODYEAR,85395,UTC-7,4660 North Cotton Lane,100 +164,193000000000000069,193000000000100069,GSO1,GSO1,US,NC,KERNERSVILLE,27284,UTC-5,1656 OLD GREENSBORO RD,131 +224,193000000000000070,193000000000100070,GSP1,GSP1,US,SC,Spartanburg,29303,UTC-5,402 John Dodd Rd,90 +188,193000000000000071,193000000000100071,GYR2,GYR2,US,AZ,GOODYEAR,85395-7672,UTC-7,17341 W MINNEZONA AVE,7 +187,193000000000000072,193000000000100072,GYR3,GYR3,US,AZ,PHOENIX,85043-2356,UTC-7,8181 W ROOSEVELT ST,8 +80,193000000000000073,193000000000100073,HGR6,HGR6,US,MD,HAGERSTOWN,21740-7301,UTC-5,55 West Oak Ridge Drive,130 +87,193000000000000074,193000000000100074,HIA1,HIA1,US,PA,MIDDLETOWN,17057,UTC-5,3327 E Harrisburg Pik,148 +44,193000000000000075,193000000000100075,HLI2,HLI2,US,CA,HOLLISTER,95023,UTC-8,600 Ernie Drive,137 +216,193000000000000076,193000000000100076,HOU1,HOU1,US,TX,Humble,77338,UTC-6,8120 Humble Westfield Rd, +215,193000000000000077,193000000000100077,HOU2,HOU2,US,TX,Houston,77038-2324,UTC-6,10550 Ella Blvd., +214,193000000000000078,193000000000100078,HOU3,HOU3,US,TX,Brookshire,77423,UTC-6,31555 Highway 90 E, +154,193000000000000079,193000000000100079,HOU8,HOU8,US,TX,FRESNO,77545,UTC-6,2303 Hurricane Lane,49 +142,193000000000000080,193000000000100080,HSV1,HSV1,US,AL,Madison,35756,UTC-6,7817 Greenbrier Road,80 +126,193000000000000081,193000000000100081,HSV2,HSV2,US,AL,MADISON,35756,UTC-6,28869 Fanning Dr NW, +155,193000000000000082,193000000000100082,IAH3,IAH3,US,TX,Houston,77032,UTC-6,15525 Milner Road,57 +3,193000000000000083,193000000000100083,IAZ1,IAZ1,US,AZ,GOODYEAR,85395-4515,UTC-7,"4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States",179 +136,193000000000000084,193000000000100084,ICT2,ICT2,US,KS,PARK CITY,67219,UTC-6,7130 N BROADWAY AVE,109 +2,193000000000000085,193000000000100085,IGA3,IGA3,US,GA,MACON,31216-6427,UTC-5,"7001 Skipper Rd 31216-6427 MACON, GA - United States", +152,193000000000000086,193000000000100086,IGQ2,IGQ2,US,IL,University park,60484,UTC-6,23257 S. Central Avenue,110 +119,193000000000000087,193000000000100087,ILG1,ILG1,US,DE,NEW CASTLE,19720,UTC-5,780 S. DuPont Highway,35 +10,193000000000000088,193000000000100088,IMO1,IMO1,US,MO,LIBERTY,64068-8933,UTC-6,2361 S Withers RD,178 +6,193000000000000089,193000000000100089,IMS1,IMS1,US,MS,OLIVE BRANCH,38654,UTC-6,IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US,189 +5,193000000000000090,193000000000100090,INC1,INC1,US,NC,FAYETTEVILLE,28303-2215,UTC-5,"6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States",190 +88,193000000000000091,193000000000100091,IND2,IND2,US,IN,Plainfield,46168-7442,UTC-5,715 Airtech Pkwy,127 +276,193000000000000092,193000000000100092,IND4,IND4,US,IN,Indianapolis,46231,UTC-5,710 S. Girls School Rd, +275,193000000000000093,193000000000100093,IND5,IND5,US,IN,Plainfield,46168,UTC-5,800 Perry Road,140 +274,193000000000000094,193000000000100094,IND6,IND6,US,IN,Jeffersonville,47130,UTC-5,1453 E 10th St, +173,193000000000000095,193000000000100095,IND9,IND9,US,IN,GREENWOOD,46143-7830,UTC-5,1151 S GRAHAM RD,17 +9,193000000000000096,193000000000100096,ITX3,ITX3,US,TX,AMARILLO,79108-7749,UTC-6,"ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US",187 +22,193000000000000097,193000000000100097,IUSF,IUSF,US,TX,DALLAS,75241,UTC-6,3351 Balmorhea Dr,169 +69,193000000000000098,193000000000100098,IUSJ,IUSJ,US,CA,BEAUMONT,92223,UTC-8,36900 West 4th Street,98 +74,193000000000000099,193000000000100099,IUSP,IUSP,US,CA,"HESPERIA,",92344,UTC-8,8140 Caliente Rd,83 +70,193000000000000100,193000000000100100,IUSQ,IUSQ,US,CA,REDLANDS,92374,UTC-8,2125 W San Bernardino Ave,95 +40,193000000000000101,193000000000100101,IUSR,IUSR,US,SC,COWPENS,29330,UTC-5,1120 Mt Olive Rd,158 +39,193000000000000102,193000000000100102,IUST,IUST,US,PA,GREENCASTLE,17225,UTC-5,"3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States",149 +33,193000000000000103,193000000000100103,IUSW,IUSW,US,CA,Hesperia,92344,UTC-8,"8130 Caliente Rd 92344 - Hesperia, CA - United States",156 +21,193000000000000104,193000000000100104,IUTE,IUTE,US,AZ,GOODYEAR,85338,UTC-7,16155 W Elwood St,170 +8,193000000000000105,193000000000100105,IUTJ,IUTJ,US,CA,Fontana,92335,UTC-8,"9253 Dreamland Drive 92335 - Fontana, CA - United",177 +7,193000000000000106,193000000000100106,IUTL,IUTL,US,CA,Moreno Valley,92551,UTC-8,"24405 Krameria Ave 92551 - Moreno Valley, CA - United States",188 +4,193000000000000107,193000000000100107,IWA6,IWA6,US,WA,PASCO,99301-6161,UTC-8,"1202 S Road 40 E 99301-6161 - PASCO, WA - United States",191 +72,193000000000000108,193000000000100108,JOT1,JOT1,US,CA,SAN BERNARDINO,92408,UTC-8,1494 S WATERMAN AVE,93 +150,193000000000000109,193000000000100109,JVL1,JVL1,US,WI,BELOIT,53511,UTC-6,1255 Gateway Blvd,125 +178,193000000000000110,193000000000100110,KRB4,KRB4,US,CA,PERRIS,92571-9722,UTC-8,4501 PATTERSON AVE, +161,193000000000000111,193000000000100111,KY1,KY1,US,KY,Shepherdsville,40165,UTC-5,"120 Velocity Way.,",60 +100,193000000000000112,193000000000100112,LAN2,LAN2,US,MI,LANSING,48917,UTC-5,6500 W Mt Hope Hwy,96 +247,193000000000000113,193000000000100113,LAS1,LAS1,US,NV,HENDERSON,89044,UTC-8,12300 Bermuda Road,5 +246,193000000000000114,193000000000100114,LAS2,LAS2,US,NV,Las Vegas,89030,UTC-8,"3837 Bay Lake Trail, Suite 111 North", +132,193000000000000115,193000000000100115,LAS6,LAS6,US,NV,Las Vegas,89115,UTC-8,4550 Nexus Way,50 +162,193000000000000116,193000000000100116,LAX2T,LAX2T,US,CA,CHINO,91708,UTC-8,6730 Kimball Ave,19 +183,193000000000000117,193000000000100117,LAX9,LAX9,US,CA,FONTANA,92337,UTC-8,11263 Oleander Ave,3 +86,193000000000000118,193000000000100118,LBE1,LBE1,US,PA,Greensburg,15605,UTC-5,165 Glen Fox Road,112 +268,193000000000000119,193000000000100119,LEX1,LEX1,US,KY,Lexington,40511,UTC-5,1850 Mercer RD, +267,193000000000000120,193000000000100120,LEX2,LEX2,US,KY,Lexington,40511,UTC-5,172 Trade St., +107,193000000000000121,193000000000100121,LFT1,LFT1,US,LA,CARENCRO,70520-5976,UTC-6,3550 NE EVANGELINE TRWY,31 +291,193000000000000122,193000000000100122,LGB3,LGB3,US,CA,Eastvale,91752,UTC-8,4950 Goodman Way, +292,193000000000000123,193000000000100123,LGB4,LGB4,US,CA,Redlands,92374,UTC-8,27517 Pioneer Avenue,45 +293,193000000000000124,193000000000100124,LGB6,LGB6,US,CA,Riverside,92518-1513,UTC-8,20901 Krameria Ave,26 +294,193000000000000125,193000000000100125,LGB8,LGB8,US,CA,Rialto,92376,UTC-8,1568 N Linden Ave,2 +106,193000000000000126,193000000000100126,LIT2,LIT2,US,AR,NORTH LITTLE ROCK,72117,UTC-6,13001 HIGHWAY 70,159 +32,193000000000000127,193000000000100127,MCC1,MCC1,US,CA,RANCHO CORDOVA,95742,UTC-8,"3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States",161 +157,193000000000000128,193000000000100128,MCE1,MCE1,US,CA,TURLOCK,95380,UTC-8,FULKERTH RD,65 +271,193000000000000129,193000000000100129,MCI1,MCI1,US,KS,Lenexa,66219,UTC-6,113th St & Renner Blvd., +12,193000000000000130,193000000000100130,MCI4,MCI4,US,MO,KANSAS CITY,64163,UTC-6,MCI4 - 10501 NW 136th Street,176 +115,193000000000000131,193000000000100131,MCO2,MCO2,US,FL,Deltona,32725-2610,UTC-5,2600 N Normandy Blvd,106 +230,193000000000000132,193000000000100132,MDT1,MDT1,US,PA,Carlisle,17015,UTC-5,2Ames Drive,121 +128,193000000000000133,193000000000100133,MDT4,MDT4,US,PA,York,17408,UTC-5,5125 Commerce Drive,144 +200,193000000000000134,193000000000100134,MDW2,MDW2,US,IL,Joliet,60433-3280,UTC-6,250 Emerald Drive,46 +199,193000000000000135,193000000000100135,MDW6,MDW6,US,IL,ROMEOVILLE,60446-6529,UTC-6,1125 W REMINGTON BLVD ROMEOVILLE,143 +198,193000000000000136,193000000000100136,MDW7,MDW7,US,IL,Monee,60449,UTC-6,6605 Monee Manhattan Road, +197,193000000000000137,193000000000100137,MDW8,MDW8,US,IL,Waukegan,60085,UTC-6,1750 Bridge Drive, +196,193000000000000138,193000000000100138,MDW9,MDW9,US,IL,Aurora,60502,UTC-6,2865 Duke Parkway,36 +163,193000000000000139,193000000000100139,MEM1,MEM1,US,TN,Memphis,38118,UTC-6,3292 E Holmes Rd,85 +118,193000000000000140,193000000000100140,MEM6,MEM6,US,MS,OLIVE BRANCH,38654,UTC-6,11505 Progress Way,51 +201,193000000000000141,193000000000100141,MGE3,MGE3,US,GA,Jefferson,30549-2010,UTC-5,808 Hog Mountain Road,133 +77,193000000000000142,193000000000100142,MIT2,MIT2,US,CA,SHAFTER,93263,UTC-8,5408 Express Avenue,82 +270,193000000000000143,193000000000100143,MKC4,MKC4,US,KS,Edgerton,66021-9588,UTC-6,19645 Waverly Rd,67 +269,193000000000000144,193000000000100144,MKC6,MKC6,US,KS,Kansas City,66102,UTC-6,6925 Riverview Avenue, +203,193000000000000145,193000000000100145,MKE1,MKE1,US,WI,Kenosha,53144,UTC-6,3501 120th Ave, +94,193000000000000146,193000000000100146,MQJ1,MQJ1,US,IN,GREENFIELD,46140,UTC-5,4412 W 300 RD N,88 +190,193000000000000147,193000000000100147,MSP1,MSP1,US,MN,Shakopee,55379,UTC-6,2601 4th Avenue East, +17,193000000000000148,193000000000100148,NJ3,NJ3,US,NJ,Pedricktown,08067,UTC-5,3 Sorbello Way,175 +299,193000000000000149,193000000000100149,OAK3,OAK3,US,CA,Patterson,95363-8876,UTC-8,255 Park Center Drive,41 +300,193000000000000150,193000000000100150,OAK4,OAK4,US,CA,Tracy,95304-9370,UTC-8,1555 N. Chrisman Rd, +133,193000000000000151,193000000000100151,OKC2,OKC2,US,OK,OKLAHOMA CITY,73159,UTC-6,8991 S PORTLAND AVENUE,33 +19,193000000000000152,193000000000100152,OLM1,OLM1,US,WA,OLYMPIA,98516,UTC-8,"3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States",172 +303,193000000000000153,193000000000100153,ONT2,ONT2,US,CA,San Bernardino,92408,UTC-8,1910 E Central Ave, +302,193000000000000154,193000000000100154,ONT6,ONT6,US,CA,Moreno Valley,92551-9561,UTC-8,24208 San Michele Rd, +301,193000000000000155,193000000000100155,ONT8,ONT8,US,CA,Moreno Valley,92551-9534,UTC-8,24300 Nandina Ave,1 +298,193000000000000156,193000000000100156,ONT9,ONT9,US,CA,Redlands,92374,UTC-8,2125 W. San Bernandino Ave,25 +103,193000000000000157,193000000000100157,ORD2,ORD2,US,IL,CHANNAHON,60410-8758,UTC-6,23714 W Amoco Rd,21 +195,193000000000000158,193000000000100158,ORD6,ORD6,US,IL,Wood Dale,60191,UTC-6,1250 N Mittel Blvd, +113,193000000000000159,193000000000100159,ORF2,ORF2,US,VA,CHESAPEAKE,23321-1425,UTC-5,ORF2 - 5045 PORTSMOUTH BLVD,38 +81,193000000000000160,193000000000100160,PBI3,PBI3,US,FL,Jacksonville,32206,UTC-5,21 Jump Street,113 +127,193000000000000161,193000000000100161,PDX7,PDX7,US,OR,Salem,97317,UTC-8,4775 Depot Ct SE,68 +288,193000000000000162,193000000000100162,PHL1,PHL1,US,DE,New Castle,19720,UTC-5,1 Centerpoint Blvd., +287,193000000000000163,193000000000100163,PHL3,PHL3,US,DE,New Castle,19720,UTC-5,1600 Johnson Way, +236,193000000000000164,193000000000100164,PHL4,PHL4,US,PA,Carlisle,17015,UTC-5,21 Roadway Dr,52 +235,193000000000000165,193000000000100165,PHL5,PHL5,US,PA,Lewisberry,17339,UTC-5,500 McCarthy Dr, +20,193000000000000166,193000000000100166,PHL5S,PHL5S,US,PA,Greencastle,17225,UTC-5,"1915 Ebberts Spg Ct, Greencastle, PA 17225",171 +234,193000000000000167,193000000000100167,PHL6,PHL6,US,PA,Carlisle,17015,UTC-5,675 Allen Rd.,69 +286,193000000000000168,193000000000100168,PHL7,PHL7,US,DE,Middletown,19709,UTC-5,560 Merrimac Ave, +285,193000000000000169,193000000000100169,PHL8,PHL8,US,DE,Middletown,19709,UTC-5,727 N Broad St, +307,193000000000000170,193000000000100170,PHX3,PHX3,US,AZ,Phoenix,85043,UTC-7,6835 West Buckeye Road, +306,193000000000000171,193000000000100171,PHX5,PHX5,US,AZ,Goodyear,85338,UTC-7,16920 W Commerce Drive,72 +305,193000000000000172,193000000000100172,PHX6,PHX6,US,AZ,Phoenix,85043,UTC-7,4750 West Mohave St, +304,193000000000000173,193000000000100173,PHX7,PHX7,US,AZ,Phoenix,85043,UTC-7,800 N 75th Ave,74 +229,193000000000000174,193000000000100174,PIT1,PIT1,US,PA,Pittsburgh,15205,UTC-5,2250 Roswell Drive, +158,193000000000000175,193000000000100175,PIT2,PIT2,US,PA,IMPERIAL,15126-1002,UTC-5,1200 Westport Rd,78 +46,193000000000000176,193000000000100176,POC1,POC1,US,CA,FONTANA,92336-1123,UTC-8,4121 LYTLE CREEK RD,128 +38,193000000000000177,193000000000100177,POC2,POC2,US,CA,ONTARIO,91761,UTC-8,4000 South Hamner Avenue,151 +45,193000000000000178,193000000000100178,POC3,POC3,US,CA,JURUPA VALLEY,92509,UTC-8,6120 Clinker Drive,129 +49,193000000000000179,193000000000100179,PPO4,PPO4,US,IN,MERRILLVILLE,46410,UTC-6,9850 Mississippi Street,122 +93,193000000000000180,193000000000100180,PSC2,PSC2,US,WA,PASCO,99301,UTC-8,1351 S Road 40 E,75 +28,193000000000000181,193000000000100181,PSP3,PSP3,US,CA,DESERT HOT SPRINGS,92240,UTC-8,64165 19th AVE,162 +71,193000000000000182,193000000000100182,QXY5,QXY5,US,CA,STOCKTON,95215,UTC-8,4532 NEWCASTLE RD,92 +35,193000000000000183,193000000000100183,QXY8,QXY8,US,CA,FRESNO,93725-1962,UTC-8,3611 S NORTHPOINTE DR,154 +14,193000000000000184,193000000000100184,QZZ7,QZZ7,US,AZ,MESA,85212-9608,UTC-7,8560 E ELLIOT RD,184 +146,193000000000000185,193000000000100185,RDU2,RDU2,US,NC,Smithfield,27577,UTC-5,2150 US HWY 70 Business West,56 +95,193000000000000186,193000000000100186,RDU4,RDU4,US,NC,FAYETTEVILLE,28303,UTC-5,6309 Bragg Blvd,145 +145,193000000000000187,193000000000100187,RFD2,RFD2,US,IL,Huntley,60142,UTC-6,11500 Freeman Road,71 +16,193000000000000188,193000000000100188,RFD4,RFD4,US,IL,HUNTLEY,60142,UTC-6,"11400 Venture Court 60142-8075 - HUNTLEY, IL - United States",174 +210,193000000000000189,193000000000100189,RIC1,RIC1,US,VA,Petersburg,23803,UTC-5,5000 Commerce Way,142 +209,193000000000000190,193000000000100190,RIC2,RIC2,US,VA,Chester,23836,UTC-5,1901 Meadowville Technology Pkwy, +144,193000000000000191,193000000000100191,RMN3,RMN3,US,VA,Fredericksburg,22406,UTC-5,220 Centreport Parkway,84 +248,193000000000000192,193000000000100192,RNO1,RNO1,US,NV,Fernley,89408,UTC-8,1600 East Newlands RD, +245,193000000000000193,193000000000100193,RNO2,RNO2,US,NV,Reno,85906,UTC-8,8000 North Virginia Street, +244,193000000000000194,193000000000100194,RNO4,RNO4,US,NV,Reno,89506,UTC-8,8000 North Virginia Street, +73,193000000000000195,193000000000100195,RYY2,RYY2,US,GA,White,30184,UTC-5,"1250 Cassville White Road,",94 +218,193000000000000196,193000000000100196,SAT1,SAT1,US,TX,Schertz,78154-1461,UTC-6,6000 Enterprise Avenue,23 +149,193000000000000197,193000000000100197,SAT4,SAT4,US,TX,SAN ANTONIO,78245,UTC-6,10384 W US HIGHWAY 90,61 +15,193000000000000198,193000000000100198,SAT6,SAT6,US,TX,SAN MARCOS,78666,UTC-6,"1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States",183 +98,193000000000000199,193000000000100199,SAV3,SAV3,US,GA,Macon,31216-6427,UTC-5,7001 Skipper Rd,124 +185,193000000000000200,193000000000100200,SBD1,SBD1,US,CA,BLOOMINGTON,92316-3819,UTC-8,3388 S Cactus Ave,4 +179,193000000000000201,193000000000100201,SBD2,SBD2,US,CA,SAN BERNARDINO,92408-2805,UTC-8,1494 S WATERMAN AVE,15 +97,193000000000000202,193000000000100202,SCK1,SCK1,US,CA,STOCKTON,95215,UTC-8,4532 NEWCASTLE RD,20 +60,193000000000000203,193000000000100203,SCK3,SCK3,US,CA,MANTECA,95336,UTC-8,3565 N AIRPORT WAY MANTECA,54 +186,193000000000000204,193000000000100204,SCK4,SCK4,US,CA,STOCKTON,95215-8354,UTC-8,6001 S AUSTIN RD,10 +104,193000000000000205,193000000000100205,SCK8,SCK8,US,CA,OAKLEY,94561-2677,UTC-8,4700 WILBUR AVE,141 +266,193000000000000206,193000000000100206,SDF1,SDF1,US,KY,Campbellsville,42718,UTC-5,1050 South Columbia AVE, +265,193000000000000207,193000000000100207,SDF2,SDF2,US,KY,Watterson Park,40218,UTC-5,4360 Robards Ln, +264,193000000000000208,193000000000100208,SDF4,SDF4,US,KY,Shepherdsville,40165,UTC-5,376 Zappos.com Blvd, +263,193000000000000209,193000000000100209,SDF6,SDF6,US,KY,Shepherdsville,40165,UTC-5,271 Omega Pkwy, +258,193000000000000210,193000000000100210,SDF7,SDF7,US,KY,Shepherdsville,40165,UTC-5,300 Omicron Court, +273,193000000000000211,193000000000100211,SDF8,SDF8,US,IN,Jeffersonville,47130,UTC-5,900 Patrol Rd, +257,193000000000000212,193000000000100212,SDF9,SDF9,US,KY,Shepherdsville,40165,UTC-5,100 W. Thomas P. Echols Lane, +205,193000000000000213,193000000000100213,SEA6,SEA6,US,WA,South Seattle,98144,UTC-8,2646 Rainier Ave., +204,193000000000000214,193000000000100214,SEA8,SEA8,US,WA,Northeast Bellevue,98005,UTC-8,1475 124th Avenue, +295,193000000000000215,193000000000100215,SJC7,SJC7,US,CA,Tracy,95377-8906,UTC-8,188 Mountain House Parkway,30 +153,193000000000000216,193000000000100216,SLC2,SLC2,US,UT,West Jordan,84081,UTC-7,7148 W. Old Bingham Hwy,86 +13,193000000000000217,193000000000100217,SLC3,SLC3,US,UT,SALT LAKE CITY,84116-4413,UTC-7,355 N JOHN GLENN RD,185 +184,193000000000000218,193000000000100218,SMF3,SMF3,US,CA,Stockton,95206-8202,UTC-8,3923 S B ST,9 +105,193000000000000219,193000000000100219,SMF6,SMF6,US,CA,SACRAMENTO,95837-9109,UTC-8,4930 ALLBAUGH DR,28 +296,193000000000000220,193000000000100220,SNA4,SNA4,US,CA,Rialto,92376-3009,UTC-8,2496 W Walnut Ave,18 +109,193000000000000221,193000000000100221,STL3,STL3,US,MO,REPUBLIC,65738-2779,UTC-6,3200 E SAWYER RD,152 +194,193000000000000222,193000000000100222,STL4,STL4,US,IL,Edwardsville,62025-2815,UTC-6,3050 GATEWAY COMMERCE CENTER DR S,115 +122,193000000000000223,193000000000100223,SWF1,SWF1,US,NY,ROCK TAVERN,12575,UTC-5,635 INTERNATIONAL BLVD,134 +76,193000000000000224,193000000000100224,SWF2,SWF2,US,NY,Hopewell Junction,12533,UTC-5,76 Patriot Way Hopewell Junction,62 +41,193000000000000225,193000000000100225,TCY1,TCY1,US,CA,STOCKTON,95206,UTC-8,2690 East Arch Airport Road,139 +43,193000000000000226,193000000000100226,TCY2,TCY2,US,CA,STOCKTON,95215,UTC-8,6201 Newcastle Rd,138 +250,193000000000000227,193000000000100227,TEB3,TEB3,US,NJ,Logan Township,08085,UTC-5,2651 Oldmans Creek Rd,47 +130,193000000000000228,193000000000100228,TEB4,TEB4,US,NJ,PENNS GROVE,08069,UTC-5,747 COURSES LANDING RD,55 +249,193000000000000229,193000000000100229,TEB6,TEB6,US,NJ,Cranbury,08512,UTC-5,22 Hightstown Cranbury State Road,114 +156,193000000000000230,193000000000100230,TEB9,TEB9,US,NJ,Somerset,08873,UTC-5,601 Randolph Road,40 +65,193000000000000231,193000000000100231,TMB8,TMB8,US,FL,HOMESTEAD,33032-8597,UTC-5,27505 SW 132nd AVE,116 +18,193000000000000232,193000000000100232,TOL3,TOL3,US,OH,PERRYSBURG,43551,UTC-5,"9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States",173 +284,193000000000000233,193000000000100233,TPA1,TPA1,US,FL,Ruskin,33570,UTC-5,3350 Laurel Ridge Ave., +283,193000000000000234,193000000000100234,TPA2,TPA2,US,FL,Lakeland,33811,UTC-5,1760 County Line Rd,22 +124,193000000000000235,193000000000100235,TPA3,TPA3,US,FL,Auburndale,33823,UTC-5,676 C. Fred Jones Boulevard,146 +272,193000000000000236,193000000000100236,TUL1,TUL1,US,KS,Coffeyville,67337,UTC-6,2654 North Highway 169, +172,193000000000000237,193000000000100237,VGT2,VGT2,US,NV,LAS VEGAS,89115-2013,UTC-8,6401 Howdy Wells Ave,6 +228,193000000000000238,193000000000100238,VUBA,VUBA,US,PA,Scranton,18512,UTC-5,1000 Keystone Industrial Park, +227,193000000000000239,193000000000100239,VUGA,VUGA,US,PA,Pittston,18643,UTC-5,508 Delaware Avenue West, +82,193000000000000240,193000000000100240,WBW2,WBW2,US,PA,OLYPHANT,18447,UTC-5,1300 Corporate Way,119 +36,193000000000000241,193000000000100241,XAV3,XAV3,US,GA,PORT WENTWORTH,31407,UTC-5,"XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States",180 +37,193000000000000242,193000000000100242,XEW5,XEW5,US,NJ,Cinnaminson,08077-2015,UTC-5,995 Taylors Ln 08077-2015,163 +25,193000000000000243,193000000000100243,XIN5,XIN5,US,IN,Avon,46123-1700,UTC-5,8838 E COUNTY ROAD 100 S,182 +34,193000000000000244,193000000000100244,XLX1,XLX1,US,MD,HAGERSTOWN,21740,UTC-5,"1115 Wesel Blvd HAGERSTOWN,MD 21740,USA",160 +23,193000000000000245,193000000000100245,XLX6,XLX6,US,NC,PINEVILLE,28134,UTC-5,"12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA",166 +63,193000000000000246,193000000000100246,XLX7,XLX7,US,CA,VICTORVILLE,92394,UTC-8,"18580 Gateway Dr,Victorville,CA 92394,USA",27 +26,193000000000000247,193000000000100247,XPB2,XPB2,US,FL,APOPKA,32712,UTC-5,"XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States",165 +24,193000000000000248,193000000000100248,XRI3,XRI3,US,VA,Richmond,23234,UTC-5,"4949 Commerce Rd, Richmond, VA 23234",167 +213,193000000000000249,193000000000100249,XUSB,XUSB,US,TX,Fort Worth,76155,UTC-6,14900 Frye Rd., +226,193000000000000250,193000000000100250,XUSC,XUSC,US,PA,Carlisle,17013,UTC-5,40 Logistics Drive, +297,193000000000000251,193000000000100251,XUSD,XUSD,US,CA,Stockton,95206,UTC-8,1909 Zephyr St, +280,193000000000000252,193000000000100252,XUSE,XUSE,US,IN,Whitestown,46075,UTC-5,5100 S Indianapolis Rd, diff --git a/warehouse/src/main/resources/db/master-data/fba-public-addresses.rejected.csv b/warehouse/src/main/resources/db/master-data/fba-public-addresses.rejected.csv new file mode 100644 index 000000000..8b7ab239a --- /dev/null +++ b/warehouse/src/main/resources/db/master-data/fba-public-addresses.rejected.csv @@ -0,0 +1,55 @@ +source_row,warehouse_code,reject_reason,country_code,state_province,city,postal_code,timezone,address_line1,source_internal_id +29,纽约仓一件代发,invalid_fba_code|non_fba_business_node,US,NJ,New Jersey,08817,UTC-5,"Door13-14,75 Carter Dr Edison,",NJDF +30,纽约仓,invalid_fba_code|non_fba_business_node,US,NJ,New Jersey,08817,UTC-5,"13、14号门75 Carter Dr Edison,", +42,海外仓打托,missing_city|missing_state|invalid_fba_code|non_fba_business_node,US,,,91730,,91730,13 +47,FEDEX,missing_city|missing_state|non_fba_business_node,US,,,92337,,10767,12 +48,UPS,missing_city|missing_state|non_fba_business_node,US,,,92337,,10767,11 +50,JAX3,missing_city|missing_state,US,,,32210,,JAX3-32210,111 +51,私人仓,missing_city|missing_state|invalid_fba_code|non_fba_business_node,US,,,私人仓,,私人仓,14 +52,洛杉矶仓一件代发,invalid_fba_code|non_fba_business_node,US,CA,RANCHO CUCAMONGA,91730,UTC-8,10767 7TH STREET,CADF +53,XBN3,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,91 +54,XMI3,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,89 +55,XCH2,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,87 +56,CEG2,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,79 +57,HEA2,missing_city|missing_state,US,,,18031,,"650 BOULDER DR Suite A 18031-1536 - BREINIGSVILLE, PA - United States",77 +58,STL6,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,64 +59,HOU7,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,63 +61,SMF7,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,53 +62,IAK3,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,44 +64,AZA4,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,16 +66,TMB8-UPS,invalid_fba_code|non_fba_business_node,US,FL,Miami Gardens,33055,UTC-5,8675309 Jenny Lane, +79,NJ3-WALMART,invalid_fba_code|non_fba_business_node,US,NJ,Pedricktown,08067,UTC-5,3 Sorbello Way, +83,IUSP-AWD,invalid_fba_code,US,CA,HESPERIA,92344,UTC-8,8140 Caliente Rd,83 +85,LBE1-UPS,invalid_fba_code|non_fba_business_node,US,PA,New Stanton,15672,UTC-5,165 GLENN FOX RD, +90,PHL5S-WALMART,invalid_fba_code|non_fba_business_node,US,PA,Greencastle,17225,UTC-5,1915 Ebberts Spg Ct, +91,ORD1S-WALMART,invalid_fba_code|non_fba_business_node,US,IL,Joliet,60436,UTC-6,3501 Brandon Road, +92,PSC2-UPS,invalid_fba_code|non_fba_business_node,US,OR,Portland,97242,UTC-8,9930 NE Alderwood Rd, +96,ABQ2-UPS,invalid_fba_code|non_fba_business_node,US,AZ,GOODYEAR,85395,UTC-7,16601 W. SELLS DRIVE, +101,LAN2-48917,invalid_fba_code,US,MI,LANSING,48917,UTC-5,6500 W Mt Hope Hwy, +108,MCO1-WALMART,invalid_fba_code|non_fba_business_node,US,FL,Davenport,33897,UTC-5,5100 NORTH RIDGE TRAIL, +111,MDW2-60433-3280,invalid_fba_code,US,IL,Joliet,60433-3280,UTC-6,250 Emerald Drive, +112,VGT2-FEDEX,invalid_fba_code|non_fba_business_node,US,NV,LAS VEGAS,89115-201,UTC-8,6401 Howdy Wells Ave, +116,ORF2-UPS,invalid_fba_code|non_fba_business_node,US,VA,Richmond,23297,UTC-5,9603 Coach Road, +137,SWF2-10710,invalid_fba_code,US,NY,Yonkers,10710,UTC-5,557 Tuckahoe Road, +138,ATL1-WALMART,invalid_fba_code|non_fba_business_node,US,GA,Atlanta,30349,UTC-5,"6055 S Fulton Pkwy,", +139,IND3-亚马逊,invalid_fba_code|timezone_pending_confirmation,US,IN,Pkwy Plainfield,46168,,"717 Airtech Pkwy Plainfield IN 46168,USA", +159,ORF2-FEDEX,invalid_fba_code|non_fba_business_node,US,VA,CHESAPEAKE,23321-1425,UTC-5,ORF2 - 5045 PORTSMOUTH BLVD, +160,SWF2-12533,invalid_fba_code,US,NY,Hopewell Junction,12533-6159,UTC-5,76 Patriot Way, +165,SMF1-WALMART,invalid_fba_code|non_fba_business_node,US,CA,Sacramento,95837,UTC-8,7000 Powerline Rd, +166,MIA1-33054,invalid_fba_code,US,FL,Opa-Locka,33054,UTC-5,14000 NW 37th Avenue, +167,JAX3-32221,invalid_fba_code,US,FL,Jacksonville,32221,UTC-5,13333 103rd Street, +168,IND1-WALMART,invalid_fba_code|non_fba_business_node,US,IN,Avon,46123,UTC-5,9590 AllPoints Pkwy, +169,IND2-WALMART,invalid_fba_code|non_fba_business_node,US,IN,Avon,46123,UTC-5,9360 AllPoints Pkwy, +170,VGT2-UPS,invalid_fba_code|non_fba_business_node,US,CA,March Air Reserve Base,92518,UTC-8,20701 Krameria Ave, +175,MEM1S-WALMART,invalid_fba_code|non_fba_business_node,US,MS,OLIVE BRANCH,38654,UTC-6,9200 Alexander Road, +177,LAX1-WALMART,invalid_fba_code|non_fba_business_node,US,CA,Chino,91708,UTC-8,6750 Kimball Avenue, +180,LAX2T-WALMART,invalid_fba_code|non_fba_business_node,US,CA,Chino,91708,UTC-8,6720 Kimball Ave, +239,CMH1-11999,invalid_fba_code,US,OH,Etna,43062-7793,UTC-5,11999 National Road SW, +251,ABE8-309,invalid_fba_code,US,NJ,Florence,08518-2802,UTC-5,309 Cedar Lane, +252,ABE8-401,invalid_fba_code,US,NJ,Florence,08518,UTC-5,401 Independence Road, +277,IND3-沃尔玛,invalid_fba_code,US,IN,Mccordsville,46055,UTC-5,"5756 Enterprise Dr, Mccordsville, IN 46055", +278,IND2-AMAZON,invalid_fba_code,US,IN,Plainfield,46168,UTC-5,715 Airtech Pkwy, +279,IND1-AMAZON,invalid_fba_code,US,IN,Whitestown,46075,UTC-5,4255 Anson Blvd, +281,JAX3-32210,invalid_fba_code,US,FL,Jacksonville,32210-8686,UTC-5,13333 103rd Street,111 +282,MIA1-33182,invalid_fba_code,US,FL,Miami,33182,UTC-5,1900 NW 132nd Place Doral, +290,SMF1-AMAZON,invalid_fba_code,US,CA,Sacramento,95835-9505,UTC-8,4900 W Elkhorn Blvd, diff --git a/warehouse/src/main/resources/db/master-data/platform-fixed-warehouses.csv b/warehouse/src/main/resources/db/master-data/platform-fixed-warehouses.csv new file mode 100644 index 000000000..82ae74c36 --- /dev/null +++ b/warehouse/src/main/resources/db/master-data/platform-fixed-warehouses.csv @@ -0,0 +1,5 @@ +warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,state_province,city,postal_code,address_line1,note +193000000000200001,193000000000300001,CN-YIWU-01,义乌仓,DOMESTIC,CN,UTC+8,浙江省,义乌市,322000,义乌仓通用地址待维护,按用户确认生成通用格式;后续运营可维护真实地址。 +193000000000200002,193000000000300002,CN-DONGGUAN-01,东莞仓,DOMESTIC,CN,UTC+8,广东省,东莞市,523000,东莞仓通用地址待维护,按用户确认生成通用格式;后续运营可维护真实地址。 +193000000000200003,193000000000300003,US-LAX-01,洛杉矶仓,OVERSEAS,US,UTC-8,CA,Los Angeles,90001,Los Angeles warehouse generic address pending maintenance,按用户确认生成通用格式;后续运营可维护真实地址。 +193000000000200004,193000000000300004,US-NYC-01,纽约仓,OVERSEAS,US,UTC-5,NY,New York,10001,New York warehouse generic address pending maintenance,按用户确认生成通用格式;后续运营可维护真实地址。 diff --git a/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql index a24778951..f0f06aad5 100644 --- a/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql +++ b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql @@ -36,7 +36,7 @@ DELIMITER ; -- 早期 wh_warehouse 缺少的业务与审计字段。 CALL add_column_if_missing('wh_warehouse', 'warehouse_type', 'VARCHAR(32) NOT NULL DEFAULT ''DOMESTIC'' COMMENT ''仓库类型'' AFTER warehouse_name'); CALL add_column_if_missing('wh_warehouse', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' COMMENT ''所在国家二字码'' AFTER warehouse_type'); -CALL add_column_if_missing('wh_warehouse', 'timezone', 'VARCHAR(64) NOT NULL DEFAULT ''Asia/Shanghai'' COMMENT ''IANA时区'' AFTER country_code'); +CALL add_column_if_missing('wh_warehouse', 'timezone', 'VARCHAR(64) NOT NULL DEFAULT ''UTC+8'' COMMENT ''UTC偏移量'' AFTER country_code'); CALL add_column_if_missing('wh_warehouse', 'contact_name', 'VARCHAR(64) NULL AFTER timezone'); CALL add_column_if_missing('wh_warehouse', 'contact_phone', 'VARCHAR(32) NULL AFTER contact_name'); CALL add_column_if_missing('wh_warehouse', 'contact_email', 'VARCHAR(128) NULL AFTER contact_phone'); diff --git a/warehouse/src/main/resources/db/warehouse-public-master-data.md b/warehouse/src/main/resources/db/warehouse-public-master-data.md new file mode 100644 index 000000000..9bc06831e --- /dev/null +++ b/warehouse/src/main/resources/db/warehouse-public-master-data.md @@ -0,0 +1,45 @@ +# 平台公共仓库主数据 + +## 范围 + +- 正式脚本:`warehouse-public-master-data.sql`。 +- 清洗通过清单:`master-data/fba-public-addresses.accepted.csv`。 +- 拒绝清单:`master-data/fba-public-addresses.rejected.csv`。 +- 四个固定仓清单:`master-data/platform-fixed-warehouses.csv`。 + +本脚本写平台公共作用域 `tenant_id=0` 的四个固定作业仓和 FBA 地址主数据,不写演示仓,不写 Admin 代码。当前内容已内联到 `deploy/sql/头程数据库统一增量-MySQL8.sql`,测试环境应执行固定入口写入正式主数据。 + +## 清洗规则 + +- 读取 `C:\Users\BY-OY\Downloads\FBA地址表.xls`,原始文件只读。 +- 对核心字段去首尾空格。 +- FBA 编码转大写,接受 `^[A-Z0-9]{3,5}$`。 +- 邮编按文本保存,保留 `31216-6427` 这类后缀。 +- 城市、州、详细地址、邮编任一缺失则拒绝。 +- 带 `UPS`、`FEDEX`、`WALMART`、`一件代发`、`私人仓`、`纽约仓`、`洛杉矶仓`、`海外仓打托` 等非 FBA 业务节点语义的记录拒绝。 +- 多时区州必须按城市映射时区;无法可靠判断时列入拒绝清单的 `timezone_pending_confirmation`,不使用州级默认值。 +- 数据库存储固定 UTC 偏移量(如 `UTC+8`、`UTC-8`),当前仅作为维护和展示字段使用。 + +## 固定仓规则 + +- 国内固定仓:义乌、东莞,时区固定 `UTC+8`。 +- 海外固定仓:洛杉矶、纽约,分别使用 `UTC-8`、`UTC-5`。 +- 地址按通用占位格式写入,后续运营维护真实地址后,重复执行脚本不会把地址覆盖回占位文本。 +- 固定仓本轮不批量创建 `wh_service_capability`,避免在真实作业规则确认前扩大业务可操作范围。 + +## 写入语义 + +- 新环境使用稳定 ID 区间:FBA 仓库 `193000000000000001+`、FBA 地址 `193000000000100001+`,固定仓库 `193000000000200001+`、固定仓地址 `193000000000300001+`。 +- 已有环境同编码仓库存在时,通过 `tenant_id=0 + warehouse_code + deleted=0` 解析实际 `warehouse_id` 后写地址。 +- 重复执行不会把停用仓库重新启用。 +- 重复执行不会覆盖运营维护的联系人、电话、邮箱、备注。 +- FBA 地址不创建 `RECEIVING`、`STORAGE`、`PICK_PACK`、`RETURN` 等平台作业能力。 +- 如果稳定种子 ID 已被其他数据占用,或同编码存在已删除仓库,脚本会 `SIGNAL` 终止,交由人工确认。 + +## 当前结果 + +- Excel 数据行:306。 +- 接受:252。 +- 拒绝:54。 +- 时区待确认:1,且该行同时存在无效编码和异常城市文本。 +- 固定仓:4,分别为义乌、东莞、洛杉矶、纽约。 diff --git a/warehouse/src/main/resources/db/warehouse-public-master-data.sql b/warehouse/src/main/resources/db/warehouse-public-master-data.sql new file mode 100644 index 000000000..942fd0a05 --- /dev/null +++ b/warehouse/src/main/resources/db/warehouse-public-master-data.sql @@ -0,0 +1,663 @@ +SET NAMES utf8mb4; +USE baiyue_warehouse; + +-- 正式平台公共仓库主数据:固定作业仓 + FBA 地址库。 +-- 来源:C:\Users\BY-OY\Downloads\FBA地址表.xls,只读清洗后生成。 +-- 接受 252 条,拒绝 54 条;拒绝清单见 master-data/fba-public-addresses.rejected.csv。 +-- 四个固定作业仓按通用占位地址写入;清单见 master-data/platform-fixed-warehouses.csv。 +-- FBA 是目的地址主数据,不创建 RECEIVING、STORAGE、PICK_PACK、RETURN 等平台作业能力。 + +DROP TEMPORARY TABLE IF EXISTS tmp_wh_fixed_platform_warehouse; +CREATE TEMPORARY TABLE tmp_wh_fixed_platform_warehouse ( + warehouse_id_seed BIGINT UNSIGNED NOT NULL, + address_id_seed BIGINT UNSIGNED NOT NULL, + warehouse_code VARCHAR(50) NOT NULL, + warehouse_name VARCHAR(120) NOT NULL, + warehouse_type VARCHAR(32) NOT NULL, + country_code CHAR(2) NOT NULL, + timezone VARCHAR(64) NOT NULL, + state_province VARCHAR(100) NOT NULL, + city VARCHAR(100) NOT NULL, + postal_code VARCHAR(20) NOT NULL, + address_line1 VARCHAR(255) NOT NULL, + PRIMARY KEY (warehouse_code), + UNIQUE KEY uk_tmp_fixed_warehouse_id_seed (warehouse_id_seed), + UNIQUE KEY uk_tmp_fixed_address_id_seed (address_id_seed) +) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO tmp_wh_fixed_platform_warehouse + (warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,state_province,city,postal_code,address_line1) +VALUES +(193000000000200001,193000000000300001,'CN-YIWU-01','义乌仓','DOMESTIC','CN','UTC+8','浙江省','义乌市','322000','义乌仓通用地址待维护'), +(193000000000200002,193000000000300002,'CN-DONGGUAN-01','东莞仓','DOMESTIC','CN','UTC+8','广东省','东莞市','523000','东莞仓通用地址待维护'), +(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','CA','Los Angeles','90001','Los Angeles warehouse generic address pending maintenance'), +(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','NY','New York','10001','New York warehouse generic address pending maintenance'); + +DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed; +DELIMITER // +CREATE PROCEDURE assert_wh_fixed_platform_warehouse_seed() +BEGIN + IF (SELECT COUNT(*) FROM tmp_wh_fixed_platform_warehouse) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓源数量不是4'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM tmp_wh_fixed_platform_warehouse + WHERE warehouse_code <> UPPER(TRIM(warehouse_code)) + OR warehouse_type NOT IN ('DOMESTIC', 'OVERSEAS') + OR country_code NOT IN ('CN', 'US') + OR TRIM(timezone) = '' + OR TRIM(city) = '' + OR TRIM(state_province) = '' + OR TRIM(address_line1) = '' + OR TRIM(postal_code) = '' + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓主数据存在未清洗或必填缺失记录'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse existing + JOIN tmp_wh_fixed_platform_warehouse source + ON existing.id = source.warehouse_id_seed + AND (existing.tenant_id <> 0 OR existing.warehouse_code <> source.warehouse_code OR existing.deleted <> 0) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓仓库种子ID与既有数据冲突'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse_address existing + JOIN tmp_wh_fixed_platform_warehouse source + ON existing.id = source.address_id_seed + LEFT JOIN wh_warehouse parent + ON parent.id = existing.warehouse_id + AND parent.tenant_id = existing.tenant_id + WHERE existing.tenant_id <> 0 + OR existing.deleted <> 0 + OR parent.warehouse_code IS NULL + OR parent.warehouse_code <> source.warehouse_code + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址种子ID与既有数据冲突'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse existing + JOIN tmp_wh_fixed_platform_warehouse source + ON existing.tenant_id = 0 + AND existing.warehouse_code = source.warehouse_code + AND existing.deleted = 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓存在同编码已删除仓库,需人工确认后恢复或清理'; + END IF; +END// +DELIMITER ; +CALL assert_wh_fixed_platform_warehouse_seed(); +DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed; + +INSERT INTO wh_warehouse ( + id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, + contact_name, contact_phone, contact_email, status, remark, + created_by, created_at, updated_by, updated_at, deleted, version +) +SELECT + source.warehouse_id_seed, + 0, + source.warehouse_code, + source.warehouse_name, + source.warehouse_type, + source.country_code, + source.timezone, + NULL, + NULL, + NULL, + 'ACTIVE', + NULL, + NULL, + CURRENT_TIMESTAMP(3), + NULL, + CURRENT_TIMESTAMP(3), + 0, + 0 +FROM tmp_wh_fixed_platform_warehouse source +ON DUPLICATE KEY UPDATE + warehouse_name = VALUES(warehouse_name), + warehouse_type = VALUES(warehouse_type), + country_code = VALUES(country_code), + timezone = VALUES(timezone), + updated_at = CURRENT_TIMESTAMP(3); + +INSERT INTO wh_warehouse_address ( + id, tenant_id, warehouse_id, address_type, country_code, state_province, city, + district, postal_code, address_line1, address_line2, latitude, longitude, + created_by, created_at, updated_by, updated_at, deleted, version +) +SELECT + source.address_id_seed, + 0, + warehouse.id, + 'OPERATING', + source.country_code, + source.state_province, + source.city, + NULL, + source.postal_code, + source.address_line1, + NULL, + NULL, + NULL, + NULL, + CURRENT_TIMESTAMP(3), + NULL, + CURRENT_TIMESTAMP(3), + 0, + 0 +FROM tmp_wh_fixed_platform_warehouse source +JOIN wh_warehouse warehouse + ON warehouse.tenant_id = 0 + AND warehouse.warehouse_code = source.warehouse_code + AND warehouse.deleted = 0 +ON DUPLICATE KEY UPDATE + tenant_id = VALUES(tenant_id); + +DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data; +CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data ( + warehouse_id_seed BIGINT UNSIGNED NOT NULL, + address_id_seed BIGINT UNSIGNED NOT NULL, + warehouse_code VARCHAR(50) NOT NULL, + warehouse_name VARCHAR(120) NOT NULL, + state_province VARCHAR(100) NOT NULL, + city VARCHAR(100) NOT NULL, + postal_code VARCHAR(20) NOT NULL, + timezone VARCHAR(64) NOT NULL, + address_line1 VARCHAR(255) NOT NULL, + source_row INT NOT NULL, + PRIMARY KEY (warehouse_code), + UNIQUE KEY uk_tmp_fba_warehouse_id_seed (warehouse_id_seed), + UNIQUE KEY uk_tmp_fba_address_id_seed (address_id_seed) +) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO tmp_wh_fba_public_master_data + (warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,state_province,city,postal_code,timezone,address_line1,source_row) +VALUES +(193000000000000001,193000000000100001,'ABE2','ABE2','PA','Breinigsville','18031','UTC-5','705 Boulder Drive',238), +(193000000000000002,193000000000100002,'ABE3','ABE3','PA','Breinigsville','18031','UTC-5','650 Boulder Drive',237), +(193000000000000003,193000000000100003,'ABE4','ABE4','PA','Easton','18045','UTC-5','1610 Van Buren Road',134), +(193000000000000004,193000000000100004,'ABE5','ABE5','PA','Harrisburg','17112','UTC-5','6455 Allentown Boulevard',231), +(193000000000000005,193000000000100005,'ABE8','ABE8','NJ','Florence','08518','UTC-5','401 Independence Road 08518-2200',125), +(193000000000000006,193000000000100006,'ABQ2','ABQ2','NM','LOS LUNAS','87031','UTC-7','6251 Pioneer Trail NW',99), +(193000000000000007,193000000000100007,'ACY2','ACY2','NJ','BURLINGTON','08016','UTC-5','1101 E PEARL ST',84), +(193000000000000008,193000000000100008,'AFW1','AFW1','TX','FORT WORTH','76131','UTC-6','1851 NE LOOP 820',141), +(193000000000000009,193000000000100009,'AFW5','AFW5','TX','BALCH SPRINGS','75180-1636','UTC-6','12374 LAKE JUNE RD',27), +(193000000000000010,193000000000100010,'AKR1','AKR1','OH','Canton','44705','UTC-5','4747 Rebar Ave NE',148), +(193000000000000011,193000000000100011,'ALB1','ALB1','NY','Castleton','12033-9634','UTC-5','1835 US Route 9',174), +(193000000000000012,193000000000100012,'AMA1','AMA1','TX','Amarillo','79108','UTC-6','8590 NE 24th Avenue',129), +(193000000000000013,193000000000100013,'ATL2N','ATL2N','GA','Carrollton','30117','UTC-5','3101 N. Highway 27',31), +(193000000000000014,193000000000100014,'ATL3','ATL3','GA','Pendergrass','30567','UTC-5','445 Valentine Industrial Parkway',147), +(193000000000000015,193000000000100015,'ATL6','ATL6','GA','East Point','30344','UTC-5','4200 North Commerce',202), +(193000000000000016,193000000000100016,'AVP1','AVP1','PA','Hazle Township','18202-9361','UTC-5','550 Oak Ridge Road',233), +(193000000000000017,193000000000100017,'AVP3','AVP3','PA','GOULDSBORO','18424-9492','UTC-5','298 1ST AVE',232), +(193000000000000018,193000000000100018,'BDL1','BDL1','CT','Windsor','06095','UTC-5','801 Day Hill Rd.',289), +(193000000000000019,193000000000100019,'BFI1','BFI1','WA','Sumner','98390','UTC-8','1800 140th Avenue E.',208), +(193000000000000020,193000000000100020,'BFI3','BFI3','WA','Dupont','98327-9607','UTC-8','2700 Center Drive',207), +(193000000000000021,193000000000100021,'BFI7','BFI7','WA','Sumner','98390','UTC-8','1901 140th Ave E',206), +(193000000000000022,193000000000100022,'BJC1','BJC1','CO','AURORA','80019','UTC-7','6300 N Powhaton Road',11), +(193000000000000023,193000000000100023,'BNA1','BNA1','TN','Lebanon','37090','UTC-6','14840 Central Pike Suite 190',221), +(193000000000000024,193000000000100024,'BNA2','BNA2','TN','Lebanon','37090','UTC-6','500 Duke DR',131), +(193000000000000025,193000000000100025,'BNA3','BNA3','TN','Murfreesboro','37127','UTC-6','2020 Joe B Jackson Pkwy',140), +(193000000000000026,193000000000100026,'BNA4','BNA4','TN','Nashville','37217','UTC-6','50 airways Blvd',220), +(193000000000000027,193000000000100027,'BNA6','BNA6','TN','CLARKSVILLE','37040-5502','UTC-6','3875 GUTHRIE HWY',110), +(193000000000000028,193000000000100028,'BOS1','BOS1','NH','Nashua','03063','UTC-5','10 State St.',243), +(193000000000000029,193000000000100029,'BOS2','BOS2','MA','Drive Stoughton','02072','UTC-5','1000 Tech Centre',193), +(193000000000000030,193000000000100030,'BOS7','BOS7','MA','Fall River','02720','UTC-5','1180 Innovation Way',192), +(193000000000000031,193000000000100031,'BWI1','BWI1','VA','Sterling','20166-2058','UTC-5','45121 Global Plaza',242), +(193000000000000032,193000000000100032,'BWI2','BWI2','MD','Baltimore','21224','UTC-5','2010 Broening Hwy',241), +(193000000000000033,193000000000100033,'BWI4','BWI4','VA','CLEARBROOK','22624','UTC-5','165 Business Blvd',102), +(193000000000000034,193000000000100034,'CAE1','CAE1','SC','West Columbia','29172','UTC-5','4400 12TH STREET EXT',225), +(193000000000000035,193000000000100035,'CHA1','CHA1','TN','Chattanooga','37416','UTC-5','7200 Discovery Drive',223), +(193000000000000036,193000000000100036,'CHA2','CHA2','TN','Charleston','37310','UTC-5','225 Infinity Dr NW',222), +(193000000000000037,193000000000100037,'CHO1','CHO1','VA','FISHERSVILLE','22939','UTC-5','32 TRADER RD',75), +(193000000000000038,193000000000100038,'CLT2','CLT2','NC','Charlotte','28214-8082','UTC-5','10240 Old Dowd Rd',191), +(193000000000000039,193000000000100039,'CLT3','CLT3','NC','CONCORD','28027','UTC-5','6500 Davidson Hwy',114), +(193000000000000040,193000000000100040,'CMH2','CMH2','OH','Groveport','43125-9016','UTC-5','6050 Gateway Court',240), +(193000000000000041,193000000000100041,'CMH3','CMH3','OH','Monroe','45050-1848','UTC-5','700 GATEWAY BLVD',181), +(193000000000000042,193000000000100042,'CVG1','CVG1','KY','Hebron','41048','UTC-5','1155 Worldwide Blvd',262), +(193000000000000043,193000000000100043,'CVG2','CVG2','KY','Hebron','41048','UTC-5','1600 Worldwide Blvd',261), +(193000000000000044,193000000000100044,'CVG3','CVG3','KY','Hebron','41048','UTC-5','3680 Langley Dr.',260), +(193000000000000045,193000000000100045,'CVG5','CVG5','KY','Hebron','41048','UTC-5','2285 Litton Lane',259), +(193000000000000046,193000000000100046,'DCA6','DCA6','MD','BALTIMORE','21219','UTC-5','6001 Bethlehem Blvd',135), +(193000000000000047,193000000000100047,'DEN2','DEN2','CO','Aurora','80019','UTC-7','22205 East 19th Ave',123), +(193000000000000048,193000000000100048,'DEN8','DEN8','CO','Aurora','80018','UTC-7','21000 E 13th Ave',120), +(193000000000000049,193000000000100049,'DET1','DET1','MI','Livonia','48150-1543','UTC-5','39000 Amrhein Road',189), +(193000000000000050,193000000000100050,'DET2','DET2','MI','Shelby Township','48317-1318','UTC-5','50500 Mound Rd',182), +(193000000000000051,193000000000100051,'DFW5S','DFW5S','TX','Lancaster','75146','UTC-6','2500 E Belt Line Rd,',143), +(193000000000000052,193000000000100052,'DFW6','DFW6','TX','Coppell','75019','UTC-6','940 W Bethel Road',219), +(193000000000000053,193000000000100053,'DFW8','DFW8','TX','DFW Airport','75261','UTC-6','2700 Regent Blvd',217), +(193000000000000054,193000000000100054,'EWR4','EWR4','NJ','Robbinsville','08691','UTC-5','50 New Canton Way',256), +(193000000000000055,193000000000100055,'EWR5','EWR5','NJ','Avenel','07001','UTC-5','301 Blair Rd.',255), +(193000000000000056,193000000000100056,'EWR7','EWR7','NJ','Avenel','07001','UTC-5','301 Blair Rd.',254), +(193000000000000057,193000000000100057,'EWR9','EWR9','NJ','Carteret','07008','UTC-5','8003 Industrial Parkway',253), +(193000000000000058,193000000000100058,'FAT2','FAT2','CA','VISALIA','93291-1882','UTC-8','3315 N KELSEYST',117), +(193000000000000059,193000000000100059,'FOE1','FOE1','KS','KANSAS CITY','66109-3665','UTC-6','9400 LEAVENWORTH RD',176), +(193000000000000060,193000000000100060,'FTW1','FTW1','TX','Dallas','75241','UTC-6','33333 Lyndon B Johnson FREEWAY',212), +(193000000000000061,193000000000100061,'FTW2','FTW2','TX','Coppell','75019','UTC-6','2701 West Bethel Road',211), +(193000000000000062,193000000000100062,'FTW3','FTW3','TX','Fort Worth','76177-2517','UTC-6','15201 Heritage Pkwy',89), +(193000000000000063,193000000000100063,'FTW5','FTW5','TX','Forney','75126','UTC-6','1475 AKRON WAY',121), +(193000000000000064,193000000000100064,'FWA4','FWA4','IN','FORT WAYNE','46809-9771','UTC-5','9798 Smith Road',171), +(193000000000000065,193000000000100065,'GEG2','GEG2','WA','SPOKANE','99216','UTC-8','18007 E Garland Ave',151), +(193000000000000066,193000000000100066,'GEU2','GEU2','AZ','Glendale','85307','UTC-7','15301 West Northern Avenue',67), +(193000000000000067,193000000000100067,'GEU3','GEU3','AZ','BUCKEYE','85396','UTC-7','18900 W McDowell Road',78), +(193000000000000068,193000000000100068,'GEU5','GEU5','AZ','GOODYEAR','85395','UTC-7','4660 North Cotton Lane',68), +(193000000000000069,193000000000100069,'GSO1','GSO1','NC','KERNERSVILLE','27284','UTC-5','1656 OLD GREENSBORO RD',164), +(193000000000000070,193000000000100070,'GSP1','GSP1','SC','Spartanburg','29303','UTC-5','402 John Dodd Rd',224), +(193000000000000071,193000000000100071,'GYR2','GYR2','AZ','GOODYEAR','85395-7672','UTC-7','17341 W MINNEZONA AVE',188), +(193000000000000072,193000000000100072,'GYR3','GYR3','AZ','PHOENIX','85043-2356','UTC-7','8181 W ROOSEVELT ST',187), +(193000000000000073,193000000000100073,'HGR6','HGR6','MD','HAGERSTOWN','21740-7301','UTC-5','55 West Oak Ridge Drive',80), +(193000000000000074,193000000000100074,'HIA1','HIA1','PA','MIDDLETOWN','17057','UTC-5','3327 E Harrisburg Pik',87), +(193000000000000075,193000000000100075,'HLI2','HLI2','CA','HOLLISTER','95023','UTC-8','600 Ernie Drive',44), +(193000000000000076,193000000000100076,'HOU1','HOU1','TX','Humble','77338','UTC-6','8120 Humble Westfield Rd',216), +(193000000000000077,193000000000100077,'HOU2','HOU2','TX','Houston','77038-2324','UTC-6','10550 Ella Blvd.',215), +(193000000000000078,193000000000100078,'HOU3','HOU3','TX','Brookshire','77423','UTC-6','31555 Highway 90 E',214), +(193000000000000079,193000000000100079,'HOU8','HOU8','TX','FRESNO','77545','UTC-6','2303 Hurricane Lane',154), +(193000000000000080,193000000000100080,'HSV1','HSV1','AL','Madison','35756','UTC-6','7817 Greenbrier Road',142), +(193000000000000081,193000000000100081,'HSV2','HSV2','AL','MADISON','35756','UTC-6','28869 Fanning Dr NW',126), +(193000000000000082,193000000000100082,'IAH3','IAH3','TX','Houston','77032','UTC-6','15525 Milner Road',155), +(193000000000000083,193000000000100083,'IAZ1','IAZ1','AZ','GOODYEAR','85395-4515','UTC-7','4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States',3), +(193000000000000084,193000000000100084,'ICT2','ICT2','KS','PARK CITY','67219','UTC-6','7130 N BROADWAY AVE',136), +(193000000000000085,193000000000100085,'IGA3','IGA3','GA','MACON','31216-6427','UTC-5','7001 Skipper Rd 31216-6427 MACON, GA - United States',2), +(193000000000000086,193000000000100086,'IGQ2','IGQ2','IL','University park','60484','UTC-6','23257 S. Central Avenue',152), +(193000000000000087,193000000000100087,'ILG1','ILG1','DE','NEW CASTLE','19720','UTC-5','780 S. DuPont Highway',119), +(193000000000000088,193000000000100088,'IMO1','IMO1','MO','LIBERTY','64068-8933','UTC-6','2361 S Withers RD',10), +(193000000000000089,193000000000100089,'IMS1','IMS1','MS','OLIVE BRANCH','38654','UTC-6','IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US',6), +(193000000000000090,193000000000100090,'INC1','INC1','NC','FAYETTEVILLE','28303-2215','UTC-5','6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States',5), +(193000000000000091,193000000000100091,'IND2','IND2','IN','Plainfield','46168-7442','UTC-5','715 Airtech Pkwy',88), +(193000000000000092,193000000000100092,'IND4','IND4','IN','Indianapolis','46231','UTC-5','710 S. Girls School Rd',276), +(193000000000000093,193000000000100093,'IND5','IND5','IN','Plainfield','46168','UTC-5','800 Perry Road',275), +(193000000000000094,193000000000100094,'IND6','IND6','IN','Jeffersonville','47130','UTC-5','1453 E 10th St',274), +(193000000000000095,193000000000100095,'IND9','IND9','IN','GREENWOOD','46143-7830','UTC-5','1151 S GRAHAM RD',173), +(193000000000000096,193000000000100096,'ITX3','ITX3','TX','AMARILLO','79108-7749','UTC-6','ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US',9), +(193000000000000097,193000000000100097,'IUSF','IUSF','TX','DALLAS','75241','UTC-6','3351 Balmorhea Dr',22), +(193000000000000098,193000000000100098,'IUSJ','IUSJ','CA','BEAUMONT','92223','UTC-8','36900 West 4th Street',69), +(193000000000000099,193000000000100099,'IUSP','IUSP','CA','HESPERIA,','92344','UTC-8','8140 Caliente Rd',74), +(193000000000000100,193000000000100100,'IUSQ','IUSQ','CA','REDLANDS','92374','UTC-8','2125 W San Bernardino Ave',70), +(193000000000000101,193000000000100101,'IUSR','IUSR','SC','COWPENS','29330','UTC-5','1120 Mt Olive Rd',40), +(193000000000000102,193000000000100102,'IUST','IUST','PA','GREENCASTLE','17225','UTC-5','3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States',39), +(193000000000000103,193000000000100103,'IUSW','IUSW','CA','Hesperia','92344','UTC-8','8130 Caliente Rd 92344 - Hesperia, CA - United States',33), +(193000000000000104,193000000000100104,'IUTE','IUTE','AZ','GOODYEAR','85338','UTC-7','16155 W Elwood St',21), +(193000000000000105,193000000000100105,'IUTJ','IUTJ','CA','Fontana','92335','UTC-8','9253 Dreamland Drive 92335 - Fontana, CA - United',8), +(193000000000000106,193000000000100106,'IUTL','IUTL','CA','Moreno Valley','92551','UTC-8','24405 Krameria Ave 92551 - Moreno Valley, CA - United States',7), +(193000000000000107,193000000000100107,'IWA6','IWA6','WA','PASCO','99301-6161','UTC-8','1202 S Road 40 E 99301-6161 - PASCO, WA - United States',4), +(193000000000000108,193000000000100108,'JOT1','JOT1','CA','SAN BERNARDINO','92408','UTC-8','1494 S WATERMAN AVE',72), +(193000000000000109,193000000000100109,'JVL1','JVL1','WI','BELOIT','53511','UTC-6','1255 Gateway Blvd',150), +(193000000000000110,193000000000100110,'KRB4','KRB4','CA','PERRIS','92571-9722','UTC-8','4501 PATTERSON AVE',178), +(193000000000000111,193000000000100111,'KY1','KY1','KY','Shepherdsville','40165','UTC-5','120 Velocity Way.,',161), +(193000000000000112,193000000000100112,'LAN2','LAN2','MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',100), +(193000000000000113,193000000000100113,'LAS1','LAS1','NV','HENDERSON','89044','UTC-8','12300 Bermuda Road',247), +(193000000000000114,193000000000100114,'LAS2','LAS2','NV','Las Vegas','89030','UTC-8','3837 Bay Lake Trail, Suite 111 North',246), +(193000000000000115,193000000000100115,'LAS6','LAS6','NV','Las Vegas','89115','UTC-8','4550 Nexus Way',132), +(193000000000000116,193000000000100116,'LAX2T','LAX2T','CA','CHINO','91708','UTC-8','6730 Kimball Ave',162), +(193000000000000117,193000000000100117,'LAX9','LAX9','CA','FONTANA','92337','UTC-8','11263 Oleander Ave',183), +(193000000000000118,193000000000100118,'LBE1','LBE1','PA','Greensburg','15605','UTC-5','165 Glen Fox Road',86), +(193000000000000119,193000000000100119,'LEX1','LEX1','KY','Lexington','40511','UTC-5','1850 Mercer RD',268), +(193000000000000120,193000000000100120,'LEX2','LEX2','KY','Lexington','40511','UTC-5','172 Trade St.',267), +(193000000000000121,193000000000100121,'LFT1','LFT1','LA','CARENCRO','70520-5976','UTC-6','3550 NE EVANGELINE TRWY',107), +(193000000000000122,193000000000100122,'LGB3','LGB3','CA','Eastvale','91752','UTC-8','4950 Goodman Way',291), +(193000000000000123,193000000000100123,'LGB4','LGB4','CA','Redlands','92374','UTC-8','27517 Pioneer Avenue',292), +(193000000000000124,193000000000100124,'LGB6','LGB6','CA','Riverside','92518-1513','UTC-8','20901 Krameria Ave',293), +(193000000000000125,193000000000100125,'LGB8','LGB8','CA','Rialto','92376','UTC-8','1568 N Linden Ave',294), +(193000000000000126,193000000000100126,'LIT2','LIT2','AR','NORTH LITTLE ROCK','72117','UTC-6','13001 HIGHWAY 70',106), +(193000000000000127,193000000000100127,'MCC1','MCC1','CA','RANCHO CORDOVA','95742','UTC-8','3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States',32), +(193000000000000128,193000000000100128,'MCE1','MCE1','CA','TURLOCK','95380','UTC-8','FULKERTH RD',157), +(193000000000000129,193000000000100129,'MCI1','MCI1','KS','Lenexa','66219','UTC-6','113th St & Renner Blvd.',271), +(193000000000000130,193000000000100130,'MCI4','MCI4','MO','KANSAS CITY','64163','UTC-6','MCI4 - 10501 NW 136th Street',12), +(193000000000000131,193000000000100131,'MCO2','MCO2','FL','Deltona','32725-2610','UTC-5','2600 N Normandy Blvd',115), +(193000000000000132,193000000000100132,'MDT1','MDT1','PA','Carlisle','17015','UTC-5','2Ames Drive',230), +(193000000000000133,193000000000100133,'MDT4','MDT4','PA','York','17408','UTC-5','5125 Commerce Drive',128), +(193000000000000134,193000000000100134,'MDW2','MDW2','IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',200), +(193000000000000135,193000000000100135,'MDW6','MDW6','IL','ROMEOVILLE','60446-6529','UTC-6','1125 W REMINGTON BLVD ROMEOVILLE',199), +(193000000000000136,193000000000100136,'MDW7','MDW7','IL','Monee','60449','UTC-6','6605 Monee Manhattan Road',198), +(193000000000000137,193000000000100137,'MDW8','MDW8','IL','Waukegan','60085','UTC-6','1750 Bridge Drive',197), +(193000000000000138,193000000000100138,'MDW9','MDW9','IL','Aurora','60502','UTC-6','2865 Duke Parkway',196), +(193000000000000139,193000000000100139,'MEM1','MEM1','TN','Memphis','38118','UTC-6','3292 E Holmes Rd',163), +(193000000000000140,193000000000100140,'MEM6','MEM6','MS','OLIVE BRANCH','38654','UTC-6','11505 Progress Way',118), +(193000000000000141,193000000000100141,'MGE3','MGE3','GA','Jefferson','30549-2010','UTC-5','808 Hog Mountain Road',201), +(193000000000000142,193000000000100142,'MIT2','MIT2','CA','SHAFTER','93263','UTC-8','5408 Express Avenue',77), +(193000000000000143,193000000000100143,'MKC4','MKC4','KS','Edgerton','66021-9588','UTC-6','19645 Waverly Rd',270), +(193000000000000144,193000000000100144,'MKC6','MKC6','KS','Kansas City','66102','UTC-6','6925 Riverview Avenue',269), +(193000000000000145,193000000000100145,'MKE1','MKE1','WI','Kenosha','53144','UTC-6','3501 120th Ave',203), +(193000000000000146,193000000000100146,'MQJ1','MQJ1','IN','GREENFIELD','46140','UTC-5','4412 W 300 RD N',94), +(193000000000000147,193000000000100147,'MSP1','MSP1','MN','Shakopee','55379','UTC-6','2601 4th Avenue East',190), +(193000000000000148,193000000000100148,'NJ3','NJ3','NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',17), +(193000000000000149,193000000000100149,'OAK3','OAK3','CA','Patterson','95363-8876','UTC-8','255 Park Center Drive',299), +(193000000000000150,193000000000100150,'OAK4','OAK4','CA','Tracy','95304-9370','UTC-8','1555 N. Chrisman Rd',300), +(193000000000000151,193000000000100151,'OKC2','OKC2','OK','OKLAHOMA CITY','73159','UTC-6','8991 S PORTLAND AVENUE',133), +(193000000000000152,193000000000100152,'OLM1','OLM1','WA','OLYMPIA','98516','UTC-8','3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States',19), +(193000000000000153,193000000000100153,'ONT2','ONT2','CA','San Bernardino','92408','UTC-8','1910 E Central Ave',303), +(193000000000000154,193000000000100154,'ONT6','ONT6','CA','Moreno Valley','92551-9561','UTC-8','24208 San Michele Rd',302), +(193000000000000155,193000000000100155,'ONT8','ONT8','CA','Moreno Valley','92551-9534','UTC-8','24300 Nandina Ave',301), +(193000000000000156,193000000000100156,'ONT9','ONT9','CA','Redlands','92374','UTC-8','2125 W. San Bernandino Ave',298), +(193000000000000157,193000000000100157,'ORD2','ORD2','IL','CHANNAHON','60410-8758','UTC-6','23714 W Amoco Rd',103), +(193000000000000158,193000000000100158,'ORD6','ORD6','IL','Wood Dale','60191','UTC-6','1250 N Mittel Blvd',195), +(193000000000000159,193000000000100159,'ORF2','ORF2','VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',113), +(193000000000000160,193000000000100160,'PBI3','PBI3','FL','Jacksonville','32206','UTC-5','21 Jump Street',81), +(193000000000000161,193000000000100161,'PDX7','PDX7','OR','Salem','97317','UTC-8','4775 Depot Ct SE',127), +(193000000000000162,193000000000100162,'PHL1','PHL1','DE','New Castle','19720','UTC-5','1 Centerpoint Blvd.',288), +(193000000000000163,193000000000100163,'PHL3','PHL3','DE','New Castle','19720','UTC-5','1600 Johnson Way',287), +(193000000000000164,193000000000100164,'PHL4','PHL4','PA','Carlisle','17015','UTC-5','21 Roadway Dr',236), +(193000000000000165,193000000000100165,'PHL5','PHL5','PA','Lewisberry','17339','UTC-5','500 McCarthy Dr',235), +(193000000000000166,193000000000100166,'PHL5S','PHL5S','PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct, Greencastle, PA 17225',20), +(193000000000000167,193000000000100167,'PHL6','PHL6','PA','Carlisle','17015','UTC-5','675 Allen Rd.',234), +(193000000000000168,193000000000100168,'PHL7','PHL7','DE','Middletown','19709','UTC-5','560 Merrimac Ave',286), +(193000000000000169,193000000000100169,'PHL8','PHL8','DE','Middletown','19709','UTC-5','727 N Broad St',285), +(193000000000000170,193000000000100170,'PHX3','PHX3','AZ','Phoenix','85043','UTC-7','6835 West Buckeye Road',307), +(193000000000000171,193000000000100171,'PHX5','PHX5','AZ','Goodyear','85338','UTC-7','16920 W Commerce Drive',306), +(193000000000000172,193000000000100172,'PHX6','PHX6','AZ','Phoenix','85043','UTC-7','4750 West Mohave St',305), +(193000000000000173,193000000000100173,'PHX7','PHX7','AZ','Phoenix','85043','UTC-7','800 N 75th Ave',304), +(193000000000000174,193000000000100174,'PIT1','PIT1','PA','Pittsburgh','15205','UTC-5','2250 Roswell Drive',229), +(193000000000000175,193000000000100175,'PIT2','PIT2','PA','IMPERIAL','15126-1002','UTC-5','1200 Westport Rd',158), +(193000000000000176,193000000000100176,'POC1','POC1','CA','FONTANA','92336-1123','UTC-8','4121 LYTLE CREEK RD',46), +(193000000000000177,193000000000100177,'POC2','POC2','CA','ONTARIO','91761','UTC-8','4000 South Hamner Avenue',38), +(193000000000000178,193000000000100178,'POC3','POC3','CA','JURUPA VALLEY','92509','UTC-8','6120 Clinker Drive',45), +(193000000000000179,193000000000100179,'PPO4','PPO4','IN','MERRILLVILLE','46410','UTC-6','9850 Mississippi Street',49), +(193000000000000180,193000000000100180,'PSC2','PSC2','WA','PASCO','99301','UTC-8','1351 S Road 40 E',93), +(193000000000000181,193000000000100181,'PSP3','PSP3','CA','DESERT HOT SPRINGS','92240','UTC-8','64165 19th AVE',28), +(193000000000000182,193000000000100182,'QXY5','QXY5','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',71), +(193000000000000183,193000000000100183,'QXY8','QXY8','CA','FRESNO','93725-1962','UTC-8','3611 S NORTHPOINTE DR',35), +(193000000000000184,193000000000100184,'QZZ7','QZZ7','AZ','MESA','85212-9608','UTC-7','8560 E ELLIOT RD',14), +(193000000000000185,193000000000100185,'RDU2','RDU2','NC','Smithfield','27577','UTC-5','2150 US HWY 70 Business West',146), +(193000000000000186,193000000000100186,'RDU4','RDU4','NC','FAYETTEVILLE','28303','UTC-5','6309 Bragg Blvd',95), +(193000000000000187,193000000000100187,'RFD2','RFD2','IL','Huntley','60142','UTC-6','11500 Freeman Road',145), +(193000000000000188,193000000000100188,'RFD4','RFD4','IL','HUNTLEY','60142','UTC-6','11400 Venture Court 60142-8075 - HUNTLEY, IL - United States',16), +(193000000000000189,193000000000100189,'RIC1','RIC1','VA','Petersburg','23803','UTC-5','5000 Commerce Way',210), +(193000000000000190,193000000000100190,'RIC2','RIC2','VA','Chester','23836','UTC-5','1901 Meadowville Technology Pkwy',209), +(193000000000000191,193000000000100191,'RMN3','RMN3','VA','Fredericksburg','22406','UTC-5','220 Centreport Parkway',144), +(193000000000000192,193000000000100192,'RNO1','RNO1','NV','Fernley','89408','UTC-8','1600 East Newlands RD',248), +(193000000000000193,193000000000100193,'RNO2','RNO2','NV','Reno','85906','UTC-8','8000 North Virginia Street',245), +(193000000000000194,193000000000100194,'RNO4','RNO4','NV','Reno','89506','UTC-8','8000 North Virginia Street',244), +(193000000000000195,193000000000100195,'RYY2','RYY2','GA','White','30184','UTC-5','1250 Cassville White Road,',73), +(193000000000000196,193000000000100196,'SAT1','SAT1','TX','Schertz','78154-1461','UTC-6','6000 Enterprise Avenue',218), +(193000000000000197,193000000000100197,'SAT4','SAT4','TX','SAN ANTONIO','78245','UTC-6','10384 W US HIGHWAY 90',149), +(193000000000000198,193000000000100198,'SAT6','SAT6','TX','SAN MARCOS','78666','UTC-6','1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States',15), +(193000000000000199,193000000000100199,'SAV3','SAV3','GA','Macon','31216-6427','UTC-5','7001 Skipper Rd',98), +(193000000000000200,193000000000100200,'SBD1','SBD1','CA','BLOOMINGTON','92316-3819','UTC-8','3388 S Cactus Ave',185), +(193000000000000201,193000000000100201,'SBD2','SBD2','CA','SAN BERNARDINO','92408-2805','UTC-8','1494 S WATERMAN AVE',179), +(193000000000000202,193000000000100202,'SCK1','SCK1','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',97), +(193000000000000203,193000000000100203,'SCK3','SCK3','CA','MANTECA','95336','UTC-8','3565 N AIRPORT WAY MANTECA',60), +(193000000000000204,193000000000100204,'SCK4','SCK4','CA','STOCKTON','95215-8354','UTC-8','6001 S AUSTIN RD',186), +(193000000000000205,193000000000100205,'SCK8','SCK8','CA','OAKLEY','94561-2677','UTC-8','4700 WILBUR AVE',104), +(193000000000000206,193000000000100206,'SDF1','SDF1','KY','Campbellsville','42718','UTC-5','1050 South Columbia AVE',266), +(193000000000000207,193000000000100207,'SDF2','SDF2','KY','Watterson Park','40218','UTC-5','4360 Robards Ln',265), +(193000000000000208,193000000000100208,'SDF4','SDF4','KY','Shepherdsville','40165','UTC-5','376 Zappos.com Blvd',264), +(193000000000000209,193000000000100209,'SDF6','SDF6','KY','Shepherdsville','40165','UTC-5','271 Omega Pkwy',263), +(193000000000000210,193000000000100210,'SDF7','SDF7','KY','Shepherdsville','40165','UTC-5','300 Omicron Court',258), +(193000000000000211,193000000000100211,'SDF8','SDF8','IN','Jeffersonville','47130','UTC-5','900 Patrol Rd',273), +(193000000000000212,193000000000100212,'SDF9','SDF9','KY','Shepherdsville','40165','UTC-5','100 W. Thomas P. Echols Lane',257), +(193000000000000213,193000000000100213,'SEA6','SEA6','WA','South Seattle','98144','UTC-8','2646 Rainier Ave.',205), +(193000000000000214,193000000000100214,'SEA8','SEA8','WA','Northeast Bellevue','98005','UTC-8','1475 124th Avenue',204), +(193000000000000215,193000000000100215,'SJC7','SJC7','CA','Tracy','95377-8906','UTC-8','188 Mountain House Parkway',295), +(193000000000000216,193000000000100216,'SLC2','SLC2','UT','West Jordan','84081','UTC-7','7148 W. Old Bingham Hwy',153), +(193000000000000217,193000000000100217,'SLC3','SLC3','UT','SALT LAKE CITY','84116-4413','UTC-7','355 N JOHN GLENN RD',13), +(193000000000000218,193000000000100218,'SMF3','SMF3','CA','Stockton','95206-8202','UTC-8','3923 S B ST',184), +(193000000000000219,193000000000100219,'SMF6','SMF6','CA','SACRAMENTO','95837-9109','UTC-8','4930 ALLBAUGH DR',105), +(193000000000000220,193000000000100220,'SNA4','SNA4','CA','Rialto','92376-3009','UTC-8','2496 W Walnut Ave',296), +(193000000000000221,193000000000100221,'STL3','STL3','MO','REPUBLIC','65738-2779','UTC-6','3200 E SAWYER RD',109), +(193000000000000222,193000000000100222,'STL4','STL4','IL','Edwardsville','62025-2815','UTC-6','3050 GATEWAY COMMERCE CENTER DR S',194), +(193000000000000223,193000000000100223,'SWF1','SWF1','NY','ROCK TAVERN','12575','UTC-5','635 INTERNATIONAL BLVD',122), +(193000000000000224,193000000000100224,'SWF2','SWF2','NY','Hopewell Junction','12533','UTC-5','76 Patriot Way Hopewell Junction',76), +(193000000000000225,193000000000100225,'TCY1','TCY1','CA','STOCKTON','95206','UTC-8','2690 East Arch Airport Road',41), +(193000000000000226,193000000000100226,'TCY2','TCY2','CA','STOCKTON','95215','UTC-8','6201 Newcastle Rd',43), +(193000000000000227,193000000000100227,'TEB3','TEB3','NJ','Logan Township','08085','UTC-5','2651 Oldmans Creek Rd',250), +(193000000000000228,193000000000100228,'TEB4','TEB4','NJ','PENNS GROVE','08069','UTC-5','747 COURSES LANDING RD',130), +(193000000000000229,193000000000100229,'TEB6','TEB6','NJ','Cranbury','08512','UTC-5','22 Hightstown Cranbury State Road',249), +(193000000000000230,193000000000100230,'TEB9','TEB9','NJ','Somerset','08873','UTC-5','601 Randolph Road',156), +(193000000000000231,193000000000100231,'TMB8','TMB8','FL','HOMESTEAD','33032-8597','UTC-5','27505 SW 132nd AVE',65), +(193000000000000232,193000000000100232,'TOL3','TOL3','OH','PERRYSBURG','43551','UTC-5','9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States',18), +(193000000000000233,193000000000100233,'TPA1','TPA1','FL','Ruskin','33570','UTC-5','3350 Laurel Ridge Ave.',284), +(193000000000000234,193000000000100234,'TPA2','TPA2','FL','Lakeland','33811','UTC-5','1760 County Line Rd',283), +(193000000000000235,193000000000100235,'TPA3','TPA3','FL','Auburndale','33823','UTC-5','676 C. Fred Jones Boulevard',124), +(193000000000000236,193000000000100236,'TUL1','TUL1','KS','Coffeyville','67337','UTC-6','2654 North Highway 169',272), +(193000000000000237,193000000000100237,'VGT2','VGT2','NV','LAS VEGAS','89115-2013','UTC-8','6401 Howdy Wells Ave',172), +(193000000000000238,193000000000100238,'VUBA','VUBA','PA','Scranton','18512','UTC-5','1000 Keystone Industrial Park',228), +(193000000000000239,193000000000100239,'VUGA','VUGA','PA','Pittston','18643','UTC-5','508 Delaware Avenue West',227), +(193000000000000240,193000000000100240,'WBW2','WBW2','PA','OLYPHANT','18447','UTC-5','1300 Corporate Way',82), +(193000000000000241,193000000000100241,'XAV3','XAV3','GA','PORT WENTWORTH','31407','UTC-5','XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States',36), +(193000000000000242,193000000000100242,'XEW5','XEW5','NJ','Cinnaminson','08077-2015','UTC-5','995 Taylors Ln 08077-2015',37), +(193000000000000243,193000000000100243,'XIN5','XIN5','IN','Avon','46123-1700','UTC-5','8838 E COUNTY ROAD 100 S',25), +(193000000000000244,193000000000100244,'XLX1','XLX1','MD','HAGERSTOWN','21740','UTC-5','1115 Wesel Blvd HAGERSTOWN,MD 21740,USA',34), +(193000000000000245,193000000000100245,'XLX6','XLX6','NC','PINEVILLE','28134','UTC-5','12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA',23), +(193000000000000246,193000000000100246,'XLX7','XLX7','CA','VICTORVILLE','92394','UTC-8','18580 Gateway Dr,Victorville,CA 92394,USA',63), +(193000000000000247,193000000000100247,'XPB2','XPB2','FL','APOPKA','32712','UTC-5','XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States',26), +(193000000000000248,193000000000100248,'XRI3','XRI3','VA','Richmond','23234','UTC-5','4949 Commerce Rd, Richmond, VA 23234',24), +(193000000000000249,193000000000100249,'XUSB','XUSB','TX','Fort Worth','76155','UTC-6','14900 Frye Rd.',213), +(193000000000000250,193000000000100250,'XUSC','XUSC','PA','Carlisle','17013','UTC-5','40 Logistics Drive',226), +(193000000000000251,193000000000100251,'XUSD','XUSD','CA','Stockton','95206','UTC-8','1909 Zephyr St',297), +(193000000000000252,193000000000100252,'XUSE','XUSE','IN','Whitestown','46075','UTC-5','5100 S Indianapolis Rd',280); + +DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed; +DELIMITER // +CREATE PROCEDURE assert_wh_fba_public_master_data_seed() +BEGIN + IF (SELECT COUNT(*) FROM tmp_wh_fba_public_master_data) <> 252 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据源数量不是252'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM tmp_wh_fba_public_master_data + WHERE warehouse_code <> UPPER(TRIM(warehouse_code)) + OR warehouse_code NOT REGEXP '^[A-Z0-9]{3,5}$' + OR TRIM(city) = '' + OR TRIM(state_province) = '' + OR TRIM(address_line1) = '' + OR TRIM(postal_code) = '' + OR TRIM(timezone) = '' + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在未清洗或必填缺失记录'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse existing + JOIN tmp_wh_fba_public_master_data source + ON existing.id = source.warehouse_id_seed + AND (existing.tenant_id <> 0 OR existing.warehouse_code <> source.warehouse_code OR existing.deleted <> 0) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据仓库种子ID与既有数据冲突'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse_address existing + JOIN tmp_wh_fba_public_master_data source + ON existing.id = source.address_id_seed + LEFT JOIN wh_warehouse parent + ON parent.id = existing.warehouse_id + AND parent.tenant_id = existing.tenant_id + WHERE existing.tenant_id <> 0 + OR existing.deleted <> 0 + OR parent.warehouse_code IS NULL + OR parent.warehouse_code <> source.warehouse_code + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据地址种子ID与既有数据冲突'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_warehouse existing + JOIN tmp_wh_fba_public_master_data source + ON existing.tenant_id = 0 + AND existing.warehouse_code = source.warehouse_code + AND existing.deleted = 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在同编码已删除仓库,需人工确认后恢复或清理'; + END IF; +END// +DELIMITER ; +CALL assert_wh_fba_public_master_data_seed(); +DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed; + +INSERT INTO wh_warehouse ( + id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, + contact_name, contact_phone, contact_email, status, remark, + created_by, created_at, updated_by, updated_at, deleted, version +) +SELECT + source.warehouse_id_seed, + 0, + source.warehouse_code, + source.warehouse_name, + 'FBA', + 'US', + source.timezone, + NULL, + NULL, + NULL, + 'ACTIVE', + NULL, + NULL, + CURRENT_TIMESTAMP(3), + NULL, + CURRENT_TIMESTAMP(3), + 0, + 0 +FROM tmp_wh_fba_public_master_data source +ON DUPLICATE KEY UPDATE + warehouse_type = VALUES(warehouse_type), + country_code = VALUES(country_code), + timezone = VALUES(timezone), + updated_at = CURRENT_TIMESTAMP(3); + +INSERT INTO wh_warehouse_address ( + id, tenant_id, warehouse_id, address_type, country_code, state_province, city, + district, postal_code, address_line1, address_line2, latitude, longitude, + created_by, created_at, updated_by, updated_at, deleted, version +) +SELECT + source.address_id_seed, + 0, + warehouse.id, + 'OPERATING', + 'US', + source.state_province, + source.city, + NULL, + source.postal_code, + source.address_line1, + NULL, + NULL, + NULL, + NULL, + CURRENT_TIMESTAMP(3), + NULL, + CURRENT_TIMESTAMP(3), + 0, + 0 +FROM tmp_wh_fba_public_master_data source +JOIN wh_warehouse warehouse + ON warehouse.tenant_id = 0 + AND warehouse.warehouse_code = source.warehouse_code + AND warehouse.deleted = 0 +ON DUPLICATE KEY UPDATE + tenant_id = VALUES(tenant_id), + country_code = VALUES(country_code), + state_province = VALUES(state_province), + city = VALUES(city), + postal_code = VALUES(postal_code), + address_line1 = VALUES(address_line1), + updated_at = CURRENT_TIMESTAMP(3); + +DROP PROCEDURE IF EXISTS verify_wh_fba_public_master_data; +DELIMITER // +CREATE PROCEDURE verify_wh_fba_public_master_data() +BEGIN + IF (SELECT COUNT(*) + FROM wh_warehouse warehouse + JOIN tmp_wh_fba_public_master_data source + ON source.warehouse_code = warehouse.warehouse_code + WHERE warehouse.tenant_id = 0 + AND warehouse.warehouse_type = 'FBA' + AND warehouse.country_code = 'US' + AND warehouse.deleted = 0) <> 252 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共仓库写入数量校验失败'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM tmp_wh_fba_public_master_data source + LEFT JOIN wh_warehouse warehouse + ON warehouse.tenant_id = 0 + AND warehouse.warehouse_code = source.warehouse_code + AND warehouse.deleted = 0 + LEFT JOIN wh_warehouse_address address + ON address.tenant_id = 0 + AND address.warehouse_id = warehouse.id + AND address.address_type = 'OPERATING' + AND address.deleted = 0 + WHERE warehouse.id IS NULL + OR address.id IS NULL + OR address.country_code <> 'US' + OR address.state_province <> source.state_province + OR address.city <> source.city + OR address.postal_code <> source.postal_code + OR address.address_line1 <> source.address_line1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共地址父子解析或地址字段校验失败'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM wh_service_capability capability + JOIN wh_warehouse warehouse + ON warehouse.id = capability.warehouse_id + AND warehouse.tenant_id = capability.tenant_id + WHERE warehouse.tenant_id = 0 + AND warehouse.warehouse_type = 'FBA' + AND warehouse.warehouse_code IN (SELECT warehouse_code FROM tmp_wh_fba_public_master_data) + AND capability.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共地址不得创建平台作业能力'; + END IF; +END// +DELIMITER ; +CALL verify_wh_fba_public_master_data(); +DROP PROCEDURE IF EXISTS verify_wh_fba_public_master_data; +DROP PROCEDURE IF EXISTS verify_wh_fixed_platform_warehouse; +DELIMITER // +CREATE PROCEDURE verify_wh_fixed_platform_warehouse() +BEGIN + IF (SELECT COUNT(*) + FROM wh_warehouse warehouse + JOIN tmp_wh_fixed_platform_warehouse source + ON source.warehouse_code = warehouse.warehouse_code + WHERE warehouse.tenant_id = 0 + AND warehouse.warehouse_type = source.warehouse_type + AND warehouse.country_code = source.country_code + AND warehouse.deleted = 0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓写入数量校验失败'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM tmp_wh_fixed_platform_warehouse source + LEFT JOIN wh_warehouse warehouse + ON warehouse.tenant_id = 0 + AND warehouse.warehouse_code = source.warehouse_code + AND warehouse.deleted = 0 + LEFT JOIN wh_warehouse_address address + ON address.tenant_id = 0 + AND address.warehouse_id = warehouse.id + AND address.address_type = 'OPERATING' + AND address.deleted = 0 + WHERE warehouse.id IS NULL + OR address.id IS NULL + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址父子解析失败'; + END IF; +END// +DELIMITER ; +CALL verify_wh_fixed_platform_warehouse(); +DROP PROCEDURE IF EXISTS verify_wh_fixed_platform_warehouse; +DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data; +DROP TEMPORARY TABLE IF EXISTS tmp_wh_fixed_platform_warehouse; diff --git a/warehouse/src/main/resources/db/warehouse-seed.sql b/warehouse/src/main/resources/db/warehouse-seed.sql index c6a7cb1b6..4011ae8da 100644 --- a/warehouse/src/main/resources/db/warehouse-seed.sql +++ b/warehouse/src/main/resources/db/warehouse-seed.sql @@ -2,8 +2,8 @@ SET NAMES utf8mb4; USE baiyue_warehouse; -- 一期联调初始化仓库,重复执行不会产生重复数据。 INSERT INTO wh_warehouse(id,tenant_id,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,contact_name,contact_phone,status,remark) -VALUES(190000000000000001,0,'CN-SZX-01','深圳头程收货仓','DOMESTIC','CN','Asia/Shanghai','仓库收货组','0755-88886666','ACTIVE','工作日预约收货') -ON DUPLICATE KEY UPDATE tenant_id=0,warehouse_name=VALUES(warehouse_name),status='ACTIVE'; +VALUES(190000000000000001,0,'CN-SZX-01','深圳头程收货仓','DOMESTIC','CN','UTC+8','仓库收货组','0755-88886666','ACTIVE','工作日预约收货') +ON DUPLICATE KEY UPDATE tenant_id=0,warehouse_name=VALUES(warehouse_name),timezone=VALUES(timezone),status='ACTIVE'; INSERT INTO wh_warehouse_address(id,tenant_id,warehouse_id,address_type,country_code,state_province,city,district,postal_code,address_line1) VALUES(190000000000000011,0,190000000000000001,'OPERATING','CN','广东省','深圳市','宝安区','518000','航城街道示例物流园1号仓') ON DUPLICATE KEY UPDATE tenant_id=0,address_line1=VALUES(address_line1); diff --git a/warehouse/src/main/resources/db/warehouse.sql b/warehouse/src/main/resources/db/warehouse.sql index f9b2ca769..942f4f542 100644 --- a/warehouse/src/main/resources/db/warehouse.sql +++ b/warehouse/src/main/resources/db/warehouse.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse ( warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC、FBA、OVERSEAS', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', - timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', + timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地UTC偏移量', contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', contact_phone VARCHAR(32) DEFAULT NULL COMMENT '联系电话', contact_email VARCHAR(128) DEFAULT NULL COMMENT '联系邮箱', diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/MerchantControllerIdentityContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/MerchantControllerIdentityContractTest.java index 95bd4950f..c204ef190 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/MerchantControllerIdentityContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/MerchantControllerIdentityContractTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import org.junit.jupiter.api.Test; +import java.lang.reflect.Modifier; import java.lang.reflect.Method; import java.util.Set; @@ -27,10 +28,15 @@ class MerchantControllerIdentityContractTest { } @Test - void merchantWarehouseDefaultsToPlatformResourceScope() throws Exception { - var value = MerchantWarehouseController.class.getDeclaredField("publicTenantId") - .getAnnotation(org.springframework.beans.factory.annotation.Value.class); - assertThat(value.value()).isEqualTo("${baiyue.warehouse.public-tenant-id:0}"); + void merchantControllersUseFixedPlatformResourceScope() throws Exception { + for (Class controller : Set.of(MerchantWarehouseController.class, MerchantPartnerController.class)) { + var value = controller.getDeclaredField("PUBLIC_TENANT_ID"); + value.setAccessible(true); + assertThat(Modifier.isStatic(value.getModifiers())).isTrue(); + assertThat(Modifier.isFinal(value.getModifiers())).isTrue(); + assertThat(value.getLong(null)).isEqualTo(0L); + assertThat(value.getAnnotation(org.springframework.beans.factory.annotation.Value.class)).isNull(); + } } private static AuthenticatedUser user(String appCode, Long tenantId) { diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java index 75f53a3cf..9315601ef 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java @@ -49,6 +49,8 @@ class PlatformMasterDataControllerContractTest { assertWarehouseMethod("create", PostMapping.class, "platform:warehouse:create"); assertWarehouseMethod("update", PutMapping.class, "platform:warehouse:edit"); assertWarehouseMethod("status", PatchMapping.class, "platform:warehouse:status"); + assertWarehouseMethod("importTemplate", GetMapping.class, "platform:warehouse:create"); + assertWarehouseMethod("importWarehouses", PostMapping.class, "platform:warehouse:create"); Method page = java.util.Arrays.stream(PlatformWarehouseController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals("page")) @@ -56,6 +58,8 @@ class PlatformMasterDataControllerContractTest { .orElseThrow(); assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class); assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); + assertThat(page.getGenericReturnType().getTypeName()) + .contains("WarehouseSummaryResponse"); } @Test @@ -110,6 +114,23 @@ class PlatformMasterDataControllerContractTest { } } + @Test + void upsertWarehouseImportRequiresEditPermission() throws Exception { + var guard = PlatformWarehouseController.class.getDeclaredMethod("requireImportPermission", + AuthenticatedUser.class, com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode.class); + guard.setAccessible(true); + var createOnlyUser = new AuthenticatedUser(1L, 0L, 0L, 1L, + "admin", "PLATFORM", "WEB", java.util.Set.of("platform:warehouse:create")); + var editUser = new AuthenticatedUser(1L, 0L, 0L, 1L, + "admin", "PLATFORM", "WEB", java.util.Set.of("platform:warehouse:create", "platform:warehouse:edit")); + + guard.invoke(null, createOnlyUser, com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode.CREATE_ONLY); + guard.invoke(null, editUser, com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode.UPSERT); + assertThatThrownBy(() -> guard.invoke(null, createOnlyUser, + com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode.UPSERT)) + .hasCauseInstanceOf(BizException.class); + } + private static void assertContract(Class controller, String authority, String path) throws Exception { RequestMapping mapping = controller.getAnnotation(RequestMapping.class); assertThat(mapping.value()).containsExactly(path); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java new file mode 100644 index 000000000..463c6ad5c --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java @@ -0,0 +1,83 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.mapper.ServiceCapabilityMapper; +import com.baiyue.warehouse.mapper.WarehouseAddressMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(WarehouseRepository.class) +class WarehouseRepositoryTest { + @MockBean private WarehouseMapper warehouses; + @MockBean private WarehouseAddressMapper addresses; + @MockBean private ServiceCapabilityMapper capabilities; + @Resource private WarehouseRepository repository; + + @BeforeAll + static void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "warehouse-test"), WarehouseEntity.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "warehouse-address-test"), WarehouseAddressEntity.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "warehouse-capability-test"), ServiceCapabilityEntity.class); + } + + @Test + void pageSummariesSearchesAddressKeywordAndHydratesPrimaryAddress() { + WarehouseAddressEntity matchedAddress = new WarehouseAddressEntity(); + matchedAddress.setWarehouseId(7L); + WarehouseAddressEntity primaryAddress = new WarehouseAddressEntity(); + primaryAddress.setId(70L); + primaryAddress.setWarehouseId(7L); + primaryAddress.setCountryCode("US"); + primaryAddress.setStateProvince("CA"); + primaryAddress.setCity("Los Angeles"); + primaryAddress.setAddressLine1("123 Logistics Ave"); + when(addresses.selectList(any())).thenReturn(List.of(matchedAddress), List.of(primaryAddress)); + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setId(7L); + warehouse.setTenantId(0L); + warehouse.setWarehouseCode("FBA-LAX9"); + warehouse.setWarehouseName("LAX9"); + warehouse.setWarehouseType("FBA"); + warehouse.setCountryCode("US"); + warehouse.setTimezone("UTC-8"); + warehouse.setStatus("ACTIVE"); + when(warehouses.selectPage(any(), any())).thenAnswer(invocation -> { + Page page = invocation.getArgument(0); + page.setRecords(List.of(warehouse)); + page.setTotal(1); + return page; + }); + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "Los Angeles", + null, null, "FBA", "ACTIVE"); + + var result = repository.pageSummaries(0L, query); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.records()).singleElement().satisfies(item -> { + assertThat(item.warehouseCode()).isEqualTo("FBA-LAX9"); + assertThat(item.primaryAddress().city()).isEqualTo("Los Angeles"); + assertThat(item.primaryAddress().displayAddress()).contains("123 Logistics Ave"); + }); + verify(addresses, org.mockito.Mockito.times(2)).selectList(any()); + verify(warehouses).selectPage(any(), any()); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index aa73a9395..a3eddd161 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -2,10 +2,13 @@ package com.baiyue.warehouse.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ResultCode; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.WarehouseRepository; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.common.core.api.PageResult; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; @@ -17,6 +20,15 @@ import static org.mockito.Mockito.*; class WarehouseApplicationServiceTest { @MockBean private WarehouseRepository repository; @Resource private WarehouseApplicationService service; + + @BeforeEach + void setUp() { + doAnswer(invocation -> { + WarehouseEntity warehouse = invocation.getArgument(0); + warehouse.setId(1L); + return null; + }).when(repository).insertWarehouse(any(WarehouseEntity.class)); + } @Test void shouldHandleCase01() { WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("ACTIVE"); @@ -40,10 +52,68 @@ class WarehouseApplicationServiceTest { } @Test void pagesAllPlatformWarehousesWithoutCustomerFilter() { - WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 200, null, null, null, null); + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 200, null, null, null, null, null); when(repository.page(null, query)).thenReturn(new PageResult<>(java.util.List.of(), 1, 200, 0, 0)); assertThat(service.page(null, query).records()).isEmpty(); verify(repository).page(null, query); } + + @Test + void createsDomesticWarehouseWithPlatformDefaults() { + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "CN-SZX-01", "深圳作业仓", + null, null, null, "张三", "13800138000", null, null, null, + new WarehouseRequests.AddressRequest(null, null, "广东省", "深圳市", "南山区", + "518000", "前海路 1 号", null, null, null), + java.util.List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null))); + + service.create(request); + + var warehouse = org.mockito.ArgumentCaptor.forClass(WarehouseEntity.class); + var address = org.mockito.ArgumentCaptor.forClass(WarehouseAddressEntity.class); + var capability = org.mockito.ArgumentCaptor.forClass(ServiceCapabilityEntity.class); + verify(repository).insertWarehouse(warehouse.capture()); + verify(repository).insertAddress(address.capture()); + verify(repository).insertCapability(capability.capture()); + assertThat(warehouse.getValue().getTenantId()).isZero(); + assertThat(warehouse.getValue().getWarehouseType()).isEqualTo("DOMESTIC"); + assertThat(warehouse.getValue().getCountryCode()).isEqualTo("CN"); + assertThat(warehouse.getValue().getTimezone()).isEqualTo("UTC+8"); + assertThat(warehouse.getValue().getStatus()).isEqualTo("ACTIVE"); + assertThat(address.getValue().getAddressType()).isEqualTo("WAREHOUSE"); + assertThat(address.getValue().getCountryCode()).isEqualTo("CN"); + } + + @Test + void fbaWarehouseUsesConfirmedStateTimezoneAndDoesNotCreateCapabilities() { + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "FBA-LAX9", "LAX9", + "FBA", "US", null, null, null, null, null, null, + new WarehouseRequests.AddressRequest(null, "US", "CA", "Redlands", null, + "92374", "Amazon Fulfillment Center", null, null, null), + java.util.List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null))); + + service.create(request); + + var warehouse = org.mockito.ArgumentCaptor.forClass(WarehouseEntity.class); + verify(repository).insertWarehouse(warehouse.capture()); + verify(repository, never()).insertCapability(any()); + assertThat(warehouse.getValue().getTenantId()).isZero(); + assertThat(warehouse.getValue().getWarehouseType()).isEqualTo("FBA"); + assertThat(warehouse.getValue().getTimezone()).isEqualTo("UTC-8"); + assertThat(warehouse.getValue().getStatus()).isEqualTo("ACTIVE"); + } + + @Test + void fbaWarehouseInMultiTimezoneStateRequiresExplicitTimezone() { + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "FBA-DFW", "DFW", + "FBA", "US", null, null, null, null, null, null, + new WarehouseRequests.AddressRequest(null, "US", "TX", "Dallas", null, + "75201", "Amazon", null, null, null), + null); + + assertThatThrownBy(() -> service.create(request)).isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + verify(repository, never()).insertWarehouse(any()); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java new file mode 100644 index 000000000..4425f3d0e --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java @@ -0,0 +1,180 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode; +import com.baiyue.warehouse.dto.WarehouseImportDtos.WarehouseImportResult; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.repository.WarehouseRepository; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("仓库批量导入") +@SpringJUnitConfig(WarehouseImportService.class) +class WarehouseImportServiceTest { + @MockBean private WarehouseRepository repository; + @MockBean private WarehouseApplicationService warehouseService; + @Resource private WarehouseImportService service; + + @Test + @DisplayName("默认 CREATE_ONLY 只新增新仓库并返回预检分类") + void shouldCreateOnlyNewWarehousesByDefault() throws Exception { + byte[] file = withRows(service.template(), new String[][] { + {"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "南山区", "前海路 1 号", "518000", "", "", "张三", "13800138000", "", ""}, + {"FBA-LAX9", "", "FBA", "US", "CA", "Redlands", "", "Amazon FC", "92374", "", "", "", "", "", ""} + }); + + WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null); + + assertThat(result.success()).isTrue(); + assertThat(result.committed()).isTrue(); + assertThat(result.mode()).isEqualTo(ImportMode.CREATE_ONLY); + assertThat(result.createdRows()).isEqualTo(2); + assertThat(result.previewRows()).extracting("action").containsExactly("CREATE", "CREATE"); + var captor = org.mockito.ArgumentCaptor.forClass(WarehouseRequests.SaveRequest.class); + verify(warehouseService, org.mockito.Mockito.times(2)).create(captor.capture()); + assertThat(captor.getAllValues().get(0).tenantId()).isZero(); + assertThat(captor.getAllValues().get(1).warehouseType()).isEqualTo("FBA"); + assertThat(captor.getAllValues().get(1).warehouseName()).isEqualTo("FBA-LAX9"); + } + + @Test + @DisplayName("任一行错误时整批零写入") + void shouldRejectWholeBatchWhenAnyRowInvalid() throws Exception { + byte[] file = withRows(service.template(), new String[][] { + {"FBA-DFW", "DFW", "FBA", "US", "TX", "Dallas", "", "Amazon FC", "75201", "", "", "", "", "", ""}, + {"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "", "前海路 1 号", "", "", "", "", "", "", ""} + }); + + WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null); + + assertThat(result.success()).isFalse(); + assertThat(result.committed()).isFalse(); + assertThat(result.createdRows()).isZero(); + assertThat(result.errors()).anyMatch(error -> error.row() == 4 && error.field().equals("时区")); + verify(warehouseService, never()).create(any()); + verify(warehouseService, never()).update(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any()); + } + + @Test + @DisplayName("UPSERT 更新现有仓库时保留联系人、备注并不重启停用数据") + void shouldPreserveContactRemarkAndInactiveStatusOnUpsert() throws Exception { + WarehouseEntity existing = new WarehouseEntity(); + existing.setId(9L); + existing.setTenantId(0L); + existing.setWarehouseCode("CN-SZX-01"); + existing.setWarehouseName("旧名称"); + existing.setWarehouseType("DOMESTIC"); + existing.setCountryCode("CN"); + existing.setTimezone("UTC+8"); + existing.setContactName("旧联系人"); + existing.setContactPhone("旧电话"); + existing.setContactEmail("old@example.com"); + existing.setStatus("INACTIVE"); + existing.setRemark("旧备注"); + WarehouseAddressEntity address = new WarehouseAddressEntity(); + address.setCountryCode("CN"); + address.setStateProvince("广东省"); + address.setCity("深圳市"); + address.setAddressLine1("旧地址"); + when(repository.findByCode(0L, "CN-SZX-01")).thenReturn(existing); + when(repository.findAddresses(0L, 9L)).thenReturn(java.util.List.of(address)); + byte[] file = withRows(service.template(), new String[][] { + {"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "", "新地址", "", "", "ACTIVE", "新联系人", "新电话", "new@example.com", "新备注"} + }); + + WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, ImportMode.UPSERT); + + assertThat(result.success()).isTrue(); + assertThat(result.committed()).isTrue(); + assertThat(result.updatedRows()).isEqualTo(1); + var captor = org.mockito.ArgumentCaptor.forClass(WarehouseRequests.SaveRequest.class); + verify(warehouseService).update(eq(0L), eq(9L), captor.capture()); + assertThat(captor.getValue().contactName()).isEqualTo("旧联系人"); + assertThat(captor.getValue().remark()).isEqualTo("旧备注"); + assertThat(captor.getValue().status()).isEqualTo("INACTIVE"); + } + + @Test + @DisplayName("dryRun 只返回预检分类和计数,不写入仓库") + void shouldOnlyPreviewWhenDryRun() throws Exception { + byte[] file = withRows(service.template(), new String[][] { + {"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "南山区", "前海路 1 号", "518000", "", "", "张三", "13800138000", "", ""}, + {"FBA-LAX9", "LAX9", "FBA", "US", "CA", "Redlands", "", "Amazon FC", "92374", "", "", "", "", "", ""} + }); + + WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null, true); + + assertThat(result.success()).isTrue(); + assertThat(result.committed()).isFalse(); + assertThat(result.createdRows()).isEqualTo(2); + assertThat(result.previewRows()).extracting("action").containsExactly("CREATE", "CREATE"); + verify(warehouseService, never()).create(any()); + verify(warehouseService, never()).update(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any()); + } + + @Test + @DisplayName("CREATE_ONLY 遇到现有仓库时不更新并按无变化统计") + void shouldSkipExistingWarehouseInCreateOnlyMode() throws Exception { + WarehouseEntity existing = new WarehouseEntity(); + existing.setId(9L); + existing.setTenantId(0L); + existing.setWarehouseCode("CN-SZX-01"); + existing.setWarehouseName("旧名称"); + existing.setWarehouseType("DOMESTIC"); + existing.setCountryCode("CN"); + existing.setTimezone("UTC+8"); + existing.setStatus("ACTIVE"); + WarehouseAddressEntity address = new WarehouseAddressEntity(); + address.setCountryCode("CN"); + address.setStateProvince("广东省"); + address.setCity("深圳市"); + address.setAddressLine1("旧地址"); + when(repository.findByCode(0L, "CN-SZX-01")).thenReturn(existing); + when(repository.findAddresses(0L, 9L)).thenReturn(java.util.List.of(address)); + byte[] file = withRows(service.template(), new String[][] { + {"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "", "新地址", "", "", "", "", "", "", ""} + }); + + WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, ImportMode.CREATE_ONLY); + + assertThat(result.success()).isTrue(); + assertThat(result.committed()).isTrue(); + assertThat(result.createdRows()).isZero(); + assertThat(result.updatedRows()).isZero(); + assertThat(result.unchangedRows()).isEqualTo(1); + assertThat(result.previewRows()).extracting("action").containsExactly("UNCHANGED"); + assertThat(result.previewRows()).extracting("reason").containsExactly("仅新增模式,现有仓库不修改"); + verify(warehouseService, never()).create(any()); + verify(warehouseService, never()).update(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any()); + } + + private static byte[] withRows(byte[] template, String[][] values) throws Exception { + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(template)); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + for (int rowIndex = 0; rowIndex < values.length; rowIndex++) { + Row row = workbook.getSheetAt(0).createRow(rowIndex + 3); + for (int column = 0; column < values[rowIndex].length; column++) { + row.createCell(column).setCellValue(values[rowIndex][column]); + } + } + workbook.write(output); + return output.toByteArray(); + } + } +} diff --git a/warehouse/src/test/resources/db/verify-warehouse-public-master-data.sql b/warehouse/src/test/resources/db/verify-warehouse-public-master-data.sql new file mode 100644 index 000000000..a7beada7f --- /dev/null +++ b/warehouse/src/test/resources/db/verify-warehouse-public-master-data.sql @@ -0,0 +1,48 @@ +-- 运行前请先在同一连接中执行 warehouse/src/main/resources/db/warehouse-public-master-data.sql。 +USE baiyue_warehouse; + +SELECT COUNT(*) AS fixed_platform_warehouse_count + FROM wh_warehouse + WHERE tenant_id = 0 + AND warehouse_code IN ('CN-YIWU-01', 'CN-DONGGUAN-01', 'US-LAX-01', 'US-NYC-01') + AND warehouse_type IN ('DOMESTIC', 'OVERSEAS') + AND deleted = 0; + +SELECT COUNT(*) AS fixed_platform_address_count + FROM wh_warehouse_address address + JOIN wh_warehouse warehouse + ON warehouse.id = address.warehouse_id + AND warehouse.tenant_id = address.tenant_id + WHERE warehouse.tenant_id = 0 + AND warehouse.warehouse_code IN ('CN-YIWU-01', 'CN-DONGGUAN-01', 'US-LAX-01', 'US-NYC-01') + AND address.address_type = 'OPERATING' + AND address.deleted = 0 + AND warehouse.deleted = 0; + +SELECT COUNT(*) AS fba_public_warehouse_count + FROM wh_warehouse + WHERE tenant_id = 0 + AND warehouse_type = 'FBA' + AND country_code = 'US' + AND deleted = 0; + +SELECT COUNT(*) AS fba_public_address_count + FROM wh_warehouse_address address + JOIN wh_warehouse warehouse + ON warehouse.id = address.warehouse_id + AND warehouse.tenant_id = address.tenant_id + WHERE warehouse.tenant_id = 0 + AND warehouse.warehouse_type = 'FBA' + AND address.address_type = 'OPERATING' + AND address.country_code = 'US' + AND address.deleted = 0 + AND warehouse.deleted = 0; + +SELECT COUNT(*) AS fba_capability_violations + FROM wh_service_capability capability + JOIN wh_warehouse warehouse + ON warehouse.id = capability.warehouse_id + AND warehouse.tenant_id = capability.tenant_id + WHERE warehouse.tenant_id = 0 + AND warehouse.warehouse_type = 'FBA' + AND capability.deleted = 0; diff --git a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs new file mode 100644 index 000000000..cfcd5a871 --- /dev/null +++ b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const currentDir = dirname(fileURLToPath(import.meta.url)) +const warehouseRoot = resolve(currentDir, '..', '..', '..') +const sqlPath = resolve(warehouseRoot, 'src/main/resources/db/warehouse-public-master-data.sql') +const acceptedPath = resolve(warehouseRoot, 'src/main/resources/db/master-data/fba-public-addresses.accepted.csv') +const rejectedPath = resolve(warehouseRoot, 'src/main/resources/db/master-data/fba-public-addresses.rejected.csv') +const fixedWarehousePath = resolve(warehouseRoot, 'src/main/resources/db/master-data/platform-fixed-warehouses.csv') + +function parseCsv(text) { + const rows = [] + let field = '' + let row = [] + let quoted = false + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + if (quoted) { + if (char === '"' && text[index + 1] === '"') { + field += '"' + index += 1 + } else if (char === '"') { + quoted = false + } else { + field += char + } + } else if (char === '"') { + quoted = true + } else if (char === ',') { + row.push(field) + field = '' + } else if (char === '\n') { + row.push(field.replace(/\r$/, '')) + rows.push(row) + row = [] + field = '' + } else { + field += char + } + } + if (field.length > 0 || row.length > 0) { + row.push(field) + rows.push(row) + } + const [headers, ...data] = rows.filter((item) => item.some((value) => value !== '')) + return data.map((values) => Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']))) +} + +const sql = readFileSync(sqlPath, 'utf8') +const accepted = parseCsv(readFileSync(acceptedPath, 'utf8')) +const rejected = parseCsv(readFileSync(rejectedPath, 'utf8')) +const fixedWarehouses = parseCsv(readFileSync(fixedWarehousePath, 'utf8')) + +assert.equal(accepted.length, 252) +assert.equal(rejected.length, 54) +assert.equal(fixedWarehouses.length, 4) + +const codes = new Set() +const warehouseIds = new Set() +const addressIds = new Set() +for (const row of accepted) { + assert.match(row.warehouse_code, /^[A-Z0-9]{3,5}$/) + assert.equal(row.warehouse_code, row.warehouse_code.trim().toUpperCase()) + assert.ok(row.city.trim()) + assert.ok(row.state_province.trim()) + assert.ok(row.address_line1.trim()) + assert.ok(row.postal_code.trim()) + assert.ok(row.timezone.trim()) + assert.equal(row.country_code, 'US') + assert.ok(!codes.has(row.warehouse_code), `duplicate code ${row.warehouse_code}`) + assert.ok(!warehouseIds.has(row.warehouse_id_seed), `duplicate warehouse id ${row.warehouse_id_seed}`) + assert.ok(!addressIds.has(row.address_id_seed), `duplicate address id ${row.address_id_seed}`) + codes.add(row.warehouse_code) + warehouseIds.add(row.warehouse_id_seed) + addressIds.add(row.address_id_seed) +} + +assert.ok(rejected.some((row) => row.reject_reason.includes('non_fba_business_node'))) +assert.equal(rejected.filter((row) => row.reject_reason.includes('timezone_pending_confirmation')).length, 1) + +assert.deepEqual(fixedWarehouses.map((row) => row.warehouse_code), [ + 'CN-YIWU-01', + 'CN-DONGGUAN-01', + 'US-LAX-01', + 'US-NYC-01', +]) +assert.deepEqual(fixedWarehouses.map((row) => row.timezone), [ + 'UTC+8', + 'UTC+8', + 'UTC-8', + 'UTC-5', +]) +assert.ok(fixedWarehouses.every((row) => row.address_line1.includes('待维护') || row.address_line1.includes('pending maintenance'))) + +assert.match(sql, /CREATE TEMPORARY TABLE tmp_wh_fixed_platform_warehouse/) +assert.match(sql, /'CN-YIWU-01','义乌仓','DOMESTIC','CN','UTC\+8'/) +assert.match(sql, /'CN-DONGGUAN-01','东莞仓','DOMESTIC','CN','UTC\+8'/) +assert.match(sql, /'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8'/) +assert.match(sql, /'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5'/) +assert.match(sql, /固定平台仓地址种子ID与既有数据冲突/) +assert.match(sql, /固定平台仓地址父子解析失败/) +assert.match(sql, /FROM tmp_wh_fixed_platform_warehouse source[\s\S]+ON DUPLICATE KEY UPDATE\s+tenant_id = VALUES\(tenant_id\);/) + +assert.match(sql, /CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data/) +assert.match(sql, /JOIN wh_warehouse warehouse\s+ON warehouse\.tenant_id = 0\s+AND warehouse\.warehouse_code = source\.warehouse_code/s) +assert.match(sql, /ON DUPLICATE KEY UPDATE\s+warehouse_type = VALUES\(warehouse_type\),\s+country_code = VALUES\(country_code\),\s+timezone = VALUES\(timezone\),\s+updated_at = CURRENT_TIMESTAMP\(3\);/s) +assert.doesNotMatch(sql, /status\s*=\s*VALUES\(status\)/) +assert.doesNotMatch(sql, /contact_name\s*=\s*VALUES\(contact_name\)/) +assert.doesNotMatch(sql, /contact_phone\s*=\s*VALUES\(contact_phone\)/) +assert.doesNotMatch(sql, /contact_email\s*=\s*VALUES\(contact_email\)/) +assert.doesNotMatch(sql, /remark\s*=\s*VALUES\(remark\)/) +assert.doesNotMatch(sql, /INSERT INTO wh_service_capability/i) +assert.match(sql, /FBA公共主数据地址种子ID与既有数据冲突/) +assert.match(sql, /FBA公共地址不得创建平台作业能力/) From dca3a48284b33acda1ef70f26c7e568a1049bb99 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 13:56:20 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InternalInboundOrderController.java | 33 +++ .../firstleg/domain/InboundProcessStatus.java | 3 +- .../firstleg/dto/UnifiedInboundDetail.java | 14 ++ .../firstleg/dto/UnifiedInboundQuery.java | 27 +++ .../firstleg/dto/UnifiedInboundRequest.java | 17 ++ .../firstleg/dto/UnifiedInboundSummary.java | 23 ++ .../repository/InboundOrderRepository.java | 29 +++ .../UnifiedInboundReadRepository.java | 105 +++++++++ .../service/UnifiedInboundQueryService.java | 174 +++++++++++++++ ...alInboundOrderReferenceControllerTest.java | 17 ++ .../UnifiedInboundQueryServiceTest.java | 40 ++++ .../client/FirstlegUnifiedOrderClient.java | 57 +++++ .../MerchantUnifiedOrderController.java | 45 ++++ .../PlatformUnifiedOrderController.java | 50 +++++ .../baiyue/order/dto/UnifiedOrderQuery.java | 29 +++ .../order/dto/UnifiedOrderResponse.java | 45 ++++ .../order/repository/OrderRepository.java | 21 ++ .../service/UnifiedOrderQueryService.java | 206 ++++++++++++++++++ .../src/main/resources/openapi/order-v1.yaml | 115 +++++++++- .../OrderControllerPermissionTest.java | 13 ++ .../service/UnifiedOrderQueryServiceTest.java | 93 ++++++++ 21 files changed, 1154 insertions(+), 2 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundRequest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundSummary.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java create mode 100644 order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java create mode 100644 order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java create mode 100644 order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java create mode 100644 order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java create mode 100644 order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java create mode 100644 order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java create mode 100644 order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index dd550b6ac..71a1caa22 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -9,6 +9,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; import com.baiyue.firstleg.dto.InboundOrderReference; import com.baiyue.firstleg.service.InboundOrderCreationService; +import com.baiyue.firstleg.service.UnifiedInboundQueryService; +import com.baiyue.firstleg.dto.UnifiedInboundDetail; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; +import com.baiyue.firstleg.dto.UnifiedInboundRequest; +import com.baiyue.firstleg.dto.UnifiedInboundSummary; +import com.baiyue.common.core.api.PageResult; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -28,6 +34,8 @@ import java.security.MessageDigest; public class InternalInboundOrderController { @Resource private InboundOrderCreationService service; + @Resource + private UnifiedInboundQueryService unifiedQueryService; @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expectedToken; @@ -60,6 +68,31 @@ public class InternalInboundOrderController { request.tenantId(), request.orderIds().stream().distinct().toList())); } + @PostMapping("/unified-orders/query") + public ApiResponse> unifiedPage( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody UnifiedInboundQuery request) { + requireToken(token); + return ApiResponse.success(unifiedQueryService.page(request)); + } + + @PostMapping("/unified-orders/summaries") + public ApiResponse> unifiedSummaries( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody UnifiedInboundRequest.BatchRequest request) { + requireToken(token); + return ApiResponse.success(unifiedQueryService.summaries( + request.tenantId(), request.orderIds().stream().distinct().toList())); + } + + @PostMapping("/unified-orders/detail") + public ApiResponse unifiedDetail( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody UnifiedInboundRequest.DetailRequest request) { + requireToken(token); + return ApiResponse.success(unifiedQueryService.detail(request.tenantId(), request.orderId())); + } + private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java index 9087c4414..7339ccc18 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java @@ -6,7 +6,8 @@ import java.util.Set; public final class InboundProcessStatus { public static final Set VALUES = Set.of( "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", - "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "VOIDED" + "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", + "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "VOIDED" ); private InboundProcessStatus() { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java new file mode 100644 index 000000000..e50938cac --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java @@ -0,0 +1,14 @@ +package com.baiyue.firstleg.dto; + +import java.time.LocalDateTime; +import java.util.List; + +/** Detail payload for the product-level order detail. */ +public record UnifiedInboundDetail( + UnifiedInboundSummary summary, + List timeline +) { + public record TimelineEvent(String source, String eventType, String fromStatus, String toStatus, + String action, String remark, Long operatorId, String operatorName, + LocalDateTime occurredAt) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java new file mode 100644 index 000000000..ef8f7fcda --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +/** Order service internal query for the product-level order line. */ +public record UnifiedInboundQuery( + Long tenantId, + @Size(max = 64) String keyword, + @Size(max = 64) String orderNo, + @Size(max = 32) String mainStatus, + Long customerId, + Long receivingWarehouseId, + Long destinationWarehouseId, + LocalDateTime createdFrom, + LocalDateTime createdTo, + @Min(1) long page, + @Min(1) @Max(200) long size +) { + public UnifiedInboundQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundRequest.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundRequest.java new file mode 100644 index 000000000..0eb3c0653 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundRequest.java @@ -0,0 +1,17 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** Internal request contracts used by Order service. */ +public final class UnifiedInboundRequest { + private UnifiedInboundRequest() {} + + public record BatchRequest(Long tenantId, + @NotEmpty @Size(max = 200) List<@Positive Long> orderIds) {} + + public record DetailRequest(@Positive long tenantId, @Positive long orderId) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundSummary.java new file mode 100644 index 000000000..1f3d3efee --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundSummary.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.dto; + +import java.time.LocalDateTime; + +/** Product-level status and operational summaries returned to Order service. */ +public record UnifiedInboundSummary( + InboundOrderSummary inbound, + String mainStatus, + String nextAction, + PreloadSummary preload, + LoadingSummary loading, + DeliverySummary delivery +) { + public record PreloadSummary(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {} + /** + * loadingVersion is currently a compatibility alias of pickingVersion because no separate + * loading aggregate row exists. C04 loading complete must use inbound.version instead. + */ + public record LoadingSummary(String status, Integer loadingVersion, Integer pickingVersion, + Integer expectedCount, Integer pickedCount, Integer evidenceCount, + String sealNo, LocalDateTime updatedAt) {} + public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index f0aba181c..4582af4a1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; /** 入库单只读仓储;平台分页可跨企业查询,详情继续强制租户隔离。 */ @Repository @@ -103,6 +104,34 @@ public class InboundOrderRepository { .last("LIMIT 1")); } + /** Internal unified-query accessors. They always retain the tenant predicate when one is supplied. */ + public Page pageUnifiedEntities(UnifiedInboundQuery query, List processStatuses) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(query.tenantId() != null, InboundOrderEntity::getTenantId, query.tenantId()) + .in(processStatuses != null && !processStatuses.isEmpty(), InboundOrderEntity::getProcessStatus, processStatuses) + .like(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo()) + .and(StringUtils.hasText(query.keyword()), nested -> nested + .like(InboundOrderEntity::getOrderNo, query.keyword()) + .or().like(InboundOrderEntity::getInboundCode, query.keyword()) + .or().like(InboundOrderEntity::getCustomerName, query.keyword())) + .eq(query.customerId() != null, InboundOrderEntity::getCustomerId, query.customerId()) + .eq(query.receivingWarehouseId() != null, InboundOrderEntity::getWarehouseId, query.receivingWarehouseId()) + .eq(query.destinationWarehouseId() != null, InboundOrderEntity::getDeliveryWarehouseId, query.destinationWarehouseId()) + .ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo()) + .orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId); + return orders.selectPage(new Page<>(query.page(), query.size()), wrapper); + } + + public List findByOrderIds(Long tenantId, List orderIds) { + if (orderIds == null || orderIds.isEmpty()) return List.of(); + return orders.selectList(new LambdaQueryWrapper() + .eq(tenantId != null, InboundOrderEntity::getTenantId, tenantId) + .in(InboundOrderEntity::getOrderId, orderIds) + .orderByDesc(InboundOrderEntity::getCreatedAt)); + } + + public InboundOrderDetail detail(long tenantId, InboundOrderEntity order) { long inboundId = order.getId(); InboundTransportSnapshotEntity transport = transports.selectOne( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java new file mode 100644 index 000000000..798ab618a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java @@ -0,0 +1,105 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; +import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper; +import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper; +import com.baiyue.firstleg.split.entity.PreloadPlanEntity; +import com.baiyue.firstleg.split.mapper.PreloadPlanMapper; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** Read-only access to phase-three operational summaries for unified order queries. */ +@Repository +public class UnifiedInboundReadRepository { + @Resource private PreloadPlanMapper preloads; + @Resource private LoadingPickTaskMapper picks; + @Resource private LoadingEvidenceMapper evidences; + @Resource private InboundStatusHistoryMapper histories; + @Resource private InboundTransportSnapshotMapper transports; + + public PreloadPlanEntity findPreload(long tenantId, long inboundId) { + return preloads.selectOne(new LambdaQueryWrapper() + .eq(PreloadPlanEntity::getTenantId, tenantId) + .eq(PreloadPlanEntity::getInboundId, inboundId)); + } + + public LoadingPickTaskEntity findPick(long tenantId, long inboundId) { + return picks.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickTaskEntity::getTenantId, tenantId) + .eq(LoadingPickTaskEntity::getInboundId, inboundId)); + } + + public List findEvidence(long tenantId, long inboundId) { + return evidences.selectList(new LambdaQueryWrapper() + .eq(LoadingEvidenceEntity::getTenantId, tenantId) + .eq(LoadingEvidenceEntity::getInboundId, inboundId) + .orderByAsc(LoadingEvidenceEntity::getEvidenceType)); + } + + public Map findPreloads(Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return preloads.selectList(new LambdaQueryWrapper() + .in(PreloadPlanEntity::getInboundId, tenantByInbound.keySet())) + .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.toMap(PreloadPlanEntity::getInboundId, Function.identity(), (left, right) -> left)); + } + + public Map findPicks(Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return picks.selectList(new LambdaQueryWrapper() + .in(LoadingPickTaskEntity::getInboundId, tenantByInbound.keySet())) + .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.toMap(LoadingPickTaskEntity::getInboundId, Function.identity(), (left, right) -> left)); + } + + public Map> findEvidence(Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return evidences.selectList(new LambdaQueryWrapper() + .in(LoadingEvidenceEntity::getInboundId, tenantByInbound.keySet()) + .orderByAsc(LoadingEvidenceEntity::getEvidenceType)) + .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.groupingBy(LoadingEvidenceEntity::getInboundId)); + } + + public Map findDeliveryHistory(Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return histories.selectList(new LambdaQueryWrapper() + .in(InboundStatusHistoryEntity::getInboundId, tenantByInbound.keySet()) + .and(nested -> nested.eq(InboundStatusHistoryEntity::getAction, "COMPLETE_DELIVERY") + .or().eq(InboundStatusHistoryEntity::getToStatus, "DELIVERED")) + .orderByDesc(InboundStatusHistoryEntity::getOccurredAt)) + .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.toMap(InboundStatusHistoryEntity::getInboundId, + Function.identity(), (latest, ignored) -> latest)); + } + + public InboundTransportSnapshotEntity findTransport(long tenantId, long inboundId) { + return transports.selectOne(new LambdaQueryWrapper() + .eq(InboundTransportSnapshotEntity::getTenantId, tenantId) + .eq(InboundTransportSnapshotEntity::getInboundId, inboundId)); + } + + public Map findTransports(Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return transports.selectList(new LambdaQueryWrapper() + .in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet())) + .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId, + Function.identity(), (left, right) -> left)); + } + + private static boolean owns(Map tenantByInbound, Long inboundId, Long tenantId) { + return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java new file mode 100644 index 000000000..fff815a8d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -0,0 +1,174 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.dto.InboundOrderDetail; +import com.baiyue.firstleg.dto.UnifiedInboundDetail; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; +import com.baiyue.firstleg.dto.UnifiedInboundSummary; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; +import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; +import com.baiyue.firstleg.repository.InboundOrderRepository; +import com.baiyue.firstleg.repository.UnifiedInboundReadRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** Read-only adapter from internal inbound states to the seven product main statuses. */ +@Service +public class UnifiedInboundQueryService { + private static final Set MAIN_STATUSES = Set.of( + "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + + @Resource private InboundOrderRepository inbounds; + @Resource private UnifiedInboundReadRepository operations; + @Resource private ObjectMapper json; + + public PageResult page(UnifiedInboundQuery query) { + validate(query); + if ("PENDING_REVIEW".equals(query.mainStatus())) { + return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + } + Page source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus())); + List records = summaries(source.getRecords()); + return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages()); + } + + public List summaries(Long tenantId, List orderIds) { + Map byOrder = new LinkedHashMap<>(); + for (UnifiedInboundSummary item : summaries(inbounds.findByOrderIds(tenantId, orderIds))) + byOrder.putIfAbsent(item.inbound().orderId(), item); + return orderIds.stream().distinct().map(byOrder::get).filter(java.util.Objects::nonNull).toList(); + } + + public UnifiedInboundDetail detail(long tenantId, long orderId) { + InboundOrderEntity inbound = inbounds.findByOrderId(tenantId, orderId); + if (inbound == null) return null; + InboundOrderDetail detail = inbounds.detail(tenantId, inbound); + List timeline = detail.statusHistory().stream() + .map(item -> new UnifiedInboundDetail.TimelineEvent( + "FIRSTLEG", "STATUS", item.fromStatus(), item.toStatus(), item.action(), item.reason(), + item.operatorId(), item.operatorName(), item.occurredAt())) + .toList(); + return new UnifiedInboundDetail(summary(inbound, detail, + operations.findPreload(tenantId, inbound.getId()), operations.findPick(tenantId, inbound.getId()), + operations.findEvidence(tenantId, inbound.getId()), deliveryAt(detail.statusHistory()), + operations.findTransport(tenantId, inbound.getId())), timeline); + } + + private List summaries(List records) { + Map owners = records.stream().collect(Collectors.toMap( + InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left)); + var preloads = operations.findPreloads(owners); + var picks = operations.findPicks(owners); + var evidences = operations.findEvidence(owners); + var deliveries = operations.findDeliveryHistory(owners); + var transports = operations.findTransports(owners); + return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()), + picks.get(inbound.getId()), evidences.getOrDefault(inbound.getId(), List.of()), + deliveries.get(inbound.getId()) == null ? null : deliveries.get(inbound.getId()).getOccurredAt(), + transports.get(inbound.getId()))).toList(); + } + + private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail, + com.baiyue.firstleg.split.entity.PreloadPlanEntity preload, + com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity pick, + List evidence, LocalDateTime deliveryAt, + InboundTransportSnapshotEntity transport) { + String mainStatus = mainStatus(inbound.getProcessStatus()); + String sealNo = evidence.stream().filter(item -> "SEAL".equals(item.getEvidenceType())) + .map(LoadingEvidenceEntity::getSealNo).filter(StringUtils::hasText).findFirst().orElse(null); + var preloadSummary = preload == null ? null : new UnifiedInboundSummary.PreloadSummary( + preload.getStatus(), preload.getVersion(), itemCount(preload.getItemsJson()), preload.getUpdatedAt()); + var loadingSummary = pick == null && evidence.isEmpty() ? null : new UnifiedInboundSummary.LoadingSummary( + pick == null ? null : pick.getStatus(), pick == null ? null : pick.getVersion(), + pick == null ? null : pick.getVersion(), pick == null ? null : pick.getExpectedCount(), + pick == null ? null : pick.getPickedCount(), evidence.size(), sealNo, + pick == null ? evidence.stream().map(LoadingEvidenceEntity::getUpdatedAt) + .filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null) : pick.getUpdatedAt()); + var deliverySummary = "LOADED".equals(mainStatus) || "DELIVERED".equals(mainStatus) + ? new UnifiedInboundSummary.DeliverySummary( + "DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt, + inbound.getExpressTrackingNo()) : null; + var inboundSummary = detail == null + ? com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, null) : detail.summary(); + return new UnifiedInboundSummary(inboundSummary, mainStatus, + nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()), + preloadSummary, loadingSummary, deliverySummary); + } + + private LocalDateTime deliveryAt(List history) { + return history.stream() + .filter(item -> "COMPLETE_DELIVERY".equals(item.action()) || "DELIVERED".equals(item.toStatus())) + .map(InboundOrderDetail.StatusHistorySummary::occurredAt) + .filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null); + } + + static String mainStatus(String status) { + if (status == null) return null; + return switch (status) { + case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE"; + case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT"; + case "INBOUNDED" -> "INBOUNDED"; + case "LOADING", "LOADED", "DELIVERED" -> status; + default -> null; + }; + } + + static String nextAction(String mainStatus, String processStatus, String preloadStatus) { + if (mainStatus == null) return null; + return switch (mainStatus) { + case "PENDING_REVIEW" -> "APPROVE_ORDER"; + case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null; + case "WAIT_SPLIT" -> !"WAIT_SPLIT".equals(processStatus) ? null + : "CONFIRMED".equals(preloadStatus) ? null + : preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD"; + case "LOADING" -> "COMPLETE_LOADING"; + case "LOADED" -> "COMPLETE_DELIVERY"; + case "DELIVERED" -> null; + default -> null; + }; + } + + private List internalStatuses(String mainStatus) { + if (!StringUtils.hasText(mainStatus)) return List.of(); + return switch (mainStatus) { + case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED"); + case "WAIT_SPLIT" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT"); + case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus); + default -> List.of("__NO_MATCH__"); + }; + } + + private int itemCount(String itemsJson) { + if (!StringUtils.hasText(itemsJson)) return 0; + try { + JsonNode node = json.readTree(itemsJson); + return node.isArray() ? node.size() : 0; + } catch (Exception ignored) { + return 0; + } + } + + private void validate(UnifiedInboundQuery query) { + if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法"); + } + if (query.createdFrom() != null && query.createdTo() != null + && query.createdFrom().isAfter(query.createdTo())) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "创建时间起点不能晚于终点"); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index 6413d93f1..020bb429e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -3,6 +3,9 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.InboundOrderReference; import com.baiyue.firstleg.service.InboundOrderCreationService; +import com.baiyue.firstleg.service.UnifiedInboundQueryService; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; +import com.baiyue.common.core.api.PageResult; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; @@ -22,6 +25,8 @@ import static org.mockito.Mockito.when; class InternalInboundOrderReferenceControllerTest { @MockBean private InboundOrderCreationService service; + @MockBean + private UnifiedInboundQueryService unifiedQueryService; @Resource private InternalInboundOrderController controller; @@ -44,4 +49,16 @@ class InternalInboundOrderReferenceControllerTest { assertThatThrownBy(() -> controller.references("bad-token", request)) .isInstanceOf(BizException.class); } + + @Test + void unifiedQueryRequiresInternalTokenAndForwardsBatchRequest() { + UnifiedInboundQuery request = new UnifiedInboundQuery( + 10001L, null, null, "WAIT_SPLIT", null, null, null, null, null, 1, 20); + when(unifiedQueryService.page(request)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + assertThat(controller.unifiedPage("internal-token", request).data().records()).isEmpty(); + assertThatThrownBy(() -> controller.unifiedPage("bad-token", request)) + .isInstanceOf(BizException.class); + verify(unifiedQueryService).page(request); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java new file mode 100644 index 000000000..983912356 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.service; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UnifiedInboundQueryServiceTest { + @Test + void mapsAllCompatibleInternalStatesToProductMainStatuses() { + assertThat(UnifiedInboundQueryService.mainStatus("WAIT_MEASURE")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("MEASURING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_PENDING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_REJECTED")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("WAIT_SPLIT"); + assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_SPLIT"); + assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_SPLIT"); + assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED"); + assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING"); + assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED"); + assertThat(UnifiedInboundQueryService.mainStatus("DELIVERED")).isEqualTo("DELIVERED"); + assertThat(UnifiedInboundQueryService.mainStatus("VOIDED")).isNull(); + } + + @Test + void nextActionReflectsExecutableMeasurementAndPreloadSubstates() { + assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURING", null)).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURE_PENDING", null)) + .isEqualTo("CONFIRM_MEASUREMENT"); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null)) + .isEqualTo("SMART_SPLIT"); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "PENDING")) + .isEqualTo("CONFIRM_PRELOAD"); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "INBOUNDING", null)).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "CONFIRMED")).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", null)).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)) + .isEqualTo("COMPLETE_DELIVERY"); + assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull(); + } +} diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java new file mode 100644 index 000000000..8a4846002 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -0,0 +1,57 @@ +package com.baiyue.order.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** Typed internal read contract from Order service to Firstleg service. */ +@FeignClient(name = "firstleg-service", contextId = "orderFirstlegUnifiedOrderClient", + url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/inbounds/unified-orders") +public interface FirstlegUnifiedOrderClient { + @PostMapping("/query") + ApiResponse> page(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody Query request); + + @PostMapping("/summaries") + ApiResponse> summaries(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody BatchRequest request); + + @PostMapping("/detail") + ApiResponse detail(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody DetailRequest request); + + record Query(Long tenantId, String keyword, String orderNo, String mainStatus, Long customerId, + Long receivingWarehouseId, Long destinationWarehouseId, + LocalDateTime createdFrom, LocalDateTime createdTo, long page, long size) {} + record BatchRequest(Long tenantId, List orderIds) {} + record DetailRequest(long tenantId, long orderId) {} + + record Summary(Inbound inbound, String mainStatus, String nextAction, + Preload preload, Loading loading, Delivery delivery) {} + record Detail(Summary summary, List timeline) {} + record Inbound(Long id, Long tenantId, Long orderId, String orderNo, String inboundCode, + String processStatus, String orderStatusSnapshot, String measureReviewStatus, + Integer version, + Long customerId, String customerName, Long warehouseId, String warehouseName, + Long deliveryWarehouseId, String deliveryWarehouseName, String channelName, + Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, + BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt, + String loadingStatus, String containerNo, String cabinetNo, String vesselName, String voyageNo, + LocalDateTime sailingAt, LocalDateTime portArrivalAt, + String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {} + record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {} + record Loading(String status, Integer loadingVersion, Integer pickingVersion, + Integer expectedCount, Integer pickedCount, Integer evidenceCount, + String sealNo, LocalDateTime updatedAt) {} + record Delivery(String status, LocalDateTime completedAt, String trackingNo) {} + record Timeline(String source, String eventType, String fromStatus, String toStatus, + String action, String remark, Long operatorId, String operatorName, + LocalDateTime occurredAt) {} +} diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java new file mode 100644 index 000000000..6ec107ab9 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java @@ -0,0 +1,45 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderResponse; +import com.baiyue.order.service.OrderScopeGuard; +import com.baiyue.order.service.UnifiedOrderQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Merchant product-level order query restricted to the authenticated customer. */ +@Tag(name = "Merchant unified orders / 客户统一订单") +@RestController +@RequestMapping("/api/merchant/v1/orders") +public class MerchantUnifiedOrderController { + @Resource private UnifiedOrderQueryService service; + + @GetMapping + @PreAuthorize("hasAuthority('merchant:order:list')") + @Operation(summary = "我的统一订单列表") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid UnifiedOrderQuery query) { + return ApiResponse.success(service.page(OrderScopeGuard.merchant(user), query)); + } + + @GetMapping("/{orderId}") + @PreAuthorize("hasAuthority('merchant:order:view')") + @Operation(summary = "我的统一订单详情", description = "仅返回当前客户订单,不包含平台内部备注。") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long orderId) { + return ApiResponse.success(service.detail(OrderScopeGuard.merchant(user), orderId, false)); + } +} diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java new file mode 100644 index 000000000..30a503f59 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -0,0 +1,50 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderResponse; +import com.baiyue.order.service.OrderScopeGuard; +import com.baiyue.order.service.UnifiedOrderQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** Platform product-level order query. Legacy first-leg paths remain available. */ +@Tag(name = "Platform unified orders / 平台统一订单") +@RestController +@RequestMapping("/api/platform/v1/orders") +public class PlatformUnifiedOrderController { + @Resource private UnifiedOrderQueryService service; + + @GetMapping + @PreAuthorize("hasAuthority('platform:order:list')") + @Operation(summary = "统一订单列表", description = "返回七态产品主状态及测量、预装、装柜、派送摘要。") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) Long tenantId, + @Valid UnifiedOrderQuery query) { + OrderScopeGuard.platform(user); + if (tenantId != null) OrderScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.page(tenantId, query)); + } + + @GetMapping("/{orderId}") + @PreAuthorize("hasAuthority('platform:order:list')") + @Operation(summary = "统一订单详情", description = "合并订单和履约时间线;平台响应包含内部备注。") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long orderId) { + return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), orderId, true)); + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java new file mode 100644 index 000000000..1141db65a --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java @@ -0,0 +1,29 @@ +package com.baiyue.order.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +/** Unified product order list filters shared by platform and merchant entry points. */ +public record UnifiedOrderQuery( + @Size(max = 64) String keyword, + @Size(max = 64) String orderNo, + @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", + "LOADING", "LOADED", "DELIVERED"}) + @Size(max = 32) String mainStatus, + Long customerId, + Long receivingWarehouseId, + Long destinationWarehouseId, + LocalDateTime createdFrom, + LocalDateTime createdTo, + @Min(1) long page, + @Min(1) @Max(200) long size +) { + public UnifiedOrderQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java new file mode 100644 index 000000000..46544709c --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -0,0 +1,45 @@ +package com.baiyue.order.dto; + +import com.baiyue.order.entity.OrderStatus; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** Stable product-level order list/detail responses. */ +public final class UnifiedOrderResponse { + private UnifiedOrderResponse() {} + + public record Row( + Long orderId, Long tenantId, String orderNo, String mainStatus, String nextAction, + OrderStatus internalOrderStatus, + Long customerId, String customerName, String channelName, + Long receivingWarehouseId, String receivingWarehouseName, + Long destinationWarehouseId, String destinationWarehouseName, + Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate, + Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, + MeasurementSummary measurement, PreloadSummary preload, + LoadingSummary loading, DeliverySummary delivery, TransportSummary transport, + LocalDateTime createdAt, LocalDateTime updatedAt + ) {} + + public record Detail(Row summary, OrderDetailResponse order, + List timeline) {} + + public record MeasurementSummary(String reviewStatus, BigDecimal actualGrossWeightKg, + BigDecimal actualVolumeCbm, BigDecimal chargeableWeightKg, + LocalDateTime completedAt) {} + public record PreloadSummary(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {} + /** loadingVersion currently aliases pickingVersion; inboundVersion is the C04 loading expectedVersion. */ + public record LoadingSummary(String status, Integer loadingVersion, Integer pickingVersion, + Integer expectedCount, Integer pickedCount, Integer evidenceCount, + String sealNo, LocalDateTime updatedAt) {} + public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {} + public record TransportSummary(String loadingStatus, String containerNo, String cabinetNo, + String vesselName, String voyageNo, LocalDateTime sailingAt, + LocalDateTime portArrivalAt) {} + public record TimelineEvent(String source, String eventType, String mainStatus, String action, + String remark, Long operatorId, String operatorName, + LocalDateTime occurredAt) {} +} diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index f018fdd4c..14a70363f 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -5,6 +5,7 @@ import jakarta.annotation.Resource; import com.baiyue.common.core.api.PageResult; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.MerchantInboundOrderQuery; +import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.entity.*; import com.baiyue.order.mapper.*; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -154,6 +155,26 @@ public class OrderRepository { return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); } + public PageResult unifiedPage(Long tenantId, UnifiedOrderQuery query, + List statuses) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(tenantId != null, OrderEntity::getTenantId, tenantId) + .in(OrderEntity::getStatus, statuses) + .like(StringUtils.hasText(query.orderNo()), OrderEntity::getOrderNo, query.orderNo()) + .eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId()) + .eq(query.receivingWarehouseId() != null, OrderEntity::getReceivingWarehouseId, + query.receivingWarehouseId()) + .eq(query.destinationWarehouseId() != null, OrderEntity::getDestinationWarehouseId, + query.destinationWarehouseId()) + .ge(query.createdFrom() != null, OrderEntity::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, OrderEntity::getCreatedAt, query.createdTo()) + .and(StringUtils.hasText(query.keyword()), nested -> nested + .like(OrderEntity::getOrderNo, query.keyword()) + .or().like(OrderEntity::getCustomerName, query.keyword())) + .orderByDesc(OrderEntity::getCreatedAt).orderByDesc(OrderEntity::getId); + return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + public List findCargoByOrderIds(long tenantId, List orderIds) { if (orderIds.isEmpty()) return List.of(); return cargoMapper.selectList(new LambdaQueryWrapper() diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java new file mode 100644 index 000000000..7f94d077e --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -0,0 +1,206 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.FirstlegUnifiedOrderClient; +import com.baiyue.order.dto.OrderDetailResponse; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderResponse; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.repository.OrderRepository; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** Product-level read façade. Existing Order and Firstleg state machines stay unchanged. */ +@Service +public class UnifiedOrderQueryService { + private static final Set MAIN_STATUSES = Set.of( + "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + private static final List ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of( + OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, + OrderStatus.PROCESSING, OrderStatus.COMPLETED)); + + @Resource private OrderRepository orders; + @Resource private FirstlegUnifiedOrderClient firstleg; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public PageResult page(Long tenantId, UnifiedOrderQuery query) { + validate(query); + if (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus())) { + return pageFromFirstleg(tenantId, query); + } + List statuses = "PENDING_REVIEW".equals(query.mainStatus()) + ? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES; + PageResult source = orders.unifiedPage(tenantId, query, statuses); + Map inboundByOrder = summaries( + tenantId, source.records().stream().map(OrderEntity::getId).toList()); + List rows = source.records().stream() + .map(order -> row(order, inboundByOrder.get(order.getId()))).toList(); + return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages()); + } + + public UnifiedOrderResponse.Detail detail(long tenantId, long orderId, boolean includeInternalNotes) { + OrderEntity order = orders.findOwned(tenantId, orderId); + if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在"); + OrderDetailResponse orderDetail = new OrderDetailResponse(order, orders.findCargo(tenantId, orderId), + orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(), + orders.findHistory(tenantId, orderId)); + FirstlegUnifiedOrderClient.Detail inbound = firstlegDetail(tenantId, orderId); + UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary()); + List timeline = new ArrayList<>(); + orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( + "ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(), + item.getOperatorId(), item.getOperatorName(), item.getOccurredAt()))); + if (inbound != null && inbound.timeline() != null) { + inbound.timeline().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( + item.source(), item.eventType(), inboundMainStatus(item.toStatus()), item.action(), item.remark(), + item.operatorId(), item.operatorName(), item.occurredAt()))); + } + timeline.sort(Comparator.comparing(UnifiedOrderResponse.TimelineEvent::occurredAt, + Comparator.nullsLast(Comparator.naturalOrder()))); + return new UnifiedOrderResponse.Detail(summary, orderDetail, timeline); + } + + private PageResult pageFromFirstleg(Long tenantId, UnifiedOrderQuery query) { + FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query( + tenantId, query.keyword(), query.orderNo(), query.mainStatus(), query.customerId(), + query.receivingWarehouseId(), query.destinationWarehouseId(), query.createdFrom(), query.createdTo(), + query.page(), query.size()); + PageResult source = require( + call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效"); + List records = source.records().stream().map(this::row).toList(); + return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); + } + + private Map summaries(Long tenantId, List orderIds) { + if (orderIds.isEmpty()) return Map.of(); + List data = require( + call(() -> firstleg.summaries(internalToken, + new FirstlegUnifiedOrderClient.BatchRequest(tenantId, orderIds))), + "统一订单履约摘要响应无效"); + return data.stream().collect(Collectors.toMap(item -> item.inbound().orderId(), + Function.identity(), (left, right) -> left)); + } + + private FirstlegUnifiedOrderClient.Detail firstlegDetail(long tenantId, long orderId) { + return requireNullable(call(() -> firstleg.detail(internalToken, + new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效"); + } + + private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary) { + if (summary == null) { + String mainStatus = orderMainStatus(order.getStatus()); + return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus, + order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null, + order.getStatus(), order.getCustomerId(), order.getCustomerName(), + order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), + order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, null, + order.getExpectedShipDate(), null, null, null, null, null, null, null, null, + order.getCreatedAt(), order.getUpdatedAt()); + } + FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound(); + return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), + summary.mainStatus(), summary.nextAction(), order.getStatus(), order.getCustomerId(), order.getCustomerName(), + order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), + order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), + order.getExpectedShipDate(), inbound.totalPackageCount(), inbound.actualGrossWeightKg(), + inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), + delivery(summary.delivery()), transport(inbound), order.getCreatedAt(), order.getUpdatedAt()); + } + + private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary) { + var inbound = summary.inbound(); + return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(), + summary.mainStatus(), summary.nextAction(), parseOrderStatus(inbound.orderStatusSnapshot()), + inbound.customerId(), inbound.customerName(), inbound.channelName(), inbound.warehouseId(), + inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(), + inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), inbound.actualGrossWeightKg(), + inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), + delivery(summary.delivery()), transport(inbound), inbound.createdAt(), inbound.updatedAt()); + } + + private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) { + return new UnifiedOrderResponse.MeasurementSummary(inbound.measureReviewStatus(), + inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.chargeableWeightKg(), + inbound.measureCompletedAt()); + } + private UnifiedOrderResponse.PreloadSummary preload(FirstlegUnifiedOrderClient.Preload value) { + return value == null ? null : new UnifiedOrderResponse.PreloadSummary( + value.status(), value.version(), value.itemCount(), value.updatedAt()); + } + private UnifiedOrderResponse.LoadingSummary loading(FirstlegUnifiedOrderClient.Loading value) { + return value == null ? null : new UnifiedOrderResponse.LoadingSummary(value.status(), value.loadingVersion(), + value.pickingVersion(), value.expectedCount(), value.pickedCount(), value.evidenceCount(), + value.sealNo(), value.updatedAt()); + } + private UnifiedOrderResponse.DeliverySummary delivery(FirstlegUnifiedOrderClient.Delivery value) { + return value == null ? null : new UnifiedOrderResponse.DeliverySummary( + value.status(), value.completedAt(), value.trackingNo()); + } + private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) { + return new UnifiedOrderResponse.TransportSummary(inbound.loadingStatus(), inbound.containerNo(), + inbound.cabinetNo(), inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt()); + } + + private String orderMainStatus(OrderStatus status) { + if (status == null) return null; + return status == OrderStatus.SUBMITTED ? "PENDING_REVIEW" + : ACTIVE_ORDER_STATUSES.contains(status) ? "WAIT_MEASURE" : null; + } + private String inboundMainStatus(String status) { + if (status == null) return null; + return switch (status) { + case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE"; + case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT"; + case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> status; + default -> null; + }; + } + private OrderStatus parseOrderStatus(String value) { + try { return value == null ? null : OrderStatus.valueOf(value); } + catch (IllegalArgumentException ignored) { return null; } + } + + private void validate(UnifiedOrderQuery query) { + if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())) { + throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法"); + } + if (query.createdFrom() != null && query.createdTo() != null + && query.createdFrom().isAfter(query.createdTo())) { + throw new BizException(ResultCode.BAD_REQUEST, "创建时间起点不能晚于终点"); + } + } + + private ApiResponse call(java.util.concurrent.Callable> invocation) { + try { return invocation.call(); } + catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); } + catch (Exception exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败"); } + } + private T require(ApiResponse response, String message) { + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message); + } + return response.data(); + } + private T requireNullable(ApiResponse response, String message) { + if (response == null || !response.successful()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message); + } + return response.data(); + } +} diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index a7ae00244..e550ba556 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -2,10 +2,74 @@ openapi: 3.0.3 info: title: 柏悦头程订单 API description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。 - version: 1.1.0 + version: 1.2.0 security: - bearerAuth: [] paths: + /api/platform/v1/orders: + get: + summary: 平台统一订单列表 + description: >- + 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 + 有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion + 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 + tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, + 指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。 + parameters: + - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedKeyword' + - $ref: '#/components/parameters/UnifiedOrderNo' + - $ref: '#/components/parameters/UnifiedMainStatus' + - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedCreatedFrom' + - $ref: '#/components/parameters/UnifiedCreatedTo' + - $ref: '#/components/parameters/UnifiedPage' + - $ref: '#/components/parameters/UnifiedSize' + responses: + '200': {description: 成功或业务失败,响应体 code 为字符串 200 时成功} + '403': {description: 需要平台身份和 platform:order:list 权限} + /api/platform/v1/orders/{orderId}: + get: + summary: 平台统一订单详情 + description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;平台可见内部备注。 + parameters: + - $ref: '#/components/parameters/UnifiedOrderId' + - $ref: '#/components/parameters/UnifiedTenantId' + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 需要平台身份和 platform:order:list 权限} + /api/merchant/v1/orders: + get: + summary: 客户统一订单列表 + description: >- + 只按登录态客户隔离查询,需要 merchant:order:list;不接受目标客户参数。 + 有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion + 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 + parameters: + - $ref: '#/components/parameters/UnifiedKeyword' + - $ref: '#/components/parameters/UnifiedOrderNo' + - $ref: '#/components/parameters/UnifiedMainStatus' + - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedCreatedFrom' + - $ref: '#/components/parameters/UnifiedCreatedTo' + - $ref: '#/components/parameters/UnifiedPage' + - $ref: '#/components/parameters/UnifiedSize' + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 需要 merchant:order:list 权限} + /api/merchant/v1/orders/{orderId}: + get: + summary: 客户统一订单详情 + description: 仅返回登录客户所属订单及客户可见时间线,不返回平台内部备注。 + parameters: + - $ref: '#/components/parameters/UnifiedOrderId' + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 需要 merchant:order:view 权限} /api/merchant/v1/orders/first-leg: get: summary: 客户端入库订单聚合列表 @@ -119,6 +183,55 @@ components: scheme: bearer bearerFormat: JWT parameters: + UnifiedKeyword: + name: keyword + in: query + required: false + schema: {type: string, maxLength: 64} + UnifiedOrderNo: + name: orderNo + in: query + required: false + schema: {type: string, maxLength: 64} + UnifiedMainStatus: + name: mainStatus + in: query + required: false + schema: + type: string + enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + UnifiedCreatedFrom: + name: createdFrom + in: query + required: false + description: 无主状态或待审核筛选按订单创建时间;其他主状态按履约快照创建时间 + schema: {type: string, format: date-time} + UnifiedCreatedTo: + name: createdTo + in: query + required: false + description: 无主状态或待审核筛选按订单创建时间;其他主状态按履约快照创建时间 + schema: {type: string, format: date-time} + UnifiedPage: + name: page + in: query + required: false + schema: {type: integer, minimum: 1, default: 1} + UnifiedSize: + name: size + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 200, default: 20} + UnifiedOrderId: + name: orderId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + UnifiedTenantId: + name: tenantId + in: query + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} TenantId: name: tenantId in: query diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 5b6addeff..03870fd24 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -3,6 +3,7 @@ package com.baiyue.order.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.MerchantInboundOrderQuery; +import com.baiyue.order.dto.UnifiedOrderQuery; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -42,6 +43,18 @@ class OrderControllerPermissionTest { AuthenticatedUser.class, long.class, long.class, String.class); } + @Test + void unifiedOrderQueriesReuseExistingReadPermissions() throws Exception { + assertAuthority(PlatformUnifiedOrderController.class, "page", "hasAuthority('platform:order:list')", + AuthenticatedUser.class, Long.class, UnifiedOrderQuery.class); + assertAuthority(PlatformUnifiedOrderController.class, "detail", "hasAuthority('platform:order:list')", + AuthenticatedUser.class, long.class, long.class); + assertAuthority(MerchantUnifiedOrderController.class, "page", "hasAuthority('merchant:order:list')", + AuthenticatedUser.class, UnifiedOrderQuery.class); + assertAuthority(MerchantUnifiedOrderController.class, "detail", "hasAuthority('merchant:order:view')", + AuthenticatedUser.class, long.class); + } + private void assertAuthority(Class controller, String methodName, String expected, Class... parameterTypes) throws NoSuchMethodException { Method method = controller.getDeclaredMethod(methodName, parameterTypes); diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java new file mode 100644 index 000000000..b42e01194 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -0,0 +1,93 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.order.client.FirstlegUnifiedOrderClient; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.repository.OrderRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(UnifiedOrderQueryService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class UnifiedOrderQueryServiceTest { + @MockBean private OrderRepository orders; + @MockBean private FirstlegUnifiedOrderClient firstleg; + @Resource private UnifiedOrderQueryService service; + + @Test + void pendingReviewIsPagedFromOrderAndMappedToApproveAction() { + UnifiedOrderQuery query = query("PENDING_REVIEW"); + OrderEntity order = order(OrderStatus.SUBMITTED); + when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of())); + + var result = service.page(10001L, query); + + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW"); + assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER"); + }); + } + + @Test + void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() { + UnifiedOrderQuery query = query("WAIT_SPLIT"); + var summary = summary("WAIT_SPLIT", "CONFIRM_PRELOAD"); + when(firstleg.page(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + + var result = service.page(null, query); + + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(row.nextAction()).isEqualTo("CONFIRM_PRELOAD"); + assertThat(row.inboundId()).isEqualTo(88L); + assertThat(row.inboundVersion()).isEqualTo(7); + assertThat(row.loading().loadingVersion()).isEqualTo(3); + assertThat(row.loading().pickingVersion()).isEqualTo(3); + assertThat(row.inboundCode()).isEqualTo("RK-77"); + }); + verify(orders, never()).unifiedPage(any(), any(), any()); + } + + private UnifiedOrderQuery query(String status) { + return new UnifiedOrderQuery(null, null, status, null, null, null, null, null, 1, 20); + } + + private OrderEntity order(OrderStatus status) { + OrderEntity order = new OrderEntity(); + order.setId(77L); order.setTenantId(10001L); order.setOrderNo("FL-77"); + order.setStatus(status); order.setCustomerId(30001L); order.setCustomerName("测试客户"); + order.setCreatedAt(LocalDateTime.of(2026, 8, 6, 10, 0)); + return order; + } + + private FirstlegUnifiedOrderClient.Summary summary(String status, String action) { + var inbound = new FirstlegUnifiedOrderClient.Inbound( + 88L, 10001L, 77L, "FL-77", "RK-77", "WAIT_SPLIT", "APPROVED", "APPROVED", 7, + 30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, null, null, null, + null, null, null, null, null, null, null, null, null, + LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0)); + return new FirstlegUnifiedOrderClient.Summary(inbound, status, action, + new FirstlegUnifiedOrderClient.Preload("PENDING", 1, 2, null), + new FirstlegUnifiedOrderClient.Loading("IN_PROGRESS", 3, 3, 10, 5, 1, "SEAL-1", null), null); + } +} From f3cfbdf59bd6e89337f013d6784541f8c78d63a4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 14:55:24 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 46 +++- deploy/sql/头程数据库统一增量-MySQL8.sql | 245 +++++++++++++++++- .../baiyue/warehouse/dto/PalletRequests.java | 11 +- .../baiyue/warehouse/entity/PalletEntity.java | 8 +- .../repository/PalletRepository.java | 25 ++ .../service/PalletApplicationService.java | 15 +- ...hase2-pallet-location-schema-migration.sql | 12 + .../db/phase2-pallet-location-schema.sql | 2 + .../src/main/resources/db/warehouse-seed.sql | 7 + 9 files changed, 351 insertions(+), 20 deletions(-) diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 6e38c9018..2c11ba65a 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,10 +1,10 @@ # 测试环境 SQL 执行说明 -## 唯一数据库发布入口 +## 唯一数据库初始化与发布入口 -- 固定入口:`头程数据库统一增量-MySQL8.sql`。 -- 该脚本汇总一期、二期当前全部数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 -- 后续数据库变更只允许在该文件末尾增加带日期和业务说明的幂等增量区块,不再创建新的部署入口 SQL。 +- 固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 +- 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 +- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 - 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 @@ -46,7 +46,43 @@ 在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。 -执行结束后必须看到 `migration_status = PASS`,并确认消息为“一期与二期统一增量执行完成,关键结构与二期权限校验通过”。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。 +执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。 + +## 三期分柜、预装、拣货与装柜当前基线 + +- 模块表结构来源:`firstleg/src/main/resources/db/phase3-split-preload-schema.sql`、`firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql`。 +- 三期平台权限直接维护在统一初始化与发布入口中,避免额外增加独立权限脚本。 +- 测试和生产环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不得再串行执行上述模块脚本。 +- 必须先执行统一增量并取得 `PASS`,再启动或重启 Firstleg Service;否则统一订单摘要查询会因缺少三期操作表而失败。 +- 统一脚本会创建 `fl_preload_plan`、`fl_split_operation`、`fl_loading_evidence`、`fl_loading_operation`、`fl_loading_pick_task`,并补齐 9 项平台操作权限。新增权限默认只授予平台系统管理员,其他角色由平台权限管理按职责配置。 + +部署前可只读盘点: + +```sql +SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' + AND table_name IN ( + 'fl_preload_plan', 'fl_split_operation', 'fl_loading_evidence', + 'fl_loading_operation', 'fl_loading_pick_task' + ) + ORDER BY table_name; + +SELECT permission_code + FROM baiyue_iam.iam_permission + WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0 + AND permission_code IN ( + 'firstleg:inbound:split', 'firstleg:inbound:preload', + 'firstleg:inbound:preload-view', + 'platform:firstleg:inbound:loading:edit', + 'platform:firstleg:inbound:loading:view', + 'platform:firstleg:inbound:loading:complete', + 'platform:firstleg:inbound:delivery:complete', + 'platform:firstleg:inbound:picking:edit', + 'platform:firstleg:inbound:picking:view' + ) + ORDER BY permission_code; +``` ## 公共导出中心存量升级 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 129b0d130..784ba3cc1 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4503,10 +4503,253 @@ CALL unified_publish_fee_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_pub 'KEY `idx_inbound_fee_published` (`tenant_id`, `inbound_id`, `publication_status`, `source_fee_version`)'); DROP PROCEDURE IF EXISTS unified_publish_fee_add_index; +-- ============================================================================ +-- 2026-08-06:三期分柜、预装、拣货、装柜和派送当前基线 +-- ============================================================================ +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_preload_plan ( + id BIGINT UNSIGNED NOT NULL COMMENT '预装清单ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单/订单ID', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CONFIRMING/CONFIRMED', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + items_json JSON NOT NULL COMMENT '可编辑分柜明细快照', + remark VARCHAR(1000) NULL COMMENT '预装备注', + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + updated_by BIGINT UNSIGNED NOT NULL, + updated_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_preload_tenant_inbound (tenant_id, inbound_id), + KEY idx_preload_status (tenant_id, status, updated_at), + CONSTRAINT fk_preload_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='订单分柜预装清单'; + +CREATE TABLE IF NOT EXISTS fl_split_operation ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + inbound_id BIGINT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operation VARCHAR(40) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING/SUCCESS', + result_json JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_split_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key), + KEY idx_split_operation_time (tenant_id, inbound_id, created_at), + CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='分柜操作幂等与审计索引'; + +CREATE TABLE IF NOT EXISTS fl_loading_evidence ( + id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1..4/SEAL', + file_id BIGINT UNSIGNED NULL COMMENT 'File 服务逻辑文件ID', + file_name VARCHAR(255) NULL, + seal_no VARCHAR(64) NULL, + remark VARCHAR(1000) NULL, + version INT NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + updated_by BIGINT UNSIGNED NOT NULL, + updated_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type), + KEY idx_loading_evidence_time (tenant_id, inbound_id, updated_at), + CONSTRAINT fk_loading_evidence_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='订单装柜四步照片和铅封证据'; + +CREATE TABLE IF NOT EXISTS fl_loading_operation ( + id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operation VARCHAR(48) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING', + result_json JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + completed_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_loading_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key), + KEY idx_loading_operation_time (tenant_id, inbound_id, created_at), + CONSTRAINT fk_loading_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='装柜与派送写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( + id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/IN_PROGRESS/COMPLETED', + version INT NOT NULL DEFAULT 0, + expected_count INT UNSIGNED NULL, + picked_count INT UNSIGNED NULL, + remark VARCHAR(1000) NULL, + updated_by BIGINT UNSIGNED NOT NULL, + updated_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id, inbound_id), + KEY idx_loading_pick_status (tenant_id, status, updated_at), + CONSTRAINT fk_loading_pick_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='PDA拣货任务及结果'; + +USE baiyue_iam; + +INSERT INTO iam_permission ( + id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted +) +VALUES + (5102091, 0, 'PLATFORM', 'BUTTON', 5102030, + 'firstleg:inbound:split', '智能分柜', NULL, NULL, 140, 'ACTIVE', 1, 0), + (5102092, 0, 'PLATFORM', 'BUTTON', 5102030, + 'firstleg:inbound:preload', '编辑并确认预装清单', NULL, NULL, 150, 'ACTIVE', 1, 0), + (5102093, 0, 'PLATFORM', 'BUTTON', 5102030, + 'firstleg:inbound:preload-view', '查看预装清单', NULL, NULL, 160, 'ACTIVE', 1, 0), + (5102094, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:loading:edit', '维护装柜证据', NULL, NULL, 170, 'ACTIVE', 1, 0), + (5102095, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:loading:view', '查看装柜证据', NULL, NULL, 180, 'ACTIVE', 1, 0), + (5102096, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:loading:complete', '完成装柜', NULL, NULL, 190, 'ACTIVE', 1, 0), + (5102097, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:delivery:complete', '完成派送', NULL, NULL, 200, 'ACTIVE', 1, 0), + (5102098, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:picking:edit', '执行拣货', NULL, NULL, 210, 'ACTIVE', 1, 0), + (5102099, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:picking:view', '查看拣货任务', NULL, NULL, 220, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = VALUES(http_method), sort_no = VALUES(sort_no), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'firstleg:inbound:split', + 'firstleg:inbound:preload', + 'firstleg:inbound:preload-view', + 'platform:firstleg:inbound:loading:edit', + 'platform:firstleg:inbound:loading:view', + 'platform:firstleg:inbound:loading:complete', + 'platform:firstleg:inbound:delivery:complete', + 'platform:firstleg:inbound:picking:edit', + 'platform:firstleg:inbound:picking:view' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + DROP PROCEDURE IF EXISTS unified_assert_final_extensions; DELIMITER $$ CREATE PROCEDURE unified_assert_final_extensions() BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' + AND table_name IN ('fl_preload_plan', 'fl_split_operation', + 'fl_loading_evidence', 'fl_loading_operation', + 'fl_loading_pick_task')) <> 5 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜表未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_preload_plan' + AND column_name IN ('id', 'tenant_id', 'inbound_id', 'status', 'version', + 'items_json', 'remark', 'created_by', 'created_by_name', + 'updated_by', 'updated_by_name', 'created_at', 'updated_at')) <> 13 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_split_operation' + AND column_name IN ('id', 'tenant_id', 'inbound_id', 'operator_id', 'operation', + 'idempotency_key', 'status', 'result_json', 'created_at')) <> 9 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_evidence' + AND column_name IN ('id', 'tenant_id', 'inbound_id', 'evidence_type', 'file_id', + 'file_name', 'seal_no', 'remark', 'version', 'created_by', + 'created_by_name', 'updated_by', 'updated_by_name', + 'created_at', 'updated_at')) <> 15 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_operation' + AND column_name IN ('id', 'tenant_id', 'inbound_id', 'operator_id', 'operation', + 'idempotency_key', 'request_hash', 'status', 'result_json', + 'created_at', 'completed_at')) <> 11 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' + AND column_name IN ('id', 'tenant_id', 'inbound_id', 'status', 'version', + 'expected_count', 'picked_count', 'remark', 'updated_by', + 'updated_by_name', 'created_at', 'updated_at')) <> 12 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜关键字段未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name IN ('fl_preload_plan', 'fl_split_operation', 'fl_loading_operation') + AND column_name IN ('items_json', 'result_json') AND data_type = 'json') <> 3 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order' + AND column_name = 'process_status' AND character_maximum_length >= 24) <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:三期 JSON 字段或入库状态字段类型不兼容'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name)) + FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' + AND index_name IN ('uk_preload_tenant_inbound', 'idx_preload_status', + 'uk_split_operation', 'idx_split_operation_time', + 'uk_loading_evidence_type', 'idx_loading_evidence_time', + 'uk_loading_operation', 'idx_loading_operation_time', + 'uk_loading_pick_tenant_inbound', 'idx_loading_pick_status')) <> 10 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜索引未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = 'baiyue_firstleg' AND constraint_type = 'FOREIGN KEY' + AND table_name IN ('fl_preload_plan', 'fl_split_operation', + 'fl_loading_evidence', 'fl_loading_operation', + 'fl_loading_pick_task')) <> 5 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:三期操作表入库单外键未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission + WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0 + AND permission_code IN ( + 'firstleg:inbound:split', 'firstleg:inbound:preload', + 'firstleg:inbound:preload-view', + 'platform:firstleg:inbound:loading:edit', + 'platform:firstleg:inbound:loading:view', + 'platform:firstleg:inbound:loading:complete', + 'platform:firstleg:inbound:delivery:complete', + 'platform:firstleg:inbound:picking:edit', + 'platform:firstleg:inbound:picking:view')) <> 9 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜权限未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT rp.permission_id) + FROM baiyue_iam.iam_role_permission rp + JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id + WHERE rp.role_id = 4000001 AND rp.tenant_id = 0 + AND p.permission_code IN ( + 'firstleg:inbound:split', 'firstleg:inbound:preload', + 'firstleg:inbound:preload-view', + 'platform:firstleg:inbound:loading:edit', + 'platform:firstleg:inbound:loading:view', + 'platform:firstleg:inbound:loading:complete', + 'platform:firstleg:inbound:delivery:complete', + 'platform:firstleg:inbound:picking:edit', + 'platform:firstleg:inbound:picking:view')) <> 9 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:平台系统管理员未获得完整三期操作权限'; + END IF; IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' AND table_name IN ('fl_fee_item', 'fl_inbound_fee_detail')) <> 2 THEN @@ -4693,4 +4936,4 @@ SELECT COUNT(*) AS unresolved_third_party_calls WHERE tenant_id = 0; SELECT 'PASS' AS migration_status, - '统一增量全部执行完成,平台公共资源归0、Warehouse父子回填、费用及可靠审计结构校验通过' AS message; + '统一数据库脚本执行完成,三期出仓、费用、公共资源及可靠审计结构校验通过' AS message; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java index 3b5e567bb..65397b801 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -22,15 +22,18 @@ public final class PalletRequests { @NotNull @Min(1) @Schema(description = "所属仓库ID", example = "190000000000000002", requiredMode = Schema.RequiredMode.REQUIRED) Long warehouseId, - @NotBlank @Size(max = 50) - @Schema(description = "仓库内唯一托盘编码", example = "TP-001", requiredMode = Schema.RequiredMode.REQUIRED) - String palletCode, @NotBlank @Size(max = 32) @Schema(description = "托盘类型", example = "木托盘", requiredMode = Schema.RequiredMode.REQUIRED) String palletType, @NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) @Schema(description = "托盘容积,单位m³", example = "1.200000", requiredMode = Schema.RequiredMode.REQUIRED) BigDecimal capacityCbm, + @NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 3) + @Schema(description = "托盘重量,单位kg", example = "8.000", requiredMode = Schema.RequiredMode.REQUIRED) + BigDecimal weightKg, + @NotBlank @Size(max = 32) + @Schema(description = "托盘颜色", example = "黄色", requiredMode = Schema.RequiredMode.REQUIRED) + String color, @Size(max = 500) @Schema(description = "备注", example = "标准川字托盘") String remark @@ -64,6 +67,8 @@ public final class PalletRequests { String palletCode, String palletType, BigDecimal capacityCbm, + BigDecimal weightKg, + String color, String remark, LocalDateTime createdAt, LocalDateTime updatedAt diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java index 2edf443bd..1bfb73b41 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java @@ -11,7 +11,7 @@ import lombok.Setter; import java.math.BigDecimal; /** - * 托盘主数据实体。状态同时承载可用、预占、占用和停用四种资源状态。 + * 托盘主数据实体。状态仅供内部预占、占用流程使用,不属于平台维护字段。 */ @Getter @Setter @@ -37,6 +37,12 @@ public class PalletEntity extends BaseEntity { @Schema(description = "托盘容积,单位m³", example = "1.200000") private BigDecimal capacityCbm; + @Schema(description = "托盘重量,单位kg", example = "8.000") + private BigDecimal weightKg; + + @Schema(description = "托盘颜色", example = "黄色") + private String color; + @Schema(description = "资源状态", allowableValues = {"AVAILABLE", "RESERVED", "OCCUPIED", "DISABLED"}) private String status; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java index b5e981f50..8b8d3bd4d 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java @@ -13,16 +13,21 @@ import org.springframework.util.StringUtils; import java.util.Collection; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** 托盘持久化仓储;平台分页支持跨租户筛选,资源操作始终包含租户和仓库边界。 */ @Repository public class PalletRepository { + private static final Pattern PALLET_CODE = Pattern.compile("TP-(\\d+)"); + @Resource private PalletMapper mapper; public PageResult page(Long tenantId, PalletRequests.PageQuery query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(tenantId != null, PalletEntity::getTenantId, tenantId) + .eq(PalletEntity::getDeleted, false) .eq(query.warehouseId() != null, PalletEntity::getWarehouseId, query.warehouseId()) .like(StringUtils.hasText(query.palletCode()), PalletEntity::getPalletCode, query.palletCode()) .eq(StringUtils.hasText(query.palletType()), PalletEntity::getPalletType, query.palletType()) @@ -36,6 +41,7 @@ public class PalletRepository { return mapper.selectOne(new LambdaQueryWrapper() .eq(PalletEntity::getTenantId, tenantId) .eq(PalletEntity::getWarehouseId, warehouseId) + .eq(PalletEntity::getDeleted, false) .eq(PalletEntity::getId, id)); } @@ -44,6 +50,7 @@ public class PalletRepository { .eq(PalletEntity::getTenantId, tenantId) .eq(PalletEntity::getWarehouseId, warehouseId) .eq(PalletEntity::getPalletCode, code) + .eq(PalletEntity::getDeleted, false) .ne(excludedId != null, PalletEntity::getId, excludedId)) > 0; } @@ -52,6 +59,7 @@ public class PalletRepository { return mapper.selectList(new LambdaQueryWrapper() .eq(PalletEntity::getTenantId, tenantId) .eq(PalletEntity::getWarehouseId, warehouseId) + .eq(PalletEntity::getDeleted, false) .eq(PalletEntity::getStatus, "AVAILABLE") .eq(StringUtils.hasText(palletType), PalletEntity::getPalletType, palletType) .eq(capacityCbm != null, PalletEntity::getCapacityCbm, capacityCbm) @@ -59,6 +67,23 @@ public class PalletRepository { .last("LIMIT " + limit)); } + public String nextCode(long tenantId, long warehouseId) { + int maxSequence = mapper.selectList(new LambdaQueryWrapper() + .select(PalletEntity::getPalletCode) + .eq(PalletEntity::getTenantId, tenantId) + .eq(PalletEntity::getWarehouseId, warehouseId) + .likeRight(PalletEntity::getPalletCode, "TP-") + .eq(PalletEntity::getDeleted, false)) + .stream() + .map(PalletEntity::getPalletCode) + .map(PALLET_CODE::matcher) + .filter(Matcher::matches) + .mapToInt(matcher -> Integer.parseInt(matcher.group(1))) + .max() + .orElse(0); + return "TP-%03d".formatted(maxSequence + 1); + } + public void insert(PalletEntity entity) { mapper.insert(entity); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java index 05d810337..23bb44810 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java @@ -35,10 +35,10 @@ public class PalletApplicationService { @BusinessAudit(event = "PALLET_CREATE", resourceType = "PALLET", description = "新增托盘", captureBefore = false) public Long create(long tenantId, PalletRequests.SaveRequest request) { requireWarehouse(tenantId, request.warehouseId()); - ensureCodeUnique(tenantId, request.warehouseId(), request.palletCode(), null); PalletEntity entity = new PalletEntity(); entity.setTenantId(tenantId); entity.setWarehouseId(request.warehouseId()); + entity.setPalletCode(repository.nextCode(tenantId, request.warehouseId())); copy(entity, request); entity.setStatus("AVAILABLE"); try { @@ -56,7 +56,6 @@ public class PalletApplicationService { throw new BizException(ResultCode.FORBIDDEN, "请求仓库与托盘所属仓库不一致"); } PalletEntity entity = owned(tenantId, warehouseId, id); - ensureCodeUnique(tenantId, warehouseId, request.palletCode(), id); copy(entity, request); try { if (repository.save(entity) != 1) { @@ -90,22 +89,18 @@ public class PalletApplicationService { } } - private void ensureCodeUnique(long tenantId, long warehouseId, String code, Long excludedId) { - if (repository.existsCode(tenantId, warehouseId, code, excludedId)) { - throw new BizException(ResultCode.CONFLICT, "托盘编码已存在"); - } - } - private static void copy(PalletEntity entity, PalletRequests.SaveRequest request) { - entity.setPalletCode(request.palletCode()); entity.setPalletType(request.palletType()); entity.setCapacityCbm(request.capacityCbm()); + entity.setWeightKg(request.weightKg()); + entity.setColor(request.color()); entity.setRemark(request.remark()); } private static PalletRequests.View view(PalletEntity entity) { return new PalletRequests.View(entity.getId(), entity.getTenantId(), entity.getWarehouseId(), - entity.getPalletCode(), entity.getPalletType(), entity.getCapacityCbm(), entity.getRemark(), + entity.getPalletCode(), entity.getPalletType(), entity.getCapacityCbm(), entity.getWeightKg(), + entity.getColor(), entity.getRemark(), entity.getCreatedAt(), entity.getUpdatedAt()); } } diff --git a/warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql b/warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql index efd046aba..d80b47b5f 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql @@ -3,6 +3,18 @@ SET NAMES utf8mb4; USE baiyue_warehouse; +-- 托盘展示字段增量:重量、颜色;平台接口不暴露内部状态。 +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND column_name = 'weight_kg') = 0, + 'ALTER TABLE wh_pallet ADD COLUMN weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''托盘重量kg'' AFTER capacity_cbm', + 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND column_name = 'color') = 0, + 'ALTER TABLE wh_pallet ADD COLUMN color VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''托盘颜色'' AFTER weight_kg', + 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + -- 活动预占必须同时保证“一托一位”;A0 共享只放宽库位,不放宽托盘唯一性。 SET @schema_name = DATABASE(); DROP PROCEDURE IF EXISTS add_pallet_reservation_column_if_missing; diff --git a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql index c9ac1af2f..798c56dab 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql @@ -15,6 +15,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', pallet_name VARCHAR(100) NULL COMMENT '托盘名称', capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', + weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘重量kg', + color VARCHAR(32) NOT NULL DEFAULT '' COMMENT '托盘颜色', status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT '内部状态:AVAILABLE、RESERVED或OCCUPIED', remark VARCHAR(500) NULL COMMENT '备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', diff --git a/warehouse/src/main/resources/db/warehouse-seed.sql b/warehouse/src/main/resources/db/warehouse-seed.sql index 4011ae8da..7772bf475 100644 --- a/warehouse/src/main/resources/db/warehouse-seed.sql +++ b/warehouse/src/main/resources/db/warehouse-seed.sql @@ -11,6 +11,13 @@ INSERT INTO wh_service_capability(id,tenant_id,warehouse_id,capability_code,capa VALUES(190000000000000021,0,190000000000000001,'RECEIVING','头程收货',1,'16:00:00','工作日16点前预约可当日收货') ON DUPLICATE KEY UPDATE tenant_id=0,enabled=1,cutoff_time=VALUES(cutoff_time); +-- 二期托盘初始化数据。编码由平台维护接口自动生成,种子固定为 TP-001、TP-002。 +INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,status,remark,created_by,updated_by) +VALUES + (190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE','无备注',0,0), + (190000000000000102,0,190000000000000001,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE','无备注',0,0) +ON DUPLICATE KEY UPDATE tenant_id=0,warehouse_id=VALUES(warehouse_id),pallet_type=VALUES(pallet_type),capacity_cbm=VALUES(capacity_cbm),weight_kg=VALUES(weight_kg),color=VALUES(color),remark=VALUES(remark); + -- 一期联调快递公司主数据,供平台维护和订单履约回显验证。 INSERT INTO wh_express_company(id,tenant_id,company_code,company_name,service_phone,website,tracking_url_template,status,remark) VALUES(192000000000000001,0,'SF','顺丰速运','95338','https://www.sf-express.com','https://www.sf-express.com/chn/sc/dynamic_function/waybill/#search/bill-number/{trackingNo}','ACTIVE','平台公共快递公司样本') From 5f8b1f64dacde2f91d39d7db97f16629b5d556a4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 15:32:11 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 54 +++++++++++++- .../firstleg/dto/UnifiedInboundQuery.java | 14 ++++ .../repository/InboundOrderRepository.java | 54 ++++++++++++++ .../service/UnifiedInboundQueryService.java | 3 +- ...alInboundOrderReferenceControllerTest.java | 4 +- .../InboundOrderRepositoryTest.java | 32 +++++++++ .../client/FirstlegUnifiedOrderClient.java | 5 ++ .../baiyue/order/dto/UnifiedOrderQuery.java | 13 ++++ .../order/repository/OrderRepository.java | 18 +++++ .../service/UnifiedOrderQueryService.java | 29 +++++++- .../src/main/resources/openapi/order-v1.yaml | 72 +++++++++++++++++++ .../service/UnifiedOrderQueryServiceTest.java | 35 ++++++++- .../baiyue/warehouse/mapper/PalletMapper.java | 7 ++ .../repository/PalletRepository.java | 18 ++--- .../service/PalletApplicationService.java | 37 +++++++--- ...hase2-pallet-location-schema-migration.sql | 30 ++++++++ .../db/phase2-pallet-location-schema.sql | 2 +- .../main/resources/db/phase2-pallet-seed.sql | 17 +++++ .../src/main/resources/db/warehouse-seed.sql | 7 -- .../WarehouseSchemaContractTest.java | 3 + .../service/PalletApplicationServiceTest.java | 17 +++++ 21 files changed, 435 insertions(+), 36 deletions(-) create mode 100644 warehouse/src/main/resources/db/phase2-pallet-seed.sql diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 784ba3cc1..6ff72d6a6 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1828,6 +1828,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', pallet_name VARCHAR(100) NULL COMMENT '托盘名称', capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', + weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘重量kg', + color VARCHAR(32) NOT NULL DEFAULT '' COMMENT '托盘颜色', status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT '内部状态:AVAILABLE、RESERVED或OCCUPIED', remark VARCHAR(500) NULL COMMENT '备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', @@ -1837,13 +1839,23 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_pallet_tenant_warehouse_code (tenant_id, warehouse_id, pallet_code), + UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code), KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted), KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted), CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0) ) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例'; +-- 存量库补齐托盘重量、颜色字段(可重复执行)。 +SET @pallet_weight_sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND column_name = 'weight_kg') = 0, + 'ALTER TABLE wh_pallet ADD COLUMN weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''托盘重量kg'' AFTER capacity_cbm', 'SELECT 1'); +PREPARE pallet_weight_stmt FROM @pallet_weight_sql; EXECUTE pallet_weight_stmt; DEALLOCATE PREPARE pallet_weight_stmt; +SET @pallet_color_sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND column_name = 'color') = 0, + 'ALTER TABLE wh_pallet ADD COLUMN color VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''托盘颜色'' AFTER weight_kg', 'SELECT 1'); +PREPARE pallet_color_stmt FROM @pallet_color_sql; EXECUTE pallet_color_stmt; DEALLOCATE PREPARE pallet_color_stmt; + CREATE TABLE IF NOT EXISTS wh_location ( id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共库位固定使用 tenant_id=0', @@ -2112,6 +2124,27 @@ UPDATE wh_location SET tenant_id = 0 WHERE tenant_id <> 0; UPDATE wh_location_reservation SET tenant_id = 0 WHERE tenant_id <> 0; UPDATE wh_location_occupancy SET tenant_id = 0 WHERE tenant_id <> 0; +-- 托盘编码在平台公共作用域全局唯一;包含逻辑删除记录,编码不复用。 +DROP PROCEDURE IF EXISTS assert_pallet_code_globally_unique; +DELIMITER $$ +CREATE PROCEDURE assert_pallet_code_globally_unique() +BEGIN + IF EXISTS (SELECT 1 FROM wh_pallet GROUP BY tenant_id, pallet_code HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '托盘编码迁移失败:不同仓库存在重复编码,请先清理'; + END IF; +END$$ +DELIMITER ; +CALL assert_pallet_code_globally_unique(); +DROP PROCEDURE IF EXISTS assert_pallet_code_globally_unique; +SET @pallet_code_index_sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND index_name = 'uk_pallet_tenant_warehouse_code') > 0, + 'ALTER TABLE wh_pallet DROP INDEX uk_pallet_tenant_warehouse_code', 'SELECT 1'); +PREPARE pallet_code_index_stmt FROM @pallet_code_index_sql; EXECUTE pallet_code_index_stmt; DEALLOCATE PREPARE pallet_code_index_stmt; +SET @pallet_code_index_sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND index_name = 'uk_pallet_tenant_code') = 0, + 'ALTER TABLE wh_pallet ADD UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code)', 'SELECT 1'); +PREPARE pallet_code_index_stmt FROM @pallet_code_index_sql; EXECUTE pallet_code_index_stmt; DEALLOCATE PREPARE pallet_code_index_stmt; + DROP PROCEDURE IF EXISTS assert_phase2_public_resource_scope; DELIMITER $$ CREATE PROCEDURE assert_phase2_public_resource_scope() @@ -3928,6 +3961,23 @@ JOIN wh_warehouse warehouse ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); +-- 平台托盘初始化主数据;内部状态仅供预占/占用,不作为平台维护字段。 +INSERT INTO wh_pallet ( + id, tenant_id, warehouse_id, pallet_code, pallet_type, capacity_cbm, + weight_kg, color, status, remark, created_by, updated_by +) +VALUES + (193000000000200101,0,193000000000200001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE',NULL,0,0), + (193000000000200102,0,193000000000200001,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE',NULL,0,0) +ON DUPLICATE KEY UPDATE + tenant_id = 0, + warehouse_id = VALUES(warehouse_id), + pallet_type = VALUES(pallet_type), + capacity_cbm = VALUES(capacity_cbm), + weight_kg = VALUES(weight_kg), + color = VALUES(color), + remark = VALUES(remark); + DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data; CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data ( warehouse_id_seed BIGINT UNSIGNED NOT NULL, @@ -4936,4 +4986,4 @@ SELECT COUNT(*) AS unresolved_third_party_calls WHERE tenant_id = 0; SELECT 'PASS' AS migration_status, - '统一数据库脚本执行完成,三期出仓、费用、公共资源及可靠审计结构校验通过' AS message; + '统一增量全部执行完成;统一数据库脚本执行完成,三期出仓、费用、公共资源及可靠审计结构校验通过' AS message; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java index ef8f7fcda..0b7b4c850 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java @@ -4,7 +4,9 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Size; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; /** Order service internal query for the product-level order line. */ public record UnifiedInboundQuery( @@ -15,6 +17,18 @@ public record UnifiedInboundQuery( Long customerId, Long receivingWarehouseId, Long destinationWarehouseId, + @Size(max = 64) String inboundCode, + @Size(max = 32) String internalOrderStatus, + LocalDate expectedArrivalDate, + @Size(max = 32) String feeReviewStatus, + @Size(max = 128) String channelName, + @Size(max = 64) String containerNo, + @Size(max = 64) String cabinetNo, + LocalDate sailingDate, + Boolean loaded, + Boolean fullContainer, + @Size(max = 32) String measurementReviewStatus, + List candidateOrderIds, LocalDateTime createdFrom, LocalDateTime createdTo, @Min(1) long page, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index 4582af4a1..52c150b75 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -110,6 +110,9 @@ public class InboundOrderRepository { .eq(query.tenantId() != null, InboundOrderEntity::getTenantId, query.tenantId()) .in(processStatuses != null && !processStatuses.isEmpty(), InboundOrderEntity::getProcessStatus, processStatuses) .like(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo()) + .like(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode, query.inboundCode()) + .eq(StringUtils.hasText(query.internalOrderStatus()), InboundOrderEntity::getOrderStatusSnapshot, + query.internalOrderStatus()) .and(StringUtils.hasText(query.keyword()), nested -> nested .like(InboundOrderEntity::getOrderNo, query.keyword()) .or().like(InboundOrderEntity::getInboundCode, query.keyword()) @@ -117,6 +120,57 @@ public class InboundOrderRepository { .eq(query.customerId() != null, InboundOrderEntity::getCustomerId, query.customerId()) .eq(query.receivingWarehouseId() != null, InboundOrderEntity::getWarehouseId, query.receivingWarehouseId()) .eq(query.destinationWarehouseId() != null, InboundOrderEntity::getDeliveryWarehouseId, query.destinationWarehouseId()) + .ge(query.expectedArrivalDate() != null, InboundOrderEntity::getExpectedArrivalAt, + query.expectedArrivalDate() == null ? null : query.expectedArrivalDate().atStartOfDay()) + .lt(query.expectedArrivalDate() != null, InboundOrderEntity::getExpectedArrivalAt, + query.expectedArrivalDate() == null ? null : query.expectedArrivalDate().plusDays(1).atStartOfDay()) + .eq(StringUtils.hasText(query.channelName()), InboundOrderEntity::getChannelName, query.channelName()) + .eq(query.fullContainer() != null, InboundOrderEntity::getContainerMode, + query.fullContainer() == null ? null : query.fullContainer() ? "FULL_CONTAINER" : "LCL") + .and("UNRECORDED".equals(query.measurementReviewStatus()), nested -> nested + .isNull(InboundOrderEntity::getMeasureReviewStatus) + .or().eq(InboundOrderEntity::getMeasureReviewStatus, "")) + .eq(StringUtils.hasText(query.measurementReviewStatus()) + && !"UNRECORDED".equals(query.measurementReviewStatus()), + InboundOrderEntity::getMeasureReviewStatus, query.measurementReviewStatus()) + .in(query.candidateOrderIds() != null && !query.candidateOrderIds().isEmpty(), + InboundOrderEntity::getOrderId, + query.candidateOrderIds() == null ? List.of() : query.candidateOrderIds()) + .apply("UNRECORDED".equals(query.feeReviewStatus()), + "NOT EXISTS (SELECT 1 FROM fl_inbound_fee f WHERE f.tenant_id = fl_inbound_order.tenant_id " + + "AND f.inbound_id = fl_inbound_order.id)") + .apply(StringUtils.hasText(query.feeReviewStatus()) && !"UNRECORDED".equals(query.feeReviewStatus()), + "EXISTS (SELECT 1 FROM fl_inbound_fee f WHERE f.tenant_id = fl_inbound_order.tenant_id " + + "AND f.inbound_id = fl_inbound_order.id AND f.review_status = {0} " + + "AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer " + + "WHERE newer.tenant_id = f.tenant_id AND newer.inbound_id = f.inbound_id " + + "AND (newer.fee_version > f.fee_version " + + "OR (newer.fee_version = f.fee_version AND newer.id > f.id))))", + query.feeReviewStatus()) + .apply(StringUtils.hasText(query.containerNo()), + "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " + + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " + + "AND t.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo()) + .apply(StringUtils.hasText(query.cabinetNo()), + "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " + + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " + + "AND t.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo()) + .apply(query.sailingDate() != null, + "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " + + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " + + "AND t.sailing_at >= {0} AND t.sailing_at < {1})", + query.sailingDate() == null ? null : query.sailingDate().atStartOfDay(), + query.sailingDate() == null ? null : query.sailingDate().plusDays(1).atStartOfDay()) + .apply(Boolean.TRUE.equals(query.loaded()), + "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " + + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " + + "AND COALESCE(TRIM(t.loading_status), '') <> '' " + + "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))") + .apply(Boolean.FALSE.equals(query.loaded()), + "NOT EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " + + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " + + "AND COALESCE(TRIM(t.loading_status), '') <> '' " + + "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))") .ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo()) .orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index fff815a8d..dba6db1c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -36,7 +36,8 @@ public class UnifiedInboundQueryService { public PageResult page(UnifiedInboundQuery query) { validate(query); - if ("PENDING_REVIEW".equals(query.mainStatus())) { + if ("PENDING_REVIEW".equals(query.mainStatus()) + || query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) { return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); } Page source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus())); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index 020bb429e..a24689cdc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -53,7 +53,9 @@ class InternalInboundOrderReferenceControllerTest { @Test void unifiedQueryRequiresInternalTokenAndForwardsBatchRequest() { UnifiedInboundQuery request = new UnifiedInboundQuery( - 10001L, null, null, "WAIT_SPLIT", null, null, null, null, null, 1, 20); + 10001L, null, null, "WAIT_SPLIT", null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, + null, null, 1, 20); when(unifiedQueryService.page(request)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); assertThat(controller.unifiedPage("internal-token", request).data().records()).isEmpty(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java index 9d8eb8804..c7523ba68 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.InboundOrderQuery; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; @@ -29,6 +30,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; +import java.time.LocalDate; +import java.util.List; + @SpringJUnitConfig(InboundOrderRepository.class) class InboundOrderRepositoryTest { @MockBean private InboundOrderMapper orders; @@ -38,6 +42,34 @@ class InboundOrderRepositoryTest { @MockBean private InboundInternalNoteMapper notes; @MockBean private InboundStatusHistoryMapper histories; @Resource private InboundOrderRepository repository; + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void unifiedQueryPushesExpandedFiltersIntoDatabasePage() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class); + when(orders.selectPage(any(Page.class), any(Wrapper.class))) + .thenReturn(new Page(1, 20)); + UnifiedInboundQuery query = new UnifiedInboundQuery( + 10001L, null, null, "LOADING", 30001L, 40001L, 50001L, + "RK-", "APPROVED", LocalDate.of(2026, 8, 6), "APPROVED", "美森", + "MSCU", "CAB", LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", + List.of(77L, 78L), null, null, 1, 20); + + repository.pageUnifiedEntities(query, List.of("LOADING")); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(orders).selectPage(any(Page.class), captor.capture()); + String sql = captor.getValue().getCustomSqlSegment(); + assertThat(sql).contains("inbound_code", "order_status_snapshot", "expected_arrival_at", + "channel_name", "container_mode", "measure_review_status", "order_id IN", + "fl_inbound_fee", "fl_inbound_transport_snapshot", "sailing_at", "loading_status"); + LambdaQueryWrapper wrapper = + (LambdaQueryWrapper) captor.getValue(); + assertThat(wrapper.getParamNameValuePairs().values()) + .contains(10001L, 30001L, 40001L, 50001L, "%RK-%", "APPROVED", "美森", + "MSCU", "CAB", 77L, 78L); + } + @Test @SuppressWarnings({"rawtypes", "unchecked"}) void shouldApplyTenantCompanyAndWarehouseFilters() { diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index 8a4846002..7dd7f1ec8 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -29,6 +30,10 @@ public interface FirstlegUnifiedOrderClient { record Query(Long tenantId, String keyword, String orderNo, String mainStatus, Long customerId, Long receivingWarehouseId, Long destinationWarehouseId, + String inboundCode, String internalOrderStatus, LocalDate expectedArrivalDate, + String feeReviewStatus, String channelName, String containerNo, String cabinetNo, + LocalDate sailingDate, Boolean loaded, Boolean fullContainer, + String measurementReviewStatus, List candidateOrderIds, LocalDateTime createdFrom, LocalDateTime createdTo, long page, long size) {} record BatchRequest(Long tenantId, List orderIds) {} record DetailRequest(long tenantId, long orderId) {} diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java index 1141db65a..fecdc4dd6 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Size; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; import java.time.LocalDateTime; /** Unified product order list filters shared by platform and merchant entry points. */ @@ -17,6 +18,18 @@ public record UnifiedOrderQuery( Long customerId, Long receivingWarehouseId, Long destinationWarehouseId, + @Size(max = 64) String inboundCode, + @Size(max = 32) String internalOrderStatus, + LocalDate expectedArrivalDate, + @Size(max = 32) String feeReviewStatus, + @Size(max = 128) String channelName, + @Size(max = 64) String containerNo, + @Size(max = 64) String cabinetNo, + LocalDate sailingDate, + Boolean loaded, + Boolean fullContainer, + @Size(max = 32) String measurementReviewStatus, + @Size(max = 64) String fbaCode, LocalDateTime createdFrom, LocalDateTime createdTo, @Min(1) long page, diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 14a70363f..95a58f973 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -166,6 +166,15 @@ public class OrderRepository { query.receivingWarehouseId()) .eq(query.destinationWarehouseId() != null, OrderEntity::getDestinationWarehouseId, query.destinationWarehouseId()) + .eq(StringUtils.hasText(query.internalOrderStatus()), OrderEntity::getStatus, + StringUtils.hasText(query.internalOrderStatus()) ? OrderStatus.valueOf(query.internalOrderStatus()) : null) + .eq(StringUtils.hasText(query.channelName()), OrderEntity::getChannelName, query.channelName()) + .eq(query.fullContainer() != null, OrderEntity::getContainerMode, + query.fullContainer() == null ? null : query.fullContainer() ? "FULL_CONTAINER" : "LCL") + .apply(StringUtils.hasText(query.fbaCode()), + "EXISTS (SELECT 1 FROM ord_order_cargo c WHERE c.tenant_id = ord_order.tenant_id " + + "AND c.order_id = ord_order.id AND c.deleted = 0 AND c.sku_no LIKE CONCAT('%', {0}, '%'))", + query.fbaCode()) .ge(query.createdFrom() != null, OrderEntity::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, OrderEntity::getCreatedAt, query.createdTo()) .and(StringUtils.hasText(query.keyword()), nested -> nested @@ -175,6 +184,15 @@ public class OrderRepository { return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); } + public List findOrderIdsBySkuNo(Long tenantId, String fbaCode) { + if (!StringUtils.hasText(fbaCode)) return List.of(); + QueryWrapper wrapper = new QueryWrapper() + .select("DISTINCT order_id") + .eq(tenantId != null, "tenant_id", tenantId) + .like("sku_no", fbaCode); + return cargoMapper.selectObjs(wrapper).stream().map(value -> ((Number) value).longValue()).toList(); + } + public List findCargoByOrderIds(long tenantId, List orderIds) { if (orderIds.isEmpty()) return List.of(); return cargoMapper.selectList(new LambdaQueryWrapper() diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 7f94d077e..d64df8f7c 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -41,7 +41,10 @@ public class UnifiedOrderQueryService { public PageResult page(Long tenantId, UnifiedOrderQuery query) { validate(query); - if (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus())) { + if (requiresFirstlegQuery(query)) { + if ("PENDING_REVIEW".equals(query.mainStatus())) { + return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + } return pageFromFirstleg(tenantId, query); } List statuses = "PENDING_REVIEW".equals(query.mainStatus()) @@ -77,9 +80,17 @@ public class UnifiedOrderQueryService { } private PageResult pageFromFirstleg(Long tenantId, UnifiedOrderQuery query) { + List candidateOrderIds = StringUtils.hasText(query.fbaCode()) + ? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null; + if (candidateOrderIds != null && candidateOrderIds.isEmpty()) { + return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + } FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query( tenantId, query.keyword(), query.orderNo(), query.mainStatus(), query.customerId(), - query.receivingWarehouseId(), query.destinationWarehouseId(), query.createdFrom(), query.createdTo(), + query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(), + query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(), + query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(), + query.measurementReviewStatus(), candidateOrderIds, query.createdFrom(), query.createdTo(), query.page(), query.size()); PageResult source = require( call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效"); @@ -184,6 +195,20 @@ public class UnifiedOrderQueryService { && query.createdFrom().isAfter(query.createdTo())) { throw new BizException(ResultCode.BAD_REQUEST, "创建时间起点不能晚于终点"); } + if (StringUtils.hasText(query.internalOrderStatus())) { + try { OrderStatus.valueOf(query.internalOrderStatus()); } + catch (IllegalArgumentException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "订单内部状态不合法"); + } + } + } + + private boolean requiresFirstlegQuery(UnifiedOrderQuery query) { + return StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus()) + || StringUtils.hasText(query.inboundCode()) || query.expectedArrivalDate() != null + || StringUtils.hasText(query.feeReviewStatus()) || StringUtils.hasText(query.containerNo()) + || StringUtils.hasText(query.cabinetNo()) || query.sailingDate() != null || query.loaded() != null + || StringUtils.hasText(query.measurementReviewStatus()); } private ApiResponse call(java.util.concurrent.Callable> invocation) { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index e550ba556..8042d7a4e 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -23,6 +23,18 @@ paths: - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedInboundCode' + - $ref: '#/components/parameters/UnifiedInternalOrderStatus' + - $ref: '#/components/parameters/UnifiedExpectedArrivalDate' + - $ref: '#/components/parameters/UnifiedFeeReviewStatus' + - $ref: '#/components/parameters/UnifiedChannelName' + - $ref: '#/components/parameters/UnifiedContainerNo' + - $ref: '#/components/parameters/UnifiedCabinetNo' + - $ref: '#/components/parameters/UnifiedSailingDate' + - $ref: '#/components/parameters/UnifiedLoaded' + - $ref: '#/components/parameters/UnifiedFullContainer' + - $ref: '#/components/parameters/UnifiedMeasurementReviewStatus' + - $ref: '#/components/parameters/UnifiedFbaCode' - $ref: '#/components/parameters/UnifiedCreatedFrom' - $ref: '#/components/parameters/UnifiedCreatedTo' - $ref: '#/components/parameters/UnifiedPage' @@ -54,6 +66,18 @@ paths: - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedInboundCode' + - $ref: '#/components/parameters/UnifiedInternalOrderStatus' + - $ref: '#/components/parameters/UnifiedExpectedArrivalDate' + - $ref: '#/components/parameters/UnifiedFeeReviewStatus' + - $ref: '#/components/parameters/UnifiedChannelName' + - $ref: '#/components/parameters/UnifiedContainerNo' + - $ref: '#/components/parameters/UnifiedCabinetNo' + - $ref: '#/components/parameters/UnifiedSailingDate' + - $ref: '#/components/parameters/UnifiedLoaded' + - $ref: '#/components/parameters/UnifiedFullContainer' + - $ref: '#/components/parameters/UnifiedMeasurementReviewStatus' + - $ref: '#/components/parameters/UnifiedFbaCode' - $ref: '#/components/parameters/UnifiedCreatedFrom' - $ref: '#/components/parameters/UnifiedCreatedTo' - $ref: '#/components/parameters/UnifiedPage' @@ -200,6 +224,54 @@ components: schema: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + UnifiedInboundCode: + {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 64}} + UnifiedInternalOrderStatus: + name: internalOrderStatus + in: query + required: false + description: Order 内部生命周期状态;不是七态产品主状态 + schema: {type: string, enum: [DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED]} + UnifiedExpectedArrivalDate: + {name: expectedArrivalDate, in: query, required: false, schema: {type: string, format: date}} + UnifiedFeeReviewStatus: + name: feeReviewStatus + in: query + required: false + description: 当前最高版本入库费用审核状态;UNRECORDED 表示尚无费用记录 + schema: {type: string, maxLength: 32} + UnifiedChannelName: + {name: channelName, in: query, required: false, schema: {type: string, maxLength: 128}} + UnifiedContainerNo: + {name: containerNo, in: query, required: false, schema: {type: string, maxLength: 64}} + UnifiedCabinetNo: + {name: cabinetNo, in: query, required: false, schema: {type: string, maxLength: 64}} + UnifiedSailingDate: + {name: sailingDate, in: query, required: false, schema: {type: string, format: date}} + UnifiedLoaded: + name: loaded + in: query + required: false + description: true 表示运输装载状态存在且不是 UNLOADED/PENDING;false 表示无该状态 + schema: {type: boolean} + UnifiedFullContainer: + name: fullContainer + in: query + required: false + description: true 对应 FULL_CONTAINER,false 对应 LCL + schema: {type: boolean} + UnifiedMeasurementReviewStatus: + name: measurementReviewStatus + in: query + required: false + description: 测量审核状态;UNRECORDED 表示字段为空 + schema: {type: string, maxLength: 32} + UnifiedFbaCode: + name: fbaCode + in: query + required: false + description: 兼容匹配订单货物 sku_no + schema: {type: string, maxLength: 64} UnifiedCreatedFrom: name: createdFrom in: query diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index b42e01194..d57a14cc1 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -9,11 +9,13 @@ import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.repository.OrderRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -68,8 +70,39 @@ class UnifiedOrderQueryServiceTest { verify(orders, never()).unifiedPage(any(), any(), any()); } + @Test + void expandedFulfillmentFiltersAreForwardedBeforeDatabasePaging() { + UnifiedOrderQuery query = new UnifiedOrderQuery( + null, null, "LOADING", 30001L, 11L, 22L, "RK-77", "APPROVED", + LocalDate.of(2026, 8, 6), "APPROVED", "美森", "MSCU", "CAB-1", + LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", "FBA-1", + null, null, 1, 20); + when(orders.findOrderIdsBySkuNo(10001L, "FBA-1")).thenReturn(List.of(77L)); + when(firstleg.page(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 20, 0, 0))); + + service.page(10001L, query); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); + verify(firstleg).page(eq("internal-token"), captor.capture()); + assertThat(captor.getValue()).satisfies(request -> { + assertThat(request.inboundCode()).isEqualTo("RK-77"); + assertThat(request.internalOrderStatus()).isEqualTo("APPROVED"); + assertThat(request.expectedArrivalDate()).isEqualTo(LocalDate.of(2026, 8, 6)); + assertThat(request.containerNo()).isEqualTo("MSCU"); + assertThat(request.sailingDate()).isEqualTo(LocalDate.of(2026, 8, 8)); + assertThat(request.loaded()).isTrue(); + assertThat(request.fullContainer()).isTrue(); + assertThat(request.measurementReviewStatus()).isEqualTo("UNRECORDED"); + assertThat(request.candidateOrderIds()).containsExactly(77L); + }); + } + private UnifiedOrderQuery query(String status) { - return new UnifiedOrderQuery(null, null, status, null, null, null, null, null, 1, 20); + return new UnifiedOrderQuery(null, null, status, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, + null, null, 1, 20); } private OrderEntity order(OrderStatus status) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java index 693c8b691..9497d69b8 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java @@ -2,7 +2,14 @@ package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.PalletEntity; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; /** 托盘主数据 Mapper。 */ public interface PalletMapper extends BaseMapper { + /** 编码序列必须包含逻辑删除记录,避免历史编码被复用。 */ + @Select("SELECT pallet_code FROM wh_pallet WHERE tenant_id = #{tenantId} AND pallet_code LIKE 'TP-%'") + List selectAllCodes(@Param("tenantId") long tenantId); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java index 8b8d3bd4d..d34bf08de 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java @@ -27,7 +27,6 @@ public class PalletRepository { public PageResult page(Long tenantId, PalletRequests.PageQuery query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(tenantId != null, PalletEntity::getTenantId, tenantId) - .eq(PalletEntity::getDeleted, false) .eq(query.warehouseId() != null, PalletEntity::getWarehouseId, query.warehouseId()) .like(StringUtils.hasText(query.palletCode()), PalletEntity::getPalletCode, query.palletCode()) .eq(StringUtils.hasText(query.palletType()), PalletEntity::getPalletType, query.palletType()) @@ -41,7 +40,6 @@ public class PalletRepository { return mapper.selectOne(new LambdaQueryWrapper() .eq(PalletEntity::getTenantId, tenantId) .eq(PalletEntity::getWarehouseId, warehouseId) - .eq(PalletEntity::getDeleted, false) .eq(PalletEntity::getId, id)); } @@ -50,7 +48,6 @@ public class PalletRepository { .eq(PalletEntity::getTenantId, tenantId) .eq(PalletEntity::getWarehouseId, warehouseId) .eq(PalletEntity::getPalletCode, code) - .eq(PalletEntity::getDeleted, false) .ne(excludedId != null, PalletEntity::getId, excludedId)) > 0; } @@ -59,7 +56,6 @@ public class PalletRepository { return mapper.selectList(new LambdaQueryWrapper() .eq(PalletEntity::getTenantId, tenantId) .eq(PalletEntity::getWarehouseId, warehouseId) - .eq(PalletEntity::getDeleted, false) .eq(PalletEntity::getStatus, "AVAILABLE") .eq(StringUtils.hasText(palletType), PalletEntity::getPalletType, palletType) .eq(capacityCbm != null, PalletEntity::getCapacityCbm, capacityCbm) @@ -67,15 +63,13 @@ public class PalletRepository { .last("LIMIT " + limit)); } - public String nextCode(long tenantId, long warehouseId) { - int maxSequence = mapper.selectList(new LambdaQueryWrapper() - .select(PalletEntity::getPalletCode) - .eq(PalletEntity::getTenantId, tenantId) - .eq(PalletEntity::getWarehouseId, warehouseId) - .likeRight(PalletEntity::getPalletCode, "TP-") - .eq(PalletEntity::getDeleted, false)) + /** + * 生成平台作用域内下一个编码。编码序列跨仓库共用,且包含逻辑删除记录,确保编码永不复用。 + */ + public String nextCode(long tenantId) { + List existing = mapper.selectAllCodes(tenantId); + int maxSequence = (existing == null ? List.of() : existing) .stream() - .map(PalletEntity::getPalletCode) .map(PALLET_CODE::matcher) .filter(Matcher::matches) .mapToInt(matcher -> Integer.parseInt(matcher.group(1))) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java index 23bb44810..7d5fff487 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java @@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional; /** 托盘主数据应用服务。 */ @Service public class PalletApplicationService { + private static final int CODE_INSERT_ATTEMPTS = 5; + @Resource private PalletRepository repository; @Resource @@ -34,19 +36,23 @@ public class PalletApplicationService { @Transactional @BusinessAudit(event = "PALLET_CREATE", resourceType = "PALLET", description = "新增托盘", captureBefore = false) public Long create(long tenantId, PalletRequests.SaveRequest request) { - requireWarehouse(tenantId, request.warehouseId()); + requireWarehouse(tenantId, request.warehouseId(), true); PalletEntity entity = new PalletEntity(); entity.setTenantId(tenantId); entity.setWarehouseId(request.warehouseId()); - entity.setPalletCode(repository.nextCode(tenantId, request.warehouseId())); copy(entity, request); entity.setStatus("AVAILABLE"); - try { - repository.insert(entity); - } catch (DataIntegrityViolationException exception) { - throw new BizException(ResultCode.CONFLICT, "托盘编码已存在"); + String code = repository.nextCode(tenantId); + for (int attempt = 0; attempt < CODE_INSERT_ATTEMPTS; attempt++) { + entity.setPalletCode(code); + try { + repository.insert(entity); + return entity.getId(); + } catch (DataIntegrityViolationException exception) { + code = incrementCode(code); + } } - return entity.getId(); + throw new BizException(ResultCode.CONFLICT, "托盘编码生成冲突,请重试"); } @Transactional @@ -55,6 +61,7 @@ public class PalletApplicationService { if (warehouseId != request.warehouseId()) { throw new BizException(ResultCode.FORBIDDEN, "请求仓库与托盘所属仓库不一致"); } + requireWarehouse(tenantId, warehouseId, false); PalletEntity entity = owned(tenantId, warehouseId, id); copy(entity, request); try { @@ -83,10 +90,17 @@ public class PalletApplicationService { return entity; } - private void requireWarehouse(long tenantId, long warehouseId) { - if (warehouseRepository.findOwned(tenantId, warehouseId) == null) { + private void requireWarehouse(long tenantId, long warehouseId, boolean requireActive) { + var warehouse = warehouseRepository.findOwned(tenantId, warehouseId); + if (warehouse == null) { throw new BizException(ResultCode.FORBIDDEN, "仓库不存在或不属于目标租户"); } + if (!"DOMESTIC".equalsIgnoreCase(warehouse.getWarehouseType())) { + throw new BizException(ResultCode.FORBIDDEN, "托盘仅支持国内仓"); + } + if (requireActive && !"ACTIVE".equalsIgnoreCase(warehouse.getStatus())) { + throw new BizException(ResultCode.FORBIDDEN, "新增托盘仅支持启用中的国内仓"); + } } private static void copy(PalletEntity entity, PalletRequests.SaveRequest request) { @@ -97,6 +111,11 @@ public class PalletApplicationService { entity.setRemark(request.remark()); } + private static String incrementCode(String code) { + int sequence = Integer.parseInt(code.substring("TP-".length())) + 1; + return "TP-%03d".formatted(sequence); + } + private static PalletRequests.View view(PalletEntity entity) { return new PalletRequests.View(entity.getId(), entity.getTenantId(), entity.getWarehouseId(), entity.getPalletCode(), entity.getPalletType(), entity.getCapacityCbm(), entity.getWeightKg(), diff --git a/warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql b/warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql index d80b47b5f..14486e5a1 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql @@ -9,6 +9,7 @@ SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns 'ALTER TABLE wh_pallet ADD COLUMN weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''托盘重量kg'' AFTER capacity_cbm', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND column_name = 'color') = 0, 'ALTER TABLE wh_pallet ADD COLUMN color VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''托盘颜色'' AFTER weight_kg', @@ -53,6 +54,35 @@ UPDATE wh_location SET tenant_id = 0 WHERE tenant_id <> 0; UPDATE wh_location_reservation SET tenant_id = 0 WHERE tenant_id <> 0; UPDATE wh_location_occupancy SET tenant_id = 0 WHERE tenant_id <> 0; +-- 托盘编码在平台公共作用域全局唯一,避免不同仓库并发生成重复编码。 +DROP PROCEDURE IF EXISTS assert_pallet_code_globally_unique; +DELIMITER $$ +CREATE PROCEDURE assert_pallet_code_globally_unique() +BEGIN + IF EXISTS ( + SELECT 1 FROM wh_pallet + GROUP BY tenant_id, pallet_code + HAVING COUNT(*) > 1 + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '托盘编码迁移失败:不同仓库存在重复编码,请先清理'; + END IF; +END$$ +DELIMITER ; +CALL assert_pallet_code_globally_unique(); +DROP PROCEDURE IF EXISTS assert_pallet_code_globally_unique; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' + AND index_name = 'uk_pallet_tenant_warehouse_code') > 0, + 'ALTER TABLE wh_pallet DROP INDEX uk_pallet_tenant_warehouse_code', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' + AND index_name = 'uk_pallet_tenant_code') = 0, + 'ALTER TABLE wh_pallet ADD UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' diff --git a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql index 798c56dab..540b3a541 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql @@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_pallet_tenant_warehouse_code (tenant_id, warehouse_id, pallet_code), + UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code), KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted), KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted), CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), diff --git a/warehouse/src/main/resources/db/phase2-pallet-seed.sql b/warehouse/src/main/resources/db/phase2-pallet-seed.sql new file mode 100644 index 000000000..c97815071 --- /dev/null +++ b/warehouse/src/main/resources/db/phase2-pallet-seed.sql @@ -0,0 +1,17 @@ +-- 二期托盘初始化数据。执行顺序:warehouse-seed.sql、phase2-pallet-location-schema.sql、增量迁移、本文件。 +SET NAMES utf8mb4; +USE baiyue_warehouse; + +-- 编码由平台维护接口自动生成;种子固定为 TP-001、TP-002。 +INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,status,remark,created_by,updated_by) +VALUES + (190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE',NULL,0,0), + (190000000000000102,0,190000000000000001,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE',NULL,0,0) +ON DUPLICATE KEY UPDATE + tenant_id=0, + warehouse_id=VALUES(warehouse_id), + pallet_type=VALUES(pallet_type), + capacity_cbm=VALUES(capacity_cbm), + weight_kg=VALUES(weight_kg), + color=VALUES(color), + remark=VALUES(remark); diff --git a/warehouse/src/main/resources/db/warehouse-seed.sql b/warehouse/src/main/resources/db/warehouse-seed.sql index 7772bf475..4011ae8da 100644 --- a/warehouse/src/main/resources/db/warehouse-seed.sql +++ b/warehouse/src/main/resources/db/warehouse-seed.sql @@ -11,13 +11,6 @@ INSERT INTO wh_service_capability(id,tenant_id,warehouse_id,capability_code,capa VALUES(190000000000000021,0,190000000000000001,'RECEIVING','头程收货',1,'16:00:00','工作日16点前预约可当日收货') ON DUPLICATE KEY UPDATE tenant_id=0,enabled=1,cutoff_time=VALUES(cutoff_time); --- 二期托盘初始化数据。编码由平台维护接口自动生成,种子固定为 TP-001、TP-002。 -INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,status,remark,created_by,updated_by) -VALUES - (190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE','无备注',0,0), - (190000000000000102,0,190000000000000001,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE','无备注',0,0) -ON DUPLICATE KEY UPDATE tenant_id=0,warehouse_id=VALUES(warehouse_id),pallet_type=VALUES(pallet_type),capacity_cbm=VALUES(capacity_cbm),weight_kg=VALUES(weight_kg),color=VALUES(color),remark=VALUES(remark); - -- 一期联调快递公司主数据,供平台维护和订单履约回显验证。 INSERT INTO wh_express_company(id,tenant_id,company_code,company_name,service_phone,website,tracking_url_template,status,remark) VALUES(192000000000000001,0,'SF','顺丰速运','95338','https://www.sf-express.com','https://www.sf-express.com/chn/sc/dynamic_function/waybill/#search/bill-number/{trackingNo}','ACTIVE','平台公共快递公司样本') diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 697f0fd70..3894f5d18 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -43,6 +43,9 @@ class WarehouseSchemaContractTest { @Test void shouldHandleCase04() throws IOException { String sql = read("phase2-pallet-location-schema.sql"); + assertColumns(sql, "wh_pallet", "tenant_id", "warehouse_id", "pallet_code", "pallet_type", + "capacity_cbm", "weight_kg", "color", "status", "remark", "deleted", "version"); + assertThat(sql).contains("uk_pallet_tenant_code (tenant_id, pallet_code)"); assertColumns(sql, "wh_location", "tenant_id", "warehouse_id", "zone_code", "zone_name", "location_code", "location_type", "max_weight_kg", "shared_overflow", "status", "remark", "deleted", "version"); assertColumns(sql, "wh_location_reservation", "idempotency_key", "business_id", "pallet_id", diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java index b566bc9ae..be0252b42 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.PageResult; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.entity.PalletEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.PalletRepository; import com.baiyue.warehouse.repository.WarehouseRepository; import org.junit.jupiter.api.Test; @@ -16,6 +17,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.math.BigDecimal; + @SpringJUnitConfig(PalletApplicationService.class) class PalletApplicationServiceTest { @MockBean private PalletRepository repository; @@ -46,6 +49,20 @@ class PalletApplicationServiceTest { verify(repository).page(null, query); } + @Test + void rejectsOverseasWarehouseForCreate() { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setWarehouseType("OVERSEAS"); + warehouse.setStatus("ACTIVE"); + when(warehouseRepository.findOwned(0, 20)).thenReturn(warehouse); + PalletRequests.SaveRequest request = new PalletRequests.SaveRequest( + 20L, "木托盘", new BigDecimal("1.2"), new BigDecimal("8"), "黄色", null); + + assertThatThrownBy(() -> service.create(0, request)) + .isInstanceOf(BizException.class) + .hasMessageContaining("仅支持国内仓"); + } + private static PalletEntity pallet(String status) { PalletEntity entity = new PalletEntity(); entity.setId(30L); From 3ecb8d7613eb1044a1900fbe30e94b54afee03bd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 15:36:23 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/InboundOrderRepositoryTest.java | 6 +++--- .../order/repository/OrderRepository.java | 1 + .../order/service/UnifiedOrderQueryService.java | 10 +++++++++- order/src/main/resources/openapi/order-v1.yaml | 2 +- .../service/UnifiedOrderQueryServiceTest.java | 17 ++++++++++++++++- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java index c7523ba68..8fa57849c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java @@ -23,6 +23,9 @@ import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import java.time.LocalDate; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -30,9 +33,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; -import java.time.LocalDate; -import java.util.List; - @SpringJUnitConfig(InboundOrderRepository.class) class InboundOrderRepositoryTest { @MockBean private InboundOrderMapper orders; diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 95a58f973..c2d9b2eab 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -189,6 +189,7 @@ public class OrderRepository { QueryWrapper wrapper = new QueryWrapper() .select("DISTINCT order_id") .eq(tenantId != null, "tenant_id", tenantId) + .eq("deleted", 0) .like("sku_no", fbaCode); return cargoMapper.selectObjs(wrapper).stream().map(value -> ((Number) value).longValue()).toList(); } diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index d64df8f7c..e45b24dd9 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -31,6 +31,7 @@ import java.util.stream.Collectors; public class UnifiedOrderQueryService { private static final Set MAIN_STATUSES = Set.of( "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); private static final List ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of( OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, OrderStatus.PROCESSING, OrderStatus.COMPLETED)); @@ -201,10 +202,17 @@ public class UnifiedOrderQueryService { throw new BizException(ResultCode.BAD_REQUEST, "订单内部状态不合法"); } } + if (StringUtils.hasText(query.feeReviewStatus()) && !REVIEW_STATUSES.contains(query.feeReviewStatus())) { + throw new BizException(ResultCode.BAD_REQUEST, "费用审核状态不合法"); + } + if (StringUtils.hasText(query.measurementReviewStatus()) + && !REVIEW_STATUSES.contains(query.measurementReviewStatus())) { + throw new BizException(ResultCode.BAD_REQUEST, "测量审核状态不合法"); + } } private boolean requiresFirstlegQuery(UnifiedOrderQuery query) { - return StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus()) + return (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus())) || StringUtils.hasText(query.inboundCode()) || query.expectedArrivalDate() != null || StringUtils.hasText(query.feeReviewStatus()) || StringUtils.hasText(query.containerNo()) || StringUtils.hasText(query.cabinetNo()) || query.sailingDate() != null || query.loaded() != null diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 8042d7a4e..8587de123 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 柏悦头程订单 API description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。 - version: 1.2.0 + version: 1.3.0 security: - bearerAuth: [] paths: diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index d57a14cc1..246bcec01 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -2,6 +2,7 @@ package com.baiyue.order.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.FirstlegUnifiedOrderClient; import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.entity.OrderEntity; @@ -14,11 +15,12 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import java.time.LocalDateTime; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; @@ -99,6 +101,19 @@ class UnifiedOrderQueryServiceTest { }); } + @Test + void rejectsUnknownReviewStatusesBeforeQueryingEitherService() { + UnifiedOrderQuery query = new UnifiedOrderQuery( + null, null, null, null, null, null, null, null, + null, "UNKNOWN", null, null, null, null, null, null, "INVALID", null, + null, null, 1, 20); + + assertThatThrownBy(() -> service.page(10001L, query)) + .isInstanceOf(BizException.class); + verify(orders, never()).unifiedPage(any(), any(), any()); + verify(firstleg, never()).page(any(), any()); + } + private UnifiedOrderQuery query(String status) { return new UnifiedOrderQuery(null, null, status, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, From 9c4bae9f5c43e99b7190608363f08158f87a82ea Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 15:43:35 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/UnifiedInboundQueryService.java | 10 ++++++++++ .../service/UnifiedInboundQueryServiceTest.java | 14 ++++++++++++++ order/src/main/resources/openapi/order-v1.yaml | 4 ++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index dba6db1c4..014a70ed6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -29,6 +29,7 @@ import java.util.stream.Collectors; public class UnifiedInboundQueryService { private static final Set MAIN_STATUSES = Set.of( "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); @Resource private InboundOrderRepository inbounds; @Resource private UnifiedInboundReadRepository operations; @@ -171,5 +172,14 @@ public class UnifiedInboundQueryService { throw new com.baiyue.common.core.exception.BizException( com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "创建时间起点不能晚于终点"); } + if (StringUtils.hasText(query.feeReviewStatus()) && !REVIEW_STATUSES.contains(query.feeReviewStatus())) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "费用审核状态不合法"); + } + if (StringUtils.hasText(query.measurementReviewStatus()) + && !REVIEW_STATUSES.contains(query.measurementReviewStatus())) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "测量审核状态不合法"); + } } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java index 983912356..7d3d2a564 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -1,8 +1,11 @@ package com.baiyue.firstleg.service; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class UnifiedInboundQueryServiceTest { @Test @@ -37,4 +40,15 @@ class UnifiedInboundQueryServiceTest { .isEqualTo("COMPLETE_DELIVERY"); assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull(); } + + @Test + void rejectsUnknownReviewStatusBeforeRepositoryQuery() { + UnifiedInboundQuery query = new UnifiedInboundQuery( + null, null, null, null, null, null, null, null, + null, null, "UNKNOWN", null, null, null, null, null, null, "INVALID", + null, null, null, 1, 20); + + assertThatThrownBy(() -> new UnifiedInboundQueryService().page(query)) + .isInstanceOf(BizException.class); + } } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 8587de123..5be30c370 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -239,7 +239,7 @@ components: in: query required: false description: 当前最高版本入库费用审核状态;UNRECORDED 表示尚无费用记录 - schema: {type: string, maxLength: 32} + schema: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]} UnifiedChannelName: {name: channelName, in: query, required: false, schema: {type: string, maxLength: 128}} UnifiedContainerNo: @@ -265,7 +265,7 @@ components: in: query required: false description: 测量审核状态;UNRECORDED 表示字段为空 - schema: {type: string, maxLength: 32} + schema: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]} UnifiedFbaCode: name: fbaCode in: query