diff --git a/deploy/sql/migrations/cn/baiyue_iam/V6__cargo_discard_customer_confirmation_permission.sql b/deploy/sql/migrations/cn/baiyue_iam/V6__cargo_discard_customer_confirmation_permission.sql new file mode 100644 index 000000000..b4952fe4f --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_iam/V6__cargo_discard_customer_confirmation_permission.sql @@ -0,0 +1,21 @@ +-- 仅 baiyue_iam,新增客户废弃确认按钮权限并授权默认客户管理员。 +-- 自定义客户角色由管理员按需分配;不自动扩大其权限。 +-- 可重复执行;授权和持久化安全版本在同一事务提交。 +START TRANSACTION; +INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0; +INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) +SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p + ON p.tenant_id=0 AND p.app_code='MERCHANT' AND p.permission_code='merchant:overseas-warehouse:discard:confirm' +WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0; +INSERT INTO iam_security_subject_version(auth_user_id,permission_version) +SELECT DISTINCT u.auth_user_id,1 FROM iam_user u +JOIN iam_membership m ON m.user_id=u.id +JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id +JOIN iam_role r ON r.id=mr.role_id +WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0 + AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='MERCHANT' + AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 +ON DUPLICATE KEY UPDATE permission_version=permission_version+1; +COMMIT; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V26__allow_zero_return_carton_item_quantity.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V26__allow_zero_return_carton_item_quantity.sql new file mode 100644 index 000000000..7f5a22c6b --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V26__allow_zero_return_carton_item_quantity.sql @@ -0,0 +1,13 @@ +-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。 +-- 原子替换约束,可重复执行;不修改业务记录。 +SET @ddl = IF(EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' + AND constraint_name='chk_ll_return_inventory_carton_item_quantity' +), + 'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)', + 'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V27__cargo_discard_customer_confirmation.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V27__cargo_discard_customer_confirmation.sql new file mode 100644 index 000000000..c8109e3c2 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V27__cargo_discard_customer_confirmation.sql @@ -0,0 +1,3 @@ +-- 客户确认废弃完结时间,可重复执行;仅结构变更。 +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认废弃完结时间''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 100c68e60..a306102e9 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7121,7 +7121,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306172,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306180,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306181,0,CURRENT_TIMESTAMP(3)); +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, created_at, updated_at, deleted) VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306182,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306183,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306190,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306191,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306192,0,CURRENT_TIMESTAMP(3)); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 1549cd42d..d939d5816 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2473,7 +2473,7 @@ CREATE TABLE ll_return_inventory_carton_item ( PRIMARY KEY (id), UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code), - CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0)), + CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity >= 0)), packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量'; @@ -4387,6 +4387,37 @@ CREATE TABLE ll_selfop_product ( SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 199 AS initialized_tables; +-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。 +USE baiyue_lastleg; +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='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; + +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='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; + +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='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; + +-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数''')); +PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt; + USE baiyue_lastleg; -- 历史库存基线;执行前必须取得指定环境的建表授权。 diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 6b4c39d13..cb9388409 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -142,6 +142,7 @@ VALUES (5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,0), (5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,0), (5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,0), + (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0), (5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货换标','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,0), (5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,0), (5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,0) @@ -336,6 +337,7 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code 'merchant:overseas-warehouse:discard:list', 'merchant:overseas-warehouse:discard:create', 'merchant:overseas-warehouse:discard:cancel', + 'merchant:overseas-warehouse:discard:confirm', 'merchant:overseas-warehouse:return-order:list', 'merchant:overseas-warehouse:return-order:create', 'merchant:overseas-warehouse:return-order:relabel' @@ -371,9 +373,10 @@ BEGIN 'merchant:overseas-warehouse:discard:list', 'merchant:overseas-warehouse:discard:create', 'merchant:overseas-warehouse:discard:cancel', + 'merchant:overseas-warehouse:discard:confirm', 'merchant:overseas-warehouse:return-order:list', 'merchant:overseas-warehouse:return-order:create', - 'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN + 'merchant:overseas-warehouse:return-order:relabel')) <> 25 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整'; END IF; IF (SELECT COUNT(*) FROM iam_role_permission grant_row @@ -405,9 +408,10 @@ BEGIN 'merchant:overseas-warehouse:discard:list', 'merchant:overseas-warehouse:discard:create', 'merchant:overseas-warehouse:discard:cancel', + 'merchant:overseas-warehouse:discard:confirm', 'merchant:overseas-warehouse:return-order:list', 'merchant:overseas-warehouse:return-order:create', - 'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN + 'merchant:overseas-warehouse:return-order:relabel')) <> 25 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整'; END IF; END$$ diff --git a/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java index d7deb63ea..6768e66bd 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java @@ -20,7 +20,7 @@ class MerchantReturnInventoryPermissionSqlContractTest { assertThat(sql).contains( "(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'", "role.role_code='MERCHANT_OWNER'", - ")) <> 24 THEN", + ")) <> 25 THEN", "客户默认管理员授权不完整"); int grantStart = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口"); diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java index ba27c34e8..3bf4205bb 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java @@ -36,6 +36,7 @@ class OverseasCustomerPermissionSqlContractTest { "merchant:overseas-warehouse:discard:list", "merchant:overseas-warehouse:discard:create", "merchant:overseas-warehouse:discard:cancel", + "merchant:overseas-warehouse:discard:confirm", "merchant:overseas-warehouse:return-order:list", "merchant:overseas-warehouse:return-order:create", "merchant:overseas-warehouse:return-order:relabel"); @@ -85,7 +86,7 @@ class OverseasCustomerPermissionSqlContractTest { "海外客户权限初始化失败:客户默认管理员授权不完整", "permission.app_code='MERCHANT'", "role.role_code='MERCHANT_OWNER'", - ")) <> 24 THEN"); + ")) <> 25 THEN"); } @Test 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 d5ac0f284..6abe4ce17 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 @@ -1426,6 +1426,7 @@ public class DeliveryTransferService { new LambdaQueryWrapper().eq(StringUtils.hasText(customerId), OutboundOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo()) .like(StringUtils.hasText(query.lxCode()), OutboundOrder::getLxCodes, query.lxCode()) + .ne(StringUtils.hasText(query.lxCode()), OutboundOrder::getOutboundType, "DISCARD_OUTBOUND") .eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId()) .eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status()) .eq(StringUtils.hasText(query.outboundType()), OutboundOrder::getOutboundType, query.outboundType()) @@ -1611,7 +1612,7 @@ public class DeliveryTransferService { d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); } - private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java index e63473c3f..560138fe0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -77,13 +77,6 @@ public class CargoDiscardController { return ApiResponse.success(service.cancel(scope(user, customerId), discardNo, operator(user), true)); } - @PostMapping("/{discardNo}/complete") - public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @PathVariable String discardNo) { - requireApprovalPermission(user); - return ApiResponse.success(service.complete(scope(user, customerId), discardNo, operator(user))); - } - private String scope(AuthenticatedUser user, String requested) { 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/discard/controller/MerchantCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java index d69a705d7..300108ecc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java @@ -46,6 +46,12 @@ public class MerchantCargoDiscardController { org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content()); } + @PostMapping("/{discardNo}/confirm-completion") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CONFIRM + "')") + public ApiResponse confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String discardNo) { + return ApiResponse.success(service.confirmCompletion(customer(user), discardNo, operator(user))); + } @PostMapping("/{discardNo}/cancel") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')") public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java index 1e850e319..efde17c22 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -34,7 +34,7 @@ public final class CargoDiscardResponses { public record Summary(Long id, String customerId, String discardNo, String inboundCodes, String overseasWarehouseId, String overseasWarehouseName, int totalPackages, BigDecimal totalFee, String currency, String status, - String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo) {} + String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {} public record Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java index 369ece1f4..0997f55a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java @@ -28,6 +28,7 @@ public class CargoDiscardApplication { private String createdBy; private String reviewedBy; private String reviewRemark; + private LocalDateTime customerConfirmedAt; private LocalDateTime reviewedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index ceacb4854..ac5707bfa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -122,7 +122,7 @@ public class PdaCargoDiscardService { application.setTotalPackages(actualTotal); application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal))); discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED, - CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待仓库确认"); + CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待客户确认"); } outboundService.sync(application); return palletTasks(application).stream().filter(value -> value.palletNo().equals(palletNo.trim())) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java index 557298a07..0fc044fe6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java @@ -50,7 +50,7 @@ public class CargoDiscardOutboundService { outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId()); outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE); - outbound.setLxCodes(application.getInboundCodes()); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo()); + outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo()); outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages()); outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application)); outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO); @@ -116,6 +116,7 @@ public class CargoDiscardOutboundService { default -> lines.stream().anyMatch(i -> "COMPLETED".equals(i.getPdaStatus())) ? "PICKING" : "WAIT_PICK"; }; outbound.setStatus(status); + outbound.setLxCodes(null); if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages()); outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index f19423817..ad35cd767 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -76,6 +76,8 @@ public class CargoDiscardService { @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Resource private ObjectMapper objectMapper; + @Resource private com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient warehouseDirectory; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}") private BigDecimal feePerPackage = new BigDecimal("5.00"); @@ -105,7 +107,14 @@ public class CargoDiscardService { carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null, null, available, skus)); } - return result.stream().map(candidate -> withBoxes(customerId, candidate)).toList(); + Map names = result.stream().anyMatch(c -> !StringUtils.hasText(c.overseasWarehouseName())) + ? warehouseNames() : Map.of(); + return result.stream().map(candidate -> withBoxes(customerId, new CargoDiscardResponses.Candidate( + candidate.orderId(), candidate.sourceType(), candidate.returnCartonId(), candidate.cartonMark(), + candidate.inboundCode(), candidate.inboundOrderNo(), candidate.overseasWarehouseId(), + StringUtils.hasText(candidate.overseasWarehouseName()) ? candidate.overseasWarehouseName() : names.get(candidate.overseasWarehouseId()), + candidate.inboundCreatedAt(), candidate.availablePackages(), candidate.skus(), candidate.inventoryPositionId(), + candidate.palletNo(), candidate.cartons(), candidate.cartonSelectionSupported(), candidate.cartonSelectionBlockReason()))).toList(); } private CargoDiscardResponses.Candidate withBoxes(String customer, CargoDiscardResponses.Candidate candidate) { @@ -201,6 +210,7 @@ public class CargoDiscardService { if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存"); } LocalDateTime now = LocalDateTime.now(); + if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId); CargoDiscardApplication application = new CargoDiscardApplication(); application.setId(IdWorker.getId()); application.setCustomerId(customerId); application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId())); @@ -258,7 +268,9 @@ public class CargoDiscardService { .le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo()) .orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId); Page result = applications.selectPage(Page.of(query.page(), query.size()), wrapper); - List records = result.getRecords().stream().map(this::summary).toList(); + Map names = result.getRecords().stream().anyMatch(a -> !StringUtils.hasText(a.getOverseasWarehouseName())) + ? warehouseNames() : Map.of(); + List records = result.getRecords().stream().map(a -> summary(a, names)).toList(); return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages()); } @@ -325,12 +337,13 @@ public class CargoDiscardService { } @Transactional - public CargoDiscardResponses.Detail complete(String customerId, String discardNo, String operatorId) { + public CargoDiscardResponses.Detail confirmCompletion(String customerId, String discardNo, String operatorId) { CargoDiscardApplication application = applications.lockNo(customerId, discardNo); if (application == null) throw notFound("Cargo discard application not found"); if (COMPLETED.equals(application.getStatus())) return detail(customerId, discardNo); if (!PDA_COMPLETED.equals(application.getStatus())) throw conflict("全部托盘 PDA 作业完成后才能确认废弃完成"); - updateLinkedStatus(customerId, application, PDA_COMPLETED, COMPLETED, operatorId, "仓库确认废弃完成"); + application.setCustomerConfirmedAt(now()); + updateLinkedStatus(customerId, application, PDA_COMPLETED, COMPLETED, operatorId, "客户确认废弃完成"); return detail(customerId, discardNo); } @@ -478,10 +491,26 @@ public class CargoDiscardService { } private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) { + return summary(value, StringUtils.hasText(value.getOverseasWarehouseName()) ? Map.of() : warehouseNames()); + } + + private Map warehouseNames() { + var response = warehouseDirectory.receivingWarehouses(internalToken); + if (response == null || !response.successful() || response.data() == null) + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "海外仓库目录暂不可用"); + Map names = new HashMap<>(); + for (var warehouse : response.data()) { + if (warehouse.warehouseId() != null && StringUtils.hasText(warehouse.warehouseName())) + names.put(warehouse.warehouseId().toString(), warehouse.warehouseName()); + } + return names; + } + + private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map names) { return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(), - value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(), + value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(), value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), - value.getReviewedAt(), outboundService.outboundNo(value)); + value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt()); } private void history(String customerId, Long applicationId, String from, String to, String operatorId, 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 9da6b4fdf..b8ea1c5f4 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 @@ -58,6 +58,7 @@ public final class OverseasPermissions { public static final String MERCHANT_TRANSFER_CONFIRM = "merchant:overseas-warehouse:transfer:confirm"; public static final String MERCHANT_DISCARD_LIST = "merchant:overseas-warehouse:discard:list"; public static final String MERCHANT_DISCARD_CREATE = "merchant:overseas-warehouse:discard:create"; + public static final String MERCHANT_DISCARD_CONFIRM = "merchant:overseas-warehouse:discard:confirm"; public static final String MERCHANT_DISCARD_CANCEL = "merchant:overseas-warehouse:discard:cancel"; public static final String MERCHANT_RETURN_ORDER_LIST = "merchant:overseas-warehouse:return-order:list"; public static final String MERCHANT_RETURN_ORDER_CREATE = "merchant:overseas-warehouse:return-order:create"; 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 index 6bd39c1ce..a7d10d98e 100644 --- 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 @@ -77,6 +77,21 @@ public class PdaWarehouseTransferController { .contentType(MediaType.parseMediaType(file.contentType())).body(file.content()); } + @GetMapping("/{transferNo}/carton-label") + public ApiResponse cartonLabel( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaCartonLabel(identity.overseasWarehouseId(), transferNo)); + } + + @PostMapping("/{transferNo}/cartons/count") + public ApiResponse countCartons( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.CountCartons request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.countCartons(identity.operatorKey(), identity.overseasWarehouseId(), transferNo, request)); + } + @PostMapping("/{transferNo}/palletize") public ApiResponse palletize( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, 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 42804efba..4758b6fff 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 @@ -97,6 +97,24 @@ public final class WarehouseTransferRequests { public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} + public record CountCartons( + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 5000) List<@Valid @NotNull CountCarton> boxes, + @Size(max = 1000) String discrepancyReason + ) {} + + public record CountCarton( + @Size(max = 255) String cartonMark, + Long transferItemId, + @NotNull Long transferPalletId, + @NotNull @DecimalMin("0.01") BigDecimal length, + @NotNull @DecimalMin("0.01") BigDecimal width, + @NotNull @DecimalMin("0.01") BigDecimal height, + @NotNull @DecimalMin("0.01") BigDecimal weight, + @NotBlank @Size(max = 255) String skuCode, + @Positive @Max(2147483647) long quantity + ) {} + public record Palletize( @NotBlank @Size(max = 255) String idempotencyKey, @NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds @@ -104,13 +122,7 @@ public final class WarehouseTransferRequests { public record PalletDraft( Long palletId, - @NotBlank @Size(max = 255) String idempotencyKey, - @Positive int cartonCount, - @NotNull @DecimalMin("0.01") BigDecimal lengthCm, - @NotNull @DecimalMin("0.01") BigDecimal widthCm, - @NotNull @DecimalMin("0.01") BigDecimal heightCm, - @NotNull @DecimalMin("0.000001") BigDecimal volumeCbm, - @NotNull @DecimalMin("0.01") BigDecimal grossWeightKg + @NotBlank @Size(max = 255) String idempotencyKey ) {} public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {} @@ -151,8 +163,13 @@ public final class WarehouseTransferRequests { @Size(max = 128) String skuCode, @Size(max = 255) String productNameZh, @Positive int packageCount, - @Size(max = 255) String shippingMark - ) {} + @Size(max = 255) String shippingMark, + @Positive Long quantity + ) { + public Item(String skuCode, String productNameZh, int packageCount, String shippingMark) { + this(skuCode, productNameZh, packageCount, shippingMark, null); + } + } public record RelabelItem( @NotBlank @Size(max = 32) String operationType, 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 e8dfb55fb..00c20ef04 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 @@ -6,8 +6,10 @@ import java.util.List; public final class WarehouseTransferResponses { private WarehouseTransferResponses() {} - 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 Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark, Long quantity) {} + public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status, + Long transferItemId, Long transferPalletId, BigDecimal length, BigDecimal width, BigDecimal height, + BigDecimal weight, Long quantity, String discrepancyReason, String countedBy, LocalDateTime countedAt) {} public record Label(String transferNo, List cartons, String printableHtml) {} public record DestinationResult(String customerId, String transferNo) {} public record PalletItem(Long transferItemId, String skuCode, int packageCount) {} 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 e17cfd9c4..62d303ed2 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 @@ -13,12 +13,23 @@ public class WarehouseTransferCarton { @TableId(type = IdType.INPUT) private Long id; private Long transferOrderId; private Long transferItemId; + private Long transferPalletId; private String customerId; private String cartonMark; private String customerCartonMark; private Integer cartonIndex; private Integer totalCartons; private String status; + private java.math.BigDecimal lengthCm; + private java.math.BigDecimal widthCm; + private java.math.BigDecimal heightCm; + private java.math.BigDecimal grossWeightKg; + private Long countedQuantity; + private String countIdempotencyKey; + private String countRequestHash; + private String discrepancyReason; + private String countedBy; + private LocalDateTime countedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java index 6ca13d301..09d712433 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java @@ -15,6 +15,7 @@ public class WarehouseTransferItem { private String skuCode; private String productNameZh; private Integer packageCount; + private Long declaredQuantity; private String shippingMark; private LocalDateTime createdAt; } 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 662c8482b..fae47ed6d 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 @@ -525,7 +525,7 @@ public class WarehouseTransferService { new LambdaQueryWrapper().eq(WarehouseTransferItem::getCustomerId, customerId) .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)) .stream().map(value -> new WarehouseTransferResponses.Item(value.getId(), value.getSkuCode(), value.getProductNameZh(), - value.getPackageCount(), value.getShippingMark())).toList(); + value.getPackageCount(), value.getShippingMark(), value.getDeclaredQuantity())).toList(); Map itemById = items.stream() .collect(Collectors.toMap(WarehouseTransferResponses.Item::id, Function.identity())); List sources = sourceOrders.selectList( @@ -535,7 +535,7 @@ public class WarehouseTransferService { value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList(); List cartons = transferCartons.listOrder(customerId, transfer.getId()) .stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), - value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList(); + value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt())).toList(); List palletItemRows = transferPalletItems == null ? List.of() : transferPalletItems.listOrder(customerId, transfer.getId()); Map> palletItemsByPallet = palletItemRows.stream() @@ -607,13 +607,21 @@ public class WarehouseTransferService { transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt()); } + @Transactional(readOnly = true) + public WarehouseTransferResponses.Label pdaCartonLabel(long warehouseId, String transferNo) { + var transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("当前仓没有该转运单"); + return cartonLabel(transfer.getCustomerId(), transferNo); + } + @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"); if (DRAFT.equals(transfer.getStatus())) throw conflict("Submit the warehouse transfer order before printing carton labels"); List cartons = transferCartons.listOrder(customerId, transfer.getId()).stream() - .map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList(); + .filter(value -> !"NOT_RECEIVED".equals(value.getStatus())) + .map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt())).toList(); StringBuilder labels = new StringBuilder(); for (WarehouseTransferResponses.Carton carton : cartons) { labels.append("
仓储转运箱唛
") @@ -689,9 +697,10 @@ public class WarehouseTransferService { WarehouseTransferRequests.PalletDraft request) { WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); String customerId = transfer.getCustomerId(); - validatePalletMeasurement(request.lengthCm(), request.widthCm(), request.heightCm(), request.volumeCbm()); String requestHash = hash(request); List existing = transferPallets.listOrder(customerId, transfer.getId()); + if (transferCartons.listOrder(customerId, transfer.getId()).stream().anyMatch(box -> box.getCountedAt() != null)) + throw conflict("箱子清点已确认,不能再新增或修改托盘"); WarehouseTransferPallet pallet; if (request.palletId() == null) { pallet = existing.stream().filter(value -> request.idempotencyKey().equals(value.getDraftIdempotencyKey())) @@ -710,9 +719,9 @@ public class WarehouseTransferService { if (pallet == null) throw notFound("Warehouse transfer pallet draft not found"); if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet measurements are locked"); } - pallet.setCartonCount(request.cartonCount()); pallet.setLengthCm(request.lengthCm()); - pallet.setWidthCm(request.widthCm()); pallet.setHeightCm(request.heightCm()); - pallet.setVolumeCbm(request.volumeCbm()); pallet.setGrossWeightKg(request.grossWeightKg()); + pallet.setCartonCount(0); pallet.setLengthCm(BigDecimal.ZERO); + pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO); + pallet.setVolumeCbm(BigDecimal.ZERO); pallet.setGrossWeightKg(BigDecimal.ZERO); pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash); pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet); fleetPool.receiving(customerId, transferNo, pdaOperatorId()); @@ -782,6 +791,94 @@ public class WarehouseTransferService { return detail(transfer.getCustomerId(), transferNo); } + @Transactional + public WarehouseTransferResponses.Detail countCartons(String operatorId, long warehouseId, String transferNo, + WarehouseTransferRequests.CountCartons request) { + WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("当前仓没有该转运单"); + String customerId = transfer.getCustomerId(); + List cartons = transferCartons.listOrder(customerId, transfer.getId()); + String requestHash = hash(request); + boolean sameKey = cartons.stream().anyMatch(box -> request.idempotencyKey().equals(box.getCountIdempotencyKey())); + if (sameKey) { + if (cartons.stream().anyMatch(box -> !request.idempotencyKey().equals(box.getCountIdempotencyKey()) + || !requestHash.equals(box.getCountRequestHash()))) throw conflict("幂等键已用于其他清点内容"); + return detail(customerId, transferNo); + } + if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("转运单已结束入库测量"); + if (cartons.stream().anyMatch(box -> box.getCountedAt() != null)) throw conflict("本单已完成清点,请刷新查看结果"); + if (cartons.isEmpty()) throw conflict("转运单缺少箱号预报"); + if (request.boxes().size() != transfer.getPackageCount() && !StringUtils.hasText(request.discrepancyReason())) + throw badRequest("实收箱数与预报不一致时必须填写差异原因"); + Map byMark = cartons.stream().collect(Collectors.toMap(WarehouseTransferCarton::getCartonMark, Function.identity())); + Map byItem = transferItems.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferItem::getCustomerId, customerId).eq(WarehouseTransferItem::getTransferOrderId, transfer.getId())) + .stream().collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); + List countedPallets = transferPallets.listOrder(customerId, transfer.getId()); + if (countedPallets.stream().anyMatch(pallet -> request.boxes().stream().noneMatch(box -> pallet.getId().equals(box.transferPalletId())))) + throw badRequest("每个托盘必须至少分配一个实收箱,请核对箱子所属托盘"); + Map byPallet = countedPallets.stream().collect(Collectors.toMap(WarehouseTransferPallet::getId, Function.identity())); + Set seen = new java.util.HashSet<>(); + for (var input : request.boxes()) { + var targetPallet = byPallet.get(input.transferPalletId()); + if (targetPallet == null || targetPallet.getWarehousePalletId() == null + || !Set.of("GENERATED", "BOUND").contains(targetPallet.getStatus())) + throw badRequest("请先创建本单托盘号,再选择箱子所属托盘"); + var carton = byMark.get(input.cartonMark()); + if (StringUtils.hasText(input.cartonMark()) && (carton == null || !seen.add(input.cartonMark()))) + throw badRequest("箱号不属于本单或重复填写"); + if (carton == null && input.transferItemId() == null) throw badRequest("新增实收箱必须选择本单申报明细"); + var item = byItem.get(carton == null ? input.transferItemId() : carton.getTransferItemId()); + if (item == null || !item.getSkuCode().equals(input.skuCode())) throw badRequest("箱内 SKU 必须与该箱申报明细一致"); + if (input.quantity() <= 0 || input.quantity() > Integer.MAX_VALUE || input.length() == null || input.width() == null + || input.height() == null || input.weight() == null || input.length().signum() <= 0 || input.width().signum() <= 0 + || input.height().signum() <= 0 || input.weight().signum() <= 0) throw badRequest("每箱必须填写有效尺寸、重量和实点件数"); + } + LocalDateTime now = LocalDateTime.now(); + for (var carton : cartons) { + if (seen.contains(carton.getCartonMark())) continue; + carton.setStatus("NOT_RECEIVED"); carton.setCountedAt(now); carton.setCountedBy(operatorId); + carton.setCountIdempotencyKey(request.idempotencyKey()); carton.setCountRequestHash(requestHash); + carton.setDiscrepancyReason(trim(request.discrepancyReason())); carton.setUpdatedAt(now); transferCartons.updateById(carton); + } + int nextIndex = cartons.stream().mapToInt(WarehouseTransferCarton::getCartonIndex).max().orElse(0); + for (var input : request.boxes()) { + var carton = byMark.get(input.cartonMark()); + boolean added = carton == null; + if (added) { + carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId()); + carton.setTransferOrderId(transfer.getId()); carton.setTransferItemId(input.transferItemId()); carton.setCustomerId(customerId); + carton.setCartonIndex(++nextIndex); carton.setTotalCartons(transfer.getPackageCount()); + carton.setCartonMark(transferNo + "#EX-" + nextIndex); carton.setCreatedAt(now); + } + carton.setTransferPalletId(input.transferPalletId()); + carton.setLengthCm(input.length()); carton.setWidthCm(input.width()); carton.setHeightCm(input.height()); + carton.setGrossWeightKg(input.weight()); carton.setCountedQuantity(input.quantity()); + carton.setCountedBy(operatorId); carton.setCountedAt(now); carton.setStatus("COUNTED"); + carton.setCountIdempotencyKey(request.idempotencyKey()); carton.setCountRequestHash(requestHash); carton.setUpdatedAt(now); + carton.setDiscrepancyReason(trim(request.discrepancyReason())); + if (added) transferCartons.insert(carton); else transferCartons.updateById(carton); + } + // Aggregate only the submitted, physically assigned boxes; never infer SKU placement from declaration order. + for (var pallet : countedPallets) { + var assigned = request.boxes().stream().filter(box -> pallet.getId().equals(box.transferPalletId())).toList(); + pallet.setCartonCount(assigned.size()); + pallet.setVolumeCbm(assigned.stream().map(box -> box.length().multiply(box.width()).multiply(box.height()) + .movePointLeft(6)).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(6, java.math.RoundingMode.HALF_UP)); + pallet.setGrossWeightKg(assigned.stream().map(WarehouseTransferRequests.CountCarton::weight).reduce(BigDecimal.ZERO, BigDecimal::add)); + pallet.setUpdatedAt(now); transferPallets.updateById(pallet); + } + return detail(customerId, transferNo); + } + + private List requireCountedCartons(WarehouseTransferOrder transfer) { + var cartons = transferCartons.listOrder(transfer.getCustomerId(), transfer.getId()); + if (cartons.isEmpty() || cartons.stream().anyMatch(box -> box.getCountedAt() == null) + || cartons.stream().noneMatch(box -> "COUNTED".equals(box.getStatus()))) + throw conflict("请先完成全部箱子的尺寸测量和箱内清点"); + return cartons.stream().filter(box -> "COUNTED".equals(box.getStatus())).toList(); + } + private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) { WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); if (transfer == null) throw notFound("Warehouse transfer order not found"); @@ -818,6 +915,7 @@ public class WarehouseTransferService { if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) { throw conflict("Warehouse transfer is not awaiting PDA receiving"); } + List countedCartons = requireCountedCartons(transfer); List items = transferItems.selectList(new LambdaQueryWrapper() .eq(WarehouseTransferItem::getCustomerId, customerId) .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)); @@ -838,8 +936,10 @@ public class WarehouseTransferService { if (persistedPallets.isEmpty() || persistedPallets.stream().anyMatch(value -> !"BOUND".equals(value.getStatus()))) { throw conflict("Every pallet must be generated and scan-bound before completing palletization"); } + if (persistedPallets.stream().anyMatch(pallet -> pallet.getCartonCount() == null || pallet.getCartonCount() <= 0)) throw conflict("每个托盘必须至少分配一个实收箱"); int palletCartons = persistedPallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum(); - List derivedAllocations = derivePersistedPalletAllocations(items, persistedPallets); + if (palletCartons != countedCartons.size()) throw conflict("打托箱数必须与已清点实收箱数一致"); + List derivedAllocations = deriveCountedPalletAllocations(countedCartons, persistedPallets); LocalDateTime now = LocalDateTime.now(); List completedPalletItems = new ArrayList<>(); for (int index = 0; index < persistedPallets.size(); index++) { @@ -1452,29 +1552,20 @@ public class WarehouseTransferService { return result; } - static List derivePersistedPalletAllocations(List items, - List pallets) { - if (items == null || items.isEmpty()) throw conflict("Warehouse transfer declared cargo is incomplete"); + static List deriveCountedPalletAllocations(List cartons, + List pallets) { List result = new ArrayList<>(); - int itemIndex = 0; - int itemRemaining = items.get(0).getPackageCount(); - for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) { - int palletRemaining = pallets.get(palletIndex).getCartonCount(); - while (palletRemaining > 0) { - WarehouseTransferItem item = items.get(itemIndex); - if (itemIndex + 1 == items.size()) { - result.add(new DerivedPalletAllocation(palletIndex, item.getId(), palletRemaining)); - break; - } - int allocated = Math.min(palletRemaining, itemRemaining); - result.add(new DerivedPalletAllocation(palletIndex, item.getId(), allocated)); - palletRemaining -= allocated; - itemRemaining -= allocated; - if (itemRemaining == 0 && itemIndex + 1 < items.size()) { - itemIndex++; - itemRemaining = items.get(itemIndex).getPackageCount(); - } - } + Set palletIds = pallets.stream().map(WarehouseTransferPallet::getId).collect(Collectors.toSet()); + if (cartons.stream().anyMatch(box -> box.getTransferPalletId() == null || !palletIds.contains(box.getTransferPalletId()))) + throw conflict("每个实收箱必须分配到本单托盘"); + for (int index = 0; index < pallets.size(); index++) { + var pallet = pallets.get(index); + Map quantities = new java.util.LinkedHashMap<>(); + cartons.stream().filter(box -> pallet.getId().equals(box.getTransferPalletId())) + .forEach(box -> quantities.merge(box.getTransferItemId(), 1, Integer::sum)); + if (quantities.values().stream().mapToInt(Integer::intValue).sum() != pallet.getCartonCount()) + throw conflict("托盘箱数与分配的实收箱不一致"); + for (var entry : quantities.entrySet()) result.add(new DerivedPalletAllocation(index, entry.getKey(), entry.getValue())); } return result; } @@ -1796,18 +1887,17 @@ public class WarehouseTransferService { 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(plan.getId()); allocation.setLineId(line.getId()); - allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO); - allocation.setPlannedPackageCount(sourceItem.getPackageCount()); allocation.setCountedPackageCount(sourceItem.getPackageCount()); - // PDA 已完成测量、打托并确认了本托箱数;入仓计划列表的“实际拣货数” - // 直接汇总该字段,不能再保留初始化时的 0。 - allocation.setPickedPackageCount(sourceItem.getPackageCount()); - allocation.setActualVolumeCbm(source.getVolumeCbm()); - allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation); - } + // Plan allocations are unique by inbound line + pallet. SKU breakdown + // belongs to transfer pallet items and must not duplicate that allocation. + int receivedPackages = palletItems.stream().filter(row -> row.getTransferPalletId().equals(source.getId())) + .mapToInt(WarehouseTransferPalletItem::getPackageCount).sum(); + if (receivedPackages != source.getCartonCount()) throw conflict("托盘明细箱数与实收不一致"); + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); + allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId()); + allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO); + allocation.setPlannedPackageCount(receivedPackages); allocation.setCountedPackageCount(receivedPackages); + allocation.setPickedPackageCount(receivedPackages); allocation.setActualVolumeCbm(source.getVolumeCbm()); + allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation); } plan.setAssignedFlag(true); plan.setStatus("COMPLETED"); plan.setPalletCount(pallets.size()); plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) @@ -1879,6 +1969,7 @@ public class WarehouseTransferService { item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId); item.setSkuCode(Objects.toString(trim(source.skuCode()), "UNSPECIFIED")); item.setProductNameZh(Objects.toString(trim(source.productNameZh()), "仓储转运货物")); + item.setDeclaredQuantity(source.quantity()); item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now); transferItems.insert(item); return item.getId(); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 23942d34e..8dae55487 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -3069,7 +3069,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''换标完成时间'' AFTER relabel_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item ( id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0) + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量'; -- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。 CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement ( @@ -4005,6 +4005,37 @@ CREATE TABLE ll_selfop_product ( KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; +-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。 +USE baiyue_lastleg; +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='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; + +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='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; + +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='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; + +-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数''')); +PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt; + -- 历史库存基线;执行前必须取得指定环境的建表授权。 CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow ( id BIGINT NOT NULL COMMENT '应用生成的流水记录标识', diff --git a/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml b/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml index 34c86cf5b..07d151e04 100644 --- a/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml @@ -26,9 +26,27 @@ info: The application may select a subset of available cartons. PDA must finish every selected carton; short actual quantities are rejected. At least one photo remains required. Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks. + PDA completion leaves the application in PDA_COMPLETED (waiting for customer confirmation). + Only the authenticated merchant may confirm completion after reviewing submitted PDA photos. + Confirmation changes the application to COMPLETED, records customerConfirmedAt, and never deducts + inventory again. Identical repeated confirmation preserves the first timestamp and history. + Platform users cannot close the application on behalf of the customer. security: - bearerAuth: [] paths: + /api/merchant/v1/last-leg/cargo-discards/{discardNo}/confirm-completion: + parameters: + - $ref: '#/components/parameters/DiscardNo' + post: + summary: 客户确认废弃完结 + description: >- + Customer scope is taken only from the authenticated merchant token. + Requires PDA_COMPLETED; repeated COMPLETED calls return the existing detail. + No request customerId, stock adjustment, or new photo upload is accepted. + The client shows submitted PDA photos before the customer confirms. + x-permissions: [merchant:overseas-warehouse:discard:confirm] + responses: + '200': { description: ApiResponse with updated Detail; summary contains customerConfirmedAt } /api/pda/v1/last-leg/cargo-discards/{discardNo}/pallets/{palletNo}/photos/files: parameters: - $ref: '#/components/parameters/DiscardNo' 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 aed55f5ee..bcbb2fb1c 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -4,6 +4,67 @@ info: description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。 version: 1.0.0 paths: + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/carton-label: + get: + summary: PDA读取转运箱标签 + description: 需要 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR;只可读取Token绑定仓的转运单标签,包含新增实收箱号。 + parameters: + - {name: transferNo, in: path, required: true, schema: {type: string}} + responses: + '200': {description: 统一业务响应,成功data为包含printableHtml的箱标签} + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/drafts: + post: + summary: 先创建转运签收托盘草稿 + description: 仅绑定仓PDA可操作,提交幂等键创建空托盘草稿,再调用generate生成实物托盘号。不接收手工箱数、尺寸、体积和重量,箱子清点时按归属自动汇总。 + parameters: + - {name: transferNo, in: path, required: true, schema: {type: string}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [idempotencyKey] + properties: + palletId: {type: integer, format: int64, nullable: true} + idempotencyKey: {type: string, maxLength: 255} + responses: + '200': {description: 统一业务响应,data为含空托盘草稿的转运详情} + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/cartons/count: + post: + summary: 转运逐箱测量与清点 + description: 仅限 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR,绑定仓由统一Token确定。先创建本单托盘号,每箱选择所属托盘并按申报SKU记录实点件数,托盘体积重量由分配箱子汇总,全部实收箱子测量清点后才可完成打托入库,打托箱数与已清点实收箱数核平;短收箱保留未收到事实,超收生成系统箱号;差异需原因。事务锁定转运单;原键原载荷重试不重复写入,同键不同载荷拒绝,已确认事实不可覆盖。 + parameters: + - {name: transferNo, in: path, required: true, schema: {type: string}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [idempotencyKey, boxes] + properties: + idempotencyKey: {type: string, maxLength: 255} + discrepancyReason: {type: string, maxLength: 1000, description: 短收超收时必填差异原因} + boxes: + type: array + minItems: 1 + maxItems: 5000 + items: + type: object + required: [transferPalletId, length, width, height, weight, skuCode, quantity] + properties: + cartonMark: {type: string, maxLength: 255, nullable: true, description: 已有箱号;新增超收箱不传,由服务端生成} + transferItemId: {type: integer, format: int64, nullable: true, description: 新增超收箱所属本单申报明细} + transferPalletId: {type: integer, format: int64, description: 本单已生成的转运托盘事实ID,逐箱关联,托盘箱数体积重量由箱子汇总} + length: {type: number, minimum: 0.01, description: 箱长厘米} + width: {type: number, minimum: 0.01, description: 箱宽厘米} + height: {type: number, minimum: 0.01, description: 箱高厘米} + weight: {type: number, minimum: 0.01, description: 箱重千克} + skuCode: {type: string, maxLength: 255} + quantity: {type: integer, minimum: 1, maximum: 2147483647, description: 箱内实点件数} + responses: + '200': {description: 统一业务响应,成功data为转运详情,cartons含逐箱尺寸重量件数及清点操作人时间} /api/platform/v1/last-leg/inventory/balances/{balanceId}: get: summary: 查询单条库存余额详情 @@ -1214,7 +1275,7 @@ paths: responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } } post: summary: 客户创建仓储转运单 - description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。 + description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。货物 items[].quantity 为 SKU 申报件数(正整数),items[].packageCount 为预计箱数,整单 packageCount 等于明细预计箱数合计;件数与箱数独立,实际签收箱数和每箱件数以 PDA 清点事实为准,支持短收、超收并记录差异原因。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。 security: [{ bearerAuth: [] }] responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } } /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}: @@ -1883,6 +1944,15 @@ components: description: 保留原转运详情字段;新增持久实收命令投影供平台与当前客户恢复。成功实收返回本地事务完成后的命令状态,不暴露内部资源锁或reservationKey。 required: [receiptCommands] properties: + packageCount: { type: integer, description: 下单预计箱数,不随实际签收覆盖 } + items: + type: array + items: + type: object + properties: + quantity: { type: integer, format: int64, nullable: true, description: SKU 申报件数,历史未申报为 null } + packageCount: { type: integer, description: 该 SKU 预计箱数 } + cartons: { type: array, items: { type: object }, description: 实际签收仅统计状态 COUNTED 的箱子 } receiptCommands: type: array items: { $ref: '#/components/schemas/WarehouseTransferReceiptCommand' } 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 2b26d573e..3ecfe4615 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 @@ -672,6 +672,16 @@ class DeliveryTransferServiceTest { assertEquals(4, detail.pallets().get(0).packageCount()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"DISCARD_OUTBOUND", "ONE_CLICK_DROPSHIP"}) + void discardDoesNotExposeHistoricInboundCodeAsLxButDropshipKeepsRealLx(String type) { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "outbound-301"); + outbound.setOutboundType(type); outbound.setLxCodes("LX-REAL-001"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + var detail = service.outboundDetail("OUT-301"); + assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes()); + } + @Test void dispatchOutboundStillRequiresItsDeliveryDocument() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java index 9d4a5bd77..126566500 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java @@ -3,10 +3,51 @@ package com.baiyue.lastleg.discard; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; +import java.sql.DriverManager; +import java.sql.SQLException; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import org.apache.ibatis.annotations.Update; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; class CargoDiscardSchemaContractTest { + @Test + void returnCartonDeductionAllowsZeroButRejectsNegativeStock() throws Exception { + String baseline; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + var constraint = java.util.regex.Pattern.compile( + "CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK\\(([^)]+)\\)") + .matcher(baseline); + assertTrue(constraint.find()); + String deduction = ReturnInventoryCartonItemMapper.class.getMethod("deduct", long.class, long.class) + .getAnnotation(Update.class).value()[0] + .replace("#{quantity}", "?").replace("#{id}", "?"); + try (var connection = DriverManager.getConnection("jdbc:h2:mem:discard_zero_quantity;MODE=MySQL")) { + try (var statement = connection.createStatement()) { + statement.execute("CREATE TABLE ll_return_inventory_carton_item (id BIGINT PRIMARY KEY, " + + "quantity BIGINT NOT NULL, CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(" + + constraint.group(1) + "))"); + statement.execute("INSERT INTO ll_return_inventory_carton_item VALUES (1, 3)"); + try (var update = connection.prepareStatement(deduction)) { + update.setLong(1, 3); update.setLong(2, 1); update.setLong(3, 3); + assertEquals(1, update.executeUpdate()); + update.setLong(1, 1); update.setLong(3, 1); + assertEquals(0, update.executeUpdate()); + } + try (var result = statement.executeQuery("SELECT quantity FROM ll_return_inventory_carton_item WHERE id=1")) { + assertTrue(result.next()); + assertEquals(0, result.getLong(1)); + } + assertThrows(SQLException.class, () -> statement.executeUpdate( + "UPDATE ll_return_inventory_carton_item SET quantity=-1 WHERE id=1")); + } + } + } + @Test void baselineContainsDiscardApplicationReservationAndHistoryTables() throws Exception { String sql; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java index 1c37a78ed..d61b43786 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java @@ -15,6 +15,22 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class MerchantCargoDiscardControllerTest { + @Test + void completionUsesAuthenticatedCustomerAndIndependentPermission() throws Exception { + controller.confirmCompletion(user(100L, "MERCHANT"), "CD001"); + verify(service).confirmCompletion("100", "CD001", "1"); + assertThrows(ResponseStatusException.class, + () -> controller.confirmCompletion(user(0L, "PLATFORM"), "CD001")); + var guard = MerchantCargoDiscardController.class.getMethod("confirmCompletion", AuthenticatedUser.class, String.class) + .getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class); + org.junit.jupiter.api.Assertions.assertEquals( + "hasAuthority('merchant:overseas-warehouse:discard:confirm')", guard.value()); + org.junit.jupiter.api.Assertions.assertFalse(java.util.Arrays.stream(CargoDiscardController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)) + .flatMap(method -> java.util.Arrays.stream(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).value())) + .anyMatch(path -> path.endsWith("/complete") || path.endsWith("/confirm-completion"))); + } + private final CargoDiscardService service = mock(CargoDiscardService.class); private final MerchantCargoDiscardController controller = controller(); private final CargoDiscardRequests.Query query = new CargoDiscardRequests.Query( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java index a285eabc8..dc5997d36 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java @@ -43,6 +43,7 @@ class CargoDiscardOutboundServiceTest { application = new CargoDiscardApplication(); application.setId(1L); application.setCustomerId("C1"); application.setDiscardNo("CD-TEST"); application.setOverseasWarehouseId("99"); application.setTotalPackages(5); application.setStatus("APPROVED"); + application.setInboundCodes("RT-123,RK-456"); first = line(11L, 21L, 3); second = line(12L, 22L, 2); when(items.listApplication("C1", 1L)).thenReturn(List.of(first, second)); when(cartons.listApplication("C1", 1L)).thenReturn(List.of()); @@ -63,6 +64,7 @@ class CargoDiscardOutboundServiceTest { @Test void approvalCreatesExactlyOneDocumentAndImmutablePlannedLines() { service.create(application, "OP"); service.create(application, "OP"); assertEquals("DISCARD_OUTBOUND", document.getOutboundType()); assertEquals("WAIT_PICK", document.getStatus()); + assertNull(document.getLxCodes()); assertEquals(5, document.getPackageCount()); assertEquals(2, document.getPalletCount()); assertTrue(service.outboundNo(application).startsWith("OB-")); assertEquals(List.of(3, 2), snapshots.stream().map(OutboundPalletItem::getPackageCount).toList()); @@ -82,6 +84,13 @@ class CargoDiscardOutboundServiceTest { service.create(application, "OP"); application.setStatus("CANCELLED"); service.sync(application); assertEquals("CANCELLED", document.getStatus()); assertTrue(snapshots.stream().allMatch(s -> "CANCELLED".equals(s.getStatus()))); } + @Test void syncClearsHistoricallyMislabelledInboundCodesWithoutChangingSourceSnapshots() { + first.setInboundCode("RK-456"); + service.create(application, "OP"); document.setLxCodes("RT-123,RK-456"); + service.sync(application); + assertNull(document.getLxCodes()); assertEquals("RK-456", snapshots.get(0).getInboundCode()); + assertEquals("RT-123,RK-456", application.getInboundCodes()); + } @Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() { first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON"); var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index aa57c70e4..6ee27dd9f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -47,6 +47,36 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CargoDiscardServiceTest { + @Test + void customerConfirmationClosesPdaCompletedApplicationOnceWithoutAnotherDeduction() { + CargoDiscardApplication application = new CargoDiscardApplication(); + application.setId(1001L); application.setCustomerId("100"); application.setDiscardNo("CD001"); + application.setTotalPackages(1); application.setOverseasWarehouseName("测试仓"); application.setStatus(CargoDiscardService.PDA_COMPLETED); + storedApplication.set(application); + when(applications.lockNo("100", "CD001")).thenReturn(application); + + service.confirmCompletion("100", "CD001", "CUSTOMER"); + var confirmedAt = application.getCustomerConfirmedAt(); + org.junit.jupiter.api.Assertions.assertNotNull(confirmedAt); + assertEquals(CargoDiscardService.COMPLETED, application.getStatus()); + assertEquals("客户确认废弃完成", storedHistories.get(0).getRemark()); + service.confirmCompletion("100", "CD001", "CUSTOMER"); + assertEquals(confirmedAt, application.getCustomerConfirmedAt()); + assertEquals(1, storedHistories.size()); + verify(applications, times(1)).updateById(application); + org.mockito.Mockito.verifyNoInteractions(adjustments, inventory, returnCartonItems); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"PENDING_REVIEW", "APPROVED", "REJECTED", "CANCELLED"}) + void customerCannotConfirmBeforePdaCompletion(String status) { + CargoDiscardApplication application = new CargoDiscardApplication(); application.setStatus(status); + when(applications.lockNo("100", "CD001")).thenReturn(application); + org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.confirmCompletion("100", "CD001", "CUSTOMER")); + verify(applications, never()).updateById(any(CargoDiscardApplication.class)); + } + @Mock CargoDiscardApplicationMapper applications; @Mock CargoDiscardItemMapper items; @Mock CargoDiscardCartonMapper discardCartons; @@ -57,6 +87,7 @@ class CargoDiscardServiceTest { @Mock LastlegOrderStatusHistoryMapper orderHistories; @Mock ReturnInventoryCartonMapper returnCartons; @Mock ReturnInventoryCartonItemMapper returnCartonItems; + @Mock com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient warehouseDirectory; private CargoDiscardService service; private final CargoDiscardOutboundService outboundService = org.mockito.Mockito.mock(CargoDiscardOutboundService.class); @@ -68,6 +99,9 @@ class CargoDiscardServiceTest { @BeforeEach void setUp() { service = new CargoDiscardService(); + ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); + lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of( + new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE")))); ReflectionTestUtils.setField(service, "outboundService", outboundService); ReflectionTestUtils.setField(service, "stockService", org.mockito.Mockito.mock(CargoDiscardStockService.class)); ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class)); @@ -97,6 +131,7 @@ class CargoDiscardServiceTest { @Test void createReservesExactSkuCartonsWithoutDeductingInventory() { LastlegOrder order = order(); + order.setOverseasWarehouseId("99"); order.setOverseasWarehouseName(null); DropshipSkuInventory sku = skuInventory(); when(orders.selectById(101L)).thenReturn(order); when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(2L); @@ -109,6 +144,7 @@ class CargoDiscardServiceTest { List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged")); assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status()); + assertEquals("洛杉矶仓", storedApplication.get().getOverseasWarehouseName()); assertEquals(2, detail.summary().totalPackages()); assertEquals(new BigDecimal("10.00"), detail.summary().totalFee()); assertEquals(2, storedLinks.size()); @@ -116,6 +152,14 @@ class CargoDiscardServiceTest { verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt()); } + @Test void historicalDiscardDetailResolvesMissingWarehouseNameByOperatingWarehouseId() { + CargoDiscardApplication application = pendingApplication(); + application.setOverseasWarehouseId("99"); application.setOverseasWarehouseName(null); + storedApplication.set(application); + assertEquals("洛杉矶仓", service.detail("100", application.getDiscardNo()).summary().overseasWarehouseName()); + verify(applications, never()).updateById(any(CargoDiscardApplication.class)); + } + @Test void selectedBoxesCreatePartialApplicationWithoutReservingUnselectedStock() { when(orders.selectById(101L)).thenReturn(order()); @@ -159,7 +203,7 @@ class CargoDiscardServiceTest { ReturnInventoryCarton carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100"); - carton.setOverseasWarehouseId("WH-1"); + carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("RT-20260928-001"); carton.setCartonMark("RC-LAX-001"); carton.setStatus("AVAILABLE"); @@ -176,6 +220,7 @@ class CargoDiscardServiceTest { assertEquals(1, result.size()); assertEquals("RETURN_CARTON", result.get(0).sourceType()); + assertEquals("洛杉矶仓", result.get(0).overseasWarehouseName()); assertEquals(901L, result.get(0).returnCartonId()); assertEquals("RC-LAX-001", result.get(0).cartonMark()); assertEquals(4, result.get(0).availablePackages()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java index 3f680f3cb..1e6d7df3c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java @@ -18,6 +18,16 @@ class WarehouseTransferCargoValidationTest { } } + @Test + void skuUnitsAndExpectedCartonsAreIndependent() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + assertTrue(validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 3, null, 120L)).isEmpty()); + assertTrue(!validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 3, null, 0L)).isEmpty()); + assertTrue(!validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 0, null, 120L)).isEmpty()); + } + } + @Test void cartonRelabelAllowsOriginalSkuToBeOmitted() { try (var factory = Validation.buildDefaultValidatorFactory()) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java new file mode 100644 index 000000000..f2739a2af --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -0,0 +1,108 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.entity.*; +import com.baiyue.lastleg.transfer.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class WarehouseTransferCartonCountTest { + WarehouseTransferService service; + WarehouseTransferOrderMapper orders = mock(WarehouseTransferOrderMapper.class); + WarehouseTransferCartonMapper cartons = mock(WarehouseTransferCartonMapper.class); + WarehouseTransferItemMapper items = mock(WarehouseTransferItemMapper.class); + WarehouseTransferPalletMapper palletMapper = mock(WarehouseTransferPalletMapper.class); + WarehouseTransferPallet pallet; + WarehouseTransferOrder order; + WarehouseTransferCarton a, b; + @BeforeEach void setup() { + service = spy(new WarehouseTransferService()); + ReflectionTestUtils.setField(service, "transfers", orders); + ReflectionTestUtils.setField(service, "transferCartons", cartons); + ReflectionTestUtils.setField(service, "transferItems", items); + ReflectionTestUtils.setField(service, "transferPallets", palletMapper); + pallet = new WarehouseTransferPallet(); pallet.setId(200L); pallet.setStatus("GENERATED"); pallet.setWarehousePalletId(900L); + when(palletMapper.listOrder("C1", 1L)).thenReturn(List.of(pallet)); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + doReturn(null).when(service).detail(anyString(), anyString()); + order = new WarehouseTransferOrder(); order.setId(1L); order.setCustomerId("C1"); order.setTransferNo("WT1"); order.setStatus("WAIT_PDA_RECEIVING"); order.setPackageCount(2); + a = box("BOX1", 10L); b = box("BOX2", 11L); + when(orders.lockForPda("WT1", "77")).thenReturn(order); + when(cartons.listOrder("C1", 1L)).thenReturn(List.of(a,b)); + var ia = new WarehouseTransferItem(); ia.setId(10L); ia.setSkuCode("A"); + var ib = new WarehouseTransferItem(); ib.setId(11L); ib.setSkuCode("B"); + when(items.selectList(any())).thenReturn(List.of(ia,ib)); + } + WarehouseTransferCarton box(String mark, long item) { var box = new WarehouseTransferCarton(); box.setCartonMark(mark); box.setCartonIndex((int)item); box.setTransferItemId(item); return box; } + WarehouseTransferRequests.CountCarton input(String mark, String sku, long quantity) { return new WarehouseTransferRequests.CountCarton(mark, null, 200L, new BigDecimal("20"),new BigDecimal("30"),new BigDecimal("40"),new BigDecimal("2.5"),sku,quantity); } + WarehouseTransferRequests.CountCartons request(String key, WarehouseTransferRequests.CountCarton... rows) { return new WarehouseTransferRequests.CountCartons(key,List.of(rows),null); } + @Test void multiSkuBoxesPersistDimensionsPiecesAndReplayWithoutWriting() { + var request = request("K1",input("BOX1","A",12),input("BOX2","B",7)); + service.countCartons("OP1",77L,"WT1",request); + assertEquals(12L,a.getCountedQuantity()); assertEquals(7L,b.getCountedQuantity()); assertNotNull(a.getCountedAt()); assertEquals("OP1",a.getCountedBy()); assertEquals(new BigDecimal("2.5"),b.getGrossWeightKg()); + assertEquals(200L,a.getTransferPalletId()); assertEquals(2,pallet.getCartonCount()); + assertEquals(new BigDecimal("0.048000"),pallet.getVolumeCbm()); assertEquals(new BigDecimal("5.0"),pallet.getGrossWeightKg()); + clearInvocations(cartons); order.setStatus("WAIT_CUSTOMER_CONFIRMATION"); + service.countCartons("OP1",77L,"WT1",request); + verify(cartons,never()).updateById(any(WarehouseTransferCarton.class)); + assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K1",input("BOX1","A",13),input("BOX2","B",7)))); + } + @Test void missingDuplicateForeignSkuAndInvalidQuantityRejectBeforeWrites() { + var invalid = List.of(request("K",input("BOX1","A",1)),request("K",input("BOX1","A",1),input("BOX1","A",2)),request("K",input("OTHER","A",1),input("BOX2","B",2)),request("K",input("BOX1","B",1),input("BOX2","B",2)),request("K",input("BOX1","A",0),input("BOX2","B",2))); + for(var request:invalid) assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request)); + verify(cartons,never()).updateById(any(WarehouseTransferCarton.class)); + } + @Test void shortReceiptNeedsReasonAndPreservesMissingBoxFact() { + var request = new WarehouseTransferRequests.CountCartons("SHORT",List.of(input("BOX1","A",3)),"少到一箱"); + service.countCartons("OP1",77L,"WT1",request); + assertEquals("COUNTED",a.getStatus()); assertEquals("NOT_RECEIVED",b.getStatus()); + assertEquals("少到一箱",b.getDiscrepancyReason()); assertNull(b.getCountedQuantity()); + } + @Test void extraReceiptGeneratesOwnedBoxAndReconcilesToDeclaredSku() { + var extra = new WarehouseTransferRequests.CountCarton(null,11L,200L,new BigDecimal("10"),new BigDecimal("20"),new BigDecimal("30"),BigDecimal.ONE,"B",4); + service.countCartons("OP1",77L,"WT1",new WarehouseTransferRequests.CountCartons("EXTRA",List.of(input("BOX1","A",1),input("BOX2","B",2),extra),"多到一箱")); + var captured = org.mockito.ArgumentCaptor.forClass(WarehouseTransferCarton.class); + verify(cartons).insert(captured.capture()); + assertEquals("WT1#EX-12",captured.getValue().getCartonMark()); assertEquals("C1",captured.getValue().getCustomerId()); assertEquals(4L,captured.getValue().getCountedQuantity()); + } + @Test void cartonsRequireGeneratedPalletOwnedByThisOrder() { + when(palletMapper.listOrder("C1",1L)).thenReturn(List.of()); + assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2)))); + verify(cartons,never()).updateById(any(WarehouseTransferCarton.class)); + when(palletMapper.listOrder("C1",1L)).thenReturn(List.of(pallet)); pallet.setStatus("DRAFT"); + assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2)))); + } + @Test void multiSkuPalletCreatesOneInboundLineAllocationWithSummedBoxes() { + var plans = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper.class); + var lines = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper.class); + var pallets = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper.class); + var allocations = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper.class); + ReflectionTestUtils.setField(service,"inboundPlans",plans); ReflectionTestUtils.setField(service,"inboundPlanLines",lines); + ReflectionTestUtils.setField(service,"inboundPlanPallets",pallets); ReflectionTestUtils.setField(service,"inboundPlanAllocations",allocations); + order.setLastlegOrderId(9L); order.setInboundCode("WT1"); + var plan = new com.baiyue.lastleg.inboundplan.entity.InboundPlan(); plan.setId(100L); + var line = new com.baiyue.lastleg.inboundplan.entity.InboundPlanLine(); line.setId(101L); + when(plans.findBySourceOrder("C1",9L)).thenReturn(plan); when(lines.selectOne(any())).thenReturn(line); + var pallet = new WarehouseTransferPallet(); pallet.setId(200L); pallet.setCartonCount(3); pallet.setVolumeCbm(BigDecimal.ONE); pallet.setSequenceNo(1); + var first = new WarehouseTransferPalletItem(); first.setTransferPalletId(200L); first.setTransferItemId(10L); first.setPackageCount(1); + var second = new WarehouseTransferPalletItem(); second.setTransferPalletId(200L); second.setTransferItemId(11L); second.setPackageCount(2); + ReflectionTestUtils.invokeMethod(service,"completeTransferInboundPlan","C1",order,List.of(pallet),List.of(first,second),java.time.LocalDateTime.now()); + var result=org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation.class); + verify(allocations,times(1)).insert(result.capture()); assertEquals(3,result.getValue().getPickedPackageCount()); assertEquals(3,result.getValue().getCountedPackageCount()); + } + @Test void otherWarehouseCannotReadOrWriteAndCompletedCountsCannotBeOverwritten() { + assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",88L,"WT1",request("K",input("BOX1","A",1)))); + service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2))); + clearInvocations(cartons); + assertThrows(ResponseStatusException.class,()->service.countCartons("OP2",77L,"WT1",request("NEW",input("BOX1","A",9),input("BOX2","B",2)))); + verify(cartons,never()).updateById(any(WarehouseTransferCarton.class)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java index 1177c8a77..63ed154dc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java @@ -48,19 +48,17 @@ class WarehouseTransferPalletAllocationTest { } @Test - void persistedOverReceiptUsesTheSameStableAllocationRule() { - WarehouseTransferPallet first = persistedPallet(4); - WarehouseTransferPallet second = persistedPallet(3); - - var allocations = WarehouseTransferService.derivePersistedPalletAllocations( - List.of(item(11L, 3), item(12L, 2)), List.of(first, second)); - - assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum()); - assertEquals(3, allocations.stream().filter(value -> value.transferItemId() == 11L) - .mapToInt(value -> value.packageCount()).sum()); - assertEquals(4, allocations.stream().filter(value -> value.transferItemId() == 12L) - .mapToInt(value -> value.packageCount()).sum()); - assertEquals(List.of(0, 0, 1), allocations.stream().map(value -> value.palletIndex()).toList()); + void countedBoxesDetermineSkuPlacementRatherThanDeclarationOrder() { + WarehouseTransferPallet first = persistedPallet(2); first.setId(100L); + WarehouseTransferPallet second = persistedPallet(1); second.setId(200L); + var a = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); a.setTransferItemId(12L); a.setTransferPalletId(100L); + var b = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); b.setTransferItemId(12L); b.setTransferPalletId(100L); + var c = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); c.setTransferItemId(11L); c.setTransferPalletId(200L); + var allocations = WarehouseTransferService.deriveCountedPalletAllocations(List.of(a,b,c),List.of(first,second)); + assertEquals(List.of(new WarehouseTransferService.DerivedPalletAllocation(0,12L,2),new WarehouseTransferService.DerivedPalletAllocation(1,11L,1)),allocations); + c.setTransferPalletId(999L); + org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> WarehouseTransferService.deriveCountedPalletAllocations(List.of(a,b,c),List.of(first,second))); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 1462a23af..fe604c7ab 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -322,6 +322,10 @@ class WarehouseTransferServiceTest { WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); transfer.setOperatingWarehouseId("77"); WarehouseTransferPallet pallet = measuredPallet("GENERATED"); + var counted = java.util.stream.IntStream.rangeClosed(1, 5).mapToObj(index -> { + var box = new WarehouseTransferCarton(); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box; + }).toList(); + when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(counted); when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5))); WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto(); @@ -366,6 +370,10 @@ class WarehouseTransferServiceTest { new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false)); when(sourceOrders.selectList(any())).thenReturn(List.of()); + var counted = java.util.stream.IntStream.rangeClosed(1, 5).mapToObj(index -> { + var box = new WarehouseTransferCarton(); box.setTransferPalletId(pallet.getId()); box.setTransferItemId(item.getId()); box.setCartonIndex(index); box.setTotalCartons(5); box.setCartonMark("BOX-" + index); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box; + }).toList(); + when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(counted); var result = service.palletize("OP-1", 77L, "WTR-1", new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L))); diff --git a/scripts/README.md b/scripts/README.md index b875922f0..1db17b634 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,6 +2,8 @@ `../lastleg-service/scripts/selfoperated-test-env-acceptance.mjs`:自营统计 TEST Gateway 验收,默认仅输出计划;显式 `--execute --environment TEST --source-account <账号键> --warehouse <仓库编码> --other-warehouse <第二仓> --report <报告路径>` 才登录并查询。凭据由运行者通过脚本之前的 `node --env-file=<明确TEST文件>` 注入,需平台、只读平台、无权限平台及客户身份。`--probe-off-command` 仅在确认 OFF 后提交预期被拒绝的命令并核对无新增任务;不采集、发布、造数或写 SQL。`--synthetic-fixture <审核后的说明文件>` 验证合成样本,不能证明真实来源覆盖。报告脱敏记录各项结果及请求标识;失败保留报告,重跑使用新报告路径并重新登录,结束退出自身会话,不清理业务样本。离线合同为同目录 `selfoperated-test-env-acceptance.test.mjs`。 +`db/cargo-discard-consolidated-incremental-20261014.sql`:废弃业务结构增量汇总,MySQL 8.0.16+,仅对 `baiyue_lastleg` 已有业务库执行。覆盖废弃基础表、PDA完工字段、照片关联、退货箱废弃状态、通用库存来源和部分选箱;表/列/索引按存在性判断,约束原子替换,可重复执行。不回填历史订单、不补造出库单、不修改库存数量,无业务 DML;File及仓储资源库无需本次SQL。实例和凭据由执行者指定,完整执行、遇错停止、不使用 `--force`;DDL隐式提交,修正错误后可重跑,末尾 `SHOW CREATE TABLE` 结果由执行者保存核对,不自动清理或连接环境。 + `db/return-consolidated-incremental-20261012.sql`:10月10日/12日退货、签收、清点、换标与按件出库增量汇总,显式 `USE baiyue_lastleg`,MySQL 8.0.16+ 已有业务库可重复执行。本轮补齐签收原包装、尺寸重量来源及库存箱原包装三个字段;逐列判断存在性,不回填签收/清点事实,不改库存数量。连接实例及凭据由执行者明确指定,整份执行、遇错停止;保留执行错误,修复后可重跑,末尾输出字段、索引与约束供核对。托盘重量、体积汇总是应用逻辑,不新增资源库字段;不自动执行到测试或生产环境。 `procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。 @@ -506,3 +508,13 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e `f32-pda-hold-race.mjs` 是显式 TEST 暂扣/PDA拣货并发验收入口,离线合同 `f32-pda-hold-race.test.mjs`。命令为 `node --env-file="" --env-file="" --env-file="<本轮自有PDA配置>" scripts/f32-pda-hold-race.mjs --plan <冻结计划> --report <全新报告> --confirm-test-runtime yes`;只消费已实际入仓、4箱可用、无占用、调度审核通过且同仓PDA canPick=true的本轮独立样本,不创建订单或车次。凭据只读明确TEST/Jenkins/自有PDA配置,不写报告;计划冻结客户、来源订单、仓、余额、位置、排车明细、车次、PDA成员和不可变Lastleg镜像ID。通过Jenkins在指定TEST Lastleg所用MySQL建立仅针对原订单的12秒行锁租约(SELECT FOR UPDATE、固定等待、ROLLBACK,无业务DML),用performance_schema实际锁等待证明两条正式HTTP命令同时竞争,再按暂扣先到/拣货先到两种顺序验证。会真实暂扣、解除本轮暂扣和拣货1箱,逐位置、库存流水、拣货及来源归属、原键重放分别核对;两个阶段顺序执行,不并发造数。结束恢复自有PDA停用状态并退出认证会话,测试单据、部分拣货和资源保留。命令结果未知或断言失败时保存原报告及逐步checkpoint,不自动重发命令、重建样本、删数据或回填SQL;租约自动释放后先核对原单和原命令,未证实的暂扣恢复必须单独按原事实处理。离线通过不代表实际MySQL/PDA或四端页面通过。 - `db/return-package-packing-boundary-incremental-20261010.sql`:退货包裹/箱出库及标签字段增量,显式选定 baiyue_lastleg 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。 + +废弃明细字段专项修复:db/cargo-discard-item-columns-repair-20261014.sql。仅对 MySQL 8.0.16+ 的 baiyue_lastleg 已有 ll_cargo_discard_item 表补齐 10 个新增字段,逐列判断,可重复执行,独立于退货约束。实例和凭据由执行者指定;不写业务数据,不修改库存,不自动访问环境。完整执行、遇错停止,DDL隐式提交,修正错误后可重跑;末尾只读查询应返回10行,由执行者保存结果,无自动清理。 + +废弃退货箱归零约束修复:db/cargo-discard-return-quantity-repair-20261014.sql。仅 baiyue_lastleg,MySQL 8.0.16+,将箱内SKU数量约束改为非负以支持整箱废弃。实例及凭据由执行者指定;可重复执行,原子替换约束,无业务DML,不连接环境自动执行。整份执行、遇错停止,DDL隐式提交,修正后可重跑;末尾输出表结构供保存核对,不清理业务数据。正式增量为 deploy/sql/migrations/overseas/baiyue_lastleg/V26__allow_zero_return_carton_item_quantity.sql,已纳入废弃汇总脚本。 + +客户废弃照片确认增量:db/cargo-discard-customer-confirmation-incremental-20261014.sql。MySQL 8.0.16+,目标明确为 baiyue_lastleg 和 baiyue_iam;连接实例与凭据由执行者指定。补齐客户确认时间和独立确认权限,默认客户管理员获得授权,自定义角色需手动分配;授权与受影响管理员的安全版本在同一事务提交,重跑安全版本仍单调递增。完整执行、遇错停止、修正后可重跑;不确认或回填历史单据,不修改库存,不自动访问环境或清理。执行结果由操作者保存,权限更新后需重新登录客户账号。对应正式迁移为 Lastleg V27 和 IAM V6;未执行时页面权限与服务字段尚不可用。 + +`db/warehouse-transfer-carton-counting-incremental.sql`:仓储转运逐箱测量与清点增量,显式 USE baiyue_lastleg;逐列检查并新增箱子尺寸、重量、实点件数、清点操作人、时间、幂等字段、箱所属转运托盘ID及独立SKU申报件数 declared_quantity,可重复执行,无业务DML、不回填历史事实。适用于明确授权的本地或开发MySQL8,凭据由操作者外部提供;失败保留原错误,修复后重跑,不清理单据。已同步尾程基线和海外统一增量。 + +`local-warehouse-transfer-carton-acceptance.mjs`:仅本机统一Gateway的仓储转运逐箱验收;命令前显式 `node --env-file=<本地配置文件>`,必须提供 ADMIN_TEST_GATEWAY、ADMIN_TEST_USERNAME/PASSWORD、LOCAL_TRANSFER_PDA_PASSWORD,可选 OVERSEAS_TEST_CUSTOMER_ID、TRANSFER_CARTON_OUTPUT、TRANSFER_KEEP_PDA。复用既有有效客户和海外仓,以业务HTTP创建4笔带批次标识的新单,覆盖单箱、多SKU分箱多托、短收及超收、SKU申报件数与预计箱数独立持久化且PDA实收不覆盖原申报、先生成多托盘再逐箱选择托盘、箱级测量清点及托盘体积重量自动汇总、错误SKU/零件数/缺少差异原因拒绝、幂等重放、箱标签、打托绑定、测试照片和入库完成回查。测试照片为合成PNG,不证明真机拍照或物理打印。创建本轮PDA账号,默认finally停用;仅显式 TRANSFER_KEEP_PDA=true 时保留供本轮页面验收后人工停用。不改主档、不写业务数据库、不删除单据;报告不保存凭据或Token,失败保留已创建单号,排查后用新批次重跑,不自动清库或续跑既有单。 diff --git a/scripts/db/cargo-discard-consolidated-incremental-20261014.sql b/scripts/db/cargo-discard-consolidated-incremental-20261014.sql new file mode 100644 index 000000000..4e304ec65 --- /dev/null +++ b/scripts/db/cargo-discard-consolidated-incremental-20261014.sql @@ -0,0 +1,190 @@ +-- 货物废弃增量汇总(2026-10-14),MySQL 8.0.16+,可重复执行。 +-- 目标仅 baiyue_lastleg,连接实例和凭据由执行者指定。 +-- 前提:已有尾程入仓箱、退货库存及出库基线;不是空库初始化脚本。 +-- 覆盖基础废弃表、PDA完工、退货箱废弃、照片、通用库存及部分选箱。 +-- 不回填历史单据、不补造出库单、不修改库存数量、不执行业务DML。 +-- File复用已有文件表,无需修改 baiyue_file 或仓储资源库。 +-- 完整执行,遇错停止,不使用 --force;DDL隐式提交,失败修正后可重跑。 +-- 已有同名表/列/索引沿用,不修复任意未知旧结构,末尾输出结构供核对。 +USE `baiyue_lastleg`; +SET NAMES utf8mb4; +SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port, VERSION() AS mysql_version; + +-- 一、废弃业务表,存在则保留。 +CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认完成时间', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', + total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', + total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', + review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', + reviewed_at DATETIME(3) NULL COMMENT '审核时间', 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_cargo_discard_no(customer_id,discard_no), + UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key), + KEY idx_ll_cargo_discard_list(customer_id,status,created_at), + KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃申请单'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY', + return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID', + sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', + available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数', + inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID', + carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组', + carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子', + completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组', + actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED', + pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), + UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code), + KEY idx_ll_cargo_discard_item_list(customer_id,application_id), + KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code) +) ENGINE=InnoDB COMMENT='货物废弃SKU明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), + KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), + KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id) +) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃审批历史'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo ( + id BIGINT NOT NULL, + customer_id VARCHAR(64) NOT NULL, + application_id BIGINT NOT NULL, + pallet_no VARCHAR(128) NOT NULL, + file_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(128) NOT NULL, + size_bytes BIGINT NOT NULL, + operator_id VARCHAR(64) NOT NULL, + created_at DATETIME(6) NOT NULL, + submitted_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_cargo_discard_photo_file (file_id), + KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 二、补齐已有表的PDA字段及完工照片快照。 +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), + 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认完成时间''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_discard_packages'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_status'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_operator_id'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT ''PDA操作人''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_completed_at'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT ''PDA完成时间''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='completion_photo_file_ids'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN completion_photo_file_ids TEXT NULL COMMENT ''PDA完工照片文件ID JSON数组''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 三、退货箱来源和废弃状态约束(V4)。 +ALTER TABLE ll_cargo_discard_item + MODIFY COLUMN order_id BIGINT NULL COMMENT '一件代发订单ID', + MODIFY COLUMN profile_id BIGINT NULL COMMENT '托盘档案ID', + MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID'; + +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), + 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''OUTBOUNDED'',''DISCARD_PENDING'') AND sku_types>0 AND total_quantity>0) OR (status IN(''MERGED'',''DISCARDED'') AND sku_types=0 AND total_quantity=0))', + 'ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''OUTBOUNDED'',''DISCARD_PENDING'') AND sku_types>0 AND total_quantity>0) OR (status IN(''MERGED'',''DISCARDED'') AND sku_types=0 AND total_quantity=0))'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), + 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status, ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''MERGED'',''OUTBOUNDED'',''DISCARD_PENDING'',''DISCARDED''))', + 'ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''MERGED'',''OUTBOUNDED'',''DISCARD_PENDING'',''DISCARDED''))'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'), + 'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + + +-- 四、通用库存、部分选箱、箱来源(V25)。 +-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。 +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'), + 'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY'; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组'; +ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED'; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'), + 'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + + +-- 五、退货箱SKU归零约束(V26)。 +-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。 +-- 原子替换约束,可重复执行;不修改业务记录。 +SET @ddl = IF(EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' + AND constraint_name='chk_ll_return_inventory_carton_item_quantity' +), + 'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)', + 'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 六、只读核对。 +SHOW CREATE TABLE `ll_cargo_discard_application`; +SHOW CREATE TABLE `ll_cargo_discard_item`; +SHOW CREATE TABLE `ll_cargo_discard_carton`; +SHOW CREATE TABLE `ll_cargo_discard_status_history`; +SHOW CREATE TABLE `ll_cargo_discard_photo`; +SHOW CREATE TABLE `ll_return_inventory_carton`; +SHOW CREATE TABLE `ll_inbound_plan_carton`; +SHOW CREATE TABLE ll_return_inventory_carton_item; diff --git a/scripts/db/cargo-discard-customer-confirmation-incremental-20261014.sql b/scripts/db/cargo-discard-customer-confirmation-incremental-20261014.sql new file mode 100644 index 000000000..935f51f40 --- /dev/null +++ b/scripts/db/cargo-discard-customer-confirmation-incremental-20261014.sql @@ -0,0 +1,30 @@ +-- 客户照片确认功能增量,MySQL 8.0.16+,可重复执行。 +-- 完整执行,遇错停止;须连接同时承载这两个业务库的指定实例。 +-- 仅新增确认时间和权限,不确认/回填任何历史业务单据。 +USE `baiyue_lastleg`; +-- 客户确认废弃完结时间,可重复执行;仅结构变更。 +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认废弃完结时间''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +USE `baiyue_iam`; +-- 仅 baiyue_iam,新增客户废弃确认按钮权限并授权默认客户管理员。 +-- 自定义客户角色由管理员按需分配;不自动扩大其权限。 +-- 可重复执行;授权和持久化安全版本在同一事务提交。 +START TRANSACTION; +INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0; +INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) +SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p + ON p.tenant_id=0 AND p.app_code='MERCHANT' AND p.permission_code='merchant:overseas-warehouse:discard:confirm' +WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0; +INSERT INTO iam_security_subject_version(auth_user_id,permission_version) +SELECT DISTINCT u.auth_user_id,1 FROM iam_user u +JOIN iam_membership m ON m.user_id=u.id +JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id +JOIN iam_role r ON r.id=mr.role_id +WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0 + AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='MERCHANT' + AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 +ON DUPLICATE KEY UPDATE permission_version=permission_version+1; +COMMIT; diff --git a/scripts/db/cargo-discard-item-columns-repair-20261014.sql b/scripts/db/cargo-discard-item-columns-repair-20261014.sql new file mode 100644 index 000000000..635111a60 --- /dev/null +++ b/scripts/db/cargo-discard-item-columns-repair-20261014.sql @@ -0,0 +1,53 @@ +-- 废弃明细新增字段专项补齐,MySQL 8.0.16+,可重复执行。 +-- 仅 baiyue_lastleg;前提是 ll_cargo_discard_item 已存在。 +-- 独立于退货库存约束,无需先执行其他汇总章节。 +-- 不修改业务记录或库存数量;完整执行,遇错停止,DDL隐式提交。 +USE `baiyue_lastleg`; +SET NAMES utf8mb4; +SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_discard_packages'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_status'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_operator_id'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT ''PDA操作人''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_completed_at'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT ''PDA完成时间''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='completion_photo_file_ids'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN completion_photo_file_ids TEXT NULL COMMENT ''PDA完工照片文件ID JSON数组''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +-- 核对新增字段;应返回10行。 +SELECT column_name, column_type, is_nullable, column_default +FROM information_schema.columns +WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' +AND column_name IN ('actual_discard_packages','pda_status','pda_operator_id','pda_completed_at','completion_photo_file_ids','source_type','return_carton_id','inventory_position_id','carton_marks_json','carton_scan_required') +ORDER BY ordinal_position; diff --git a/scripts/db/cargo-discard-return-quantity-repair-20261014.sql b/scripts/db/cargo-discard-return-quantity-repair-20261014.sql new file mode 100644 index 000000000..04f71cebe --- /dev/null +++ b/scripts/db/cargo-discard-return-quantity-repair-20261014.sql @@ -0,0 +1,20 @@ +-- MySQL 8.0.16+;对Lastleg连接的实例完整执行,遇错停止。DDL隐式提交。 +USE `baiyue_lastleg`; +SET NAMES utf8mb4; +SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port; + +-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。 +-- 原子替换约束,可重复执行;不修改业务记录。 +SET @ddl = IF(EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' + AND constraint_name='chk_ll_return_inventory_carton_item_quantity' +), + 'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)', + 'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SHOW CREATE TABLE ll_return_inventory_carton_item; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 678f36011..7a50e38a8 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -4139,7 +4139,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE return_inventory_carton_dimension_stmt FROM @ddl; EXECUTE return_inventory_carton_dimension_stmt; DEALLOCATE PREPARE return_inventory_carton_dimension_stmt; CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item ( id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0) + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0) ) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量'; CREATE TABLE IF NOT EXISTS ll_return_inventory_balance ( id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', diff --git a/scripts/db/warehouse-transfer-carton-counting-incremental.sql b/scripts/db/warehouse-transfer-carton-counting-incremental.sql new file mode 100644 index 000000000..ee1954fd4 --- /dev/null +++ b/scripts/db/warehouse-transfer-carton-counting-incremental.sql @@ -0,0 +1,30 @@ +-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。 +USE baiyue_lastleg; +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='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; +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='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; + +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='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; + +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='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID''')); +PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; + +-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数''')); +PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt; diff --git a/scripts/local-warehouse-transfer-carton-acceptance.mjs b/scripts/local-warehouse-transfer-carton-acceptance.mjs new file mode 100644 index 000000000..6f653c897 --- /dev/null +++ b/scripts/local-warehouse-transfer-carton-acceptance.mjs @@ -0,0 +1,118 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { randomInt, randomUUID } from 'node:crypto' + +const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '') +if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('Explicit localhost Gateway required') +if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD) throw new Error('Explicit local platform credentials required') +const marker = `WT-BOX-${new Date().toISOString().replace(/\D/g, '').slice(0,14)}-${randomInt(1000,9999)}` +const output = resolve(process.env.TRANSFER_CARTON_OUTPUT || `reports/${marker}.json`) +const report = { marker, gateway, environment: 'LOCAL', photoEvidence: 'SYNTHETIC_TEST_PNG', passed: false, orders: [], checks: [] } +let admin, pda, userId +const ok = value => value.http === 200 && String(value.body?.code) === '200' +async function call(token, method, path, body, expectedFailure = false) { + const isForm = body instanceof FormData + const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(!isForm && body !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : isForm ? body : JSON.stringify(body) }) + let value; try { value = await response.json() } catch { value = null } + const result = { http: response.status, body: value, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId } + // Never persist auth bodies, credentials, customer profiles or tokens. + report.checks.push({ method, path, expectedFailure, http: result.http, code: value?.code, requestId: result.requestId, traceId: result.traceId, passed: expectedFailure ? !ok(result) && ['A0400','A0422','A0409','A0404','A0403','400','409','404','403'].includes(String(value?.code)) : ok(result), message: ok(result) ? undefined : value?.message }) + if (expectedFailure ? report.checks.at(-1).passed !== true : !ok(result)) throw new Error(`${method} ${path}: ${response.status}/${value?.code} ${value?.message}; requestId=${result.requestId}; traceId=${result.traceId}`) + return expectedFailure ? result : value.data +} +function assert(value, name) { report.checks.push({ name, passed: Boolean(value) }); if (!value) throw new Error(name) } +async function save() { await mkdir(resolve(output, '..'), { recursive: true }); await writeFile(output, JSON.stringify(report,null,2)) } +try { + admin = (await call('', 'POST', '/api/auth/login', { loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })).accessToken + const tenants = await call(admin,'GET','/api/platform/v1/iam/tenants?page=1&size=200') + const customer = tenants.records.find(row => row.status === 'ACTIVE' && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || row.id === process.env.OVERSEAS_TEST_CUSTOMER_ID)) + assert(customer?.id, 'Existing active local customer') + const customerId = customer.id + const warehouses = (await call(admin,'GET','/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records + const warehouse = warehouses.find(row => row.warehouseCode === 'US-LAX-01') || warehouses[0] + assert(warehouse?.id, 'Existing overseas warehouse') + report.customerId = customerId; report.warehouseId = warehouse.id + const roles = await call(admin,'GET','/api/platform/v1/iam/roles') + const role = roles.find(row => row.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && row.status === 'ACTIVE') + assert(role?.id, 'Existing PDA role') + const username = `pda_box_${marker.replace(/\D/g,'').slice(-18)}` + const password = process.env.LOCAL_TRANSFER_PDA_PASSWORD + if (!password) throw new Error('Explicit fixture PDA password required') + const user = await call(admin,'POST','/api/platform/v1/iam/users', { username, password, displayName: marker, phone: `18${randomInt(100000000,999999999)}`, email: `${username}@example.com`, tenantId: '0', roleIds: [role.id], overseasWarehouseId: warehouse.id, status:'ACTIVE' }) + userId = user.membershipId + pda = (await call('', 'POST', '/api/auth/login', { loginId: username, password })).accessToken + assert(pda, 'Dedicated warehouse-bound PDA session') + await writeFile(resolve('.env.transfer-carton-session'), `PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\n`, { mode: 0o600 }) + for (const scenario of [ + { name:'single-box', counts:[1], actual:1 }, + { name:'multi-box-multi-sku', counts:[2,1], actual:3, pallets:[1,2] }, + { name:'short-receipt', counts:[3], actual:2, reason:'验收短收一箱' }, + { name:'over-receipt', counts:[2], actual:3, reason:'验收超收一箱' } + ]) { + const key = `${marker}-${scenario.name}` + const items = scenario.counts.map((packageCount,index) => ({ skuCode:`${key}-SKU${index+1}`,productNameZh:`验收货物${index+1}`,quantity:120+index*30,packageCount,shippingMark:`${key}-CUSTOMER${index+1}` })) + let detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers?customerId=${customerId}`, { operatingWarehouseId:warehouse.id,destinationType:'PRIVATE',privateDestinationAddress:'100 Test Warehouse Road',privateDestinationPostalCode:'90001',privateDestinationContactName:'测试收件人',privateDestinationContactPhone:'0000000000',deliveryAddress:'100 Test Warehouse Road',pickupContactName:'测试操作员',pickupContactPhone:'0000000000',packageCount:scenario.counts.reduce((a,b)=>a+b,0),volumeCbm:0.1,deliveryMethod:'TRUCK',estimatedArrivalAt:new Date().toISOString().slice(0,19),estimatedShipAt:new Date(Date.now()+86400000).toISOString().slice(0,19),remark:key,needRelabel:false,idempotencyKey:key,items }) + assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity && item.packageCount === items[index].packageCount), 'SKU declared units and expected cartons persist independently') + const no = detail.transferNo + const fact = { scenario: scenario.name, transferNo:no, plannedBoxes:detail.packageCount, actualBoxes:scenario.actual, status:detail.status, palletNos:[] }; report.orders.push(fact); await save() + detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers/${no}/review?customerId=${customerId}`,{decision:'APPROVE',remark:key}) + assert(detail.status === 'WAIT_PDA_RECEIVING', 'Review enters PDA receiving') + const path = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(no)}` + await call(pda,'POST',`${path}/palletize`,{idempotencyKey:`${key}-premature`,photoFileIds:['1']},true) + detail = await call(pda,'GET',path) + const palletAssignments = []; + for (const [index, cartonCount] of (scenario.pallets || [scenario.actual]).entries()) { + detail = await call(pda,'POST',`${path}/pallets/drafts`,{idempotencyKey:`${key}-pallet${index}`}) + let pallet = detail.pallets.find(row=>row.status==='DRAFT') + assert(pallet?.id,'Saved pallet draft') + detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/generate`,{idempotencyKey:`${key}-generate${index}`}) + pallet=detail.pallets.find(row=>row.id===pallet.id) + detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/bind`,{idempotencyKey:`${key}-bind${index}`,palletCode:pallet.palletNo}) + fact.palletNos.push(pallet.palletNo); palletAssignments.push({ id: pallet.id, count: cartonCount }) + } + let allocationIndex = 0; + const palletIds = palletAssignments.flatMap(pallet => Array(pallet.count).fill(pallet.id)); + let boxes = detail.cartons.slice(0,scenario.actual).map((box,index)=>({ cartonMark:box.cartonMark,transferItemId:box.transferItemId,transferPalletId:palletIds[allocationIndex++],length:30+index,width:20+index,height:15+index,weight:2+index,skuCode:detail.items.find(row=>row.id===box.transferItemId).skuCode,quantity:5+index })) + if(scenario.actual>boxes.length) boxes.push({cartonMark:null,transferItemId:detail.items[0].id,transferPalletId:palletIds[allocationIndex++],length:44,width:33,height:22,weight:4.5,skuCode:detail.items[0].skuCode,quantity:13}) + const count = {idempotencyKey:`${key}-count`,boxes,discrepancyReason:scenario.reason || null} + await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-bad-sku`,boxes:boxes.map((row,index)=>index?row:{...row,skuCode:'WRONG-SKU'})},true) + if (scenario.reason) await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-missing-reason`,discrepancyReason:null},true) + await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-zero`,boxes:boxes.map((row,index)=>index?row:{...row,quantity:0})},true) + detail = await call(pda,'POST',`${path}/cartons/count`,count) + assert(detail.packageCount === scenario.counts.reduce((a,b)=>a+b,0), 'Actual receipt preserves declared expected carton count') + assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity), 'PDA actual units do not overwrite SKU declared units') + const counted = detail.cartons.filter(row=>row.status==='COUNTED') + for (const pallet of detail.pallets) { + const assigned = counted.filter(box => String(box.transferPalletId) === String(pallet.id)); + assert(assigned.length === pallet.cartonCount, 'Pallet count derived from assigned boxes'); + assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.length)*Number(box.width)*Number(box.height)/1000000,0)-Number(pallet.volumeCbm)) < 0.000001, 'Pallet volume derived from boxes'); + assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.weight),0)-Number(pallet.grossWeightKg)) < 0.001, 'Pallet weight derived from boxes'); + } + assert(counted.length===scenario.actual && counted.every(row=>row.countedAt && Number(row.quantity)>0 && Number(row.length)>0), `${scenario.name} persisted box measurements and quantities`) + fact.boxes = counted.map(row=>({cartonMark:row.cartonMark,length:row.length,width:row.width,height:row.height,weight:row.weight,quantity:row.quantity})) + assert(detail.cartons.filter(row=>row.status==='NOT_RECEIVED').length===Math.max(0,detail.packageCount-scenario.actual),'Missing box facts retained') + const replay = await call(pda,'POST',`${path}/cartons/count`,count) + assert(JSON.stringify(replay.cartons)===JSON.stringify(detail.cartons),'Count replay preserves original box facts') + await call(pda,'POST',`${path}/cartons/count`,{...count,boxes:boxes.map((row,index)=>index?row:{...row,quantity:row.quantity+1})},true) + const label = await call(pda,'GET',`${path}/carton-label`) + assert(counted.every(row=>label.printableHtml.includes(row.cartonMark)), 'Printable labels include every received carton') + const form = new FormData();form.append('file',new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64')],{type:'image/png'}),`${key}.png`) + const photo=await call(pda,'POST',`${path}/measurement-photos/files`,form) + const complete={idempotencyKey:`${key}-complete`,photoFileIds:[photo.fileId]} + detail=await call(pda,'POST',`${path}/palletize`,complete) + assert(detail.status==='WAIT_CUSTOMER_CONFIRMATION' && detail.orderStatus==='INBOUND_COMPLETED','Completed receiving waits for customer confirmation') + assert(detail.pallets.reduce((sum,row)=>sum+Number(row.cartonCount),0)===scenario.actual,'Pallet box total equals counted receipts') + await call(pda,'POST',`${path}/palletize`,complete) + const readback=await call(admin,'GET',`/api/platform/v1/last-leg/warehouse-transfers/${no}?customerId=${customerId}`) + assert(JSON.stringify(readback.cartons)===JSON.stringify(detail.cartons),'Platform readback matches persisted PDA box facts') + assert(readback.pallets.every(row=>row.status==='COMPLETED'),'Completed pallets persisted') + fact.status=readback.status;fact.orderStatus=readback.orderStatus;fact.passed=true + await save() + } + report.passed = report.orders.length===4 && report.orders.every(row=>row.passed) && report.checks.every(row=>row.passed) +} catch(error) { report.error=error.message; process.exitCode=1 } +finally { + if(userId && process.env.TRANSFER_KEEP_PDA !== 'true') { try {await call(admin,'PUT',`/api/platform/v1/iam/users/${userId}/status`,{status:'DISABLED'});report.fixturePdaDisabled=true} catch(error) {report.cleanupError=error.message;report.passed=false;process.exitCode=1} } + if(userId) report.fixturePdaMembershipId=userId + await save();console.log(JSON.stringify({passed:report.passed,output,orders:report.orders.map(row=>({scenario:row.scenario,transferNo:row.transferNo,status:row.status,passed:row.passed})),checks:report.checks.length,error:report.error})) +}