From 8cf7c4b9c90bec51b45240bf64f4b1e544eaa657 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 14 Sep 2026 09:58:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/local/iam-service.yml | 3 + deploy/sql/头程数据库统一增量-MySQL8.sql | 39 +- deploy/sql/海外区域统一增量-MySQL8.sql | 73 ++- .../main/resources/db/lastleg-permissions.sql | 14 +- .../db/phase5-delivery-permissions.sql | 25 +- ...erseasPhase4PermissionSqlContractTest.java | 7 +- .../controller/LastlegOrderController.java | 7 + .../delivery/mapper/OutboundOrderMapper.java | 8 + .../service/DeliveryTransferService.java | 31 +- .../controller/DispatchController.java | 11 + .../controller/SmartDispatchController.java | 4 +- .../mapper/DispatchCandidateMapper.java | 55 +- .../dispatch/mapper/DispatchDraftMapper.java | 8 +- .../dispatch/mapper/DispatchItemMapper.java | 12 + .../mapper/DispatchPalletLockMapper.java | 5 + .../model/DispatchPalletCandidate.java | 7 +- .../mapper/DispatchPdaPickOrderMapper.java | 6 +- .../service/DispatchPdaWorkflowService.java | 28 +- .../DispatchPickOrderAttributionService.java | 13 + .../dispatch/service/DispatchService.java | 164 ++++-- .../lastleg/dto/LastlegOrderSummary.java | 3 +- .../lastleg/mapper/LastlegOrderMapper.java | 9 + .../PdaPalletOperationController.java | 7 + .../dto/PalletOperationRequests.java | 2 + .../dto/PalletOperationResponses.java | 4 +- .../gateway/HttpPalletPhysicalGateway.java | 20 +- .../gateway/PalletPhysicalGateway.java | 9 +- .../service/PdaPalletOperationService.java | 38 +- .../MerchantRelabelApplicationController.java | 7 + .../RelabelApplicationController.java | 20 + .../lastleg/relabel/dto/RelabelRequests.java | 3 + .../lastleg/relabel/dto/RelabelResponses.java | 3 + .../lastleg/relabel/entity/RelabelItem.java | 3 + .../mapper/RelabelApplicationMapper.java | 8 + .../pda/service/PdaRelabelService.java | 18 + .../service/RelabelApplicationService.java | 122 ++++- .../MerchantReturnOrderController.java | 9 + .../dto/ReturnOrderRequests.java | 4 + .../dto/ReturnOrderResponses.java | 4 + .../entity/ReturnInventoryCarton.java | 1 + .../service/ReturnInventoryService.java | 43 +- .../service/ReturnReceiptService.java | 67 +++ .../lastleg/security/OverseasPermissions.java | 2 + .../lastleg/service/LastlegOrderService.java | 35 +- .../MerchantWarehouseTransferController.java | 7 + .../PdaWarehouseTransferController.java | 43 ++ .../WarehouseTransferController.java | 20 +- .../dto/WarehouseTransferRequests.java | 52 +- .../dto/WarehouseTransferResponses.java | 24 +- .../entity/WarehouseTransferCarton.java | 1 + .../entity/WarehouseTransferOrder.java | 12 + .../entity/WarehouseTransferPallet.java | 33 ++ .../entity/WarehouseTransferPalletItem.java | 20 + .../WarehouseTransferRelabelBinding.java | 25 + .../mapper/WarehouseTransferOrderMapper.java | 13 + .../WarehouseTransferPalletItemMapper.java | 16 + .../mapper/WarehouseTransferPalletMapper.java | 16 + ...WarehouseTransferRelabelBindingMapper.java | 16 + .../service/WarehouseTransferService.java | 475 +++++++++++++++++- .../src/main/resources/db/lastleg.sql | 45 ++ .../openapi/overseas-business-v1.yaml | 49 +- .../openapi/overseas-pda-operations-v1.yaml | 18 +- .../service/DeliveryTransferServiceTest.java | 3 + .../DispatchCandidateMapperContractTest.java | 18 +- .../DispatchPdaWorkflowServiceTest.java | 28 ++ .../dispatch/service/DispatchServiceTest.java | 39 +- ...PalletOperationControllerContractTest.java | 3 +- .../PdaPalletOperationServiceTest.java | 35 +- .../RelabelApplicationServiceTest.java | 29 ++ ...houseTransferControllerPermissionTest.java | 35 ++ ...WarehouseTransferPalletValidationTest.java | 38 ++ ...phase4-overseas-dispatch-contract.test.mjs | 9 + .../phase4-overseas-dispatch.mjs | 49 ++ ...-overseas-pda-operations-contract.test.mjs | 9 +- .../phase4-overseas-pda-operations.mjs | 156 +++++- ...dev-lastleg-relabel-return-incremental.sql | 143 +++++- scripts/db/fix-filter-name-503.sql | 34 ++ scripts/db/incremental.sql | 41 ++ ...one-click-dropship-outbound-candidates.sql | 18 +- ...verseas-relabel-fulfillment-acceptance.mjs | 111 ++++ scripts/overseas-relabel-real-acceptance.mjs | 193 +++++++ .../InternalWarehousePalletController.java | 9 + .../dto/PalletTransitionRequests.java | 3 +- .../dto/PalletTransitionResponse.java | 4 +- .../warehouse/dto/RelabelPalletRequests.java | 12 + .../dto/WarehousePalletResolveResponse.java | 6 +- .../WarehousePalletTransitionMapper.java | 2 +- .../WarehousePalletTransitionService.java | 40 +- .../service/WarehouseResourceService.java | 32 +- .../src/main/resources/db/warehouse.sql | 6 +- ...rehouse-pallet-transition-internal-v1.yaml | 132 +++++ .../WarehousePalletTransitionServiceTest.java | 17 + .../service/WarehouseResourceServiceTest.java | 33 ++ 93 files changed, 2945 insertions(+), 188 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPalletItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferRelabelBinding.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferRelabelBindingMapper.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferControllerPermissionTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java create mode 100644 scripts/db/fix-filter-name-503.sql create mode 100644 scripts/overseas-relabel-fulfillment-acceptance.mjs create mode 100644 scripts/overseas-relabel-real-acceptance.mjs create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/RelabelPalletRequests.java create mode 100644 warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-transition-internal-v1.yaml diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml index cc1ad14ab..f821f2289 100644 --- a/config/nacos/local/iam-service.yml +++ b/config/nacos/local/iam-service.yml @@ -4,6 +4,9 @@ spring.datasource: username: ${IAM_DB_USERNAME:root} password: ${IAM_DB_PASSWORD:123456} baiyue: + security: + # 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。 + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312} customer-dimension-initialization: fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 31f1e1a75..4b1e979d5 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -17245,6 +17245,13 @@ WHERE p.permission_code LIKE 'platform:lastleg:%'; UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE permission_code LIKE 'platform:lastleg:%'; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id + AND permission.tenant_id=role_permission.tenant_id +WHERE permission.permission_code=CONCAT('platform:overseas-', 'warehouse:warehouse-transfer:list'); +UPDATE iam_permission SET status='INACTIVE', deleted=1 +WHERE permission_code=CONCAT('platform:overseas-', 'warehouse:warehouse-transfer:list'); + 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) @@ -17252,6 +17259,7 @@ VALUES (5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0), (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0), (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), + (5107231,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,0), (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), @@ -17310,7 +17318,8 @@ VALUES (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,0), (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,0), (5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,0) -ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), +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 IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) @@ -17498,7 +17507,8 @@ FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' AND permission.permission_code IN ( 'platform:overseas-warehouse:last-mile-order:edit', - 'platform:overseas-warehouse:inbound-order:create' + 'platform:overseas-warehouse:inbound-order:create', + 'platform:overseas-warehouse:warehouse-transfer:approve' ) WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') @@ -17706,6 +17716,18 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) 'merchant:warehouse:delivery:create') AND permission.status='ACTIVE' AND permission.deleted=0; +-- 已创建的客户拥有租户级默认管理员角色;权限资源仍引用 tenant_id=0 的公共目录。 +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT role.tenant_id, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT' + WHERE role.tenant_id>0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.permission_code IN ( + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create') + AND permission.status='ACTIVE' AND permission.deleted=0; + DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions; DELIMITER $$ CREATE PROCEDURE assert_phase5_delivery_permissions() @@ -17816,13 +17838,16 @@ BEGIN 'platform:overseas-warehouse:customer-visit:edit')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限'; END IF; - IF (SELECT COUNT(*) FROM iam_role_permission grant_row - JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id - JOIN iam_permission permission ON permission.id=grant_row.permission_id - WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT' + IF EXISTS (SELECT role.id FROM iam_role role + LEFT JOIN iam_role_permission grant_row + ON grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id + LEFT JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.permission_code IN ( 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create')) <> 2 THEN + 'merchant:warehouse:delivery:create') + WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT' + AND role.status='ACTIVE' AND role.deleted=0 + GROUP BY role.id HAVING COUNT(DISTINCT permission.permission_code) <> 2) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整'; END IF; END$$ diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index f7f4c93c5..db717957a 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2477,7 +2477,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation ( request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', - before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空', after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空', before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -3526,7 +3526,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation ( request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', - before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空', after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空', before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -4581,11 +4581,60 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton ( UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index), KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status) ) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='customer_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN customer_carton_mark VARCHAR(255) NULL COMMENT ''客户原箱唛,用于换标现场绑定'' AFTER carton_mark')); +PREPARE transfer_customer_carton_stmt FROM @ddl; EXECUTE transfer_customer_carton_stmt; DEALLOCATE PREPARE transfer_customer_carton_stmt; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( + id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', sequence_no INT NOT NULL COMMENT '托序号', + warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', + warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', + height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '打托状态', + completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_item(transfer_pallet_id,transfer_item_id), KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型', + customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU', new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数', + fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_item') AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name IN ('shipment_no','destination_fba_warehouse_code','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id') AND is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)'', MODIFY COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)'', MODIFY COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''', 'SELECT 1')); PREPARE ll_relabel_pre_inbound_stmt FROM @ddl; EXECUTE ll_relabel_pre_inbound_stmt; DEALLOCATE PREPARE ll_relabel_pre_inbound_stmt; +-- 2026-09-10 / 038 补齐跨服务列表查询依赖的历史库字段,重复执行安全。 +USE baiyue_warehouse; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='internal_code'), 'SELECT 1', 'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code')); +PREPARE warehouse_internal_code_stmt FROM @ddl; EXECUTE warehouse_internal_code_stmt; DEALLOCATE PREPARE warehouse_internal_code_stmt; +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT ''关联尾程订单号'' AFTER lastleg_order_id')); +PREPARE return_order_lastleg_no_stmt FROM @ddl; EXECUTE return_order_lastleg_no_stmt; DEALLOCATE PREPARE return_order_lastleg_no_stmt; + -- 财务 P0:海外费用保留原币、月度汇率和计费依据。 USE baiyue_lastleg; +DROP PROCEDURE IF EXISTS add_delivery_fee_currency_columns; +DELIMITER $$ +CREATE PROCEDURE add_delivery_fee_currency_columns() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_fee') THEN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_fee' AND column_name='original_currency') THEN + ALTER TABLE ll_delivery_fee ADD COLUMN original_currency VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '原币币种' AFTER amount; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_fee' AND column_name='original_amount') THEN + ALTER TABLE ll_delivery_fee ADD COLUMN original_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '原币金额' AFTER original_currency; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_fee' AND column_name='exchange_rate') THEN + ALTER TABLE ll_delivery_fee ADD COLUMN exchange_rate DECIMAL(18,8) NOT NULL DEFAULT 1 COMMENT '原币折算CNY汇率' AFTER original_amount; + END IF; + END IF; +END$$ +DELIMITER ; +CALL add_delivery_fee_currency_columns(); +DROP PROCEDURE IF EXISTS add_delivery_fee_currency_columns; + DROP PROCEDURE IF EXISTS add_overseas_finance_p0_column; DELIMITER $$ CREATE PROCEDURE add_overseas_finance_p0_column(IN p_column VARCHAR(64), IN p_definition TEXT) @@ -4628,3 +4677,23 @@ SELECT 'PASS' AS migration_status, '海外财务P0费用来源字段已就绪' A -- 人工货柜节点独立于订舱:货柜尚未关联海运订舱时仍可同步当前节点。 ALTER TABLE ll_container_shipping_gate MODIFY ocean_booking_id VARCHAR(128) NULL COMMENT '来源海运订舱ID;货柜未关联订舱时为空'; + +-- 2026-09-12 退货换箱唛、转运换标回写及派车箱唛快照增量。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='return_carton_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货换箱唛来源退货库存箱ID'' AFTER carton_mark')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_carton_mark VARCHAR(255) NULL COMMENT ''退货换箱唛原箱唛快照'' AFTER return_carton_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='new_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN new_carton_mark VARCHAR(255) NULL COMMENT ''退货换箱唛新箱唛'' AFTER source_carton_mark')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''关联贴标完成时间'' AFTER relabel_no')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'PASS' AS migration_status, '退货换箱唛及转运换标关联字段已就绪' AS message; + +-- 2026-09-14 海外尾程按托盘维度作业,无库位托盘允许正常流转及补偿。 +USE baiyue_lastlag_warehouse; +ALTER TABLE wh_pallet_operation + MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空', + MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空'; +USE baiyue_lastleg; +SELECT 'PASS' AS migration_status, '无库位托盘状态流转结构已就绪' AS message; diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 3ff8d9f52..863080a63 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -7,6 +7,13 @@ WHERE p.permission_code LIKE 'platform:lastleg:%'; UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE permission_code LIKE 'platform:lastleg:%'; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id + AND permission.tenant_id=role_permission.tenant_id +WHERE permission.permission_code=CONCAT('platform:overseas-', 'warehouse:warehouse-transfer:list'); +UPDATE iam_permission SET status='INACTIVE', deleted=1 +WHERE permission_code=CONCAT('platform:overseas-', 'warehouse:warehouse-transfer:list'); + 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) @@ -14,6 +21,7 @@ VALUES (5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0), (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0), (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), + (5107231,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,0), (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), @@ -74,7 +82,8 @@ VALUES (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,0), (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,0), (5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,0) -ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), +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 IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) @@ -262,7 +271,8 @@ FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' AND permission.permission_code IN ( 'platform:overseas-warehouse:last-mile-order:edit', - 'platform:overseas-warehouse:inbound-order:create' + 'platform:overseas-warehouse:inbound-order:create', + 'platform:overseas-warehouse:warehouse-transfer:approve' ) WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') diff --git a/iam/src/main/resources/db/phase5-delivery-permissions.sql b/iam/src/main/resources/db/phase5-delivery-permissions.sql index 19557581f..5d850ee2b 100644 --- a/iam/src/main/resources/db/phase5-delivery-permissions.sql +++ b/iam/src/main/resources/db/phase5-delivery-permissions.sql @@ -109,6 +109,18 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) 'platform:overseas-warehouse:customer-visit:edit') AND permission.status='ACTIVE' AND permission.deleted=0; +-- 已创建的客户拥有租户级默认管理员角色;权限资源仍引用 tenant_id=0 的公共目录。 +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT role.tenant_id, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT' + WHERE role.tenant_id>0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.permission_code IN ( + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create') + AND permission.status='ACTIVE' AND permission.deleted=0; + -- 仓库后台岗位按最小职责授权;PDA 岗位不得获得后台排车、出库或派送权限。 DROP TEMPORARY TABLE IF EXISTS tmp_phase5_delivery_role_permission; CREATE TEMPORARY TABLE tmp_phase5_delivery_role_permission ( @@ -296,13 +308,16 @@ BEGIN 'platform:overseas-warehouse:customer-visit:edit')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限'; END IF; - IF (SELECT COUNT(*) FROM iam_role_permission grant_row - JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id - JOIN iam_permission permission ON permission.id=grant_row.permission_id - WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT' + IF EXISTS (SELECT role.id FROM iam_role role + LEFT JOIN iam_role_permission grant_row + ON grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id + LEFT JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.permission_code IN ( 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create')) <> 2 THEN + 'merchant:warehouse:delivery:create') + WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT' + AND role.status='ACTIVE' AND role.deleted=0 + GROUP BY role.id HAVING COUNT(DISTINCT permission.permission_code) <> 2) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整'; END IF; END$$ diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 29b1469dd..7e3f97d5c 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -20,6 +20,7 @@ class OverseasPhase4PermissionSqlContractTest { "platform:overseas-warehouse:inbound-order:smart-dispatch"); private static final List REQUIRED = List.of( "platform:overseas-warehouse:last-mile-order:list", + "platform:overseas-warehouse:warehouse-transfer:approve", "platform:overseas-warehouse:inbound-order:list", "platform:overseas-warehouse:inbound-order:fba-appointment", "platform:overseas-warehouse:inbound-order:smart-dispatch", @@ -41,7 +42,11 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(58).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(59).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).contains( + "(5107231,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'"); + assertThat(sql).doesNotContain( + "'platform:overseas-warehouse:warehouse-transfer:list','仓储转运审核'"); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); assertThat(sql).doesNotContain(":carton:", ":product:"); assertThat(catalog).contains("platform:overseas-warehouse:delivery:list"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index db718ffc9..6904c147c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -77,7 +77,14 @@ public class LastlegOrderController { public ApiResponse> list( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @RequestParam(required = false) String relabelNo, @Valid LastlegOrderRequests.Query query) { + return ApiResponse.success(orderService.page(platformScope(user, customerId), query, relabelNo)); + } + + /** Compatibility overload used by non-Spring callers and existing contract tests. */ + public ApiResponse> list( + AuthenticatedUser user, String customerId, LastlegOrderRequests.Query query) { return ApiResponse.success(orderService.page(platformScope(user, customerId), query)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java index 370db140b..3509fe1c2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.delivery.entity.OutboundOrder; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import java.util.List; @Mapper public interface OutboundOrderMapper extends BaseMapper { @@ -22,4 +23,11 @@ public interface OutboundOrderMapper extends BaseMapper { OutboundOrder lockByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); @Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE") OutboundOrder lockGlobalByNo(@Param("outboundNo") String outboundNo); + + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{warehouseId} AND outbound_type='RELABEL_OUTBOUND' " + + "AND outbound_no LIKE CONCAT('%',#{keyword},'%') ORDER BY created_at DESC,id DESC LIMIT 100") + List listRelabelOutbounds(@Param("customerId") String customerId, + @Param("warehouseId") String warehouseId, + @Param("keyword") String keyword); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index dee6cc405..792671322 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -57,6 +57,8 @@ import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -86,6 +88,7 @@ public class DeliveryTransferService { @Resource private InventoryPositionMapper inventoryPositions; @Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryCommandService inventoryCommands; + @Resource private WarehouseTransferPalletMapper warehouseTransferPallets; @Resource private DropshipOrderProfileMapper dropshipProfiles; @Resource private DropshipCartonMapper dropshipCartons; @Resource private DropshipSkuInventoryMapper dropshipSkuInventory; @@ -175,10 +178,8 @@ public class DeliveryTransferService { item.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); item.setDestinationWarehouseName(source.getDestinationWarehouseName()); item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); - List requestedMarks = request.palletCartonMarks() == null ? List.of() : request.palletCartonMarks().getOrDefault(source.getPalletNo(), List.of()); - if (!requestedMarks.isEmpty()) { - if (requestedMarks.size() != Objects.requireNonNullElse(source.getPackageCount(), 0)) throw conflict("Selected carton mark count must equal pallet package count"); - item.setCartonMarks(String.join(",", requestedMarks.stream().distinct().toList())); + if (StringUtils.hasText(source.getCartonMarks())) { + item.setCartonMarks(source.getCartonMarks()); } else if (source.getWarehousePalletId() != null) { String marks = inboundCartons.listByPallet(customerId, source.getWarehousePalletId()).stream() .map(c -> c.getCartonMark()).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(",")); @@ -802,7 +803,27 @@ public class DeliveryTransferService { .stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText) .map(String::trim).distinct().collect(java.util.stream.Collectors.joining(",")); } - private BigDecimal totalWeight(String customerId, List items) { List ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); } + private BigDecimal totalWeight(String customerId, List items) { + List palletIds = items.stream().map(DispatchItem::getWarehousePalletId) + .filter(Objects::nonNull).distinct().toList(); + List transferPallets = palletIds.isEmpty() ? List.of() + : warehouseTransferPallets.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferPallet::getCustomerId, customerId) + .in(WarehouseTransferPallet::getWarehousePalletId, palletIds)); + Set measuredPalletIds = transferPallets.stream() + .map(WarehouseTransferPallet::getWarehousePalletId).collect(java.util.stream.Collectors.toSet()); + BigDecimal measuredWeight = transferPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + List orderIds = items.stream() + .filter(item -> item.getWarehousePalletId() == null || !measuredPalletIds.contains(item.getWarehousePalletId())) + .map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); + if (orderIds.isEmpty()) return measuredWeight; + BigDecimal orderWeight = tailOrderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds)) + .stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + return measuredWeight.add(orderWeight); + } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private static Long parseLong(String value) { try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index 22ebb49c4..9e6701f27 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -38,6 +38,17 @@ public class DispatchController { return ApiResponse.success(service.update(dispatchNo, request)); } + @DeleteMapping("/{dispatchNo}/items/{itemId}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") + @Operation(summary = "Remove one unpicked pallet before an outbound order is generated") + public ApiResponse removeItem( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @PathVariable Long itemId) { + requirePlatform(user); + return ApiResponse.success(service.removeItem(dispatchNo, itemId)); + } + @DeleteMapping("/{dispatchNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_CANCEL + "')") @Operation(summary = "Cancel a pending dispatch and release its pallet locks") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index 5c1f97f87..97a78fe5f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -74,7 +74,9 @@ public class SmartDispatchController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody DispatchRequests.PreArrange request) { requirePlatform(user); - String scopedCustomer = service.resolveCustomerScopeForPallets(request.planPalletIds()); + String scopedCustomer = request.relabelNo() == null || request.relabelNo().isBlank() + ? service.resolveCustomerScopeForPallets(request.planPalletIds()) + : service.resolveCustomerScopeForRelabel(request.relabelNo()); String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 8786bcbef..e0c93edf6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -53,7 +53,18 @@ public interface DispatchCandidateMapper { "ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," + "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," + "(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " + - "FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id) AS cartonMarks " + + "FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" + + "SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " + + "JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " + + "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + + "AND ri.carton_mark=c.carton_mark AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + + "AND ra.status NOT IN ('REJECTED','CANCELLED'))) AS cartonMarks," + + "(SELECT GROUP_CONCAT(DISTINCT ri.fba_carton_mark ORDER BY ri.fba_carton_mark SEPARATOR ',') " + + "FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " + + "JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " + + "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + + "AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + + "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " + @@ -81,6 +92,9 @@ public interface DispatchCandidateMapper { "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + "JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " + "WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " + + "AND NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + + "WHERE rli.customer_id=ip.customer_id AND rli.source_inbound_order_id IS NOT NULL " + + "AND rli.source_inbound_order_id=o.id AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED')) " + "GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," + "p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " + "ORDER BY p.plan_id,p.sequence_no" + @@ -121,7 +135,18 @@ public interface DispatchCandidateMapper { "ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," + "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," + "(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " + - "FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id) AS cartonMarks " + + "FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" + + "SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " + + "JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " + + "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + + "AND ri.carton_mark=c.carton_mark AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + + "AND ra.status NOT IN ('REJECTED','CANCELLED'))) AS cartonMarks," + + "(SELECT GROUP_CONCAT(DISTINCT ri.fba_carton_mark ORDER BY ri.fba_carton_mark SEPARATOR ',') " + + "FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " + + "JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " + + "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + + "AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + + "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " + @@ -137,6 +162,11 @@ public interface DispatchCandidateMapper { "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + "JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " + "WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " + + "AND NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + + "JOIN ll_inbound_plan_line rpl ON rpl.order_id=rli.source_inbound_order_id " + + "WHERE rli.customer_id=#{customerId} AND rli.source_inbound_order_id IS NOT NULL AND rpl.id IN " + + "(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id) " + + "AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED')) " + "GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," + "p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " + "ORDER BY p.id FOR UPDATE" + @@ -148,20 +178,27 @@ public interface DispatchCandidateMapper { "SELECT NULL AS planId,NULL AS planPalletId,pos.pallet_id AS warehousePalletId,a.lastleg_order_id AS orderId," + "a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,NULL AS containerNo," + "NULL AS appointmentAt,NULL AS expectedLoadingAt,a.overseas_warehouse_id AS overseasWarehouseId,MIN(ri.source_warehouse_name) AS overseasWarehouseName," + - "MIN(a.relabel_no) AS orderNo,a.relabel_no AS orderRefs,MIN(ri.source_inbound_code) AS inboundCode,pos.pallet_no AS palletNo,NULL AS destinationWarehouseId," + + "MIN(COALESCE(NULLIF(ri.source_inbound_order_no,''),NULLIF(a.customer_order_no,''),a.relabel_no)) AS orderNo,a.relabel_no AS orderRefs,MIN(ri.source_inbound_code) AS inboundCode,pos.pallet_no AS palletNo,NULL AS destinationWarehouseId," + "GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode," + "GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseName," + "GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," + - "SUM(ri.notified_package_count) AS packageCount,NULL AS volumeCbm,'RELABEL' AS sourceStage," + - "GROUP_CONCAT(DISTINCT CONCAT(ri.carton_mark,'->',ri.fba_carton_mark) ORDER BY ri.carton_mark SEPARATOR ',') AS cartonMarks " + + "SUM(ri.notified_package_count) AS packageCount,MAX(wtp.volume_cbm) AS volumeCbm,'RELABEL' AS sourceStage," + + "NULL AS cartonMarks,GROUP_CONCAT(DISTINCT COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) " + + "ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks " + "FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " + "JOIN ll_inventory_position pos ON pos.id=ri.source_inventory_position_id AND pos.customer_id=a.customer_id " + + "LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " + + "LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " + + "AND wtp.warehouse_pallet_id=pos.pallet_id " + "WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " + "AND a.customer_id=#{customerId} " + "AND a.relabel_no=#{relabelNo} " + "AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') " + - "AND pos.available_packages > 0 " + - "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no) " + + "AND pos.hold_packages > 0 " + + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " + + "ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " + + "WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " + + "AND existing_dispatch.status!='CANCELLED') " + "GROUP BY a.id,a.lastleg_order_id,a.customer_id,a.overseas_warehouse_id,a.relabel_no,pos.pallet_id,pos.pallet_no" + "") List relabelAvailable(@Param("customerId") String customerId, @@ -171,4 +208,8 @@ public interface DispatchCandidateMapper { @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " + "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'") int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); + + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='WAIT_PRE_DISPATCH',updated_at=CURRENT_TIMESTAMP(3) " + + "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='PRE_DISPATCH_CREATED'") + int restoreRelabelPreDispatch(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java index 17321a547..210f8eca6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java @@ -10,7 +10,13 @@ import java.util.List; @Mapper public interface DispatchDraftMapper extends BaseMapper { - @Select("SELECT * FROM ll_dispatch_draft WHERE operator_id=#{operatorId} AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY updated_at DESC") + @Select("SELECT d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " + + "AND (d.expires_at IS NULL OR d.expires_at>CURRENT_TIMESTAMP(3)) AND NOT EXISTS (" + + "SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_outbound_order o ON o.customer_id=l.customer_id " + + "AND o.dispatch_order_id=l.dispatch_order_id WHERE l.draft_token=d.draft_token) AND NOT EXISTS (" + + "SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order x ON x.id=l.dispatch_order_id " + + "AND x.customer_id=l.customer_id WHERE l.draft_token=d.draft_token AND x.status!='PENDING_DEPARTURE') " + + "ORDER BY d.updated_at DESC") List findActiveByOperator(@Param("operatorId") String operatorId); @Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' LIMIT 1 FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java index 2dff34801..8f94757bf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java @@ -15,6 +15,18 @@ public interface DispatchItemMapper extends BaseMapper { List findByDispatchOrder(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); + @Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} " + + "AND id=#{itemId} LIMIT 1 FOR UPDATE") + DispatchItem lockById(@Param("customerId") String customerId, + @Param("dispatchOrderId") Long dispatchOrderId, + @Param("itemId") Long itemId); + + @Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} " + + "AND plan_pallet_id=#{planPalletId} LIMIT 1 FOR UPDATE") + DispatchItem lockByPlanPallet(@Param("customerId") String customerId, + @Param("dispatchOrderId") Long dispatchOrderId, + @Param("planPalletId") Long planPalletId); + @Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " + "JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=i.customer_id " + "SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java index 5d849f04b..e8efc3b30 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java @@ -17,6 +17,11 @@ public interface DispatchPalletLockMapper extends BaseMapper List findActiveByDraftContainer(@Param("draftToken") String draftToken, @Param("draftContainerId") Long draftContainerId); + @Select("SELECT l.dispatch_order_id FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order d " + + "ON d.id=l.dispatch_order_id AND d.customer_id=l.customer_id AND d.status='PENDING_DEPARTURE' " + + "WHERE l.draft_token=#{draftToken} ORDER BY l.updated_at DESC,l.id DESC LIMIT 1") + Long findDispatchOrderIdByDraft(@Param("draftToken") String draftToken); + @Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " + "AND status='ACTIVE' LIMIT 1 FOR UPDATE") DispatchPalletLock lockActive(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java index b5779de7d..043ef4c96 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -29,7 +29,8 @@ public record DispatchPalletCandidate( Integer packageCount, BigDecimal volumeCbm, String sourceStage, - String cartonMarks + String cartonMarks, + String relabelCartonMarks ) { public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, String overseasWarehouseId, String overseasWarehouseName, String orderNo, @@ -40,7 +41,7 @@ public record DispatchPalletCandidate( this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId, overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm, - sourceStage, null); + sourceStage, null, null); } public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, @@ -50,7 +51,7 @@ public record DispatchPalletCandidate( BigDecimal volumeCbm, String sourceStage) { this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, - fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null); + fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null); } public String sourcePalletKey() { // A plan pallet keeps the same lock identity before and after the physical pallet is received. diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java index c14b0f8a9..14571382b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java @@ -11,8 +11,10 @@ import java.util.List; @Mapper public interface DispatchPdaPickOrderMapper extends BaseMapper { - @Select("SELECT r.* FROM ll_inbound_stock_receipt r " + - "WHERE r.customer_id=#{customerId} AND r.inventory_position_id=#{positionId} " + + @Select("SELECT DISTINCT r.* FROM ll_inventory_position current_position " + + "JOIN ll_inventory_position receipt_position ON receipt_position.balance_id=current_position.balance_id " + + "JOIN ll_inbound_stock_receipt r ON r.inventory_position_id=receipt_position.id " + + "WHERE current_position.id=#{positionId} AND r.customer_id=#{customerId} " + "AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " + "WHERE a.customer_id=r.customer_id AND a.receipt_id=r.id) ORDER BY r.received_at,r.id") List availableReceipts(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index dfa090572..873af02a4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -15,8 +15,11 @@ import com.baiyue.lastleg.dispatch.pda.entity.*; import com.baiyue.lastleg.dispatch.pda.mapper.*; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; import com.baiyue.lastleg.inventory.entity.InventoryPosition; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; @@ -41,6 +44,8 @@ public class DispatchPdaWorkflowService { @Resource private DispatchPalletLockMapper palletLocks; @Resource private InventoryPositionMapper positions; @Resource private InventoryCommandService inventory; + @Resource private RelabelApplicationService relabelApplications; + @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Resource private DispatchPickOrderAttributionService pickAttribution; @Resource private ObjectMapper objectMapper; @@ -111,6 +116,11 @@ public class DispatchPdaWorkflowService { InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(), request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, keyBase + ":ALLOCATE", request.remark()); + if ("RELABEL_ORDER".equals(item.getSourceBusinessType())) { + inventory.release(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), + request.packages(), item.getSourceBusinessNo(), order.getDispatchNo(), null, + keyBase + ":RELABEL_RELEASE", "换标排车拣货前释放冻结库存")); + } inventory.allocate(customerId, operatorId, move); inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, @@ -125,7 +135,8 @@ public class DispatchPdaWorkflowService { pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages()); pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); - pickAttribution.attribute(customerId, pick); + pickAttribution.attribute(customerId, pick, + "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); long total = picked + request.packages(); order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages())); task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now()); @@ -216,11 +227,22 @@ public class DispatchPdaWorkflowService { long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum(); LocalDateTime now = LocalDateTime.now(); pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); + for (DispatchItem item : dispatchItems) { + if (item.getOrderId() != null) warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now); + if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) { + warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now); + } + } + dispatchItems.stream().filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) + .map(DispatchItem::getSourceBusinessNo).filter(Objects::nonNull).distinct().forEach(relabelNo -> + relabelApplications.changeStatus(customerId, relabelNo, operatorId, + new RelabelRequests.ChangeStatus("COMPLETED", "换标排车PDA发车完成"))); outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); order.setShippedPackageCount((int) shipped); order.setStatus("DEPARTED"); + order.setDepartAt(now); order.setUpdatedAt(now); orders.updateById(order); releasePalletLocks(customerId, order.getId(), now); @@ -235,11 +257,13 @@ public class DispatchPdaWorkflowService { for (DispatchItem item : source) { List palletPositions = item.getWarehousePalletId() == null ? List.of() : positions.listPallet(customerId, item.getWarehousePalletId()); + boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType()); values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(), picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p -> - new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()), + new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()) + + (relabel ? n(p.getHoldPackages()) : 0), n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList())); } OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java index c435f098e..7eb300473 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -27,6 +27,15 @@ public class DispatchPickOrderAttributionService { /** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */ public List attribute(String customerId, DispatchPdaPick pick) { + return attribute(customerId, pick, null); + } + + /** + * Relabel inventory created before receipt-detail backfill may have no + * ll_inbound_stock_receipt row. Its dispatch item still carries the + * authoritative tail-leg order, so use that order only for relabel picks. + */ + public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) { long remainingPackages = value(pick.getPackages()); long totalPackages = remainingPackages; long totalQuantity = value(pick.getProductQuantity()); @@ -58,6 +67,10 @@ public class DispatchPickOrderAttributionService { allocatedPackages = nextPackages; allocatedQuantity = nextQuantity; } + if (remainingPackages != 0 && fallbackOrderId != null && fallbackOrderId > 0) { + pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum); + remainingPackages = 0; + } if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders"); for (Map.Entry entry : pickedByOrder.entrySet()) { if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 9a3e3eca2..7e10b3f08 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -24,6 +24,7 @@ import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -36,7 +37,9 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import java.util.Arrays; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; @@ -56,6 +59,7 @@ public class DispatchService { @Resource private DispatchPdaTaskMapper pdaTasks; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private LastlegOrderMapper tailOrders; + @Resource private OutboundOrderMapper outboundOrders; public String resolveCustomerScopeForPlans(List planIds) { return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), @@ -68,6 +72,12 @@ public class DispatchService { "Selected pallets"); } + public String resolveCustomerScopeForRelabel(String relabelNo) { + List values = candidates.relabelAvailable(null, null, trimToNull(relabelNo)); + return requireSingleCustomerScope(values.stream().map(DispatchPalletCandidate::customerId) + .filter(StringUtils::hasText).distinct().toList(), "Selected relabel order"); + } + public List candidates(String customerId, List planIds, String orderNo, String fbaWarehouseCode, String sourceStage, String draftToken) { validateSourceStage(sourceStage); @@ -191,6 +201,15 @@ public class DispatchService { DispatchPalletLock active = activeLock(customerId, candidate); if (active == null) return; requireDraftOwner(active, operatorId, request.draftToken()); + if (active.getDispatchOrderId() != null) { + DispatchOrder dispatch = orders.lockById(customerId, active.getDispatchOrderId()); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + requirePalletEditingAllowed(dispatch); + DispatchItem item = items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId()); + if (item != null) removeLockedItem(dispatch, item, List.of(active)); + else releaseLock(active, LocalDateTime.now()); + return; + } releaseLock(active, LocalDateTime.now()); } @@ -199,7 +218,8 @@ public class DispatchService { List activeLocks = locks.selectList(new LambdaQueryWrapper() .eq(DispatchPalletLock::getDraftToken, draftToken) .eq(DispatchPalletLock::getOperatorId, trimToNull(operatorId)) - .eq(DispatchPalletLock::getStatus, "ACTIVE")); + .eq(DispatchPalletLock::getStatus, "ACTIVE") + .isNull(DispatchPalletLock::getDispatchOrderId)); LocalDateTime now = LocalDateTime.now(); activeLocks.forEach(lock -> releaseLock(lock, now)); draftContainers.delete(new LambdaQueryWrapper().eq(DispatchDraftContainer::getDraftToken, draftToken)); @@ -262,35 +282,60 @@ public class DispatchService { } LocalDateTime now = LocalDateTime.now(); - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(IdWorker.getId()); - dispatch.setCustomerId(customerId); - dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId())); - dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId()); - dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); + Long existingDispatchId = draftToken == null ? null : locks.findDispatchOrderIdByDraft(draftToken); + DispatchOrder dispatch = existingDispatchId == null ? null : orders.lockById(customerId, existingDispatchId); + boolean refreshing = dispatch != null; + if (refreshing) { + requirePalletEditingAllowed(dispatch); + } else { + dispatch = new DispatchOrder(); + dispatch.setId(IdWorker.getId()); + dispatch.setCustomerId(customerId); + dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId())); + dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId()); + dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); + dispatch.setStatus(PENDING_DEPARTURE); + dispatch.setPickedPackageCount(0); + dispatch.setShippedPackageCount(0); + dispatch.setCreatedBy(trimToNull(operatorId)); + dispatch.setCreatedAt(now); + } dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); - dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount) .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); - dispatch.setPickedPackageCount(0); - dispatch.setShippedPackageCount(0); - dispatch.setCreatedBy(trimToNull(operatorId)); - dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); - orders.insert(dispatch); + if (refreshing) orders.updateById(dispatch); else orders.insert(dispatch); + + List previousItems = items.findByDispatchOrder(customerId, dispatch.getId()); + Set selectedPalletIds = selected.stream().map(DispatchPalletCandidate::planPalletId) + .collect(Collectors.toSet()); + if (refreshing) { + List previousLocks = locks.selectList(new LambdaQueryWrapper() + .eq(DispatchPalletLock::getCustomerId, customerId) + .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + previousItems.stream().filter(item -> item.getPlanPalletId() != null + && !selectedPalletIds.contains(item.getPlanPalletId())) + .forEach(item -> { + previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId())) + .forEach(lock -> releaseLock(lock, now)); + items.deleteById(item.getId()); + }); + } + Set existingPalletIds = previousItems.stream() + .filter(item -> item.getPlanPalletId() == null || selectedPalletIds.contains(item.getPlanPalletId())) + .map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).collect(Collectors.toSet()); for (DispatchPalletCandidate candidate : selected) { DispatchPalletLock lock = activeLock(customerId, candidate); if (draftToken != null) { requireDraftOwner(lock, operatorId, draftToken); lock.setDispatchOrderId(dispatch.getId()); - lock.setDraftContainerId(null); - lock.setDraftToken(null); lock.setExpiresAt(null); lock.setUpdatedAt(now); locks.updateById(lock); @@ -316,6 +361,7 @@ public class DispatchService { "Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception); } } + if (existingPalletIds.contains(candidate.planPalletId())) continue; DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); @@ -337,27 +383,27 @@ public class DispatchService { item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage(candidate.sourceStage()); + item.setCartonMarks(mergeCartonMarks(candidate.cartonMarks(), candidate.relabelCartonMarks())); item.setCreatedAt(now); items.insert(item); } - DispatchPdaTask pdaTask = new DispatchPdaTask(); - pdaTask.setId(IdWorker.getId()); - pdaTask.setCustomerId(customerId); - pdaTask.setDispatchOrderId(dispatch.getId()); - pdaTask.setTaskNo("DPDA" + pdaTask.getId()); - pdaTask.setStatus("PDA_PENDING"); - pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId()); - pdaTask.setPushedAt(now); - pdaTask.setCreatedAt(now); - pdaTask.setUpdatedAt(now); - pdaTasks.insert(pdaTask); + if (!refreshing) { + DispatchPdaTask pdaTask = new DispatchPdaTask(); + pdaTask.setId(IdWorker.getId()); + pdaTask.setCustomerId(customerId); + pdaTask.setDispatchOrderId(dispatch.getId()); + pdaTask.setTaskNo("DPDA" + pdaTask.getId()); + pdaTask.setStatus("PDA_PENDING"); + pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId()); + pdaTask.setPushedAt(now); + pdaTask.setCreatedAt(now); + pdaTask.setUpdatedAt(now); + pdaTasks.insert(pdaTask); + } if (draftToken != null) { - draftContainers.delete(new LambdaQueryWrapper() - .eq(DispatchDraftContainer::getDraftToken, draftToken)); DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId)); if (draft != null) { - draft.setStatus("RELEASED"); - draft.setReleasedAt(now); + draft.setExpiresAt(null); draft.setUpdatedAt(now); drafts.updateById(draft); } @@ -377,7 +423,7 @@ public class DispatchService { DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); - for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(BigDecimal.ZERO); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.cartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } + for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); @@ -463,6 +509,50 @@ public class DispatchService { return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); } + @Transactional + public DispatchResponse removeItem(String dispatchNo, Long itemId) { + DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + requirePalletEditingAllowed(dispatch); + DispatchItem item = items.lockById(dispatch.getCustomerId(), dispatch.getId(), itemId); + if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found"); + + List activeLocks = locks.selectList(new LambdaQueryWrapper() + .eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId()) + .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + List matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) + || (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId()))) + .toList(); + return removeLockedItem(dispatch, item, matchingLocks); + } + + private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item, + List matchingLocks) { + LocalDateTime now = LocalDateTime.now(); + matchingLocks.forEach(lock -> releaseLock(lock, now)); + items.deleteById(item.getId()); + + List remaining = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()); + dispatch.setItemCount(remaining.size()); + dispatch.setPalletCount(remaining.size()); + dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) + .mapToInt(Integer::intValue).sum()); + dispatch.setUpdatedAt(now); + orders.updateById(dispatch); + return response(dispatch, remaining); + } + + private void requirePalletEditingAllowed(DispatchOrder dispatch) { + if (!PENDING_DEPARTURE.equals(dispatch.getStatus()) + || Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets"); + } + if (outboundOrders.findByDispatch(dispatch.getCustomerId(), dispatch.getId()) != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,不能再修改排车托盘"); + } + } + @Transactional public void cancel(String dispatchNo) { DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); @@ -476,6 +566,10 @@ public class DispatchService { dispatch.setStatus(CANCELLED); dispatch.setUpdatedAt(now); orders.updateById(dispatch); + items.findByDispatchOrder(customerId, dispatch.getId()).stream() + .filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) + .map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct() + .forEach(relabelNo -> candidates.restoreRelabelPreDispatch(customerId, relabelNo)); List activeLocks = locks.selectList(new LambdaQueryWrapper() .eq(DispatchPalletLock::getCustomerId, customerId) .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) @@ -567,6 +661,14 @@ public class DispatchService { return StringUtils.hasText(value) ? value.trim() : null; } + private static String mergeCartonMarks(String regularMarks, String relabelMarks) { + LinkedHashSet values = new LinkedHashSet<>(); + for (String source : List.of(Objects.toString(regularMarks, ""), Objects.toString(relabelMarks, ""))) { + Arrays.stream(source.split(",")).map(String::trim).filter(StringUtils::hasText).forEach(values::add); + } + return values.isEmpty() ? null : String.join(",", values); + } + private static void validateSourceStage(String sourceStage) { if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 88f84bda9..c6f0a5782 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -29,7 +29,6 @@ public record LastlegOrderSummary( String transferWarehouseName, String fbaWarehouseCode, String containerNo, - String containerStatus, Boolean abnormal, Boolean detained, Boolean heldChargeable, @@ -46,6 +45,8 @@ public record LastlegOrderSummary( LocalDateTime actualArrivalAt, LocalDateTime estimatedWarehouseAt, String inboundOrderNo, + @Schema(description = "排车状态", allowableValues = {"SCHEDULED", "UNSCHEDULED"}) + String dispatchStatus, String relabelOrderNo, String relabelStatus, String palletMergeFlag, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index fe481b259..4df23a15a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -17,6 +17,15 @@ public interface LastlegOrderMapper extends BaseMapper { String WAREHOUSE_FILTER_SQL = "(LOWER(TRIM(overseas_warehouse_code)) LIKE CONCAT('%',LOWER({0}),'%') " + "OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))"; + @Select("") + List findDispatchStatuses(@Param("orderIds") List orderIds); + + record DispatchStatusRow(Long orderId, String dispatchStatus) { } + String CONTAINER_PROGRESS_FILTER = " FROM ll_order o LEFT JOIN ll_inbound_plan p " + "ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + "WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id<>'' " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java index d62e93153..069b72eb2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java @@ -28,6 +28,13 @@ public class PdaPalletOperationController { return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request)); } + @PostMapping("/relabel-pallet-label") + public ApiResponse createRelabelPallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.NewRelabelPallet request) { + return ApiResponse.success(service.createRelabelPallet(PdaWarehouseIdentity.require(user), request)); + } + @PostMapping("/assemble") public ApiResponse assemble( @AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java index 783e3d9b3..2a2922edd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java @@ -13,6 +13,8 @@ public final class PalletOperationRequests { private PalletOperationRequests() { } public record Scan(@NotBlank @Size(max = 128) String code) { } + public record NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo, + @NotBlank @Size(max = 128) String idempotencyKey) { } public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode, @PositiveOrZero int expectedVersion) { } public record MoveLine(@Positive long positionId, @Positive long packages, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java index c0cd61ce7..462c13fab 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java @@ -11,8 +11,10 @@ public final class PalletOperationResponses { long availablePackages, long holdPackages, long allocatedPackages, long pickedPackages, long receivingPackages, long positionVersion) { } public record PalletContent(long palletId, String palletCode, String status, int palletVersion, - long locationId, String locationCode, String destinationWarehouseCode, + Long locationId, String locationCode, String destinationWarehouseCode, long totalPackages, List inventory) { } + public record PrintablePallet(long palletId, String palletCode, int palletVersion, + String relabelNo, String printableHtml) { } public record Operation(long operationId, String operationType, String idempotencyKey, String status, boolean replayed, LocalDateTime completedAt, List pallets) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java index 45a0448c6..3ff6e36f3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -28,6 +28,21 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { restClientProperties.getPalletPhysical()); } + @Override + public PalletReference ensureRelabelPallet(long warehouseId, String businessId) { + Map body = Map.of("warehouseId", warehouseId, "businessId", businessId); + ResolveEnvelope response = client.post().uri("/internal/v1/warehouse-pallets/ensure-relabel") + .header("X-Internal-Service-Token", internalToken).body(body).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw palletLookupFailure(result.getStatusCode()); + }).body(ResolveEnvelope.class); + if (response == null || response.data() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet service returned no usable response"); + } + return response.data(); + } + @Override public PalletReference resolve(String palletCode) { ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/{code}", palletCode) @@ -76,11 +91,12 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { } @Override - public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId, + public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, Long targetLocationId, String operationKey, String reason) { Map body = new LinkedHashMap<>(); body.put("warehouseId", warehouseId); body.put("expectedVersion", pallet.version()); - body.put("targetStatus", targetStatus); body.put("targetLocationId", targetLocationId); + body.put("targetStatus", targetStatus); + if (targetLocationId != null) body.put("targetLocationId", targetLocationId); body.put("operationKey", operationKey); if (reason != null) body.put("reason", reason); TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}", pallet.palletId()) .header("X-Internal-Service-Token", internalToken).body(body).retrieve() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java index 157e25681..9ef546a51 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java @@ -1,16 +1,17 @@ package com.baiyue.lastleg.palletops.gateway; public interface PalletPhysicalGateway { + PalletReference ensureRelabelPallet(long warehouseId, String businessId); PalletReference resolve(String palletCode); PalletReference resolve(long palletId); LocationReference resolveLocation(long warehouseId, long locationId); - Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId, + Transition apply(long warehouseId, PalletReference pallet, String targetStatus, Long targetLocationId, String operationKey, String reason); void compensate(long warehouseId, Transition transition); - record PalletReference(long palletId, String palletCode, long warehouseId, long locationId, + record PalletReference(long palletId, String palletCode, long warehouseId, Long locationId, String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) { - public PalletReference(long palletId, String palletCode, long warehouseId, long locationId, + public PalletReference(long palletId, String palletCode, long warehouseId, Long locationId, String status, String fbaWarehouseCode, int version) { this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode, java.math.BigDecimal.ZERO, version); @@ -18,6 +19,6 @@ public interface PalletPhysicalGateway { } record LocationReference(long locationId, String locationCode, String locationType, String status) { } record Transition(String operationKey, long palletId, String palletCode, long warehouseId, - String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId, + String beforeStatus, String afterStatus, Long beforeLocationId, Long afterLocationId, int beforeVersion, int afterVersion, String operationStatus, boolean replayed) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index b55f004cb..d399282b4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -30,6 +30,7 @@ import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.support.DefaultTransactionDefinition; import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.util.HtmlUtils; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; @@ -77,6 +78,30 @@ public class PdaPalletOperationService { return content(identity.overseasWarehouseId(), pallet); } + public PalletOperationResponses.PrintablePallet createRelabelPallet(PdaWarehouseIdentity identity, + PalletOperationRequests.NewRelabelPallet request) { + String relabelNo = required(request.relabelNo(), "换标单号不能为空"); + RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); + if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { + throw conflict("换标单尚未完成客户确认,不能生成新托盘"); + } + if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { + throw conflict("换标单不属于 Token 冻结的海外仓"); + } + String key = required(request.idempotencyKey(), "幂等键不能为空"); + PalletPhysicalGateway.PalletReference pallet = physical.ensureRelabelPallet(identity.overseasWarehouseId(), + relabelNo + ":" + key); + String code = HtmlUtils.htmlEscape(pallet.palletCode()); + String order = HtmlUtils.htmlEscape(relabelNo); + String html = "" + code + + "

托盘标签

" + + code + "
换标单:" + order + "
"; + return new PalletOperationResponses.PrintablePallet(pallet.palletId(), pallet.palletCode(), pallet.version(), + relabelNo, html); + } + public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity, PalletOperationRequests.Assemble request) { PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_ASSEMBLE", @@ -175,8 +200,10 @@ public class PdaPalletOperationService { Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false); Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(), new RelabelMutation(application, itemsByPosition)); + String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank() + ? "" : ":" + request.reason().trim()); return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request, - request.reason(), relabelPlan); + reason, relabelPlan); } public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity, @@ -563,9 +590,10 @@ public class PdaPalletOperationService { .digest((operator + "|" + key + "|" + palletId).getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } } - private String locationCode(long warehouseId, long locationId) { + private String locationCode(long warehouseId, Long locationId) { + if (locationId == null) return null; PalletPhysicalGateway.LocationReference location = physical.resolveLocation(warehouseId, locationId); - if (location == null || location.locationId() != locationId || "INACTIVE".equals(location.status())) { + if (location == null || location.locationId() != locationId.longValue() || "INACTIVE".equals(location.status())) { throw conflict("托盘库位不存在或已停用"); } return location.locationCode(); @@ -622,10 +650,10 @@ public class PdaPalletOperationService { private record PlannedMove(long positionId, long packages, long expectedVersion, long sourcePalletId, String sourcePalletCode, - long targetPalletId, String targetPalletCode, long targetLocationId, + long targetPalletId, String targetPalletCode, Long targetLocationId, String targetLocationCode, InventoryBucket bucket) { } private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus, - long targetLocationId) { } + Long targetLocationId) { } private record Plan(Map pallets, List moves, List changes, RelabelMutation relabelMutation) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java index 9f37c9b45..eee6fe473 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -30,6 +30,13 @@ public class MerchantRelabelApplicationController { @RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), operationWarehouseId, inboundCode)); } + @GetMapping("/historical-orders") + public ApiResponse> historicalOrders( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String operationWarehouseId, + @RequestParam(required = false, defaultValue = "") String keyword) { + return ApiResponse.success(service.historicalOrders(customer(user), operationWarehouseId, keyword)); + } @PostMapping public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 41fab1ed3..59cc13d60 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -61,6 +61,26 @@ public class RelabelApplicationController { return ApiResponse.success(service.changeStatus(null, relabelNo, operator(user), request)); } + @PostMapping("/{relabelNo}/wallet-freeze/retry") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") + @Operation(summary = "Retry a failed relabel wallet freeze", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS) + public ApiResponse retryWalletFreeze(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, + @Valid @RequestBody RelabelRequests.WalletFreezeRetry request) { + requirePlatform(user); + return ApiResponse.success(service.retryWalletFreeze(relabelNo, operator(user), request)); + } + + @PostMapping("/{relabelNo}/wallet-settlement/retry") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") + @Operation(summary = "Retry a failed relabel wallet settlement", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS) + public ApiResponse retryWalletSettlement(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, + @Valid @RequestBody RelabelRequests.WalletFreezeRetry request) { + requirePlatform(user); + return ApiResponse.success(service.retryWalletSettlement(relabelNo, operator(user), request)); + } + @PostMapping(value = "/{relabelNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')") public ApiResponse uploadFile(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index 370d6bc36..cba655675 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -117,4 +117,7 @@ public final class RelabelRequests { public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} + + public record WalletFreezeRetry(@NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 28b7f4b2d..39b08fd64 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -35,6 +35,9 @@ public final class RelabelResponses { String overseasWarehouseName, String destinationFbaWarehouseCode, String destinationFbaWarehouseName, String skuCode, String productNameZh, long availablePackages, long version, List positions) {} + public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, + String relabelOrderNo, String orderType, String status, Integer packageCount, + LocalDateTime createdAt) {} public record TailOrder(Long id, String orderNo, String overseasWarehouseId, int packageCount, String status) {} public record History(String fromStatus, String toStatus, String operatorId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java index 401ae2b98..5fedcca2f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java @@ -43,4 +43,7 @@ public class RelabelItem { private String sourceWarehouseCode; private String sourceWarehouseName; private LocalDateTime createdAt; + private Long returnCartonId; + private String sourceCartonMark; + private String newCartonMark; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java index 8fb2b3a2b..7184b7048 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.relabel.entity.RelabelApplication; 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 RelabelApplicationMapper extends BaseMapper { @@ -25,4 +26,11 @@ public interface RelabelApplicationMapper extends BaseMapper @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} LIMIT 1 FOR UPDATE") RelabelApplication lockByOutbound(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); + + @Update("UPDATE ll_relabel_application SET wallet_status=#{status}, wallet_error_code=#{errorCode}, " + + "wallet_error_message=#{errorMessage}, wallet_command_event_id=COALESCE(#{eventId},wallet_command_event_id), " + + "updated_at=NOW(3) WHERE id=#{id}") + int updateWalletState(@Param("id") Long id, @Param("status") String status, + @Param("errorCode") String errorCode, @Param("errorMessage") String errorMessage, + @Param("eventId") String eventId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java index d11e16125..ce592573e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -18,6 +18,8 @@ import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper; import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.MapperFeature; @@ -60,6 +62,7 @@ public class PdaRelabelService { @Resource private RelabelItemMapper items; @Resource private PdaRelabelTaskMapper tasks; @Resource private PdaRelabelUnitFactMapper unitFacts; + @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private PdaRelabelOperationMapper operations; @Resource private PdaRelabelHistoryMapper histories; @Resource private RelabelApplicationService applicationService; @@ -296,6 +299,21 @@ public class PdaRelabelService { fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); try { unitFacts.insert(fact); } catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); } + if ("RELABELLED".equals(resultCode)) { + item.setNewCartonMark(resolvedNewLabel); + item.setFbaCartonMark(resolvedNewLabel); + items.updateById(item); + if (item.getReturnCartonId() != null && returnCartons != null) { + ReturnInventoryCarton carton = returnCartons.selectById(item.getReturnCartonId()); + if (carton != null && application.getCustomerId().equals(carton.getCustomerId())) { + carton.setNewCartonMark(resolvedNewLabel); + carton.setFbaCartonMark(resolvedNewLabel); + carton.setRelabelStatus(PROCESSING); + carton.setUpdatedAt(LocalDateTime.now()); + returnCartons.updateById(carton); + } + } + } if ("RELABELLED".equals(resultCode)) task.setRelabelledPackages(value(task.getRelabelledPackages()) + 1); if ("SKIPPED".equals(resultCode)) task.setSkippedPackages(value(task.getSkippedPackages()) + 1); if ("EXCEPTION".equals(resultCode)) task.setExceptionPackages(value(task.getExceptionPackages()) + 1); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 89d747850..9dd5acad8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -38,6 +38,8 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.wallet.WalletCommandOutboxService; import com.baiyue.lastleg.wallet.WalletResultHandler; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.baiyue.common.event.WalletCommandCompletedV1; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -89,6 +91,7 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private OutboundPalletItemMapper outboundItems; @Resource private OutboundPdaTaskMapper outboundTasks; @Resource private OverseasFeeSourceService feeSources; + @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Transactional(readOnly = true) public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); } @@ -233,6 +236,32 @@ public class RelabelApplicationService implements WalletResultHandler { balance.getProductNameZh(), available, value(balance.getVersion()), positions); }).filter(candidate -> candidate.availablePackages() > 0).toList(); } + + @Transactional(readOnly = true) + public List historicalOrders(String customerId, String operationWarehouseId, + String keyword) { + if (!StringUtils.hasText(operationWarehouseId)) throw badRequest("Operation warehouse is required"); + String q = StringUtils.hasText(keyword) ? keyword.trim() : ""; + List orders = this.orders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .eq(LastlegOrder::getOverseasWarehouseId, operationWarehouseId.trim()) + .and(w -> w.like(StringUtils.hasText(q), LastlegOrder::getOrderNo, q) + .or().like(StringUtils.hasText(q), LastlegOrder::getSourceOrderNo, q) + .or().like(StringUtils.hasText(q), LastlegOrder::getRelabelOrderNo, q) + .or().like(StringUtils.hasText(q), LastlegOrder::getOutboundOrderNo, q)) + .orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId) + .last("LIMIT 100")); + Map result = new java.util.LinkedHashMap<>(); + orders.forEach(order -> result.put("ORDER:" + order.getId(), new RelabelResponses.HistoricalOrder(order.getId(), + order.getOrderNo(), order.getSourceOrderNo(), order.getOutboundOrderNo(), order.getRelabelOrderNo(), + order.getOrderType(), order.getCurrentStatus(), order.getPackageCount(), order.getCreatedAt()))); + for (OutboundOrder outbound : outboundOrders.listRelabelOutbounds(customerId, operationWarehouseId.trim(), q)) { + result.putIfAbsent("OUTBOUND:" + outbound.getId(), new RelabelResponses.HistoricalOrder(null, + outbound.getOutboundNo(), null, outbound.getOutboundNo(), null, outbound.getOutboundType(), + outbound.getStatus(), outbound.getPackageCount(), outbound.getCreatedAt())); + } + return result.values().stream().limit(100).toList(); + } @Transactional public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required"); @@ -411,7 +440,12 @@ public class RelabelApplicationService implements WalletResultHandler { application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION"); applications.updateById(application); } else if ("COMPLETED".equals(next)) { - if (!StringUtils.hasText(application.getOutboundOrderNo())) releaseInventory(customerId, operatorId, application); + boolean preDispatched = "PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus()); + application.setFulfillmentStatus("OUTBOUNDED"); + if (!StringUtils.hasText(application.getOutboundOrderNo()) + && !preDispatched) { + releaseInventory(customerId, operatorId, application); + } BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0)); feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo, new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(), @@ -457,6 +491,62 @@ public class RelabelApplicationService implements WalletResultHandler { return detail(customerId, relabelNo); } + @Transactional + public RelabelResponses.Detail retryWalletFreeze(String relabelNo, String operatorId, + RelabelRequests.WalletFreezeRetry request) { + RelabelApplication application = applications.lockAnyByRelabelNo(relabelNo); + if (application == null) throw notFound("Relabel application not found"); + String customerId = application.getCustomerId(); + if ("FROZEN".equals(application.getWalletStatus()) || "FREEZE_PENDING".equals(application.getWalletStatus())) { + return detail(customerId, relabelNo); + } + if (!PENDING_PROCESSING.equals(application.getStatus()) || !"FREEZE_FAILED".equals(application.getWalletStatus())) { + throw conflict("Only a pending relabel application with failed funds freeze can be retried"); + } + String retryKey = request.idempotencyKey().trim(); + long walletVersion = 100L + Long.parseLong(hash(retryKey).substring(0, 15), 16); + int holdIndex = 0; + for (RelabelItem item : itemList(application.getId())) { + if (item.getSourceInventoryPositionId() == null + || Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) <= 0) continue; + inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( + item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo, + null, "RELABEL:" + relabelNo, "RELABEL:" + application.getId() + ":RETRY_HOLD:" + + walletVersion + ":" + holdIndex++, "补款后重试冻结换标库存")); + } + String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", + relabelNo, application.getChargeAmount(), trim(request.remark()), walletVersion); + application.setWalletStatus("FREEZE_PENDING"); + application.setWalletErrorCode(null); application.setWalletErrorMessage(null); + application.setWalletCommandEventId(walletEventId); application.setUpdatedAt(LocalDateTime.now()); + applications.updateWalletState(application.getId(), application.getWalletStatus(), null, null, walletEventId); + insertHistory(customerId, application.getId(), application.getStatus(), application.getStatus(), operatorId, + "Wallet freeze retry requested", LocalDateTime.now()); + return detail(customerId, relabelNo); + } + + @Transactional + public RelabelResponses.Detail retryWalletSettlement(String relabelNo, String operatorId, + RelabelRequests.WalletFreezeRetry request) { + RelabelApplication application = applications.lockAnyByRelabelNo(relabelNo); + if (application == null) throw notFound("Relabel application not found"); + String customerId = application.getCustomerId(); + if ("SETTLED".equals(application.getWalletStatus()) || "SETTLE_PENDING".equals(application.getWalletStatus())) { + return detail(customerId, relabelNo); + } + if (!"COMPLETED".equals(application.getStatus()) || !"SETTLE_FAILED".equals(application.getWalletStatus())) { + throw conflict("Only a completed relabel application with failed funds settlement can be retried"); + } + String retryKey = request.idempotencyKey().trim(); + long walletVersion = 200L + Long.parseLong(hash(retryKey).substring(0, 15), 16); + String walletEventId = walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", + relabelNo, null, trim(request.remark()), walletVersion); + applications.updateWalletState(application.getId(), "SETTLE_PENDING", null, null, walletEventId); + insertHistory(customerId, application.getId(), application.getStatus(), application.getStatus(), operatorId, + "Wallet settlement retry requested", LocalDateTime.now()); + return detail(customerId, relabelNo); + } + @Transactional public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId, RelabelRequests.CustomerConfirm request) { @@ -492,12 +582,37 @@ public class RelabelApplicationService implements WalletResultHandler { order.setUpdatedAt(now); orders.updateById(order); if (!Objects.equals(orderBefore, order.getCurrentStatus())) { - insertOrderHistory(customerId, order.getId(), orderBefore, order.getCurrentStatus(), + insertOrderHistory(customerId, order.getId(), orderBefore, order.getCurrentStatus(), request.remark(), now); } } insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId, request.remark(), now); + // 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。 + if (warehouseTransfers != null) { + WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo); + if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) { + transfer.setStatus("READY_FOR_DISPATCH"); + transfer.setRelabelCompletedAt(now); + transfer.setUpdatedAt(now); + warehouseTransfers.updateById(transfer); + // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 + LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId()); + if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) { + String transferOrderBefore = transferOrder.getCurrentStatus(); + transferOrder.setCurrentStatus("READY_FOR_DISPATCH"); + transferOrder.setRelabelStatus(READY_FOR_OUTBOUND); + transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:" + + READY_FOR_OUTBOUND); + transferOrder.setUpdatedAt(now); + orders.updateById(transferOrder); + if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) { + insertOrderHistory(customerId, transferOrder.getId(), transferOrderBefore, + transferOrder.getCurrentStatus(), "Warehouse transfer relabel completed", now); + } + } + } + } return detail(customerId, relabelNo); } @@ -622,7 +737,8 @@ public class RelabelApplicationService implements WalletResultHandler { releaseInventory(value.getCustomerId(), "WALLET_CALLBACK", value); } } - value.setUpdatedAt(LocalDateTime.now()); applications.updateById(value); + applications.updateWalletState(value.getId(), value.getWalletStatus(), value.getWalletErrorCode(), + value.getWalletErrorMessage(), null); } private RelabelResponses.Item item(RelabelItem value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java index c5d809f5b..b014751d4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java @@ -23,6 +23,15 @@ public class MerchantReturnOrderController { @Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } @GetMapping("/{returnNo}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) { return ApiResponse.success(service.detail(customer(user), returnNo)); } + @GetMapping("/{returnNo}/relabel-candidates") public ApiResponse> relabelCandidates( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) { + return ApiResponse.success(service.relabelCandidates(customer(user), returnNo)); + } + @PostMapping("/{returnNo}/relabel") public ApiResponse createRelabel( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo, + @Valid @RequestBody ReturnOrderRequests.RelabelCreate request) { + return ApiResponse.success(service.createRelabel(customer(user), operator(user), returnNo, request)); + } private String customer(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java index e763bec5a..6fc2bedae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java @@ -27,4 +27,8 @@ public final class ReturnOrderRequests { String trackingNo, String skuCode, String status) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } + public record RelabelCreate(@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 128) String contactName, @Size(max = 64) String contactPhone, + @Size(max = 32) String deliveryMethod, @Size(max = 1000) String remark) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java index 4870407fd..bbc57322a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java @@ -14,4 +14,8 @@ public final class ReturnOrderResponses { public record Detail(Summary order, String remark, List items, List receipts) { } public record Receipt(String receiptNo, String receiptBatchNo, String cartonMark, String trackingNo, String startedBy, long receivedQuantity, String operatorName, LocalDateTime signedAt) { } + public record RelabelCandidate(Long cartonId, String cartonMark, String sourceReturnNo, String status, + long totalQuantity, List items, String relabelStatus) { } + public record RelabelCreated(String relabelNo, Long relabelApplicationId, String returnNo, + List cartonMarks, String status) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java index 6bdeed9e0..95e724962 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java @@ -36,4 +36,5 @@ public class ReturnInventoryCarton { private LocalDateTime createdAt; private LocalDateTime updatedAt; private LocalDateTime mergedAt; + private String newCartonMark; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index b6ff1ff3e..931030c4c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -18,6 +18,10 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryQueryMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryMovementMapper; import com.baiyue.lastleg.returnreceipt.model.ReturnInventoryRow; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -52,6 +56,8 @@ public class ReturnInventoryService { @Resource private LastlegOrderNoGenerator numbers; @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper orders; @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper receiptMapper; + @Resource private LastlegOrderMapper lastlegOrders; + @Resource private LastlegOrderDetailMapper lastlegOrderDetails; @Transactional public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt, @@ -66,6 +72,7 @@ public class ReturnInventoryService { ReturnInventoryCarton carton = new ReturnInventoryCarton(); carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId()); carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo()); carton.setCartonMark(cartonMark); + carton.setSourceReturnNo(order.getReturnNo()); carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now); cartons.insert(carton); for (ReceiptLine line : lines) { @@ -216,9 +223,13 @@ public class ReturnInventoryService { if (selectedOrders.stream().anyMatch(order -> !"COMPLETED".equals(order.getStatus()))) throw conflict("只能合并已完成签收的退货单"); if (selectedOrders.stream().anyMatch(order -> !customerId.equals(order.getCustomerId()))) throw conflict("不同客户的退货单禁止合箱"); List sources = new ArrayList<>(); + Map sourceOwners = new LinkedHashMap<>(); for (ReturnOrder order : selectedOrders) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) { ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId()); - if (carton != null && AVAILABLE.equals(carton.getStatus())) sources.add(cartons.lockMark(carton.getCartonMark())); + if (carton != null && AVAILABLE.equals(carton.getStatus())) { + ReturnInventoryCarton locked = cartons.lockMark(carton.getCartonMark()); + sources.add(locked); sourceOwners.put(locked.getId(), order); + } } if (sources.isEmpty()) throw conflict("所选退货单没有可合箱的库存箱"); Map requested = new LinkedHashMap<>(); @@ -229,6 +240,7 @@ public class ReturnInventoryService { } Map remaining = new LinkedHashMap<>(requested); Map emptiedBySku = new LinkedHashMap<>(); + Map> attachedByOrder = new LinkedHashMap<>(); long total = 0; for (ReturnInventoryCarton source : sources) { List sourceItems = cartonItems.listCarton(source.getId()); @@ -238,6 +250,9 @@ public class ReturnInventoryService { long take = Math.min(value(remaining.get(sku)), value(item.getQuantity())); if (take > 0) { remaining.put(sku, remaining.get(sku) - take); total = Math.addExact(total, take); + ReturnOrder owner = sourceOwners.get(source.getId()); + if (owner != null) attachedByOrder.computeIfAbsent(owner.getId(), ignored -> new LinkedHashMap<>()) + .merge(sku, take, Math::addExact); productNames.putIfAbsent(sku, item.getProductNameZh()); item.setQuantity(item.getQuantity() - take); if (item.getQuantity() == 0) cartonItems.deleteById(item.getId()); else cartonItems.updateById(item); @@ -267,10 +282,36 @@ public class ReturnInventoryService { balances.adjust(IdWorker.getId(), customerId, target.getOverseasWarehouseId(), entry.getKey(), productNames.get(entry.getKey()), 0, -entry.getValue(), cartonDelta); } + attachMergedCarton(target, attachedByOrder, selectedOrders, productNames, now); operation.setResultCartonId(targetId); operation.setUpdatedAt(now); operations.updateById(operation); return new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), total, label(target)); } + private void attachMergedCarton(ReturnInventoryCarton target, Map> attachedByOrder, + List selectedOrders, Map productNames, LocalDateTime now) { + Map byId = selectedOrders.stream().collect(java.util.stream.Collectors.toMap(ReturnOrder::getId, value -> value)); + for (Map.Entry> orderEntry : attachedByOrder.entrySet()) { + ReturnOrder returnOrder = byId.get(orderEntry.getKey()); + if (returnOrder == null || returnOrder.getLastlegOrderId() == null) continue; + LastlegOrder tail = lastlegOrders.selectById(returnOrder.getLastlegOrderId()); + if (tail == null || !returnOrder.getCustomerId().equals(tail.getCustomerId())) continue; + long quantity = orderEntry.getValue().values().stream().mapToLong(Long::longValue).sum(); + boolean firstSku = true; + for (Map.Entry skuEntry : orderEntry.getValue().entrySet()) { + LastlegOrderDetail detail = new LastlegOrderDetail(); + detail.setId(IdWorker.getId()); detail.setCustomerId(returnOrder.getCustomerId()); detail.setOrderId(tail.getId()); + detail.setSourceInboundDetailId("RETURN_CARTON:" + target.getId() + ":" + returnOrder.getId() + ":" + skuEntry.getKey()); + detail.setSkuCode(skuEntry.getKey()); detail.setProductNameZh(productNames.get(skuEntry.getKey())); + detail.setPackageCount(firstSku ? 1 : 0); detail.setProductQuantity(Math.toIntExact(skuEntry.getValue())); detail.setShippingMark(target.getCartonMark()); + detail.setDestinationWarehouseCode(target.getDestinationWarehouseCode()); detail.setDestinationWarehouseName(target.getDestinationWarehouseName()); + lastlegOrderDetails.insert(detail); firstSku = false; + } + tail.setPackageCount(Math.addExact(tail.getPackageCount() == null ? 0 : tail.getPackageCount(), 1)); + tail.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(quantity))); + tail.setUpdatedAt(now); lastlegOrders.updateById(tail); + } + } + private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) { if (!requestHash.equals(operation.getRequestHash())) throw conflict("幂等键已用于不同的合箱请求"); if (operation.getResultCartonId() == null) throw conflict("合箱请求正在处理"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index a44bf3f5f..c793cbe1f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -21,6 +21,14 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -59,6 +67,10 @@ public class ReturnReceiptService { @Resource private LastlegOrderNoGenerator numbers; @Resource private ObjectMapper objectMapper; @Resource private LastlegOrderMapper lastlegOrders; + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private ReturnInventoryCartonItemMapper cartonItems; + @Resource private RelabelApplicationMapper relabelApplications; + @Resource private RelabelItemMapper relabelItems; @Transactional public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) { @@ -159,6 +171,61 @@ public class ReturnReceiptService { return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews); } + @Transactional(readOnly = true) + public List relabelCandidates(String customerId, String returnNo) { + ReturnOrder order = orders.findNo(customerId, text(returnNo)); + if (order == null) throw notFound("退货单不存在"); + if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收"); + List result = new ArrayList<>(); + for (ReturnReceipt receipt : receipts.listOrder(order.getId())) { + ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId()); + if (carton == null || !ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) continue; + List items = cartonItems.listCarton(carton.getId()).stream() + .map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList(); + result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus())); + } + return result; + } + + @Transactional + public ReturnOrderResponses.RelabelCreated createRelabel(String customerId, String operatorId, String returnNo, + ReturnOrderRequests.RelabelCreate request) { + ReturnOrder order = orders.findNo(customerId, text(returnNo)); + if (order == null) throw notFound("退货单不存在"); + if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收"); + String key = text(request.idempotencyKey()); + RelabelApplication replay = relabelApplications.findKey(customerId, key); + if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), order.getReturnNo(), request.cartonMarks(), replay.getStatus()); + List selected = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (String mark : request.cartonMarks()) { + String normalized = text(mark); + if (!seen.add(normalized.toUpperCase(java.util.Locale.ROOT))) throw badRequest("箱唛不能重复"); + ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, normalized); + if (carton == null || !order.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) throw notFound("退货箱不存在: " + normalized); + if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) throw conflict("退货箱不可申请换标: " + normalized); + if (carton.getSourceReturnNo() == null || !java.util.Arrays.asList(carton.getSourceReturnNo().split(",")).contains(order.getReturnNo())) throw conflict("退货箱不属于当前退货单: " + normalized); + selected.add(cartons.lockMark(normalized)); + } + long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now(); + String relabelNo = numbers.next("RELABEL", order.getOverseasWarehouseId(), appId); + LastlegOrder tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId())); + tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER"); tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo); + tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus("RELABEL:RETURN_CARTON"); tail.setRelabelStatus("RETURN_CARTON"); tail.setPackageCount(selected.size()); + tail.setProductQuantity(Math.toIntExact(selected.stream().mapToLong(c -> value(c.getTotalQuantity())).sum())); tail.setCreatedAt(now); tail.setUpdatedAt(now); lastlegOrders.insert(tail); + RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId()); + app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId()); app.setContactName(text(request.contactName())); app.setContactPhone(text(request.contactPhone())); app.setDeliveryMethod(text(request.deliveryMethod()) == null ? "TRUCK" : text(request.deliveryMethod())); + app.setFulfillmentStatus("WAIT_RELABEL"); app.setRemark(text(request.remark())); app.setStatus("PENDING_PROCESSING"); app.setItemCount(selected.size()); app.setTotalPackageCount(selected.size()); app.setNotifiedPackageCount(selected.size()); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(hash(request)); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app); + for (ReturnInventoryCarton carton : selected) { + for (ReturnInventoryCartonItem ci : cartonItems.listCarton(carton.getId())) { + RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); item.setSkuCode(ci.getSkuCode()); item.setProductNameZh(ci.getProductNameZh()); item.setTotalPackageCount(1); item.setNotifiedPackageCount(1); item.setShipmentNo(order.getReturnNo()); item.setCartonMark(carton.getCartonMark()); item.setFbaCartonMark(carton.getFbaCartonMark()); item.setOperationType("CARTON_LABEL"); item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); item.setCreatedAt(now); relabelItems.insert(item); + } + carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton); + } + order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_PROCESSING"); orders.updateById(order); + return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(), selected.stream().map(ReturnInventoryCarton::getCartonMark).toList(), app.getStatus()); + } + @Transactional(readOnly = true) public PdaReturnReceiptResponses.Start start(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Start request) { ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), request.returnNo()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index ce50bb094..98f5ca509 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -5,6 +5,8 @@ public final class OverseasPermissions { public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list"; public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit"; public static final String LAST_MILE_ORDER_EXPORT = "platform:overseas-warehouse:last-mile-order:export"; + public static final String WAREHOUSE_TRANSFER_APPROVE = + "platform:overseas-warehouse:warehouse-transfer:approve"; public static final String INBOUND_ORDER_LIST = "platform:overseas-warehouse:inbound-order:list"; public static final String INBOUND_ORDER_CREATE = "platform:overseas-warehouse:inbound-order:create"; public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index bde438fbd..51b2417ae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -50,6 +50,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; @Service public class LastlegOrderService { @@ -76,10 +77,16 @@ public class LastlegOrderService { @Resource private WarehouseResourceGateway warehouseResourceGateway; public PageResult page(String customerId, LastlegOrderRequests.Query query) { + return page(customerId, query, null); + } + + public PageResult page(String customerId, LastlegOrderRequests.Query query, String relabelNo) { Page page = orderMapper.selectPage( - new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt)); + new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt)); Map> mergeRelations = palletMergeRelations(page.getRecords()); - return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()))).toList(), + Map dispatchStatuses = dispatchStatuses(page.getRecords()); + return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()), + dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -261,7 +268,8 @@ public class LastlegOrderService { .map(value -> new LastlegOrderCommandDtos.ManualNodeView( value.getNodeCode(), value.getNodeAt(), value.getSource())) .sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList(); - return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() + return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), + dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED")), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -508,6 +516,10 @@ public class LastlegOrderService { } private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query) { + return filter(customerId, query, null); + } + + private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query, String relabelNo) { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); } @@ -516,6 +528,7 @@ public class LastlegOrderService { return new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo()) + .like(StringUtils.hasText(relabelNo), LastlegOrder::getRelabelOrderNo, relabelNo) .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) .like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode()) @@ -558,18 +571,28 @@ public class LastlegOrderService { return result; } - private LastlegOrderSummary summary(LastlegOrder o, List relations) { + private Map dispatchStatuses(List orders) { + if (orders.isEmpty()) return Map.of(); + List rows = orderMapper.findDispatchStatuses( + orders.stream().map(LastlegOrder::getId).toList()); + if (rows == null) return Map.of(); + return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.DispatchStatusRow::orderId, + LastlegOrderMapper.DispatchStatusRow::dispatchStatus)); + } + + private LastlegOrderSummary summary(LastlegOrder o, List relations, + String dispatchStatus) { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), - o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), + o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(), Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER", o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), - o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(), + o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java index 811d58482..63f93c8c4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java @@ -36,6 +36,13 @@ public class MerchantWarehouseTransferController { return ApiResponse.success(service.detail(customer(user), transferNo)); } + @PostMapping("/{transferNo}/confirm-measurement") + public ApiResponse confirmMeasurement( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.CustomerConfirm request) { + return ApiResponse.success(service.confirmMeasurement(customer(user), transferNo, operator(user), request)); + } + private String customer(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java new file mode 100644 index 000000000..848f20c01 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.transfer.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; +import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/warehouse-transfers") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +public class PdaWarehouseTransferController { + @Resource private WarehouseTransferService service; + + @GetMapping("/candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId())); + } + + @PostMapping("/{transferNo}/palletize") + public ApiResponse palletize( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Palletize request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(), + transferNo, request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java index 89db8fb92..9967c47d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java @@ -3,12 +3,14 @@ package com.baiyue.lastleg.transfer.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; import com.baiyue.lastleg.transfer.service.WarehouseTransferService; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -26,22 +28,31 @@ public class WarehouseTransferController { } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) { return ApiResponse.success(service.page(scope(user, customerId), query)); } @GetMapping("/{transferNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String transferNo) { return ApiResponse.success(service.detail(scope(user, customerId), transferNo)); } + @GetMapping("/{transferNo}/carton-label") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + public ApiResponse cartonLabel(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo) { + return ApiResponse.success(service.cartonLabel(scope(user, customerId), transferNo)); + } + @PostMapping("/{transferNo}/review") + @PreAuthorize("hasAuthority('" + OverseasPermissions.WAREHOUSE_TRANSFER_APPROVE + "')") public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String transferNo, @Valid @RequestBody WarehouseTransferRequests.Review request) { - requireApprovalPermission(user); return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request)); } @@ -69,13 +80,6 @@ public class WarehouseTransferController { return ApiResponse.success(service.receive(scope(user, customerId), transferNo, operator(user), request)); } - private void requireApprovalPermission(AuthenticatedUser user) { - if (user == null || user.permissions() == null - || !user.permissions().contains("lastleg:warehouse-transfer:approve")) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer approval permission is required"); - } - } - private void requirePermission(AuthenticatedUser user, String permission) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (user.permissions() == null || !user.permissions().contains(permission)) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index 24c090272..2c0c34867 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -23,17 +23,34 @@ public final class WarehouseTransferRequests { @Size(max = 128) String fbaWarehouseCode, @NotBlank @Size(max = 1000) String deliveryAddress, @Positive int packageCount, - @NotNull @DecimalMin(value = "0.0001") BigDecimal volumeCbm, + @NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm, @NotBlank @Size(max = 64) String deliveryMethod, @NotNull LocalDateTime estimatedArrivalAt, + LocalDateTime estimatedShipAt, @Size(max = 128) String fbaShipmentNo, @Size(max = 255) String trackingNo, @Size(max = 2000) String fbaBoxNos, @Size(max = 1000) String remark, boolean needRelabel, + LocalDateTime labelDeadline, + @Size(max = 128) String relabelContactName, + @Size(max = 64) String relabelContactPhone, + @DecimalMin(value = "0.01") BigDecimal relabelChargeAmount, + @Size(max = 32) String relabelDeliveryMethod, @NotBlank @Size(max = 255) String idempotencyKey, - @NotEmpty @Size(max = 500) List<@Valid Item> items - ) {} + @NotEmpty @Size(max = 500) List<@Valid Item> items, + @Size(max = 1000) List<@Valid RelabelItem> relabelItems + ) { + public Create(String inboundCode, List sourceLastlegOrderIds, String operatingWarehouseId, + String fbaWarehouseCode, String deliveryAddress, int packageCount, BigDecimal volumeCbm, + String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo, + String trackingNo, String fbaBoxNos, String remark, boolean needRelabel, + String idempotencyKey, List items) { + this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, deliveryAddress, + packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo, + fbaBoxNos, remark, needRelabel, null, null, null, null, null, idempotencyKey, items, null); + } + } public record Review(@NotBlank @Size(max = 32) String decision, @Size(max = 1000) String remark) {} @@ -52,6 +69,24 @@ public final class WarehouseTransferRequests { public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} + public record Palletize( + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 100) List<@Valid Pallet> pallets + ) {} + + public record Pallet( + @Size(max = 128) String palletCode, + @Positive int cartonCount, + @NotNull @DecimalMin("0.000001") BigDecimal volumeCbm, + @NotNull @DecimalMin("0.01") BigDecimal grossWeightKg, + @NotEmpty @Size(max = 500) List<@Valid PalletItem> items + ) {} + + public record PalletItem(@NotNull Long transferItemId, @Positive int packageCount) {} + + public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + public record Receipt( @NotNull Long sourceInventoryPositionId, @NotBlank @Size(max = 128) String destinationOverseasWarehouseId, @@ -76,6 +111,17 @@ public final class WarehouseTransferRequests { @Size(max = 255) String shippingMark ) {} + public record RelabelItem( + @NotBlank @Size(max = 32) String operationType, + @NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> customerCartonMarks, + @NotBlank @Size(max = 128) String originalSku, + @Size(max = 128) String newSku, + @Positive int quantity, + @Size(max = 255) String fbaCartonMark, + @Size(max = 128) String destinationWarehouseCode, + @Size(max = 1000) String remark + ) {} + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String transferNo, String inboundCode, String operatingWarehouseId, String fbaWarehouseCode, String status, Boolean needRelabel, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index 5dacfd2d7..b7a5908b9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -1,12 +1,23 @@ package com.baiyue.lastleg.transfer.dto; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; public final class WarehouseTransferResponses { private WarehouseTransferResponses() {} - public record Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {} - public record Carton(String cartonMark, int cartonIndex, int totalCartons, String status) {} + public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark) {} + public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status) {} + public record Label(String transferNo, List cartons, String printableHtml) {} + public record PalletItem(Long transferItemId, String skuCode, int packageCount) {} + public record Pallet(Long id, int sequenceNo, Long warehousePalletId, String palletNo, + Long warehouseLocationId, String warehouseLocationCode, int cartonCount, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + BigDecimal grossWeightKg, BigDecimal volumeCbm, String status, + List items, LocalDateTime completedAt) {} + public record RelabelBinding(String operationType, String customerCartonMark, String originalSku, + String newSku, int quantity, String fbaCartonMark, + String destinationWarehouseCode, String remark) {} public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {} public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode, Long palletId, String palletNo, int packageCount, String status, @@ -19,9 +30,12 @@ public final class WarehouseTransferResponses { Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, String status, boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus, - String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, + String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt, + LocalDateTime estimatedShipAt, String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, - List sourceOrders, List items, List cartons, List allocations, - LocalDateTime outboundAt, LocalDateTime createdAt) {} + List sourceOrders, List items, List cartons, + List pallets, List relabelBindings, + List allocations, LocalDateTime customerConfirmedAt, + LocalDateTime actualShipAt, LocalDateTime createdAt) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferCarton.java index 5bd2a1024..e17cfd9c4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferCarton.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferCarton.java @@ -15,6 +15,7 @@ public class WarehouseTransferCarton { private Long transferItemId; private String customerId; private String cartonMark; + private String customerCartonMark; private Integer cartonIndex; private Integer totalCartons; private String status; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java index 56e8acae9..025d170cc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java @@ -22,19 +22,31 @@ public class WarehouseTransferOrder { private Integer packageCount; private String deliveryMethod; private LocalDateTime estimatedArrivalAt; + private LocalDateTime estimatedShipAt; private String fbaShipmentNo; private String trackingNo; private String fbaBoxNos; private String remark; private Boolean needRelabel; + private LocalDateTime labelDeadline; + private String relabelContactName; + private String relabelContactPhone; + private java.math.BigDecimal relabelChargeAmount; + private String relabelDeliveryMethod; private Long relabelApplicationId; private String relabelNo; + private LocalDateTime relabelCompletedAt; private String status; private String allocationIdempotencyKey; private String allocationRequestHash; private String outboundIdempotencyKey; private String outboundRequestHash; private LocalDateTime outboundAt; + private String palletizeIdempotencyKey; + private String palletizeRequestHash; + private String confirmationIdempotencyKey; + private String confirmationRequestHash; + private LocalDateTime customerConfirmedAt; private String idempotencyKey; private String requestHash; private String createdBy; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java new file mode 100644 index 000000000..749eeb1af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.transfer.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_warehouse_transfer_pallet") +public class WarehouseTransferPallet { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private Integer sequenceNo; + private Long warehousePalletId; + private String palletNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private Long warehouseResourceLockId; + private Integer cartonCount; + private BigDecimal lengthCm; + private BigDecimal widthCm; + private BigDecimal heightCm; + private BigDecimal grossWeightKg; + private BigDecimal volumeCbm; + private String status; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPalletItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPalletItem.java new file mode 100644 index 000000000..35ed63541 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPalletItem.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.transfer.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_warehouse_transfer_pallet_item") +public class WarehouseTransferPalletItem { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private Long transferPalletId; + private Long transferItemId; + private String customerId; + private Integer packageCount; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferRelabelBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferRelabelBinding.java new file mode 100644 index 000000000..f87fcca96 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferRelabelBinding.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.transfer.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_warehouse_transfer_relabel_binding") +public class WarehouseTransferRelabelBinding { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private String operationType; + private String customerCartonMark; + private String originalSku; + private String newSku; + private Integer quantity; + private String fbaCartonMark; + private String destinationWarehouseCode; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java index e062027c7..bc86eed62 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; 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 WarehouseTransferOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") WarehouseTransferOrder findKey(@Param("customerId") String customerId, @Param("key") String key); @@ -11,4 +12,16 @@ import org.apache.ibatis.annotations.Select; WarehouseTransferOrder findNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1 FOR UPDATE") WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") + WarehouseTransferOrder lockForPda(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") + WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id") + java.util.List pdaCandidates(@Param("warehouseId") String warehouseId); + @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL") + int markOutboundByOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("outboundAt") java.time.LocalDateTime outboundAt); + @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND outbound_at IS NULL") + int markOutboundByRelabel(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo, + @Param("outboundAt") java.time.LocalDateTime outboundAt); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletItemMapper.java new file mode 100644 index 000000000..b744cb4af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletItemMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface WarehouseTransferPalletItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_pallet_item WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY transfer_pallet_id,id") + List listOrder(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java new file mode 100644 index 000000000..6526636ea --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface WarehouseTransferPalletMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY sequence_no") + List listOrder(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferRelabelBindingMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferRelabelBindingMapper.java new file mode 100644 index 000000000..7c876d91e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferRelabelBindingMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface WarehouseTransferRelabelBindingMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_relabel_binding WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY id") + List listOrder(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index a9697b181..d2490202b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -20,6 +20,20 @@ import com.baiyue.lastleg.inventory.entity.InventoryBalance; import com.baiyue.lastleg.inventory.entity.InventoryPosition; import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; @@ -29,6 +43,9 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt; import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding; import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper; @@ -36,6 +53,9 @@ import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMa import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; @@ -53,7 +73,12 @@ import java.util.HexFormat; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.ArrayList; +import java.util.Comparator; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.function.Function; import java.util.stream.Collectors; @@ -68,11 +93,18 @@ public class WarehouseTransferService { public static final String OUTBOUNDED = "OUTBOUNDED"; public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED"; public static final String RECEIVED = "RECEIVED"; + public static final String WAIT_PDA_RECEIVING = "WAIT_PDA_RECEIVING"; + public static final String WAIT_CUSTOMER_CONFIRMATION = "WAIT_CUSTOMER_CONFIRMATION"; + public static final String READY_FOR_DISPATCH = "READY_FOR_DISPATCH"; + public static final String WAIT_RELABEL = "WAIT_RELABEL"; @Resource private WarehouseTransferOrderMapper transfers; @Resource private WarehouseTransferItemMapper transferItems; @Resource private WarehouseTransferCartonMapper transferCartons; @Resource private WarehouseTransferSourceOrderMapper sourceOrders; + @Resource private WarehouseTransferPalletMapper transferPallets; + @Resource private WarehouseTransferPalletItemMapper transferPalletItems; + @Resource private WarehouseTransferRelabelBindingMapper relabelBindings; @Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations; @Resource private WarehouseTransferReceiptMapper transferReceipts; @Resource private InboundSnapshotMapper inboundSnapshots; @@ -81,6 +113,13 @@ public class WarehouseTransferService { @Resource private LastlegOrderDetailMapper orderDetails; @Resource private LastlegOrderStatusHistoryMapper orderHistories; @Resource private RelabelApplicationMapper relabelApplications; + @Resource private RelabelApplicationService relabelService; + @Resource private WarehouseResourceGateway warehouseResources; + @Resource private InboundPlanMapper inboundPlans; + @Resource private InboundPlanLineMapper inboundPlanLines; + @Resource private InboundPlanPalletMapper inboundPlanPallets; + @Resource private InboundPlanPalletAllocationMapper inboundPlanAllocations; + @Resource private InboundStockReceiptMapper stockReceipts; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private InventoryCommandService inventory; @Resource private InventoryCommandMapper inventoryCommands; @@ -93,6 +132,7 @@ public class WarehouseTransferService { if (itemPackages != request.packageCount()) { throw badRequest("Cargo item package total must equal transfer package count"); } + validateRelabelRequest(request); String key = request.idempotencyKey().trim(); String requestHash = hash(request); WarehouseTransferOrder replay = transfers.findKey(customerId, key); @@ -142,10 +182,16 @@ public class WarehouseTransferService { transfer.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount()); transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); + transfer.setEstimatedShipAt(request.estimatedShipAt()); transfer.setFbaShipmentNo(trim(request.fbaShipmentNo())); transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null); transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark())); transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW); + transfer.setLabelDeadline(request.labelDeadline()); + transfer.setRelabelContactName(trim(request.relabelContactName())); + transfer.setRelabelContactPhone(trim(request.relabelContactPhone())); + transfer.setRelabelChargeAmount(request.relabelChargeAmount()); + transfer.setRelabelDeliveryMethod(trim(request.relabelDeliveryMethod())); transfer.setIdempotencyKey(key); transfer.setRequestHash(requestHash); transfer.setCreatedBy(trim(operatorId)); transfer.setCreatedAt(now); transfer.setUpdatedAt(now); transfers.insert(transfer); @@ -164,14 +210,17 @@ public class WarehouseTransferService { sourceOrders.insert(link); } + if (request.needRelabel() && request.relabelItems() != null) insertRelabelBindings(customerId, transferId, request.relabelItems(), now); + int cartonIndex = 0; for (int index = 0; index < request.items().size(); index++) { WarehouseTransferRequests.Item source = request.items().get(index); long itemId = insertTransferItem(customerId, transferId, source, now); + List customerMarks = splitMarks(source.shippingMark()); for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) { cartonIndex++; insertTransferCarton(customerId, transferId, itemId, transferNo, - cartonIndex, request.packageCount(), now); + cartonIndex, request.packageCount(), itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null, now); } insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); } @@ -213,16 +262,36 @@ public class WarehouseTransferService { List items = transferItems.selectList( new LambdaQueryWrapper().eq(WarehouseTransferItem::getCustomerId, customerId) .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)) - .stream().map(value -> new WarehouseTransferResponses.Item(value.getSkuCode(), value.getProductNameZh(), + .stream().map(value -> new WarehouseTransferResponses.Item(value.getId(), value.getSkuCode(), value.getProductNameZh(), value.getPackageCount(), value.getShippingMark())).toList(); + Map itemById = items.stream() + .collect(Collectors.toMap(WarehouseTransferResponses.Item::id, Function.identity())); List sources = sourceOrders.selectList( new LambdaQueryWrapper().eq(WarehouseTransferSourceOrder::getCustomerId, customerId) .eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferSourceOrder::getId)) .stream().map(value -> new WarehouseTransferResponses.SourceOrder(value.getSourceLastlegOrderId(), value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList(); List cartons = transferCartons.listOrder(customerId, transfer.getId()) - .stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), + .stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList(); + List palletItemRows = transferPalletItems == null + ? List.of() : transferPalletItems.listOrder(customerId, transfer.getId()); + Map> palletItemsByPallet = palletItemRows.stream() + .collect(Collectors.groupingBy(WarehouseTransferPalletItem::getTransferPalletId)); + List pallets = transferPallets == null ? List.of() : transferPallets.listOrder(customerId, transfer.getId()).stream() + .map(value -> new WarehouseTransferResponses.Pallet(value.getId(), value.getSequenceNo(), + value.getWarehousePalletId(), value.getPalletNo(), value.getWarehouseLocationId(), + value.getWarehouseLocationCode(), value.getCartonCount(), value.getLengthCm(), value.getWidthCm(), + value.getHeightCm(), value.getGrossWeightKg(), value.getVolumeCbm(), value.getStatus(), + palletItemsByPallet.getOrDefault(value.getId(), List.of()).stream().map(row -> { + WarehouseTransferResponses.Item item = itemById.get(row.getTransferItemId()); + return new WarehouseTransferResponses.PalletItem(row.getTransferItemId(), + item == null ? null : item.skuCode(), row.getPackageCount()); + }).toList(), value.getCompletedAt())).toList(); + List bindings = relabelBindings == null ? List.of() : relabelBindings.listOrder(customerId, transfer.getId()) + .stream().map(value -> new WarehouseTransferResponses.RelabelBinding(value.getOperationType(), + value.getCustomerCartonMark(), value.getOriginalSku(), value.getNewSku(), value.getQuantity(), + value.getFbaCartonMark(), value.getDestinationWarehouseCode(), value.getRemark())).toList(); Map receiptByAllocation = transferReceipts.selectList( new LambdaQueryWrapper().eq(WarehouseTransferReceipt::getCustomerId, customerId) .eq(WarehouseTransferReceipt::getTransferOrderId, transfer.getId())) @@ -252,9 +321,32 @@ public class WarehouseTransferService { Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(), relabelStatus, transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(), - transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(), - transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, cartons, allocations, - transfer.getOutboundAt(), transfer.getCreatedAt()); + transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), + transfer.getEstimatedShipAt(), transfer.getFbaShipmentNo(), transfer.getTrackingNo(), transfer.getFbaBoxNos(), + transfer.getRemark(), sources, items, cartons, pallets, bindings, allocations, + transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt()); + } + + @Transactional(readOnly = true) + public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + List cartons = transferCartons.listOrder(customerId, transfer.getId()).stream() + .map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList(); + StringBuilder labels = new StringBuilder(); + for (WarehouseTransferResponses.Carton carton : cartons) { + labels.append("
仓储转运箱唛
") + .append(escape(carton.cartonMark())).append("
") + .append(carton.cartonIndex()).append(" / ").append(carton.totalCartons()).append(" 转运单号:") + .append(escape(transferNo)).append("
"); + if (text(carton.customerCartonMark()) != null) labels.append("
客户箱唛:") + .append(escape(carton.customerCartonMark())).append("
"); + labels.append("
"); + } + String html = "" + escape(transferNo) + "-箱唛" + + labels + ""; + return new WarehouseTransferResponses.Label(transferNo, cartons, html); } @Transactional @@ -269,15 +361,18 @@ public class WarehouseTransferService { WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); if (!PENDING_REVIEW.equals(transfer.getStatus())) { - if (next.equals(transfer.getStatus())) return detail(customerId, transferNo); + if (next.equals(transfer.getStatus()) || APPROVED.equals(next) + && Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH, WAIT_RELABEL) + .contains(transfer.getStatus())) return detail(customerId, transferNo); throw conflict("Warehouse transfer order was already reviewed"); } LastlegOrder order = orders.selectById(transfer.getLastlegOrderId()); if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing"); LocalDateTime now = LocalDateTime.now(); String before = order.getCurrentStatus(); - transfer.setStatus(next); transfer.setUpdatedAt(now); transfers.updateById(transfer); boolean incomingTransit = transfer.getTransferNo().equals(transfer.getInboundCode()); + String transferStatus = APPROVED.equals(next) && incomingTransit ? WAIT_PDA_RECEIVING : next; + transfer.setStatus(transferStatus); transfer.setUpdatedAt(now); transfers.updateById(transfer); String orderStatus = APPROVED.equals(next) ? (incomingTransit ? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY") @@ -294,6 +389,191 @@ public class WarehouseTransferService { return detail(customerId, transferNo); } + @Transactional + public WarehouseTransferResponses.Detail palletize(String operatorId, long overseasWarehouseId, + String transferNo, + WarehouseTransferRequests.Palletize request) { + WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!String.valueOf(overseasWarehouseId).equals(transfer.getOperatingWarehouseId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer belongs to another warehouse"); + } + String customerId = transfer.getCustomerId(); + String requestHash = hash(request); + if (WAIT_CUSTOMER_CONFIRMATION.equals(transfer.getStatus()) + && request.idempotencyKey().equals(transfer.getPalletizeIdempotencyKey())) { + if (!requestHash.equals(transfer.getPalletizeRequestHash())) { + throw conflict("Palletization idempotency key was reused with different data"); + } + return detail(customerId, transferNo); + } + if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) { + throw conflict("Warehouse transfer is not awaiting PDA receiving"); + } + List items = transferItems.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)); + Map itemById = items.stream() + .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); + Map totals = new java.util.HashMap<>(); + int palletCartons = 0; + for (WarehouseTransferRequests.Pallet pallet : request.pallets()) { + int itemTotal = pallet.items().stream().mapToInt(WarehouseTransferRequests.PalletItem::packageCount).sum(); + if (itemTotal != pallet.cartonCount()) throw badRequest("Each pallet carton count must equal its SKU carton total"); + palletCartons = Math.addExact(palletCartons, pallet.cartonCount()); + Set uniqueItems = new HashSet<>(); + for (WarehouseTransferRequests.PalletItem item : pallet.items()) { + if (!uniqueItems.add(item.transferItemId()) || !itemById.containsKey(item.transferItemId())) { + throw badRequest("Pallet SKU rows must be unique and belong to this transfer"); + } + totals.merge(item.transferItemId(), item.packageCount(), Math::addExact); + } + } + if (palletCartons != transfer.getPackageCount()) throw badRequest("Pallet carton total must equal transfer package count"); + for (WarehouseTransferItem item : items) { + if (totals.getOrDefault(item.getId(), 0) != item.getPackageCount()) { + throw badRequest("Palletized carton total does not match declared SKU " + item.getSkuCode()); + } + } + + String batchKey = "WAREHOUSE_TRANSFER:" + transferNo + ":PALLETIZE"; + List batchItems = new ArrayList<>(); + for (int index = 0; index < request.pallets().size(); index++) { + WarehouseTransferRequests.Pallet pallet = request.pallets().get(index); + batchItems.add(new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(customerId), + transferNo + ":" + (index + 1), transferNo, index + 1, "STANDARD", + trim(transfer.getFbaWarehouseCode()), trim(pallet.palletCode()))); + } + WarehouseResourceGateway.PalletBatchResponse ensured = warehouseResources.ensurePalletBatch( + new WarehouseResourceGateway.PalletBatchEnsure(overseasWarehouseId, batchKey, batchItems)); + WarehouseResourceGateway.PalletBatchResponse confirmed = warehouseResources.confirmPalletBatch( + new WarehouseResourceGateway.PalletBatchCommand(overseasWarehouseId, batchKey, + request.idempotencyKey() + ":CONFIRM")); + if (confirmed == null || confirmed.items() == null || confirmed.items().size() != request.pallets().size()) { + throw conflict("Warehouse pallet batch did not return every pallet"); + } + LocalDateTime now = LocalDateTime.now(); + for (int index = 0; index < request.pallets().size(); index++) { + int sequenceNo = index + 1; + WarehouseTransferRequests.Pallet source = request.pallets().get(index); + WarehouseResourceGateway.PalletBatchItem resource = confirmed.items().stream() + .filter(value -> value.sequenceNo() == sequenceNo).findFirst() + .orElseThrow(() -> conflict("Warehouse pallet sequence is incomplete")); + WarehouseResourceGateway.WarehouseLocationReference location = warehouseResources.resolveLocation( + overseasWarehouseId, resource.locationId()); + WarehouseTransferPallet pallet = new WarehouseTransferPallet(); + pallet.setId(IdWorker.getId()); pallet.setTransferOrderId(transfer.getId()); pallet.setCustomerId(customerId); + pallet.setSequenceNo(sequenceNo); pallet.setWarehousePalletId(resource.palletId()); + pallet.setPalletNo(resource.palletCode()); pallet.setWarehouseLocationId(resource.locationId()); + pallet.setWarehouseLocationCode(location.locationCode()); pallet.setWarehouseResourceLockId(resource.resourceLockId()); + // 转运测量以打托后的托盘为单位,体积由 PDA 直接录入托盘总体积,不再按单箱尺寸换算。 + // 旧表字段仍为 NOT NULL;按托模式不采集长宽高,以 0 兼容存储且不得参与总体积计算。 + pallet.setLengthCm(BigDecimal.ZERO); pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO); + pallet.setCartonCount(source.cartonCount()); pallet.setGrossWeightKg(source.grossWeightKg()); + pallet.setVolumeCbm(source.volumeCbm().setScale(6, RoundingMode.HALF_UP)); + pallet.setStatus("COMPLETED"); pallet.setCompletedAt(now); pallet.setCreatedAt(now); pallet.setUpdatedAt(now); + transferPallets.insert(pallet); + for (WarehouseTransferRequests.PalletItem sourceItem : source.items()) { + WarehouseTransferPalletItem row = new WarehouseTransferPalletItem(); + row.setId(IdWorker.getId()); row.setTransferOrderId(transfer.getId()); row.setTransferPalletId(pallet.getId()); + row.setTransferItemId(sourceItem.transferItemId()); row.setCustomerId(customerId); + row.setPackageCount(sourceItem.packageCount()); row.setCreatedAt(now); transferPalletItems.insert(row); + } + } + transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim()); + transfer.setPalletizeRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); + order.setCurrentStatus(WAIT_CUSTOMER_CONFIRMATION); order.setPalletCount(request.pallets().size()); + order.setVolumeCbm(transferPallets.listOrder(customerId, transfer.getId()).stream() + .map(WarehouseTransferPallet::getVolumeCbm).reduce(BigDecimal.ZERO, BigDecimal::add)); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + WAIT_CUSTOMER_CONFIRMATION); order.setUpdatedAt(now); orders.updateById(order); + insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, + "PDA receiving measurement and palletization completed", now); + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail confirmMeasurement(String customerId, String transferNo, + String operatorId, + WarehouseTransferRequests.CustomerConfirm request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + String requestHash = hash(request); + if (Set.of(READY_FOR_DISPATCH, WAIT_RELABEL, OUTBOUNDED).contains(transfer.getStatus()) + && request.idempotencyKey().equals(transfer.getConfirmationIdempotencyKey())) { + if (!requestHash.equals(transfer.getConfirmationRequestHash())) { + throw conflict("Confirmation idempotency key was reused with different data"); + } + return detail(customerId, transferNo); + } + if (!WAIT_CUSTOMER_CONFIRMATION.equals(transfer.getStatus())) { + throw conflict("Warehouse transfer measurement is not awaiting customer confirmation"); + } + List items = transferItems.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)); + Map itemById = items.stream() + .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); + List pallets = transferPallets.listOrder(customerId, transfer.getId()); + List palletItems = transferPalletItems.listOrder(customerId, transfer.getId()); + WarehouseResourceGateway.WarehouseReference warehouse = warehouseResources.requireActiveSharedWarehouse( + Long.parseLong(transfer.getOperatingWarehouseId())); + Map> stockSourcesBySku = new java.util.LinkedHashMap<>(); + LocalDateTime now = LocalDateTime.now(); + for (WarehouseTransferPallet pallet : pallets) { + for (WarehouseTransferPalletItem row : palletItems.stream() + .filter(value -> value.getTransferPalletId().equals(pallet.getId())).toList()) { + WarehouseTransferItem item = itemById.get(row.getTransferItemId()); + String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transferNo + ":" + pallet.getSequenceNo() + ":" + item.getId(); + InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, + new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId, + transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(), + null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()), + transfer.getInboundOrderId(), transferNo, transferNo, item.getSkuCode(), item.getProductNameZh(), null, + pallet.getWarehousePalletId(), pallet.getPalletNo(), transferNo, pallet.getWarehouseLocationId(), + pallet.getWarehouseLocationCode(), row.getPackageCount(), transferNo, + "WAREHOUSE_TRANSFER_CONFIRMED:" + transferNo, operationKey, trim(request.remark()))); + stockSourcesBySku.computeIfAbsent(item.getSkuCode(), ignored -> new ArrayList<>()) + .add(new ConfirmedStockSource(result.balanceId(), result.positionId(), row.getPackageCount())); + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(IdWorker.getId()); receipt.setCustomerId(customerId); + // 转运收货没有独立 PDA 入仓任务,使用本业务链的稳定主键填充通用收货快照维度。 + receipt.setPlanId(transfer.getInboundOrderId()); receipt.setTaskId(transfer.getId()); + receipt.setAllocationId(row.getId()); receipt.setLineId(item.getId()); + receipt.setOrderId(transfer.getLastlegOrderId()); + receipt.setInboundCode(transferNo); receipt.setSkuCode(item.getSkuCode()); receipt.setProductNameZh(item.getProductNameZh()); + receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo()); + receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); receipt.setWarehouseLocationCode(pallet.getWarehouseLocationCode()); + receipt.setBatchNo(transferNo); receipt.setReceivedPackages(Long.valueOf(row.getPackageCount())); + receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId()); + receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now); stockReceipts.insert(receipt); + } + } + if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { + RelabelResponses.Detail relabel = createLinkedRelabel(customerId, operatorId, transfer, itemById, + stockSourcesBySku); + transfer.setRelabelApplicationId(relabel.application().id()); + transfer.setRelabelNo(relabel.application().relabelNo()); transfer.setStatus(WAIT_RELABEL); + } else { + if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) { + throw conflict("A destination warehouse is required before dispatch"); + } + createDispatchPlan(customerId, transfer, items, pallets, palletItems, now); + transfer.setStatus(READY_FOR_DISPATCH); + } + transfer.setConfirmationIdempotencyKey(request.idempotencyKey().trim()); + transfer.setConfirmationRequestHash(requestHash); transfer.setCustomerConfirmedAt(now); transfer.setUpdatedAt(now); + transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); + order.setCurrentStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? WAIT_RELABEL : READY_FOR_DISPATCH); + order.setRelabelStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? "PENDING_PROCESSING" : "NOT_REQUIRED"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus()); + order.setUpdatedAt(now); orders.updateById(order); + insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, + "Customer confirmed PDA measurements; actual inventory stocked in", now); + return detail(customerId, transferNo); + } + @Transactional public WarehouseTransferResponses.Detail allocate(String customerId, String transferNo, String operatorId, WarehouseTransferRequests.Allocate request) { @@ -490,6 +770,182 @@ public class WarehouseTransferService { return detail(customerId, transferNo); } + @Transactional(readOnly = true) + public List pdaCandidates(long overseasWarehouseId) { + return transfers.pdaCandidates(String.valueOf(overseasWarehouseId)).stream().map(value -> + new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), + value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), + value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(), + Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), + value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); + } + + private void validateRelabelRequest(WarehouseTransferRequests.Create request) { + if (!request.needRelabel()) { + if (request.relabelItems() != null && !request.relabelItems().isEmpty()) { + throw badRequest("Relabel items require needRelabel=true"); + } + return; + } + if (request.relabelItems() == null || request.relabelItems().isEmpty()) return; + if (request.labelDeadline() == null || !StringUtils.hasText(request.relabelContactName()) + || !StringUtils.hasText(request.relabelContactPhone()) || request.relabelChargeAmount() == null) { + throw badRequest("Relabel deadline, contact and charge are required"); + } + Set declaredSkus = request.items().stream().map(item -> item.skuCode().trim()).collect(Collectors.toSet()); + Set marks = new HashSet<>(); + for (WarehouseTransferRequests.RelabelItem row : request.relabelItems()) { + String type = row.operationType().trim().toUpperCase(); + if (!Set.of("CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU").contains(type)) { + throw badRequest("Unsupported relabel operation type"); + } + if (!declaredSkus.contains(row.originalSku().trim())) { + throw badRequest("Relabel SKU must exist in transfer cargo: " + row.originalSku()); + } + if (("CARTON_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) + && (!StringUtils.hasText(row.fbaCartonMark()) || !StringUtils.hasText(row.destinationWarehouseCode()))) { + throw badRequest("Carton relabel requires new FBA carton mark and destination warehouse"); + } + if (("SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) && !StringUtils.hasText(row.newSku())) { + throw badRequest("SKU relabel requires a new SKU"); + } + for (String mark : row.customerCartonMarks()) { + if (!marks.add(mark.trim().toUpperCase())) throw badRequest("Customer carton marks must be unique"); + } + } + if (marks.size() > request.packageCount()) throw badRequest("Relabel carton marks cannot exceed transfer cartons"); + } + + private void insertRelabelBindings(String customerId, long transferId, + List rows, LocalDateTime now) { + if (rows == null || rows.isEmpty()) return; + for (WarehouseTransferRequests.RelabelItem source : rows) { + for (String mark : source.customerCartonMarks()) { + WarehouseTransferRelabelBinding row = new WarehouseTransferRelabelBinding(); + row.setId(IdWorker.getId()); row.setTransferOrderId(transferId); row.setCustomerId(customerId); + row.setOperationType(source.operationType().trim().toUpperCase()); + row.setCustomerCartonMark(mark.trim()); row.setOriginalSku(source.originalSku().trim()); + row.setNewSku(trim(source.newSku())); row.setQuantity(source.quantity()); + row.setFbaCartonMark(trim(source.fbaCartonMark())); + row.setDestinationWarehouseCode(trim(source.destinationWarehouseCode())); + row.setRemark(trim(source.remark())); row.setCreatedAt(now); relabelBindings.insert(row); + } + } + } + + private RelabelResponses.Detail createLinkedRelabel(String customerId, String operatorId, + WarehouseTransferOrder transfer, + Map itemById, + Map> stockSourcesBySku) { + Map itemsBySku = itemById.values().stream() + .collect(Collectors.toMap(WarehouseTransferItem::getSkuCode, Function.identity(), (left, right) -> left)); + List items = new ArrayList<>(); + for (WarehouseTransferRelabelBinding binding : relabelBindings.listOrder(customerId, transfer.getId())) { + WarehouseTransferItem source = itemsBySku.get(binding.getOriginalSku()); + ConfirmedStockSource stockSource = nextStockSource(stockSourcesBySku.get(binding.getOriginalSku())); + if (source == null || stockSource == null) { + throw conflict("Relabel binding cannot find confirmed inventory for SKU " + binding.getOriginalSku()); + } + List skuItems = Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(binding.getOperationType()) + ? List.of(new RelabelRequests.SkuItem(binding.getOriginalSku(), binding.getNewSku(), + binding.getQuantity(), binding.getRemark())) : List.of(); + items.add(new RelabelRequests.Item(stockSource.balanceId, stockSource.positionId, transfer.getInboundOrderId(), + binding.getOriginalSku(), null, null, null, source.getProductNameZh(), 1, 1, + StringUtils.hasText(transfer.getFbaShipmentNo()) ? transfer.getFbaShipmentNo() : transfer.getTransferNo(), + transfer.getTrackingNo(), binding.getNewSku(), "仓储转运自动换标", binding.getRemark(), + null, null, null, binding.getCustomerCartonMark(), binding.getFbaCartonMark(), + binding.getDestinationWarehouseCode(), binding.getOperationType(), skuItems)); + } + RelabelRequests.Create request = new RelabelRequests.Create(transfer.getTransferNo(), + transfer.getOperatingWarehouseId(), transfer.getLabelDeadline(), transfer.getRelabelContactName(), + transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单", + "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items, + trim(transfer.getRelabelDeliveryMethod()), false); + return relabelService.create(customerId, operatorId, request); + } + + private static ConfirmedStockSource nextStockSource(List sources) { + if (sources == null) return null; + for (ConfirmedStockSource source : sources) { + if (source.remainingPackages > 0) { + source.remainingPackages--; + return source; + } + } + return null; + } + + private static final class ConfirmedStockSource { + private final Long balanceId; + private final Long positionId; + private int remainingPackages; + + private ConfirmedStockSource(Long balanceId, Long positionId, int remainingPackages) { + this.balanceId = balanceId; + this.positionId = positionId; + this.remainingPackages = remainingPackages; + } + } + + private void createDispatchPlan(String customerId, WarehouseTransferOrder transfer, + List items, List pallets, + List palletItems, LocalDateTime now) { + long planId = IdWorker.getId(); + InboundPlan plan = new InboundPlan(); plan.setId(planId); plan.setPlanNo("WT-" + transfer.getTransferNo()); + plan.setCustomerId(customerId); plan.setSourceOrderId(transfer.getLastlegOrderId()); + plan.setContainerNo(transfer.getTransferNo()); plan.setOverseasWarehouseId(Long.parseLong(transfer.getOperatingWarehouseId())); + plan.setPlannedArrivalDate(transfer.getEstimatedArrivalAt().toLocalDate()); plan.setPalletType("STANDARD"); + plan.setAssignedFlag(true); plan.setMergeRequired(false); plan.setUrgentFlag(false); + plan.setCreationMode("WAREHOUSE_TRANSFER"); plan.setPlanRemark("Customer-confirmed transfer measurements"); + plan.setStatus("COMPLETED"); plan.setLineCount(items.size()); plan.setPalletCount(pallets.size()); + plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add)); plan.setCreatedAt(now); plan.setUpdatedAt(now); plan.setVersion(0); + inboundPlans.insert(plan); + Map lineIds = new java.util.HashMap<>(); + for (WarehouseTransferItem source : items) { + InboundPlanLine line = new InboundPlanLine(); line.setId(IdWorker.getId()); line.setCustomerId(customerId); + line.setPlanId(planId); line.setOrderId(transfer.getLastlegOrderId()); line.setInboundCode(transfer.getTransferNo()); + line.setDestinationType("FBA"); line.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); + line.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); line.setDestinationWarehouseName(transfer.getFbaWarehouseCode()); + line.setVolumeCbm(BigDecimal.ZERO); line.setRequiredPackageCount(source.getPackageCount()); + line.setPlannedPalletCount((int) palletItems.stream().filter(row -> row.getTransferItemId().equals(source.getId())).count()); + inboundPlanLines.insert(line); lineIds.put(source.getId(), line.getId()); + } + for (WarehouseTransferPallet source : pallets) { + InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(IdWorker.getId()); pallet.setCustomerId(customerId); + pallet.setPlanId(planId); pallet.setPalletNo(source.getPalletNo()); pallet.setPalletType("STANDARD"); + pallet.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); pallet.setDestinationWarehouseName(transfer.getFbaWarehouseCode()); + pallet.setAllocatedVolumeCbm(source.getVolumeCbm()); pallet.setSequenceNo(source.getSequenceNo()); + pallet.setWarehousePalletId(source.getWarehousePalletId()); pallet.setWarehouseLocationId(source.getWarehouseLocationId()); + pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(source.getCompletedAt()); pallet.setActualVolumeCbm(source.getVolumeCbm()); + pallet.setActualPackageCount(source.getCartonCount()); inboundPlanPallets.insert(pallet); + for (WarehouseTransferPalletItem sourceItem : palletItems.stream() + .filter(row -> row.getTransferPalletId().equals(source.getId())).toList()) { + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); + allocation.setCustomerId(customerId); allocation.setPlanId(planId); allocation.setLineId(lineIds.get(sourceItem.getTransferItemId())); + allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO); + allocation.setPlannedPackageCount(sourceItem.getPackageCount()); allocation.setCountedPackageCount(sourceItem.getPackageCount()); + allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(source.getVolumeCbm()); + allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation); + } + } + } + + private static List splitMarks(String value) { + if (!StringUtils.hasText(value)) return List.of(); + return java.util.Arrays.stream(value.split("[,\\n\\r]+")) + .map(String::trim).filter(StringUtils::hasText).distinct().toList(); + } + + private static String escape(String value) { + return org.springframework.web.util.HtmlUtils.htmlEscape(Objects.toString(value, "")); + } + + private static String text(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + private Long createInboundSnapshot(String customerId, long inboundOrderId, String transferNo, WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) { @@ -544,10 +1000,11 @@ public class WarehouseTransferService { private void insertTransferCarton(String customerId, long transferId, long transferItemId, String transferNo, int cartonIndex, int totalCartons, - LocalDateTime now) { + String customerCartonMark, LocalDateTime now) { WarehouseTransferCarton carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId()); carton.setTransferOrderId(transferId); carton.setTransferItemId(transferItemId); carton.setCustomerId(customerId); carton.setCartonMark(transferNo + "#" + totalCartons + "-" + cartonIndex); + carton.setCustomerCartonMark(trim(customerCartonMark)); carton.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED"); carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 61e8772d9..9dc3c942f 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1515,6 +1515,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_outbound_hash_stmt FROM @ddl; EXECUTE transfer_outbound_hash_stmt; DEALLOCATE PREPARE transfer_outbound_hash_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash')); PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at')); +PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式''')); +PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='palletize_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN palletize_idempotency_key VARCHAR(255) NULL COMMENT ''PDA打托幂等键'', ADD COLUMN palletize_request_hash CHAR(64) NULL COMMENT ''PDA打托请求哈希'', ADD COLUMN confirmation_idempotency_key VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'', ADD COLUMN confirmation_request_hash CHAR(64) NULL COMMENT ''客户测量确认请求哈希'', ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认测量时间''')); +PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -1545,6 +1551,45 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton ( KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status) ) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='customer_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN customer_carton_mark VARCHAR(255) NULL COMMENT ''客户原箱唛,用于换标现场绑定'' AFTER carton_mark')); +PREPARE transfer_customer_carton_stmt FROM @ddl; EXECUTE transfer_customer_carton_stmt; DEALLOCATE PREPARE transfer_customer_carton_stmt; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( + id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', sequence_no INT NOT NULL COMMENT '托序号', + warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', + warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', + warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', + length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', + height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', + volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '打托状态', + completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_seq(transfer_order_id,sequence_no), + UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), + KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', + transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_pallet_item(transfer_pallet_id,transfer_item_id), + KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型', + customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU', + new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数', + fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), + KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细'; + CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( id BIGINT NOT NULL COMMENT '雪花主键', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index ac1d64cd3..9e773c74c 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -534,10 +534,19 @@ paths: '200': description: 统一 ApiResponse 包装的候选托盘数组 content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/SmartDispatchCandidate' } } } } + /api/platform/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}: + delete: + summary: 移除已排车托盘 + description: 权限 platform:overseas-warehouse:dispatch:edit;仅待发车、未拣货且尚未生成出库单时允许,成功后释放托盘锁并刷新排车汇总。 + security: [{ bearerAuth: [] }] + parameters: + - { name: dispatchNo, in: path, required: true, schema: { type: string } } + - { name: itemId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: { '200': { description: 更新后的 DispatchResponse }, '404': { description: 排车单或托盘不存在 }, '409': { description: 已生成出库单或排车已开始执行 } } /api/platform/v1/last-leg/dispatches/pre-arrange: post: summary: 创建智能预排车单 - description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;本接口只负责预排车创建,编辑、取消、转出库派送和 PDA 发车使用各自独立接口与权限。 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围。首次推送创建 PENDING_DEPARTURE 排车单,未生成出库单前保留虚拟车草稿,再次推送刷新原排车单明细而不重复建单。 security: [{ bearerAuth: [] }] requestBody: required: true @@ -1188,6 +1197,15 @@ paths: - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelStatusChange' } } } } responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } + /api/platform/v1/last-leg/relabel-applications/{relabelNo}/wallet-settlement/retry: + post: + summary: 重试失败的换标费用结算 + description: 权限 platform:overseas-warehouse:relabel-order:status;仅允许已完成且资金状态为SETTLE_FAILED的换标单,使用新幂等键重新发送结算指令。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WalletFreezeRetry' } } } } + responses: { '200': { description: 结算重试已提交 }, '409': { description: 当前换标单不满足重试条件 } } /api/platform/v1/last-leg/relabel-applications/{relabelNo}/files: post: summary: 上传总换标PDF或明细换标PDF @@ -1256,6 +1274,17 @@ paths: responses: '200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } '409': { description: 幂等键、库存或状态冲突 } + /api/merchant/v1/last-leg/relabel-applications/historical-orders: + get: + summary: 查询贴标申请历史订单候选 + description: 按海外仓和关键字查询客户历史尾程订单,并包含贴标出库单(RELABEL_OUTBOUND),供贴标申请订单下拉筛选。 + security: [{ bearerAuth: [] }] + parameters: + - { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } } + - { name: keyword, in: query, required: false, schema: { type: string, maxLength: 128 } } + responses: + '200': { description: 历史订单候选, content: { application/json: { schema: { type: object, properties: { code: { type: string, example: '200' }, data: { type: array, items: { $ref: '#/components/schemas/RelabelHistoricalOrder' } } } } } } } + '403': { description: 缺少客户换标权限 } /api/merchant/v1/last-leg/relabel-applications/{relabelNo}: get: summary: 查询客户换标申请详情 @@ -2358,6 +2387,18 @@ components: batchNo: { type: string, nullable: true } warehouseLocationCode: { type: string, nullable: true } availablePackages: { type: integer, format: int64 } + RelabelHistoricalOrder: + type: object + properties: + id: { type: string, nullable: true } + orderNo: { type: string } + sourceOrderNo: { type: string, nullable: true } + outboundOrderNo: { type: string, nullable: true } + relabelOrderNo: { type: string, nullable: true } + orderType: { type: string, nullable: true } + status: { type: string, nullable: true } + packageCount: { type: integer, nullable: true } + createdAt: { type: string, format: date-time, nullable: true } RelabelCustomerConfirm: type: object required: [idempotencyKey] @@ -2406,6 +2447,12 @@ components: properties: status: { $ref: '#/components/schemas/RelabelStatus' } remark: { type: string, nullable: true, maxLength: 1000 } + WalletFreezeRetry: + type: object + required: [idempotencyKey] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } MerchantApiResponse: type: object required: [code, data] diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 65f37d0c5..37c10f2ab 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -154,6 +154,12 @@ paths: summary: 扫码查询托盘及库存内容 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } } responses: { '200': { description: Token绑定仓托盘内容 }, '404': { description: 托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } } + /api/pda/v1/last-leg/pallet-operations/relabel-pallet-label: + post: + summary: 为换标拆托幂等生成并返回可打印的新托盘标签 + description: 返回空托盘编码、初始版本和打印HTML;随后可将该托盘作为relabel-disassemble的target。库存移入后仍继承原库存余额对应的客户和入库单。 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/NewRelabelPallet' } } } } + responses: { '200': { description: 新托盘及可打印标签 }, '409': { description: 换标单未确认或不属于Token绑定仓 } } /api/pda/v1/last-leg/pallet-operations/assemble: post: summary: 将多个来源托盘的指定库存行组到目标托盘 @@ -411,14 +417,14 @@ components: inventoryPositionCount: { type: integer } WarehousePallet: type: object - required: [palletId, palletCode, status, version, locationId, locationCode, occupiedPackages, inventoryPositionCount, inventory] + required: [palletId, palletCode, status, version, occupiedPackages, inventoryPositionCount, inventory] properties: palletId: { type: string, pattern: '^[0-9]+$' } palletCode: { type: string } status: { type: string } version: { type: integer } - locationId: { type: string, pattern: '^[0-9]+$' } - locationCode: { type: string } + locationId: { type: string, pattern: '^[0-9]+$', nullable: true } + locationCode: { type: string, nullable: true } occupiedPackages: { type: integer, format: int64 } inventoryPositionCount: { type: integer } inventory: { type: array, items: { $ref: '#/components/schemas/InventoryItem' } } @@ -548,6 +554,12 @@ components: type: object required: [code] properties: { code: { type: string, minLength: 1, maxLength: 128 } } + NewRelabelPallet: + type: object + required: [relabelNo, idempotencyKey] + properties: + relabelNo: { type: string, minLength: 1, maxLength: 64 } + idempotencyKey: { type: string, minLength: 1, maxLength: 128 } IdempotentAction: type: object required: [idempotencyKey] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index ff5355a46..57412e348 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -31,6 +31,7 @@ import com.baiyue.lastleg.inventory.entity.InventoryPosition; import com.baiyue.lastleg.inventory.entity.InventoryBalance; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; import com.baiyue.lastleg.dropship.entity.DropshipCarton; import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; @@ -83,6 +84,7 @@ class DeliveryTransferServiceTest { @Mock DropshipOrderProfileMapper dropshipProfiles; @Mock DropshipCartonMapper dropshipCartons; @Mock DropshipSkuInventoryMapper dropshipSkuInventory; + @Mock WarehouseTransferPalletMapper warehouseTransferPallets; private DeliveryTransferService service; @@ -110,6 +112,7 @@ class DeliveryTransferServiceTest { ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles); ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons); ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory); + ReflectionTestUtils.setField(service, "warehouseTransferPallets", warehouseTransferPallets); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index 3fb923d26..7cb4ac1b5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -15,7 +15,7 @@ class DispatchCandidateMapperContractTest { "planNo", "orderType", "volumeWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt", "overseasWarehouseId", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo", "destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode", - "packageCount", "volumeCbm", "sourceStage", "cartonMarks" + "packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks" ); @Test @@ -24,6 +24,8 @@ class DispatchCandidateMapperContractTest { String.class, List.class, String.class, String.class, String.class, String.class)); assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class)); + assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("relabelAvailable", + String.class, String.class, String.class)); } @Test @@ -49,6 +51,20 @@ class DispatchCandidateMapperContractTest { } } + @Test + void candidateMarksFollowPdaRelabelPalletBinding() throws Exception { + for (Method method : List.of( + DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, + String.class, String.class, String.class), + DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + assertTrue(sql.contains("rp.id=ri.source_inventory_position_id")); + assertTrue(sql.contains("rp.pallet_id=p.warehouse_pallet_id")); + assertTrue(sql.contains("ri.fba_carton_mark")); + assertTrue(sql.contains("AS relabelCartonMarks")); + } + } + private void assertConstructorColumnOrder(Method method) { String sql = String.join(" ", method.getAnnotation(Select.class).value()); int previous = -1; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java index 6f34e38ae..a4f3a384b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -226,6 +226,34 @@ class DispatchPdaWorkflowServiceTest { verify(outboundOrders, never()).updateById((OutboundOrder) any()); } + @Test + void successfulDepartureRecordsActualDepartureTime() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); + order.setVehiclePlate("CA-100"); + DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); + DispatchItem item = item(21L, "100", 6); + DispatchPdaPick pick = new DispatchPdaPick(); + pick.setPackages(6L); + OutboundOrder outbound = new OutboundOrder(); + outbound.setOutboundNo("OB-1"); + outbound.setStatus("WAIT_OUTBOUND"); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); + when(tasks.lockByDispatch("100", 1L)).thenReturn(task); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); + when(picks.totalItem("100", 11L, 21L)).thenReturn(6L); + when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); + + DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", + new DispatchPdaRequests.Depart("depart-success", null)); + + assertEquals("DEPARTED", result.status()); + assertEquals("DEPARTED", order.getStatus()); + assertTrue(order.getDepartAt() != null); + assertEquals("OUTBOUNDED", outbound.getStatus()); + } + @Test void exactDepartRetryReturnsPersistedResultAfterStatusChanged() throws Exception { DispatchOrder order = order("100", "DSP-1", "WH-1", "DEPARTED"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index aa328d90d..19ab36ab9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -20,6 +20,7 @@ import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -55,6 +56,7 @@ class DispatchServiceTest { @Mock DispatchDraftContainerMapper draftContainers; @Mock DispatchPdaTaskMapper pdaTasks; @Mock LastlegOrderMapper tailOrders; + @Mock OutboundOrderMapper outboundOrders; private DispatchService service; @BeforeEach @@ -68,6 +70,7 @@ class DispatchServiceTest { ReflectionTestUtils.setField(service, "draftContainers", draftContainers); ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); ReflectionTestUtils.setField(service, "tailOrders", tailOrders); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); lenient().when(tailOrders.selectList(any())).thenReturn(List.of()); @@ -181,7 +184,7 @@ class DispatchServiceTest { } @Test - void preArrangeConvertsOwnedDraftLockInsteadOfInsertingAnotherLock() { + void preArrangeKeepsVirtualVehicleAndItsLocksEditableUntilOutboundExists() { when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); DispatchPalletLock lock = new DispatchPalletLock(); lock.setDraftToken("draft-1"); lock.setDraftContainerId(21L); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10"); @@ -193,16 +196,40 @@ class DispatchServiceTest { service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, "draft-1")); assertNotNull(lock.getDispatchOrderId()); - assertNull(lock.getDraftToken()); - assertNull(lock.getDraftContainerId()); - assertEquals("RELEASED", draft.getStatus()); - assertNotNull(draft.getReleasedAt()); + assertEquals("draft-1", lock.getDraftToken()); + assertEquals(21L, lock.getDraftContainerId()); + assertEquals("ACTIVE", draft.getStatus()); + assertNull(draft.getExpiresAt()); verify(locks).updateById(lock); verify(locks, never()).insert(any(DispatchPalletLock.class)); - verify(draftContainers).delete(any(LambdaQueryWrapper.class)); verify(drafts).updateById(draft); } + @Test + void removingUnpickedPalletReleasesLockAndRefreshesDispatchTotals() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); + dispatch.setStatus(DispatchService.PENDING_DEPARTURE); dispatch.setPickedPackageCount(0); + DispatchItem removed = new DispatchItem(); + removed.setId(11L); removed.setPlanPalletId(10L); removed.setWarehousePalletId(20L); + DispatchItem remaining = new DispatchItem(); + remaining.setId(12L); remaining.setPlanPalletId(12L); remaining.setPackageCount(7); + DispatchPalletLock lock = new DispatchPalletLock(); + lock.setPlanPalletId(10L); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10"); + when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); + when(items.lockById("100", 1L, 11L)).thenReturn(removed); + when(locks.selectList(any())).thenReturn(List.of(lock)); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(remaining)); + + DispatchResponse result = service.removeItem("PC1", 11L); + + assertEquals(1, result.palletCount()); + assertEquals(7, result.packageCount()); + assertEquals("RELEASED", lock.getStatus()); + verify(items).deleteById(11L); + verify(orders).updateById(dispatch); + } + @Test void activeDraftOnlyRestoresLocksStillOwnedByThatDraft() { DispatchDraft draft = new DispatchDraft(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java index 133b83069..496ef1cc2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java @@ -24,7 +24,8 @@ class PdaPalletOperationControllerContractTest { .map(Method::getDeclaredAnnotations).flatMap(Arrays::stream) .filter(PostMapping.class::isInstance).map(PostMapping.class::cast) .flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet()); - assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/relabel-disassemble", "/swap", "/unbind"), paths); + assertEquals(Set.of("/pallets/resolve", "/relabel-pallet-label", "/assemble", "/disassemble", + "/relabel-disassemble", "/swap", "/unbind"), paths); } @Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index 94dcdad23..a803f1fbb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -85,6 +85,22 @@ class PdaPalletOperationServiceTest { verifyNoInteractions(mapper); } + @Test void createsPrintableRelabelPalletForConfirmedApplication() { + RelabelApplication application = new RelabelApplication(); application.setRelabelNo("RL-1"); + application.setStatus("READY_FOR_OUTBOUND"); application.setOverseasWarehouseId("88"); + when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application); + when(physical.ensureRelabelPallet(88L, "RL-1:new-pallet-1")).thenReturn( + new PalletPhysicalGateway.PalletReference(30L, "PAL-NEW-1", 88L, 1030L, "IDLE", null, 0)); + + var result = service.createRelabelPallet(identity(), + new PalletOperationRequests.NewRelabelPallet("RL-1", "new-pallet-1")); + + assertEquals("PAL-NEW-1", result.palletCode()); + assertEquals(0, result.palletVersion()); + assertTrue(result.printableHtml().contains("托盘标签")); + assertTrue(result.printableHtml().contains("RL-1")); + } + @Test void localConcurrencyFailureCompensatesEveryAppliedPhysicalTransitionInReverseSafeForm() { var source = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L, "OCCUPIED", null, 1); @@ -168,17 +184,14 @@ class PdaPalletOperationServiceTest { verifyNoInteractions(physical); } - @Test void relabelDisassembleMovesOnlySelectedHeldCarton() { - var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L, + @Test void relabelDisassembleMovesOnlySelectedHeldCartonWithoutRequiringLocations() { + var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null, "OCCUPIED", null, 1); - var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, 1010L, + var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "IDLE", null, 2); when(physical.resolve("SOURCE")).thenReturn(sourcePallet); when(physical.resolve("TARGET")).thenReturn(targetPallet); - when(physical.resolveLocation(eq(88L), anyLong())).thenAnswer(invocation -> - new PalletPhysicalGateway.LocationReference(invocation.getArgument(1), "LOC-" + invocation.getArgument(1), - "STORAGE", "ACTIVE")); - when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any())) + when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any())) .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); RelabelApplication application = new RelabelApplication(); application.setId(600L); @@ -219,10 +232,18 @@ class PdaPalletOperationServiceTest { assertEquals(1L, source.getHoldPackages()); assertEquals(0L, source.getAvailablePackages()); assertNotNull(target.get()); assertEquals(1L, target.get().getHoldPackages()); + assertNull(target.get().getWarehouseLocationId()); + assertNull(target.get().getWarehouseLocationCode()); + verify(physical, never()).resolveLocation(anyLong(), anyLong()); verify(relabelPalletMutations).moveSelectedItems(eq(application), anyMap(), argThat(targets -> targets.size() == 1 && targets.get(100L) == target.get())); verify(balances, never()).updateById(any(InventoryBalance.class)); assertEquals("RELABEL_PALLET_DISASSEMBLE", operation.get().getOperationType()); + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers, times(2)).insert(ledger.capture()); + assertTrue(ledger.getAllValues().stream().allMatch(value -> "换标拆托:split".equals(value.getRemark()))); + assertTrue(ledger.getAllValues().stream().anyMatch(value -> value.getDeltaPackages() == -1)); + assertTrue(ledger.getAllValues().stream().anyMatch(value -> value.getDeltaPackages() == 1)); } @Test void relabelDisassembleReplayDoesNotRevalidateChangedInventoryOrPallets() throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 90ecafa97..67b2b4411 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -28,6 +28,8 @@ import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.fasterxml.jackson.databind.ObjectMapper; @@ -71,6 +73,7 @@ class RelabelApplicationServiceTest { @Mock OutboundOrderMapper outboundOrders; @Mock OutboundPalletItemMapper outboundItems; @Mock OutboundPdaTaskMapper outboundTasks; + @Mock WarehouseTransferOrderMapper warehouseTransfers; @Mock OverseasFeeSourceService feeSources; @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); @@ -92,6 +95,7 @@ class RelabelApplicationServiceTest { ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); ReflectionTestUtils.setField(service, "outboundItems", outboundItems); ReflectionTestUtils.setField(service, "outboundTasks", outboundTasks); + ReflectionTestUtils.setField(service, "warehouseTransfers", warehouseTransfers); ReflectionTestUtils.setField(service, "objectMapper", mapper()); lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1"); lenient().when(inventoryPositions.findTenantPosition(any(), any())).thenAnswer(invocation -> { @@ -363,6 +367,7 @@ class RelabelApplicationServiceTest { void completedReturnRelabelOrderEntersPendingAssignment() { RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); application.setLastlegOrderId(21L); + application.setFulfillmentStatus("PRE_DISPATCH_CREATED"); LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PROCESSING"); order.setOrderType("RETURN_RELABEL"); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); @@ -373,6 +378,7 @@ class RelabelApplicationServiceTest { new RelabelRequests.ChangeStatus("COMPLETED", "finished")); assertEquals("COMPLETED", application.getStatus()); + assertEquals("OUTBOUNDED", application.getFulfillmentStatus()); assertEquals("PENDING_ASSIGNMENT", order.getCurrentStatus()); assertEquals("COMPLETED", order.getRelabelStatus()); } @@ -416,6 +422,29 @@ class RelabelApplicationServiceTest { verify(applications, times(1)).updateById(application); } + @Test + void transferRelabelConfirmationReleasesTransferForDispatch() { + RelabelApplication application = confirmationApplication("TRUCK", false); + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); + transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); + LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + stubDetail(application); + when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer); + when(orders.selectById(88L)).thenReturn(transferOrder); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-transfer", "accepted")); + + assertEquals("READY_FOR_DISPATCH", transfer.getStatus()); + assertNotNull(transfer.getRelabelCompletedAt()); + assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus()); + assertEquals(RelabelApplicationService.READY_FOR_OUTBOUND, transferOrder.getRelabelStatus()); + verify(warehouseTransfers).updateById(transfer); + verify(orders).updateById(transferOrder); + } + @Test void expressCustomerConfirmationRequiresShippingLabelWhenWarehousePrintingIsSelected() { RelabelApplication application = confirmationApplication("EXPRESS", true); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferControllerPermissionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferControllerPermissionTest.java new file mode 100644 index 000000000..dce2f0b90 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferControllerPermissionTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.transfer.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.security.OverseasPermissions; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehouseTransferControllerPermissionTest { + @Test + void readEndpointsAndReviewUseTheirDedicatedAuthorities() throws Exception { + PreAuthorize page = WarehouseTransferController.class + .getMethod("page", AuthenticatedUser.class, String.class, WarehouseTransferRequests.Query.class) + .getAnnotation(PreAuthorize.class); + PreAuthorize detail = WarehouseTransferController.class + .getMethod("detail", AuthenticatedUser.class, String.class, String.class) + .getAnnotation(PreAuthorize.class); + PreAuthorize cartonLabel = WarehouseTransferController.class + .getMethod("cartonLabel", AuthenticatedUser.class, String.class, String.class) + .getAnnotation(PreAuthorize.class); + PreAuthorize review = WarehouseTransferController.class + .getMethod("review", AuthenticatedUser.class, String.class, String.class, + WarehouseTransferRequests.Review.class) + .getAnnotation(PreAuthorize.class); + + String readAuthority = "hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')"; + assertThat(page.value()).isEqualTo(readAuthority); + assertThat(detail.value()).isEqualTo(readAuthority); + assertThat(cartonLabel.value()).isEqualTo(readAuthority); + assertThat(review.value()).isEqualTo( + "hasAuthority('" + OverseasPermissions.WAREHOUSE_TRANSFER_APPROVE + "')"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java new file mode 100644 index 000000000..92807e87a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.transfer.dto; + +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehouseTransferPalletValidationTest { + @Test + void palletAcceptsDirectTotalVolumeAndWeightWithoutCartonDimensions() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5, + new BigDecimal("1.250000"), new BigDecimal("420.50"), + List.of(new WarehouseTransferRequests.PalletItem(101L, 5))); + + assertTrue(validator.validate(pallet).isEmpty()); + assertEquals(new BigDecimal("1.250000"), pallet.volumeCbm()); + assertEquals(new BigDecimal("420.50"), pallet.grossWeightKg()); + } + } + + @Test + void palletRejectsNonPositiveTotalVolume() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5, + BigDecimal.ZERO, new BigDecimal("420.50"), + List.of(new WarehouseTransferRequests.PalletItem(101L, 5))); + + assertEquals(1, validator.validate(pallet).size()); + } + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs index 541b1438d..8c6c5b71d 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -49,3 +49,12 @@ test('module records the marker-owned dispatch without attempting excluded clean assert.match(source, /finalStatus = arranged\.data\?\.status/) assert.match(source, /PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence/) }) + +test('module prefers marker-owned relabel pallets and proves only new FBA carton marks are frozen', () => { + assert.match(source, /phase4OverseasRelabel/) + assert.match(source, /sourceStage === 'RELABEL'/) + assert.match(source, /relabel\.targetPalletIds\.includes/) + assert.match(source, /relabelCartonMarks/) + assert.match(source, /!String\(value\?\.cartonMarks \|\| ''\)\.trim\(\)/) + assert.match(source, /relabelNo: relabel\.relabelNo/) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index 48aefddd7..9fbdb834f 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -39,6 +39,49 @@ function markerFixture(context) { return { ...fixture, palletIds } } +async function runRelabelDispatch(context, relabel) { + const candidateResult = await api(context, 'GET', '查询本轮换标托盘智能排车候选', + query(`${BASE}/candidates`, { orderNo: relabel.relabelNo, sourceStage: 'RECEIVED' })) + const candidates = (Array.isArray(candidateResult.data) ? candidateResult.data : []) + .filter((value) => value?.sourceStage === 'RELABEL' + && relabel.targetPalletIds.includes(id(value?.warehousePalletId))) + const actualMarks = candidates.flatMap((value) => String(value?.relabelCartonMarks || '') + .split(',').map((mark) => mark.trim()).filter(Boolean)) + context.assert(MODULE, '智能排车只带出本轮换标后的FBA箱唛', candidateResult.response, + candidates.length === relabel.targetPalletIds.length + && relabel.relabelCartonMarks.every((mark) => actualMarks.includes(mark)) + && candidates.every((value) => !String(value?.cartonMarks || '').trim()), + `pallets=${candidates.length}, relabelMarks=${actualMarks.join(',')}, normalMarks=${candidates.map((value) => value?.cartonMarks).filter(Boolean).join(',')}; ${trace(candidateResult.response)}`, + OPTIONS) + if (candidates.length !== relabel.targetPalletIds.length) return + + const arranged = await api(context, 'POST', '将本轮换标托盘创建待出库排车', `${BASE}/pre-arrange`, { + planPalletIds: candidates.map((value) => id(value.warehousePalletId)), + route: `${context.marker}-RELABEL-ROUTE`, fbaAppointmentNo: null, + fbaAppointmentStatus: null, vehicleAppointmentStatus: null, relabelNo: relabel.relabelNo, + }) + const dispatchNo = id(arranged.data?.dispatchNo) + context.assert(MODULE, '换标多托盘原子预排并冻结换标箱唛', arranged.response, + Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE' + && Number(arranged.data?.palletCount) === candidates.length + && Array.isArray(arranged.data?.items) + && arranged.data.items.every((item) => item?.sourceStage === 'RELABEL'), + `dispatchNo=${dispatchNo || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}; ${trace(arranged.response)}`, + OPTIONS) + if (!dispatchNo) return + Object.assign(context.createdResources.phase4OverseasDispatch, { + dispatchNo, dispatchId: id(arranged.data?.id), finalStatus: arranged.data?.status || null, + relabelNo: relabel.relabelNo, planPalletId: null, + }) + const detail = await api(context, 'GET', '回查本轮换标排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`) + const detailMarks = (detail.data?.items || []).flatMap((item) => String(item?.cartonMarks || '') + .split(',').map((mark) => mark.trim()).filter(Boolean)) + context.assert(MODULE, '排车详情冻结全部换标后箱唛且不混入原箱唛', detail.response, + relabel.relabelCartonMarks.every((mark) => detailMarks.includes(mark)) + && relabel.sourceCartonMarks.every((mark) => !detailMarks.includes(mark)), + `frozenMarks=${detailMarks.join(',')}; ${trace(detail.response)}`, OPTIONS) +} + export async function run(context) { context.createdResources.phase4OverseasDispatch = { marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null, @@ -53,6 +96,12 @@ export async function run(context) { `records=${records(initialPage.data).length}; ${trace(initialPage.response)}`, OPTIONS) if (!success(initialPage.response)) return + const relabel = context.createdResources?.phase4OverseasRelabel + if (relabel?.marker === context.marker && relabel?.status === 'READY_FOR_OUTBOUND') { + await runRelabelDispatch(context, relabel) + return + } + const fixture = markerFixture(context) if (!fixture) { blocker(context, '取得本轮海外PDA已收货托盘前置', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index c977ff5fe..508c27f63 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -41,7 +41,7 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho '/api/pda/v1/last-leg/stocktakes', '/api/pda/v1/last-leg/inventory-moves', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', - '/swap', '/disassemble', '/assemble', '/unbind', + '/swap', '/disassemble', '/assemble', '/unbind', '/relabel-pallet-label', '/relabel-disassemble', '/api/pda/v1/last-leg/relabel-tasks', '/candidates', '/claim', '/units', '/skip', '/exceptions', '/photos/files', '/complete', '/review', ]) assert.ok(source.includes(endpoint), endpoint) @@ -50,14 +50,15 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho 'chargeAmount: 0.01', 'cartonMark:', 'fbaCartonMark:', 'inventory-candidates', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:', 'sourceInboundOrderId:', 'operationWarehouseId:', 'relabelItemForUnit', 'sourceLines.map', - 'const packageCount = 1', 'line.packageCount += additionalPackages', 'oldLabel: item.cartonMark', + 'sourceLines.push({ source, position, packageCount: 1 })', 'oldLabel: item.cartonMark', 'id(candidate.itemId) !== id(item.itemId)', 'oldLabel: null', 'newLabel: item.fbaCartonMark || item.newSkuCode', "operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL'", 'skuItems:', - 'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛']) { + 'P5换标费用冻结后开放PDA作业', 'CUSTOMER_CONFIRM_PENDING', 'confirm-completion', + 'P5库存流水记录换标拆托原因和正负变动', 'phase4OverseasRelabel']) { assert.ok(source.includes(marker), marker) } - for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) { + for (const forbidden of ['/returns/outbound', '/dispatch', '/delivery', '/internal/']) { assert.equal(source.includes(forbidden), false, forbidden) } assert.doesNotMatch(source, /fetch\(|https?:\/\//) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 4db39f3ba..5723b0b36 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -363,22 +363,17 @@ async function verifyRelabel(context, token, merchantToken, fixture) { for (const source of orderedCandidates) { if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue for (const position of source?.positions || []) { - if (!id(position?.positionId) || Number(position?.availablePackages) <= 0) continue - const packageCount = 1 - sourceLines.push({ source, position, packageCount }) - requiredPackages -= packageCount + const available = Number(position?.availablePackages) + if (!id(position?.positionId) || available <= 0) continue + const selected = Math.min(requiredPackages, available) + for (let packageIndex = 0; packageIndex < selected; packageIndex += 1) { + sourceLines.push({ source, position, packageCount: 1 }) + } + requiredPackages -= selected if (requiredPackages === 0) break } if (requiredPackages === 0) break } - for (const line of sourceLines) { - if (requiredPackages === 0) break - const remainingCapacity = Number(line.position.availablePackages) - line.packageCount - if (remainingCapacity <= 0) continue - const additionalPackages = Math.min(requiredPackages, remainingCapacity) - line.packageCount += additionalPackages - requiredPackages -= additionalPackages - } context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse, requiredPackages === 0, `sourceLines=${sourceLines.length}, selectedPackages=${3 - requiredPackages}; ${trace(inventoryCandidatesResponse)}`, OPTIONS) @@ -394,6 +389,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: 0.01, remark: context.marker, idempotencyKey: key(context, 'RELABEL-CREATE'), + deliveryMethod: 'TRUCK', printShippingLabel: false, items: sourceLines.map(({ source, position, packageCount }, index) => { const newSkuCode = `${source.skuCode}-NEW-${markerSuffix}-${index + 1}`.slice(0, 128) const changesCartonLabel = nonBlank(source.destinationFbaWarehouseCode) @@ -520,15 +516,135 @@ async function verifyRelabel(context, token, merchantToken, fixture) { idempotencyKey: key(context, 'RELABEL-APPROVE'), remark: `${context.marker}-approved` } const approved = await request(context, token, 'POST', 'P5复核批准换标完成', reviewPath, approveBody) const approveReplay = await request(context, token, 'POST', 'P5批准同键重放', reviewPath, approveBody) - context.assert(MODULE, 'P5最终任务和申请均COMPLETED', approveReplay.response, - approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'COMPLETED' + context.assert(MODULE, 'P5 PDA批准后等待客户确认', approveReplay.response, + approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'CUSTOMER_CONFIRM_PENDING' && approveReplay.data?.taskStatus === 'COMPLETED', trace(approveReplay.response), OPTIONS) - const settled = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`), - (response) => success(response) && response.body?.data?.application?.walletStatus === 'SETTLED') - context.addStep(MODULE, 'P5换标完成后钱包结算收敛', settled.result, - settled.result?.body?.data?.application?.walletStatus === 'SETTLED', - `pollRequests=${settled.attempts}, walletStatus=${settled.result?.body?.data?.application?.walletStatus || 'missing'}; ${trace(settled.result)}`, OPTIONS) - return success(approved.response) && settled.result?.body?.data?.application?.walletStatus === 'SETTLED' + if (approved.data?.applicationStatus !== 'CUSTOMER_CONFIRM_PENDING') return false + + const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'), + remark: `${context.marker}-customer-confirmed` } + const confirmedResponse = await context.requestOverseas('POST', + `${relabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken) + const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null + context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse, + confirmed?.application?.status === 'READY_FOR_OUTBOUND' + && confirmed?.application?.fulfillmentStatus === 'WAIT_PRE_DISPATCH', trace(confirmedResponse), OPTIONS) + if (confirmed?.application?.status !== 'READY_FOR_OUTBOUND') return false + + const operationBase = '/api/pda/v1/last-leg/pallet-operations' + const printKeyA = key(context, 'RELABEL-PRINT-A') + const printedA = await request(context, token, 'POST', 'P5打印换标新托盘A', + `${operationBase}/relabel-pallet-label`, { relabelNo, idempotencyKey: printKeyA }) + const printedAReplay = await request(context, token, 'POST', 'P5换标新托盘A打印同键重放', + `${operationBase}/relabel-pallet-label`, { relabelNo, idempotencyKey: printKeyA }) + context.assert(MODULE, 'P5新托盘标签幂等且返回可打印HTML', printedAReplay.response, + id(printedA.data?.palletId) === id(printedAReplay.data?.palletId) + && printedA.data?.palletCode === printedAReplay.data?.palletCode + && String(printedA.data?.printableHtml || '').includes(printedA.data?.palletCode || '__missing__'), + trace(printedAReplay.response), OPTIONS) + const printedB = await request(context, token, 'POST', 'P5打印换标新托盘B', + `${operationBase}/relabel-pallet-label`, { + relabelNo, idempotencyKey: key(context, 'RELABEL-PRINT-B'), + }) + if (!printedA.data?.palletCode || !printedB.data?.palletCode) return false + + const targets = [printedA.data, printedB.data] + const beforeBySource = new Map() + const movedFacts = [] + for (const [index, itemDetail] of confirmed.items.entries()) { + const sourcePositionId = id(itemDetail?.sourceInventoryPositionId) + const sourceLine = sourceLines.find((line) => id(line.position.positionId) === sourcePositionId) + const sourcePalletCode = sourceLine?.position?.palletNo + if (!sourcePalletCode || !itemDetail?.cartonMark) { + blocker(context, 'P5换标拆托来源快照', `item=${index + 1}缺少来源托盘或箱唛`, confirmedResponse) + return false + } + const sourceBefore = (await resolvePallet(context, token, sourcePalletCode, + `P5拆托${index + 1}前刷新来源托盘`)).data + if (!sourceBefore) return false + if (!beforeBySource.has(sourcePalletCode)) beforeBySource.set(sourcePalletCode, Number(sourceBefore.totalPackages)) + // A receives one carton, then B receives one and is reused as an occupied merge target. + // If the first two cartons share a source, the same source is split across A and B. + const targetSeed = index === 0 ? targets[0] : targets[1] + const targetBefore = (await resolvePallet(context, token, targetSeed.palletCode, + `P5拆托${index + 1}前刷新目标托盘`)).data + const reason = index === 0 ? null : `${context.marker}-场景${index + 1}` + const body = { + idempotencyKey: key(context, `RELABEL-DISASSEMBLE-${index + 1}`), relabelNo, + source: { palletCode: sourceBefore.palletCode, expectedVersion: Number(sourceBefore.palletVersion) }, + target: { palletCode: targetBefore.palletCode, expectedVersion: Number(targetBefore.palletVersion) }, + cartonMarks: [itemDetail.cartonMark], reason, + } + const moved = await request(context, token, 'POST', `P5换标箱${index + 1}拆托`, + `${operationBase}/relabel-disassemble`, body) + const replay = await request(context, token, 'POST', `P5换标箱${index + 1}拆托同键重放`, + `${operationBase}/relabel-disassemble`, body) + const sourceAfter = operationPallet(moved.data, sourceBefore.palletCode) + const targetAfter = operationPallet(moved.data, targetBefore.palletCode) + context.assert(MODULE, `P5换标拆托${index + 1}源减目标增且重放稳定`, replay.response, + moved.data?.operationType === 'RELABEL_PALLET_DISASSEMBLE' && replay.data?.replayed === true + && Number(sourceAfter?.totalPackages) === Number(sourceBefore.totalPackages) - 1 + && Number(targetAfter?.totalPackages) === Number(targetBefore.totalPackages) + 1, + `source=${sourceBefore.totalPackages}->${sourceAfter?.totalPackages}, target=${targetBefore.totalPackages}->${targetAfter?.totalPackages}; ${trace(replay.response)}`, + OPTIONS) + if (!success(moved.response)) return false + movedFacts.push({ sourcePalletCode, targetPalletId: id(targetAfter?.palletId), + targetPalletCode: targetAfter?.palletCode, cartonMark: itemDetail.cartonMark, + fbaCartonMark: itemDetail.fbaCartonMark, balanceId: id(itemDetail.sourceInventoryBalanceId), + inboundOrderId: id(itemDetail.sourceInboundOrderId), inboundOrderNo: itemDetail.sourceInboundOrderNo, + inboundCode: itemDetail.sourceInboundCode, packages: Number(itemDetail.notifiedPackageCount), reason }) + } + + for (const [sourcePalletCode, totalBefore] of beforeBySource) { + const sourceAfter = (await resolvePallet(context, token, sourcePalletCode, 'P5拆托后核对来源托盘')).data + const movedPackages = movedFacts.filter((fact) => fact.sourcePalletCode === sourcePalletCode) + .reduce((sum, fact) => sum + fact.packages, 0) + context.assert(MODULE, 'P5来源托盘累计箱数按拆托批次减少', null, + Number(sourceAfter?.totalPackages) === totalBefore - movedPackages, + `pallet=${sourcePalletCode}, before=${totalBefore}, moved=${movedPackages}, after=${sourceAfter?.totalPackages}`, OPTIONS) + } + context.assert(MODULE, 'P5覆盖新托盘、既有托盘合托和同源分批拆托', null, + new Set(movedFacts.map((fact) => fact.targetPalletId)).size === 2 + && movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length >= 2 + && [...beforeBySource.keys()].some((source) => new Set(movedFacts.filter((fact) => fact.sourcePalletCode === source) + .map((fact) => fact.targetPalletId)).size >= 2), + `targets=${new Set(movedFacts.map((fact) => fact.targetPalletId)).size}, targetBMoves=${movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length}`, OPTIONS) + + for (const fact of movedFacts) { + const positionsResponse = await context.requestOverseas('GET', query( + `/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(fact.balanceId)}/positions`, + { customerId }), undefined) + const positions = success(positionsResponse) && Array.isArray(positionsResponse.body?.data) + ? positionsResponse.body.data : [] + const targetPosition = positions.find((position) => id(position?.palletId) === fact.targetPalletId) + context.addStep(MODULE, `P5换标箱${fact.cartonMark}保留客户和原入仓归属`, positionsResponse, + id(targetPosition?.customerId) === customerId + && id(targetPosition?.inboundOrderId) === fact.inboundOrderId + && targetPosition?.inboundOrderNo === fact.inboundOrderNo + && targetPosition?.inboundCode === fact.inboundCode, + `targetPallet=${fact.targetPalletCode}, inbound=${targetPosition?.inboundOrderNo || 'missing'}; ${trace(positionsResponse)}`, OPTIONS) + } + const ledgerResponse = await context.requestOverseas('GET', query( + '/api/platform/v1/last-leg/inventory/ledger', { + customerId, page: 1, size: 100, businessType: 'RELABEL_PALLET_DISASSEMBLE', + occurredFrom: new Date(Date.now() - 10 * 60_000).toISOString().slice(0, 19), + })) + const ledgerRows = success(ledgerResponse) ? records(ledgerResponse.body?.data) : [] + const relabelLedgers = ledgerRows.filter((row) => movedFacts.some((fact) => + row?.palletNo === fact.sourcePalletCode || row?.palletNo === fact.targetPalletCode)) + context.addStep(MODULE, 'P5库存流水记录换标拆托原因和正负变动', ledgerResponse, + relabelLedgers.some((row) => Number(row?.deltaPackages) < 0) + && relabelLedgers.some((row) => Number(row?.deltaPackages) > 0) + && relabelLedgers.every((row) => String(row?.remark || '').startsWith('换标拆托')), + `rows=${relabelLedgers.length}, deltas=${relabelLedgers.map((row) => row.deltaPackages).join(',')}; ${trace(ledgerResponse)}`, OPTIONS) + + context.createdResources.phase4OverseasRelabel = { + marker: context.marker, relabelNo, customerId, overseasWarehouseId: id(fixture.overseasWarehouseId), + targetPalletIds: [...new Set(movedFacts.map((fact) => fact.targetPalletId))], + relabelCartonMarks: movedFacts.map((fact) => fact.fbaCartonMark).filter(nonBlank), + sourceCartonMarks: movedFacts.map((fact) => fact.cartonMark), status: 'READY_FOR_OUTBOUND', + } + return true } export async function run(context) { diff --git a/scripts/db/dev-lastleg-relabel-return-incremental.sql b/scripts/db/dev-lastleg-relabel-return-incremental.sql index df72b23b4..e48b3acf6 100644 --- a/scripts/db/dev-lastleg-relabel-return-incremental.sql +++ b/scripts/db/dev-lastleg-relabel-return-incremental.sql @@ -1,7 +1,8 @@ -- 尾程贴标申请 / 退货换标 dev 增量(MySQL 8.0) --- 目标数据库:baiyue_lastleg --- 依据:2026-09-09 本地 127.0.0.1 与 dev 8.163.49.32 的 information_schema 只读对比。 --- 说明:脚本可重复执行,不删除或修改业务行;DDL 会隐式提交,请先备份并完整执行。 +-- 目标数据库:baiyue_lastleg、baiyue_lastlag_warehouse +-- 依据:2026-09-10 本地 127.0.0.1 与 dev 8.163.49.32 的 information_schema 只读对比。 +-- 说明:脚本可重复执行,不修改或删除业务行。 +-- MySQL DDL 会隐式提交,请先备份并完整执行。 CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -88,9 +89,141 @@ DELIMITER ; CALL _dev_lastleg_relabel_return_patch(); DROP PROCEDURE IF EXISTS _dev_lastleg_relabel_return_patch; --- 执行后只读核验:应分别返回 1、1、1、1。 +-- 2026-09-10 本地相对 dev 的最近结构差异:换标完成确认、退货仓联系人及派车来源快照。 +DROP PROCEDURE IF EXISTS _dev_lastleg_recent_patch; +DELIMITER $$ +CREATE PROCEDURE _dev_lastleg_recent_patch() +BEGIN + -- 旧本地库可能缺少仓库主数据与退货列表查询字段。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_warehouse' AND table_name='wh_warehouse' AND column_name='internal_code') THEN + SET @ddl = 'ALTER TABLE baiyue_warehouse.wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code'; + PREPARE warehouse_internal_code_stmt FROM @ddl; EXECUTE warehouse_internal_code_stmt; DEALLOCATE PREPARE warehouse_internal_code_stmt; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no') THEN + ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT '关联尾程订单号' AFTER lastleg_order_id; + END IF; + + -- 非 FBA 去向的换标不要求货件编号。 + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='shipment_no' AND is_nullable='NO') THEN + ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT '货件编号(无FBA去向时为空)'; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_code' AND is_nullable='NO') THEN + ALTER TABLE ll_relabel_item MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU编码(组合换标时可为空)'; + END IF; + + -- 换标 PDA 完工、客户证据确认,以及卡车/自提/快递履约分流。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method') THEN + ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填' AFTER contact_phone; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label') THEN + ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单' AFTER delivery_method; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id') THEN + ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID' AFTER print_shipping_label; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name') THEN + ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名' AFTER shipping_label_file_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url') THEN + ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT '客户上传面单下载地址' AFTER shipping_label_file_name; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status') THEN + ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态' AFTER shipping_label_file_url; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key') THEN + ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT '客户确认完成幂等键' AFTER fulfillment_status; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at') THEN + ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认换标完成时间' AFTER customer_confirmation_key; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no') THEN + ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT '自提或快递生成的换标出库单号' AFTER fulfillment_status; + END IF; + + -- 退货换标:保存操作中转仓联系人。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name') THEN + ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照' AFTER overseas_warehouse_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone') THEN + ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照' AFTER warehouse_contact_name; + END IF; + -- 仓储转运允许非 FBA 入仓。 + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND is_nullable='NO') THEN + ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码'; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_shipment_no' AND is_nullable='NO') THEN + ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号'; + END IF; + -- 换标进入派车/出库后保留来源业务和箱唛快照。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks') THEN + ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照' AFTER source_stage; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type') THEN + ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT '来源业务类型' AFTER carton_marks; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no') THEN + ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT '来源业务单号' AFTER source_business_type; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='return_carton_id') THEN + ALTER TABLE ll_relabel_item ADD COLUMN return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID' AFTER carton_mark; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_carton_mark') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照' AFTER return_carton_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='new_carton_mark') THEN + ALTER TABLE ll_relabel_item ADD COLUMN new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛' AFTER source_carton_mark; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at') THEN + ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT '关联贴标完成时间' AFTER relabel_no; + END IF; + +END$$ +DELIMITER ; +CALL _dev_lastleg_recent_patch(); +DROP PROCEDURE IF EXISTS _dev_lastleg_recent_patch; + +-- 2026-09-13 换标/合托按托盘维度作业:海外仓物理托盘允许没有库位。 +-- 只放宽托盘状态操作审计快照,不改变已有带库位的入仓资源预留逻辑。 +CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastlag_warehouse; +DROP PROCEDURE IF EXISTS _dev_locationless_pallet_patch; +DELIMITER $$ +CREATE PROCEDURE _dev_locationless_pallet_patch() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少基础表 wh_pallet_operation,请先执行海外仓基线'; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' + AND column_name='before_location_id' AND is_nullable='NO') THEN + ALTER TABLE wh_pallet_operation + MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空'; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' + AND column_name='after_location_id' AND is_nullable='NO') THEN + ALTER TABLE wh_pallet_operation + MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空'; + END IF; +END$$ +DELIMITER ; +CALL _dev_locationless_pallet_patch(); +DROP PROCEDURE IF EXISTS _dev_locationless_pallet_patch; +USE baiyue_lastleg; + +-- 执行后只读核验:所有结果都应为 1。 SELECT (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name IN ('destination_fba_warehouse_code','operation_type','sku_relabel_items_json','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id','source_inbound_order_no','source_inbound_code','source_warehouse_id','source_warehouse_code','source_warehouse_name')) = 11 AS relabel_fields_ready, + (SELECT is_nullable='YES' FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name='shipment_no') AS relabel_shipment_optional_ready, + (SELECT is_nullable='YES' FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name='sku_code') AS relabel_sku_optional_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name IN ('delivery_method','print_shipping_label','shipping_label_file_id','shipping_label_file_name','shipping_label_file_url','fulfillment_status','outbound_order_no','customer_confirmation_key','customer_confirmed_at')) = 9 AS relabel_fulfillment_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND column_name IN ('warehouse_contact_name','warehouse_contact_phone')) = 2 AS return_order_contact_fields_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_warehouse_transfer_order' AND column_name IN ('fba_warehouse_code','fba_shipment_no') AND is_nullable='YES') = 2 AS transfer_fba_optional_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_dispatch_item' AND column_name IN ('carton_marks','source_business_type','source_business_no')) = 3 AS dispatch_relabel_source_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name IN ('return_carton_id','source_carton_mark','new_carton_mark')) = 3 AS return_relabel_binding_ready, (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_pallet_item' AND column_name='carton_marks') = 1 AS outbound_carton_marks_ready, (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_overseas_procurement_outbound') = 1 AS procurement_outbound_ready, - (SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status')) = 2 AS return_outbounded_checks_ready; + (SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status')) = 2 AS return_outbounded_checks_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation' AND column_name IN ('before_location_id','after_location_id') AND is_nullable='YES') = 2 AS locationless_pallet_operation_ready; diff --git a/scripts/db/fix-filter-name-503.sql b/scripts/db/fix-filter-name-503.sql new file mode 100644 index 000000000..b2f3d7a24 --- /dev/null +++ b/scripts/db/fix-filter-name-503.sql @@ -0,0 +1,34 @@ +-- 修复海外仓筛选名称、退货箱号和贴标申请列表 503。 +-- 可重复执行;请使用具备对应库 ALTER 权限的账号执行。 + +USE baiyue_warehouse; +SET @ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'wh_warehouse' + AND column_name = 'internal_code'), + 'SELECT 1', + 'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +USE baiyue_lastleg; +SET @ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_return_order' + AND column_name = 'lastleg_order_no'), + 'SELECT 1', + 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT ''关联尾程订单号'' AFTER lastleg_order_id' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SELECT + (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_warehouse' AND table_name = 'wh_warehouse' AND column_name = 'internal_code') AS warehouse_internal_code_ready, + (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_return_order' AND column_name = 'lastleg_order_no') AS return_lastleg_order_no_ready; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 6b11bb977..58277f0e7 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -4029,6 +4029,8 @@ USE baiyue_lastleg; -- 2026-09-09 / 036 为派送单补充 BOL 号字段(不改原始 CREATE TABLE)。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='bol_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN bol_no VARCHAR(128) NULL COMMENT ''派送资料BOL号,不作为文件归属标识''')); PREPARE ll_delivery_order_bol_no_stmt FROM @ddl; EXECUTE ll_delivery_order_bol_no_stmt; DEALLOCATE PREPARE ll_delivery_order_bol_no_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='documents_json'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN documents_json JSON NULL COMMENT ''派送三类资料当前绑定及权威文件元数据快照'' AFTER bol_no')); +PREPARE ll_delivery_order_documents_json_stmt FROM @ddl; EXECUTE ll_delivery_order_documents_json_stmt; DEALLOCATE PREPARE ll_delivery_order_documents_json_stmt; SET @ddl = (SELECT IF( EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_id'), 'SELECT 1', @@ -4150,3 +4152,42 @@ SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists, 'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)', 'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)')); PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_completion_index_stmt; DEALLOCATE PREPARE outbound_pda_completion_index_stmt; + +-- 2026-09-14 / 038 海外尾程按托盘维度作业,无库位托盘的状态流转审计允许空库位。 +USE baiyue_lastlag_warehouse; +ALTER TABLE wh_pallet_operation + MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空', + MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空'; +USE baiyue_lastleg; + +-- 2026-09-10 / 036 补齐仓库候选与退货列表查询依赖的字段,修复旧库升级后的 503。 +USE baiyue_warehouse; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='internal_code'), 'SELECT 1', 'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code')); +PREPARE warehouse_internal_code_stmt FROM @ddl; EXECUTE warehouse_internal_code_stmt; DEALLOCATE PREPARE warehouse_internal_code_stmt; +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT ''关联尾程订单号'' AFTER lastleg_order_id')); +PREPARE return_order_lastleg_no_stmt FROM @ddl; EXECUTE return_order_lastleg_no_stmt; DEALLOCATE PREPARE return_order_lastleg_no_stmt; + +-- 2026-09-11 / 037 仓储转运 PDA 打托与换标预绑定事实。 +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( + id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', + warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', + carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', + height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', + status VARCHAR(32) NOT NULL COMMENT '打托状态', completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', + transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_item(transfer_pallet_id,transfer_item_id), KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型', + customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU', new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数', + fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细'; diff --git a/scripts/db/seed-one-click-dropship-outbound-candidates.sql b/scripts/db/seed-one-click-dropship-outbound-candidates.sql index 3680c4fe7..e4103b0e9 100644 --- a/scripts/db/seed-one-click-dropship-outbound-candidates.sql +++ b/scripts/db/seed-one-click-dropship-outbound-candidates.sql @@ -1,5 +1,21 @@ -- 本地验收数据:为 17909216591 对应客户建立 3 张已完成入库的一件代发订单。 --- 可重复执行;候选数据覆盖订单、入库计划、托盘、库存余额/仓位、入库实收、一件代发档案、SKU 与箱唛。 +-- 可重复执行;候选数据覆盖海外实物托盘、订单、入库计划、库存余额/仓位、入库实收、一件代发档案、SKU 与箱唛。 +USE baiyue_lastlag_warehouse; + +INSERT INTO wh_pallet ( + id,customer_id,warehouse_id,location_id,pallet_code,pallet_type,capacity_cbm,unit_price, + status,remark,fba_warehouse_code,created_by,created_at,updated_by,updated_at,deleted,version +) VALUES + (2191000000000909901,0,193000000000200003,NULL,'DS-LAX-PALLET-01','STANDARD',2.0000,0.00, + 'OCCUPIED','一件代发本地验收固定托盘','ONT8',1000001,NOW(3),1000001,NOW(3),0,0), + (2191000000000909902,0,193000000000200003,NULL,'DS-LAX-PALLET-02','STANDARD',2.0000,0.00, + 'OCCUPIED','一件代发本地验收固定托盘','ABE2',1000001,NOW(3),1000001,NOW(3),0,0), + (2191000000000909903,0,193000000000200003,NULL,'DS-LAX-PALLET-03','STANDARD',2.0000,0.00, + 'OCCUPIED','一件代发本地验收固定托盘','ABE3',1000001,NOW(3),1000001,NOW(3),0,0) +ON DUPLICATE KEY UPDATE + warehouse_id=VALUES(warehouse_id),pallet_code=VALUES(pallet_code),pallet_type=VALUES(pallet_type), + status=VALUES(status),fba_warehouse_code=VALUES(fba_warehouse_code),deleted=0,updated_at=NOW(3); + USE baiyue_lastleg; DROP TEMPORARY TABLE IF EXISTS _tmp_dropship_fixture; diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs new file mode 100644 index 000000000..6e14525bd --- /dev/null +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -0,0 +1,111 @@ +import { readFile, writeFile } from 'node:fs/promises' + +const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' +const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +const sourceReport = process.env.OVERSEAS_ACCEPTANCE_SOURCE +const directDispatchNo = process.env.OVERSEAS_DISPATCH_NO +const directRelabelNo = process.env.OVERSEAS_RELABEL_NO +const output = process.env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json' +const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID +const marker = `FUL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` +const required = ['ADMIN_TEST_USERNAME', 'ADMIN_TEST_PASSWORD', 'PDA_TEST_USERNAME', 'PDA_TEST_PASSWORD'] +for (const name of required) if (!process.env[name]) throw new Error(`Missing ${name}`) +if ((!sourceReport && !directDispatchNo) || !customerId) throw new Error('Missing acceptance source/dispatch or customer id') + +function id(value) { return value == null ? '' : String(value) } +function key(value) { return `${marker}-${value}`.slice(0, 200) } +function assert(value, message) { if (!value) throw new Error(message) } +async function api(base, token, method, path, body) { + const response = await fetch(`${base}${path}`, { method, + headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + body: body === undefined ? undefined : JSON.stringify(body) }) + const text = await response.text(); let value + try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } + if (!response.ok || String(value?.code) !== '200') { + throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) + } + return value.data +} + +const source = sourceReport ? JSON.parse(await readFile(sourceReport, 'utf8')) : null +const scenario = source?.scenarios.find((value) => value.name === 'SPLIT-TWO-TARGETS') + || { dispatchNo: directDispatchNo, relabelNo: directRelabelNo || '' } +assert(scenario?.dispatchNo, 'Dispatch not found in source report') +const dispatchNo = scenario.dispatchNo +const platformLogin = await api(domestic, '', 'POST', '/api/auth/login', { + loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD }) +const pdaLogin = await api(domestic, '', 'POST', '/api/auth/login', { + loginId: process.env.PDA_TEST_USERNAME, password: process.env.PDA_TEST_PASSWORD }) +const ticket = await api(domestic, platformLogin.accessToken, 'POST', + `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程履约验收` }) +const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) +const platformToken = platformLogin.overseasAccessToken +const merchantToken = merchantLogin.overseasAccessToken +const pdaToken = pdaLogin.overseasAccessToken + +await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { + vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', + carrierName: '尾程验收车队', route: `${marker}-ROUTE`, + estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(), +}) +const transfer = await api(overseas, merchantToken, 'POST', + `/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, { + idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), + deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队', + pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: 'MEDIUM', + driverName: '尾程验收司机', driverPhone: '19900000000', vehiclePlate: `UAT-${marker.slice(-8)}`, + appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', + direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }], + }) +assert(transfer.outboundNo && transfer.deliveryNo, 'Transfer did not create outbound and delivery') + +let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) +assert(Array.isArray(task.items) && task.items.length === 2, 'Expected two PDA pallet items') +for (const [itemIndex, item] of task.items.entries()) { + let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) + for (const [positionIndex, position] of item.positions.entries()) { + if (remaining <= 0) break + const packages = Math.min(remaining, Number(position.availablePackages)) + if (packages <= 0) continue + await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, { + positionId: id(position.id), packages, productQuantity: packages, + idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker, + }) + remaining -= packages + } + assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`) +} +const completed = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') }) +assert(completed.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART') +const departed = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, { idempotencyKey: key('DEPART'), remark: marker }) +assert(departed.status === 'DEPARTED', 'Dispatch did not reach DEPARTED') + +const outbound = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`) +assert(outbound.outbound.status === 'OUTBOUNDED', 'Outbound did not reach OUTBOUNDED') +let delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`) +const delivering = await api(overseas, platformToken, 'PUT', + `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/status?customerId=${customerId}`, { + status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker, + }) +assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING') +delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`) +const delivered = await api(overseas, platformToken, 'POST', + `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/complete?customerId=${customerId}`, { + status: 'DELIVERED', expectedVersion: Number(delivery.order.version), remark: marker, + }) +assert(delivered.deliveryStatus === 'DELIVERED', 'Delivery did not reach DELIVERED') + +const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo, + dispatchNo, dispatchStatus: departed.status, outboundNo: transfer.outboundNo, + outboundStatus: outbound.outbound.status, deliveryNo: transfer.deliveryNo, + deliveryStatus: delivered.deliveryStatus, visitId: id(delivered.visitId), shippedPackages: departed.shippedPackages, + passed: true, finishedAt: new Date().toISOString() } +await writeFile(output, JSON.stringify(report, null, 2), 'utf8') +console.log(JSON.stringify(report, null, 2)) diff --git a/scripts/overseas-relabel-real-acceptance.mjs b/scripts/overseas-relabel-real-acceptance.mjs new file mode 100644 index 000000000..096841182 --- /dev/null +++ b/scripts/overseas-relabel-real-acceptance.mjs @@ -0,0 +1,193 @@ +import { writeFile } from 'node:fs/promises' + +const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' +const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +const platformUser = process.env.ADMIN_TEST_USERNAME +const platformPassword = process.env.ADMIN_TEST_PASSWORD +const pdaUser = process.env.PDA_TEST_USERNAME +const pdaPassword = process.env.PDA_TEST_PASSWORD +const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID +const warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID +const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json' +const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` + +for (const [name, value] of Object.entries({ platformUser, platformPassword, pdaUser, pdaPassword, customerId, warehouseId })) { + if (!value) throw new Error(`Missing environment variable: ${name}`) +} + +const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', scenarios: [], passed: false } +function key(value) { return `${marker}-${value}`.slice(0, 200) } +function id(value) { return value == null ? '' : String(value) } +async function api(base, token, method, path, body, headers = {}) { + const response = await fetch(`${base}${path}`, { + method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...headers }, + body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), + }) + const text = await response.text() + let value + try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } + if (!response.ok || String(value?.code) !== '200') { + throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) + } + return value.data +} +async function poll(action, predicate, attempts = 40) { + for (let index = 0; index < attempts; index += 1) { + const value = await action() + if (predicate(value)) return value + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error('Polling timed out') +} +function assert(condition, message) { if (!condition) throw new Error(message) } + +const platformLogin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword }) +const pdaLogin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword }) +const ticket = await api(domestic, platformLogin.accessToken, 'POST', + `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程真实验收` }) +const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) +const platformToken = platformLogin.overseasAccessToken +const merchantToken = merchantLogin.overseasAccessToken +const pdaToken = pdaLogin.overseasAccessToken +assert(platformToken && merchantToken && pdaToken, 'Missing overseas token') + +const candidates = await api(overseas, merchantToken, 'GET', + `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`) +const source = candidates.find((item) => String(item.inboundOrderNo || '').startsWith('DS-') + && Number(item.availablePackages) >= 5 + && item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5)) +assert(source, 'No overseas inventory source with at least five available cartons') +const sourcePosition = source.positions.find((position) => Number(position.availablePackages) >= 5) + +async function resolvePallet(palletCode) { + return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) +} + +async function createAndApprove(name, cartonCount) { + const suffix = `${name}-${Date.now()}` + const items = Array.from({ length: cartonCount }, (_, index) => ({ + sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId), + sourceInboundOrderId: id(source.inboundOrderId), skuCode: source.skuCode, + productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1, + shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), + labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`, + fbaCartonMark: `FBA-${suffix}-${index + 1}`, destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, + operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: source.skuCode, + newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }], + })) + const created = await api(overseas, platformToken, 'POST', + `/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, { + customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId, + labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), + contactName: '尾程验收', contactPhone: '13800000006', chargeAmount: 0.01, + remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: 'TRUCK', + printShippingLabel: false, items, + }) + const relabelNo = created.application.relabelNo + await poll(() => api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/relabel-applications/${relabelNo}`), + (detail) => detail.application.walletStatus === 'FROZEN') + let detail = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) }) + for (const [index, item] of detail.items.entries()) { + detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, { + itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark, + expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`), + }) + } + const form = new FormData() + form.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), `${marker}.png`) + const photo = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form) + detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, { + photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version), + idempotencyKey: key(`${name}-COMPLETE`), remark: marker, + }) + detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/review`, { + decision: 'APPROVE', expectedVersion: Number(detail.version), + idempotencyKey: key(`${name}-APPROVE`), remark: marker, + }) + assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`) + const confirmed = await api(overseas, merchantToken, 'POST', + `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, { + idempotencyKey: key(`${name}-CONFIRM`), remark: marker, + }) + assert(confirmed.application.status === 'READY_FOR_OUTBOUND', `${relabelNo} did not reach READY_FOR_OUTBOUND`) + return { name, relabelNo, items: confirmed.items, sourceBefore: Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) } +} + +async function printPallet(relabelNo, suffix) { + return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', { + relabelNo, idempotencyKey: key(`PRINT-${suffix}`), + }) +} +async function moveCarton(scenario, item, target, index) { + const sourcePallet = await resolvePallet(sourcePosition.palletNo) + const targetPallet = await resolvePallet(target.palletCode) + const result = await api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', { + idempotencyKey: key(`${scenario.name}-MOVE-${index}`), relabelNo: scenario.relabelNo, + source: { palletCode: sourcePallet.palletCode, expectedVersion: Number(sourcePallet.palletVersion) }, + target: { palletCode: targetPallet.palletCode, expectedVersion: Number(targetPallet.palletVersion) }, + cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`, + }) + assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type') + return result +} + +const fresh = await createAndApprove('NEW-PALLET', 1) +const sharedTarget = await printPallet(fresh.relabelNo, 'SHARED') +const printReplay = await printPallet(fresh.relabelNo, 'SHARED') +assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') +await moveCarton(fresh, fresh.items[0], sharedTarget, 0) +fresh.targets = [sharedTarget] + +const merged = await createAndApprove('MERGE-EXISTING', 1) +const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) +await moveCarton(merged, merged.items[0], sharedTarget, 0) +const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) +assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase') +merged.targets = [sharedTarget] + +const split = await createAndApprove('SPLIT-TWO-TARGETS', 2) +const splitA = await printPallet(split.relabelNo, 'SPLIT-A') +const splitB = await printPallet(split.relabelNo, 'SPLIT-B') +await moveCarton(split, split.items[0], splitA, 0) +await moveCarton(split, split.items[1], splitB, 1) +split.targets = [splitA, splitB] + +for (const scenario of [fresh, merged, split]) { + const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) + scenario.sourceAfter = sourceAfter + scenario.targetPallets = scenario.targets.map((target) => ({ palletId: id(target.palletId), palletCode: target.palletCode })) + const candidateRows = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(scenario.relabelNo)}&sourceStage=RECEIVED`) + const targetIds = new Set(scenario.targets.map((target) => id(target.palletId))) + const rows = candidateRows.filter((row) => row.sourceStage === 'RELABEL' && targetIds.has(id(row.warehousePalletId))) + const marks = rows.flatMap((row) => String(row.relabelCartonMarks || '').split(',').filter(Boolean)) + assert(rows.length === targetIds.size, `${scenario.relabelNo} missing smart-dispatch pallet candidate`) + assert(rows.every((row) => !String(row.cartonMarks || '').trim()), `${scenario.relabelNo} exposed normal carton marks`) + assert(scenario.items.every((item) => marks.includes(item.fbaCartonMark)), `${scenario.relabelNo} missing relabel carton mark`) + const dispatch = await api(overseas, platformToken, 'POST', '/api/platform/v1/last-leg/dispatches/pre-arrange', { + planPalletIds: rows.map((row) => id(row.warehousePalletId)), route: `${marker}-${scenario.name}`, + relabelNo: scenario.relabelNo, + }) + scenario.dispatchNo = dispatch.dispatchNo + scenario.dispatchStatus = dispatch.status + scenario.relabelCartonMarks = marks + delete scenario.items + delete scenario.targets +} + +const ledger = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/inventory/ledger?customerId=${customerId}&page=1&size=100&businessType=RELABEL_PALLET_DISASSEMBLE`) +const ledgerRows = ledger.records.filter((row) => String(row.remark || '').includes(marker)) +assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger') +assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger') +assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason') +report.scenarios = [fresh, merged, split] +report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) } +report.passed = true +report.finishedAt = new Date().toISOString() +await writeFile(output, JSON.stringify(report, null, 2), 'utf8') +console.log(JSON.stringify(report, null, 2)) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java index 894d3dd8a..66c6033af 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -2,6 +2,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.warehouse.dto.DropshipPalletRequests; +import com.baiyue.warehouse.dto.RelabelPalletRequests; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; import com.baiyue.warehouse.security.InternalServiceTokenGuard; @@ -24,6 +25,14 @@ public class InternalWarehousePalletController { return ApiResponse.success(service.ensureDropshipPallet(request.customerId(), request.warehouseId(), request.businessId())); } + @PostMapping("/ensure-relabel") + public ApiResponse ensureRelabel( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody RelabelPalletRequests.Ensure request) { + tokenGuard.verify(token); + return ApiResponse.success(service.ensureRelabelPallet(request.warehouseId(), request.businessId())); + } + @GetMapping("/resolve/{palletCode}") public ApiResponse resolve( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java index 9d8bdc683..336927641 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java @@ -1,7 +1,6 @@ package com.baiyue.warehouse.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; @@ -12,7 +11,7 @@ public final class PalletTransitionRequests { public record Apply(@Positive long warehouseId, @PositiveOrZero int expectedVersion, @NotBlank @Size(max = 20) String targetStatus, - @NotNull @Positive Long targetLocationId, + @Positive Long targetLocationId, @NotBlank @Size(max = 128) String operationKey, @Size(max = 500) String reason) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java index c3e159661..9eb6e0ac7 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java @@ -7,8 +7,8 @@ public record PalletTransitionResponse( long warehouseId, String beforeStatus, String afterStatus, - long beforeLocationId, - long afterLocationId, + Long beforeLocationId, + Long afterLocationId, int beforeVersion, int afterVersion, String operationStatus, diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/RelabelPalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/RelabelPalletRequests.java new file mode 100644 index 000000000..43bc003b7 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/RelabelPalletRequests.java @@ -0,0 +1,12 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public final class RelabelPalletRequests { + private RelabelPalletRequests() { } + + public record Ensure(@Positive long warehouseId, + @NotBlank @Size(max = 128) String businessId) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java index 97cac56b1..64844a708 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java @@ -4,7 +4,7 @@ public record WarehousePalletResolveResponse( long palletId, String palletCode, long warehouseId, - long locationId, + Long locationId, String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, @@ -14,13 +14,13 @@ public record WarehousePalletResolveResponse( boolean reservable, String reservationBlockReason ) { - public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, long locationId, + public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, Long locationId, String status, String fbaWarehouseCode, int version) { this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode, java.math.BigDecimal.ZERO, version, null, null, false, "LOCATION_STATE_UNKNOWN"); } - public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, long locationId, + public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, Long locationId, String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) { this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode, diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java index 3840ea4c2..7da76bb6a 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java @@ -39,6 +39,6 @@ public interface WarehousePalletTransitionMapper extends BaseMapper'SCRAPPED' AND deleted=0") int transition(@Param("warehouseId") long warehouseId, @Param("palletId") long palletId, - @Param("targetStatus") String targetStatus, @Param("targetLocationId") long targetLocationId, + @Param("targetStatus") String targetStatus, @Param("targetLocationId") Long targetLocationId, @Param("expectedVersion") int expectedVersion); } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java index 10ab34550..f4cbe17c2 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java @@ -45,16 +45,17 @@ public class WarehousePalletTransitionService { if (version != request.expectedVersion()) { throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码"); } - if (pallet.getLocationId() == null) { - throw new BizException(ResultCode.CONFLICT, "托盘没有有效库位,不能执行库内作业"); - } - transition(pallet, status, request.targetLocationId(), version); + // 当前海外尾程按托盘作业,不要求业务端选择库位。未传目标库位时保留托盘现有 + // 技术库位;历史无库位托盘则只流转托盘状态,不能因此阻断 PDA 作业。 + Long targetLocationId = request.targetLocationId() == null + ? pallet.getLocationId() : request.targetLocationId(); + transition(pallet, status, targetLocationId, version); WarehousePalletOperationEntity operation = new WarehousePalletOperationEntity(); operation.setId(IdWorker.getId()); operation.setOperationKey(key); operation.setRequestHash(requestHash); operation.setWarehouseId(request.warehouseId()); operation.setPalletId(palletId); operation.setPalletCode(pallet.getPalletCode()); operation.setBeforeStatus(pallet.getStatus()); operation.setAfterStatus(status); operation.setBeforeLocationId(pallet.getLocationId()); - operation.setAfterLocationId(request.targetLocationId()); operation.setBeforeVersion(version); + operation.setAfterLocationId(targetLocationId); operation.setBeforeVersion(version); operation.setAfterVersion(version + 1); operation.setOperationStatus("APPLIED"); operation.setReason(trim(request.reason())); operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt()); mapper.insert(operation); @@ -75,7 +76,7 @@ public class WarehousePalletTransitionService { } WarehousePalletEntity pallet = mapper.lockPublicPallet(request.warehouseId(), palletId); if (pallet == null || !operation.getAfterStatus().equals(pallet.getStatus()) - || !Long.valueOf(operation.getAfterLocationId()).equals(pallet.getLocationId())) { + || !java.util.Objects.equals(operation.getAfterLocationId(), pallet.getLocationId())) { throw new BizException(ResultCode.CONFLICT, "托盘已被后续作业修改,自动补偿失败"); } transition(pallet, operation.getBeforeStatus(), operation.getBeforeLocationId(), @@ -85,14 +86,18 @@ public class WarehousePalletTransitionService { return response(operation, false); } - private void transition(WarehousePalletEntity pallet, String targetStatus, long targetLocationId, + private void transition(WarehousePalletEntity pallet, String targetStatus, Long targetLocationId, int expectedVersion) { int currentVersion = pallet.getVersion() == null ? 0 : pallet.getVersion(); if (currentVersion != expectedVersion || !MUTABLE_STATUSES.contains(pallet.getStatus())) { throw new BizException(ResultCode.CONFLICT, "托盘版本或状态已变化,请重新扫码"); } long warehouseId = pallet.getWarehouseId(); - long sourceLocationId = pallet.getLocationId(); + Long sourceLocationId = pallet.getLocationId(); + if (sourceLocationId == null || targetLocationId == null) { + transitionWithOptionalLocation(pallet, sourceLocationId, targetLocationId, targetStatus, expectedVersion); + return; + } WarehouseLocationEntity source; WarehouseLocationEntity target; if (sourceLocationId <= targetLocationId) { @@ -116,6 +121,25 @@ public class WarehousePalletTransitionService { } } + private void transitionWithOptionalLocation(WarehousePalletEntity pallet, Long sourceLocationId, + Long targetLocationId, String targetStatus, + int expectedVersion) { + long warehouseId = pallet.getWarehouseId(); + if (sourceLocationId != null) { + WarehouseLocationEntity source = requireLocation(warehouseId, sourceLocationId); + requireLocationMatchesPallet(source, pallet.getStatus()); + releaseSourceLocation(warehouseId, source, pallet.getStatus()); + } + if (targetLocationId != null) { + WarehouseLocationEntity target = requireLocation(warehouseId, targetLocationId); + requireAvailableTarget(target, targetStatus); + occupyTargetLocation(warehouseId, target, targetStatus); + } + if (mapper.transition(warehouseId, pallet.getId(), targetStatus, targetLocationId, expectedVersion) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码"); + } + } + private WarehouseLocationEntity requireLocation(long warehouseId, long locationId) { WarehouseLocationEntity location = mapper.lockPublicLocation(warehouseId, locationId); if (location == null || "INACTIVE".equals(location.getStatus())) { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index 202881ed8..724e8540e 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -116,6 +116,30 @@ public class WarehouseResourceService { } } + @Transactional + public WarehousePalletResolveResponse ensureRelabelPallet(long warehouseId, String businessId) { + String normalizedBusinessId = businessId == null ? null : businessId.trim(); + WarehousePalletBindingEntity existing = repository.findPalletBinding(PUBLIC_RESOURCE_SCOPE, warehouseId, + "RELABEL_PDA_NEW", normalizedBusinessId); + if (existing != null) return resolvePublicPallet(existing.getPalletId()); + Long palletId = createPallet(PUBLIC_RESOURCE_SCOPE, warehouseId, + new PalletRequests.SaveRequest(null, "STANDARD", null, java.math.BigDecimal.ZERO, "IDLE", null, + "PDA换标拆托新托盘")); + WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity(); + binding.setId(IdWorker.getId()); binding.setCustomerId(PUBLIC_RESOURCE_SCOPE); binding.setWarehouseId(warehouseId); + binding.setBusinessType("RELABEL_PDA_NEW"); binding.setBusinessId(normalizedBusinessId); binding.setPalletId(palletId); + binding.setCreatedAt(java.time.LocalDateTime.now()); + try { + repository.insertPalletBinding(binding); + return resolvePublicPallet(palletId); + } catch (org.springframework.dao.DuplicateKeyException duplicate) { + WarehousePalletBindingEntity winner = repository.findPalletBinding(PUBLIC_RESOURCE_SCOPE, warehouseId, + "RELABEL_PDA_NEW", normalizedBusinessId); + if (winner == null) throw duplicate; + return resolvePublicPallet(winner.getPalletId()); + } + } + /** * Returns the warehouse-scoped virtual location used only as a pallet/inventory reference. * It is deliberately never made IDLE/OCCUPIED by resource locking. @@ -186,7 +210,6 @@ public class WarehouseResourceService { if (matches.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "托盘码不存在"); if (matches.size() != 1) throw new BizException(ResultCode.CONFLICT, "托盘码在多个仓库重复,无法解析"); WarehousePalletEntity pallet = matches.get(0); - if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位"); if ("SCRAPPED".equals(pallet.getStatus())) throw new BizException(ResultCode.CONFLICT, "托盘已报废"); return palletReference(pallet); } @@ -194,7 +217,6 @@ public class WarehouseResourceService { public WarehousePalletResolveResponse resolvePublicPallet(long palletId) { WarehousePalletEntity pallet = repository.findPublicPallet(palletId); if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "托盘不存在"); - if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位"); return palletReference(pallet); } @@ -218,8 +240,8 @@ public class WarehouseResourceService { } private WarehousePalletResolveResponse palletReference(WarehousePalletEntity pallet) { - WarehouseLocationEntity location = repository.findLocation(PUBLIC_RESOURCE_SCOPE, - pallet.getWarehouseId(), pallet.getLocationId()); + WarehouseLocationEntity location = pallet.getLocationId() == null ? null + : repository.findLocation(PUBLIC_RESOURCE_SCOPE, pallet.getWarehouseId(), pallet.getLocationId()); String blockReason = reservationBlockReason(pallet, location); return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(), pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(), pallet.getUnitPrice(), @@ -230,7 +252,7 @@ public class WarehouseResourceService { private static String reservationBlockReason(WarehousePalletEntity pallet, WarehouseLocationEntity location) { if (!"IDLE".equals(pallet.getStatus())) return "PALLET_NOT_IDLE"; - if (location == null) return "LOCATION_NOT_FOUND"; + if (location == null) return pallet.getLocationId() == null ? "LOCATION_NOT_REQUIRED" : "LOCATION_NOT_FOUND"; if ("VIRTUAL".equals(location.getLocationType())) { return "ACTIVE".equals(location.getStatus()) ? null : "VIRTUAL_LOCATION_NOT_ACTIVE"; } diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql index b6acb11c6..9d041e1b9 100644 --- a/warehouse-overseas-service/src/main/resources/db/warehouse.sql +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -114,7 +114,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation ( request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', - before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空', after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空', before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -123,6 +123,10 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation ( KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) ) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录'; +ALTER TABLE wh_pallet_operation + MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空', + MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空'; + CREATE TABLE IF NOT EXISTS wh_pallet_batch ( id BIGINT NOT NULL COMMENT '批量托盘预留ID', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', diff --git a/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-transition-internal-v1.yaml b/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-transition-internal-v1.yaml new file mode 100644 index 000000000..bea99ab41 --- /dev/null +++ b/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-transition-internal-v1.yaml @@ -0,0 +1,132 @@ +openapi: 3.0.3 +info: + title: Overseas warehouse PDA pallet transition internal API + version: 1.0.0 +paths: + /internal/v1/warehouse-pallets/resolve/{palletCode}: + get: + summary: Resolve a public pallet for PDA operations + description: Location fields are nullable because overseas tail-leg operations may work by pallet only. + security: [{ internalServiceToken: [] }] + parameters: + - in: path + name: palletCode + required: true + schema: { type: string } + responses: + '200': + description: Pallet resolution result + content: + application/json: + schema: { $ref: '#/components/schemas/PalletResolveApiResponse' } + /internal/v1/pda-pallet-transitions/{palletId}: + post: + summary: Apply an idempotent PDA pallet state transition + security: [{ internalServiceToken: [] }] + parameters: + - in: path + name: palletId + required: true + schema: { type: integer, format: int64, minimum: 1 } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/PalletTransitionApply' } + responses: + '200': + description: Transition result + content: + application/json: + schema: { $ref: '#/components/schemas/PalletTransitionApiResponse' } + /internal/v1/pda-pallet-transitions/{palletId}/{operationKey}/compensate: + post: + summary: Compensate an applied PDA pallet transition + security: [{ internalServiceToken: [] }] + parameters: + - in: path + name: palletId + required: true + schema: { type: integer, format: int64, minimum: 1 } + - in: path + name: operationKey + required: true + schema: { type: string, maxLength: 128 } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/PalletTransitionCompensate' } + responses: + '200': + description: Compensation result + content: + application/json: + schema: { $ref: '#/components/schemas/PalletTransitionApiResponse' } +components: + securitySchemes: + internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token } + schemas: + PalletTransitionApply: + type: object + required: [warehouseId, expectedVersion, targetStatus, operationKey] + properties: + warehouseId: { type: string, pattern: '^[1-9][0-9]*$' } + expectedVersion: { type: integer, minimum: 0 } + targetStatus: { type: string, enum: [IDLE, OCCUPIED] } + targetLocationId: + type: string + pattern: '^[1-9][0-9]*$' + nullable: true + description: Optional; omitted for pallet-only operations without location management + operationKey: { type: string, maxLength: 128 } + reason: { type: string, maxLength: 500, nullable: true } + PalletTransitionCompensate: + type: object + required: [warehouseId] + properties: + warehouseId: { type: string, pattern: '^[1-9][0-9]*$' } + PalletResolveResult: + type: object + required: [palletId, palletCode, warehouseId, status, unitPrice, version, reservable] + properties: + palletId: { type: string, pattern: '^[1-9][0-9]*$' } + palletCode: { type: string } + warehouseId: { type: string, pattern: '^[1-9][0-9]*$' } + locationId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } + status: { type: string } + fbaWarehouseCode: { type: string, nullable: true } + unitPrice: { type: number } + version: { type: integer, minimum: 0 } + locationType: { type: string, nullable: true } + locationStatus: { type: string, nullable: true } + reservable: { type: boolean } + reservationBlockReason: { type: string, nullable: true } + PalletTransitionResult: + type: object + required: [operationKey, palletId, palletCode, warehouseId, beforeStatus, afterStatus, beforeVersion, afterVersion, operationStatus, replayed] + properties: + operationKey: { type: string } + palletId: { type: string, pattern: '^[1-9][0-9]*$' } + palletCode: { type: string } + warehouseId: { type: string, pattern: '^[1-9][0-9]*$' } + beforeStatus: { type: string } + afterStatus: { type: string } + beforeLocationId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } + afterLocationId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } + beforeVersion: { type: integer } + afterVersion: { type: integer } + operationStatus: { type: string } + replayed: { type: boolean } + PalletTransitionApiResponse: + type: object + properties: + code: { type: string, example: '200' } + message: { type: string } + data: { $ref: '#/components/schemas/PalletTransitionResult' } + PalletResolveApiResponse: + type: object + properties: + code: { type: string, example: '200' } + message: { type: string } + data: { $ref: '#/components/schemas/PalletResolveResult' } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java index f0b5bb896..5d37cc44c 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java @@ -111,6 +111,23 @@ class WarehousePalletTransitionServiceTest { verify(mapper, never()).transitionLocation(anyLong(), anyLong(), anyString(), anyString()); } + @Test void locationlessPalletCanChangeStatusWithoutSelectingLocation() { + WarehousePalletEntity pallet = pallet("P-NO-LOCATION", "IDLE", 2); + pallet.setLocationId(null); + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet); + when(mapper.transition(88L, 10L, "OCCUPIED", null, 2)).thenReturn(1); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 2, "OCCUPIED", null, "locationless-occupy", "pallet-only operation")); + + assertNull(response.beforeLocationId()); + assertNull(response.afterLocationId()); + assertEquals("OCCUPIED", response.afterStatus()); + verify(mapper, never()).lockPublicLocation(anyLong(), anyLong()); + verify(mapper, never()).transitionLocation(anyLong(), anyLong(), anyString(), anyString()); + verify(mapper).transition(88L, 10L, "OCCUPIED", null, 2); + } + @Test void inconsistentIdlePalletAndOccupiedLocationIsRejectedBeforeMutation() { when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "IDLE", 4)); when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "OCCUPIED")); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index 807382a3f..888a44884 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -7,6 +7,7 @@ import com.baiyue.warehouse.dto.LocationRequests; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; import com.baiyue.warehouse.dto.SharedWarehouseReference; import com.baiyue.warehouse.repository.WarehouseResourceRepository; import org.junit.jupiter.api.BeforeEach; @@ -97,6 +98,21 @@ class WarehouseResourceServiceTest { assertThat(result.reservationBlockReason()).isEqualTo("LOCATION_NOT_IDLE"); } + @Test + void resolvesLocationlessPalletForPalletOnlyWorkflow() { + WarehousePalletEntity pallet = pallet(40001L, "IDLE", 30001L); + pallet.setLocationId(null); + when(repository.findPublicPalletsByCode("PLT-NO-LOCATION")).thenReturn(List.of(pallet)); + pallet.setPalletCode("PLT-NO-LOCATION"); + + var result = service.resolvePublicPallet("PLT-NO-LOCATION"); + + assertThat(result.locationId()).isNull(); + assertThat(result.reservable()).isFalse(); + assertThat(result.reservationBlockReason()).isEqualTo("LOCATION_NOT_REQUIRED"); + verify(repository, never()).findLocation(0L, WAREHOUSE, 0L); + } + @Test void createsLocationWithSnowflakeAssignedByMapper() { doAnswer(invocation -> { @@ -378,6 +394,23 @@ class WarehouseResourceServiceTest { verify(repository, never()).updatePallet(org.mockito.ArgumentMatchers.any()); } + @Test + void relabelPalletCreationReplaysExistingBusinessBinding() { + WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity(); + binding.setPalletId(40001L); + when(repository.findPalletBinding(0L, WAREHOUSE, "RELABEL_PDA_NEW", "RL-1:key-1")) + .thenReturn(binding); + when(repository.findPublicPallet(40001L)).thenReturn(pallet(40001L, "IDLE", 30001L)); + when(repository.findLocation(0L, WAREHOUSE, 30001L)).thenReturn(location(30001L, "VIRTUAL", "ACTIVE")); + + var result = service.ensureRelabelPallet(WAREHOUSE, " RL-1:key-1 "); + + assertThat(result.palletId()).isEqualTo(40001L); + assertThat(result.palletCode()).isEqualTo("PLT-01"); + verify(repository, never()).insertPallet(org.mockito.ArgumentMatchers.any()); + verify(repository, never()).insertPalletBinding(org.mockito.ArgumentMatchers.any()); + } + private static SharedWarehouseReference warehouse() { return new SharedWarehouseReference(WAREHOUSE, "US-LAX-01", "Los Angeles", "ACTIVE"); }