From 1fe99eb6d0055a1b7488afaac668ecc5b220e82a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 17 Sep 2026 15:09:57 +0800 Subject: [PATCH 1/4] fix(iam): grant merchant return inventory permission --- .../main/resources/db/lastleg-permissions.sql | 12 ++-- ...urnInventoryPermissionSqlContractTest.java | 63 +++++++++++++++++++ ...seasCustomerPermissionSqlContractTest.java | 10 +-- .../MerchantReturnInventoryController.java | 3 +- .../lastleg/security/OverseasPermissions.java | 2 + 5 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 3e5897429..90f805f4f 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -81,7 +81,8 @@ VALUES (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement','海外采购','/cross-border/procurement',NULL,70,'ACTIVE',1,0), (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,0), (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,0), - (5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,0) + (5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,0), + (5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; @@ -227,7 +228,8 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code 'merchant:cross-border:procurement', 'merchant:cross-border:domestic-trucking', 'merchant:cross-border:pallet-shipping', - 'merchant:warehouse:inbound:list' + 'merchant:warehouse:inbound:list', + 'merchant:overseas-warehouse:return-inventory:list' ) WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; @@ -242,7 +244,8 @@ BEGIN 'merchant:cross-border:procurement', 'merchant:cross-border:domestic-trucking', 'merchant:cross-border:pallet-shipping', - 'merchant:warehouse:inbound:list')) <> 4 THEN + 'merchant:warehouse:inbound:list', + 'merchant:overseas-warehouse:return-inventory:list')) <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整'; END IF; IF (SELECT COUNT(*) FROM iam_role_permission grant_row @@ -256,7 +259,8 @@ BEGIN 'merchant:cross-border:procurement', 'merchant:cross-border:domestic-trucking', 'merchant:cross-border:pallet-shipping', - 'merchant:warehouse:inbound:list')) <> 4 THEN + 'merchant:warehouse:inbound:list', + 'merchant:overseas-warehouse:return-inventory:list')) <> 5 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 new file mode 100644 index 000000000..4cff5c013 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java @@ -0,0 +1,63 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class MerchantReturnInventoryPermissionSqlContractTest { + private static final String PERMISSION = "merchant:overseas-warehouse:return-inventory:list"; + + @Test + void moduleCatalogGrantsReturnInventoryToTheDefaultMerchantAdministrator() throws Exception { + String sql = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql).contains( + "(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'", + "role.role_code='MERCHANT_OWNER'", + "'" + PERMISSION + "')) <> 5", + "客户默认管理员授权不完整"); + + int grantStart = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口"); + int grantEnd = sql.indexOf("DROP PROCEDURE IF EXISTS assert_overseas_merchant_permissions", grantStart); + assertThat(grantStart).isGreaterThanOrEqualTo(0); + assertThat(grantEnd).isGreaterThan(grantStart); + assertThat(sql.substring(grantStart, grantEnd)).contains("'" + PERMISSION + "'"); + } + + @Test + void cleanInitializationKeepsThePermissionAndOwnerGrant() throws Exception { + String sql = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql"); + + assertThat(sql).contains( + "(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'", + "(4000100,5306140,0,CURRENT_TIMESTAMP(3))"); + } + + @Test + void lastlegUsesTheCanonicalOverseasPermissionConstant() throws Exception { + String permissions = repositoryFile( + "lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java"); + String controller = repositoryFile( + "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java"); + + assertThat(permissions).contains( + "MERCHANT_RETURN_INVENTORY_LIST =", + "\"" + PERMISSION + "\""); + assertThat(controller).contains( + "LIST_PERMISSION = OverseasPermissions.MERCHANT_RETURN_INVENTORY_LIST", + "@GetMapping(\"/summary\")", + "@PreAuthorize(\"hasAuthority('\" + LIST_PERMISSION + \"')\")"); + } + + private static String repositoryFile(String relativePath) throws Exception { + Path path = Path.of(relativePath); + if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); + return Files.readString(path, StandardCharsets.UTF_8); + } +} 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 d760aec46..f65c809ef 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java @@ -18,7 +18,8 @@ class OverseasCustomerPermissionSqlContractTest { "merchant:cross-border:procurement", "merchant:cross-border:domestic-trucking", "merchant:cross-border:pallet-shipping", - "merchant:warehouse:inbound:list"); + "merchant:warehouse:inbound:list", + "merchant:overseas-warehouse:return-inventory:list"); @Test void declaresTheFrozenCustomerOverseasPermissionCatalog() throws Exception { @@ -32,11 +33,12 @@ class OverseasCustomerPermissionSqlContractTest { "'merchant:cross-border:procurement','海外采购','/cross-border/procurement'", "'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking'", "'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping'", - "'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound'"); + "'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound'", + "'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory'"); } @Test - void grantsAllFourPermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception { + void grantsAllFivePermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception { String sql = sql(); int comment = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口"); int grantStart = sql.indexOf("INSERT IGNORE INTO iam_role_permission", comment); @@ -64,7 +66,7 @@ class OverseasCustomerPermissionSqlContractTest { "海外客户权限初始化失败:客户默认管理员授权不完整", "permission.app_code='MERCHANT'", "role.role_code='MERCHANT_OWNER'", - ")) <> 4 THEN"); + ")) <> 5 THEN"); } @Test diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java index 0713b49bc..7e82602ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java @@ -6,6 +6,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests; import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; @@ -20,7 +21,7 @@ import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/merchant/v1/last-leg/return-inventory") public class MerchantReturnInventoryController { - public static final String LIST_PERMISSION = "merchant:overseas-warehouse:return-inventory:list"; + public static final String LIST_PERMISSION = OverseasPermissions.MERCHANT_RETURN_INVENTORY_LIST; @Resource private ReturnInventoryService service; @GetMapping 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 98f5ca509..3d4bf6ae0 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 @@ -43,6 +43,8 @@ public final class OverseasPermissions { public static final String MERCHANT_DELIVERY_LIST = "merchant:warehouse:delivery:list"; public static final String MERCHANT_DELIVERY_CREATE = "merchant:warehouse:delivery:create"; public static final String MERCHANT_INBOUND_LIST = "merchant:warehouse:inbound:list"; + public static final String MERCHANT_RETURN_INVENTORY_LIST = + "merchant:overseas-warehouse:return-inventory:list"; public static final String MERCHANT_PROCUREMENT = "merchant:cross-border:procurement"; public static final String MERCHANT_TRUCKING = "merchant:cross-border:domestic-trucking"; public static final String MERCHANT_LOOSEBOARD = "merchant:cross-border:pallet-shipping"; From 9dc344bdefdf16170daf4cfec3020e8dbfbcd58f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 17 Sep 2026 15:10:06 +0800 Subject: [PATCH 2/4] test(sql): align contracts with clean initialization --- .../baiyue/finance/FinanceContractTest.java | 7 +- .../FinanceSettlementSchemaContractTest.java | 13 +- .../FinanceSqlCommentContractTest.java | 27 +- .../FinanceSubjectSeedContractTest.java | 16 +- .../finance/FinanceWalletContractTest.java | 22 +- .../PdaConvergenceOpenApiContractTest.java | 2 +- ...ruckAppointmentControllerContractTest.java | 5 +- .../coload/ColoadGroupSqlContractTest.java | 17 +- .../LoadingEvidenceSqlContractTest.java | 7 +- .../PdaConvergenceSqlContractTest.java | 9 +- .../CarrierScheduleQuerySqlContractTest.java | 71 +-- .../ContainerBookingMapperContractTest.java | 20 +- ...ustomerDimensionConfigSqlContractTest.java | 88 ++-- .../DropshipPalletizationSqlContractTest.java | 45 +- .../FeeItemMigrationSqlContractTest.java | 11 +- .../FulfillmentTypeSqlContractTest.java | 10 +- .../InboundCartonLabelSqlContractTest.java | 21 +- ...boundStatusUnificationSqlContractTest.java | 2 +- .../InboundWorkflowSqlContractTest.java | 20 +- ...ojiaMeasurementReceiptSqlContractTest.java | 20 +- .../PdaSplitSettlementSqlContractTest.java | 16 +- ...BookingTruckPermissionSqlContractTest.java | 34 +- ...ChannelContainerMigrationContractTest.java | 16 +- .../Phase3FinalUnifiedSqlContractTest.java | 395 +++------------ .../ServiceCenterUnifiedSqlContractTest.java | 32 +- .../SmartLoadingSqlContractTest.java | 450 +++--------------- .../TerminalDrayageSqlContractTest.java | 31 +- .../NotificationSqlContractTest.java | 17 +- ...rototypeMenuPermissionSqlContractTest.java | 18 +- ...nerWorkspacePermissionSqlContractTest.java | 21 +- .../CustomerCodeSchemaContractTest.java | 15 +- ...imensionInitializationSqlContractTest.java | 4 +- .../CustomerProfileMigrationContractTest.java | 41 +- .../FinanceG1G2PermissionSqlContractTest.java | 24 +- .../FinanceG3G4PermissionSqlContractTest.java | 16 +- .../InboundHoldPermissionSqlContractTest.java | 16 +- .../InitialIdentitySeedSqlContractTest.java | 20 +- .../LocationPermissionSqlContractTest.java | 11 +- ...erseasPhase4PermissionSqlContractTest.java | 52 +- ...ase5DeliveryPermissionSqlContractTest.java | 35 +- .../QualificationSchemaContractTest.java | 7 +- .../SecurityEventSqlContractTest.java | 13 +- .../WarehouseRoleSqlContractTest.java | 29 +- .../DeliveryDocumentContractTest.java | 12 +- .../delivery/DeliverySchemaContractTest.java | 10 +- .../Phase5PdaDeploymentSqlContractTest.java | 32 +- .../pda/ReceivingContainerContractTest.java | 34 +- .../LocalLooseboardSchemaContractTest.java | 16 +- ...ticPartnerWarehouseClientContractTest.java | 3 +- ...OverseasProcurementSchemaContractTest.java | 31 +- .../service/ReturnCartonQueryTest.java | 5 +- .../LastlegOrderDetailSchemaContractTest.java | 11 +- ...hase5ExceptionVisitSchemaContractTest.java | 16 +- .../Phase5OverseasUpgradeSqlContractTest.java | 91 ++-- .../SchemaChineseCommentContractTest.java | 27 +- .../security/LastlegSecurityContractTest.java | 22 +- .../ContainerPlanProgressContractTest.java | 8 +- .../UsTruckOrderSchemaContractTest.java | 20 +- ...eOrderExportPermissionSqlContractTest.java | 5 +- .../OrderDeclarationUpdateContractTest.java | 11 +- .../InboundCodeSequenceSqlContractTest.java | 13 +- .../OrderParentChildSqlContractTest.java | 25 +- .../OrderPickupSqlContractTest.java | 19 +- ...lWarehouseResourceSeedSqlContractTest.java | 14 +- .../OverseasStorageFeeSqlContractTest.java | 46 +- ...tionReleaseIdempotencySqlContractTest.java | 31 +- .../repository/SupplierSqlContractTest.java | 8 +- .../WarehouseSchemaContractTest.java | 43 +- 68 files changed, 851 insertions(+), 1448 deletions(-) diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java index a7d673357..d3cbb730c 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -43,12 +43,11 @@ class FinanceContractTest { assertFalse(operations.contains("bl.source_id=f.source_fee_id")); String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance")); - assertTrue(unified.contains("assert_finance_permissions")); assertTrue(unified.contains("merchant:finance:statement:view")); - int walletEventSchema = unified.indexOf("CREATE TABLE IF NOT EXISTS fin_wallet_event_outbox"); - int finalPass = unified.lastIndexOf("SELECT 'PASS' AS migration_status"); + int walletEventSchema = unified.indexOf("CREATE TABLE fin_wallet_event_outbox"); + int finalPass = unified.lastIndexOf("SELECT 'PASS' AS initialization_status"); assertTrue(walletEventSchema >= 0); assertTrue(finalPass > walletEventSchema); - assertTrue(unified.contains("CREATE PROCEDURE assert_wallet_event_schema()")); + assertFalse(unified.contains("CREATE PROCEDURE assert_wallet_event_schema()")); } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java index c8b178151..13f0ae1e8 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java @@ -22,12 +22,13 @@ class FinanceSettlementSchemaContractTest { .contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)") .contains("total_amount DECIMAL(20,6)", "paid_amount DECIMAL(20,6)", "outstanding_amount DECIMAL(20,6)") .contains("updated_at DATETIME(3)"); - assertThat(unified) - .contains("CREATE TABLE IF NOT EXISTS fin_settlement (") - .contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (") - .contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (") - .contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)") - .contains("'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16"); + String normalized = unified.toLowerCase().replaceAll("\\s+", ""); + assertThat(normalized) + .contains("createtablefin_settlement(") + .contains("createtablefin_settlement_line(") + .contains("createtablefin_settlement_payment(") + .contains("idx_fin_settlement_customer_direction_status(tenant_id,direction,status)"); + assertThat(unified).doesNotContain("ALTER TABLE fin_settlement", "assert_finance"); } private static Path resolveUnifiedSql() { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index 964d12bdd..30795d6ec 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -62,16 +62,14 @@ class FinanceSqlCommentContractTest { } @Test - void unifiedMigrationAddsBillLineAdjustmentReasonForNewAndExistingSchemas() throws Exception { + void unifiedInitializationDefinesBillLineAdjustmentReasonDirectly() throws Exception { Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); String sql = Files.readString(unified, StandardCharsets.UTF_8); assertThat(sql) - .contains("adjustment_reason VARCHAR(500) NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录'") - .contains("table_schema='baiyue_finance' AND table_name='fin_bill_line' AND column_name='adjustment_reason'") - .contains("ALTER TABLE baiyue_finance.fin_bill_line ADD COLUMN adjustment_reason") - .contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'"); + .contains("adjustment_reason varchar(500) DEFAULT NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录'") + .doesNotContain("ALTER TABLE baiyue_finance.fin_bill_line", "queue_column_comment"); } @Test @@ -97,12 +95,17 @@ class FinanceSqlCommentContractTest { assertThat(baseline) .contains("updated_by_name VARCHAR(128) NULL COMMENT '最后修改操作人名称快照'") .contains("created_by_name VARCHAR(128) NULL COMMENT '登记操作人名称快照'"); - assertThat(unified) - .contains("CALL finance_add_operator_snapshot_column('fin_bill', 'created_by_name', 'created_by')") - .contains("CALL finance_add_operator_snapshot_column('fin_bill', 'updated_by_name', 'updated_by')") - .contains("CALL finance_add_operator_snapshot_column('fin_receipt', 'created_by_name', 'created_by')") - .contains("CALL finance_add_operator_snapshot_column('fin_cost', 'created_by_name', 'created_by')") - .contains("CALL finance_add_operator_snapshot_column('fin_settlement_payment', 'created_by_name', 'created_by')") - .contains("CALL finance_add_operator_snapshot_column('fin_bill_revision', 'created_by_name', 'created_by')"); + assertThat(tableBlock(unified, "fin_bill")).contains("created_by_name", "updated_by_name"); + assertThat(tableBlock(unified, "fin_receipt")).contains("created_by_name"); + assertThat(tableBlock(unified, "fin_cost")).contains("created_by_name"); + assertThat(tableBlock(unified, "fin_settlement_payment")).contains("created_by_name"); + assertThat(tableBlock(unified, "fin_bill_revision")).contains("created_by_name"); + assertThat(unified).doesNotContain("finance_add_operator_snapshot_column"); + } + + private static String tableBlock(String sql, String table) { + int start = sql.indexOf("CREATE TABLE " + table + " ("); + int end = sql.indexOf(';', start); + return start < 0 || end < 0 ? "" : sql.substring(start, end); } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSubjectSeedContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSubjectSeedContractTest.java index a20c7772e..194cc0759 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSubjectSeedContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSubjectSeedContractTest.java @@ -14,12 +14,14 @@ class FinanceSubjectSeedContractTest { String module = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); - for (String sql : new String[] {module, unified}) { - assertThat(sql) - .contains("INSERT INTO fin_subject") - .contains("('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)") - .contains("ON DUPLICATE KEY UPDATE") - .contains("status = 'ACTIVE'"); - } + assertThat(module) + .contains("INSERT INTO fin_subject") + .contains("('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)") + .contains("ON DUPLICATE KEY UPDATE") + .contains("status = 'ACTIVE'"); + assertThat(unified) + .contains("INSERT INTO fin_subject (id, subject_code, subject_name, status") + .contains("VALUES (1,'PLATFORM_DEFAULT','平台','ACTIVE',0,0,0") + .doesNotContain("ON DUPLICATE KEY UPDATE"); } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java index 2b2ecd69c..d41aa62a4 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java @@ -10,18 +10,16 @@ import static org.assertj.core.api.Assertions.assertThat; class FinanceWalletContractTest { @Test - void unifiedMigrationDropsTheLegacyBalanceConstraintBeforeRewritingBalances() throws Exception { + void unifiedInitializationDefinesTheFinalWalletBalanceConstraintWithoutRewritingData() throws Exception { Path migration = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); if (!Files.exists(migration)) migration = Path.of("..").resolve(migration); String sql = Files.readString(migration, StandardCharsets.UTF_8); - int procedure = sql.indexOf("CREATE PROCEDURE migrate_fin_wallet_credit()"); - int dropConstraint = sql.indexOf("ALTER TABLE fin_wallet_account DROP CHECK chk_fin_wallet_account_balance", procedure); - int rewriteBalance = sql.indexOf("SET actual_balance=actual_balance-credit_used", procedure); - - assertThat(procedure).isGreaterThanOrEqualTo(0); - assertThat(sql.substring(procedure, rewriteBalance)) - .contains("CHECK_CLAUSE LIKE '%actual_balance%'", "v_legacy_wallet_constraint OR v_credit_expiry_missing"); - assertThat(dropConstraint).isBetween(procedure, rewriteBalance - 1); + String account = tableBlock(sql, "fin_wallet_account"); + assertThat(account).contains( + "credit_expires_on date DEFAULT NULL", + "CONSTRAINT chk_fin_wallet_account_balance CHECK", + "frozen_balance >= credit_frozen"); + assertThat(sql).doesNotContain("migrate_fin_wallet_credit", "SET actual_balance=actual_balance-credit_used"); } @Test @@ -68,4 +66,10 @@ class FinanceWalletContractTest { .contains("/holds/{businessType}/{businessNo}/release") .contains("Idempotency-Key"); } + + private static String tableBlock(String sql, String table) { + int start = sql.indexOf("CREATE TABLE " + table + " ("); + int end = sql.indexOf(';', start); + return start < 0 || end < 0 ? "" : sql.substring(start, end); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java index 089336d9d..2c853fa6c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -65,7 +65,7 @@ class PdaConvergenceOpenApiContractTest { .contains("loadingPlanId:", "containerBookingId:") .doesNotContain("containerNo:"); assertThat(batchItemRequest) - .contains("PICK_PALLET", "palletNo:", "every pickItem", "No carton quantities") + .contains("PICK_PALLET", "palletNo:", "every SKU line", "No carton quantities") .doesNotContain("releaseMode:", "reservationIds:", "locationIds:"); String pickItem = yaml.substring(yaml.indexOf(" PickItem:"), yaml.indexOf(" PendingTask:")); assertThat(pickItem).contains("unitType:", "palletId:", "palletNo:", "indivisible:", "SEALED_PALLET"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index 56fb39b2b..36927b842 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -25,9 +25,8 @@ class TruckAppointmentControllerContractTest { Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql"); Path unifiedPath=Files.exists(direct)?direct:Path.of("..","deploy","sql","头程数据库统一增量-MySQL8.sql"); String unifiedSql=Files.readString(unifiedPath,StandardCharsets.UTF_8); - for(String sql:new String[]{moduleSql,unifiedSql}) { - assertThat(sql).contains("'bill_of_lading_no'","'actual_arrived_warehouse_at'","'actual_loading_at'","'actual_departed_at'"); - } + assertThat(moduleSql).contains("'bill_of_lading_no'","'actual_arrived_warehouse_at'","'actual_loading_at'","'actual_departed_at'"); + assertThat(unifiedSql).contains("bill_of_lading_no varchar(128)","actual_arrived_warehouse_at datetime(3)","actual_loading_at datetime(3)","actual_departed_at datetime(3)"); } @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} @Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","database booking-level unique key");} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java index 4404c8d1b..3ed5a8802 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java @@ -17,17 +17,22 @@ class ColoadGroupSqlContractTest { String baseline = Files.readString(Path.of("src/main/resources/db/phase3-coload-group-schema.sql")); String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); for (String source : new String[]{baseline, unified}) { + String normalized = source + .replace("CREATE TABLE IF NOT EXISTS ", "CREATE TABLE ") + .replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS "); assertThat(source) - .contains("CREATE TABLE IF NOT EXISTS fl_coload_group (") - .contains("CREATE TABLE IF NOT EXISTS fl_coload_group_member (") - .contains("CREATE TABLE IF NOT EXISTS fl_coload_group_history (") - .contains("CREATE TABLE IF NOT EXISTS fl_coload_command (") - .contains("ACTIVE','LOCKED','CONSUMED','CANCELLED") + .contains("ACTIVE", "LOCKED", "CONSUMED", "CANCELLED") + .contains("chk_coload_group_status") .contains("uk_coload_active_inbound") .contains("full_package_count") .contains("uk_coload_command") .contains("locked_plan_id"); - assertThat(tableDefinition(source, "fl_coload_group")) + assertThat(normalized) + .contains("CREATE TABLE IF NOT EXISTS fl_coload_group (") + .contains("CREATE TABLE IF NOT EXISTS fl_coload_group_member (") + .contains("CREATE TABLE IF NOT EXISTS fl_coload_group_history (") + .contains("CREATE TABLE IF NOT EXISTS fl_coload_command ("); + assertThat(tableDefinition(normalized, "fl_coload_group")) .doesNotContain("chk_coload_group_destination") .doesNotContain("destination_type") .doesNotContain("destination_warehouse_id") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java index 9dc06259a..c424ee458 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java @@ -30,11 +30,12 @@ class LoadingEvidenceSqlContractTest { Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); String sql = Files.readString(unified, StandardCharsets.UTF_8); - assertThat(sql).contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence") + assertThat(sql).contains("CREATE TABLE fl_loading_cabinet_evidence") .contains("uk_cabinet_evidence_active") .contains("uk_cabinet_evidence_operation") - .contains("normalize_loading_cabinet_evidence_collation") - .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_evidence") + .contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id)") + .doesNotContain("CREATE TABLE fl_loading_evidence") + .doesNotContain("normalize_loading_cabinet_evidence_collation") .doesNotContain("phase3_loading_evidence_add_column"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java index 257649cee..54651c253 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java @@ -38,8 +38,13 @@ class PdaConvergenceSqlContractTest { String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); - assertThat(createBlock(unified, "fl_loading_pick_item_assignment")) - .isEqualTo(createBlock(sql, "fl_loading_pick_item_assignment")); + assertThat(unified) + .contains("CREATE TABLE fl_loading_pick_item_assignment") + .contains("assignment_type varchar(16) NOT NULL") + .contains("PRIMARY KEY (pick_item_id)") + .contains("KEY idx_loading_pick_item_assignee (assignee_id,updated_at)") + .contains("CONSTRAINT fk_loading_pick_item_assignment_item") + .contains("platform:firstleg:pda:assign"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index 8d9952d3a..c5b2d01d9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -108,49 +108,28 @@ class CarrierScheduleQuerySqlContractTest { @Test void unifiedDeploymentEntryContainsTheSameContract() throws Exception { String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), - StandardCharsets.UTF_8); + StandardCharsets.UTF_8).toLowerCase(); assertThat(unified) - .contains("fl_carrier_schedule_query") + .contains("create table fl_carrier_schedule_query") .contains("uk_carrier_schedule_query_scope") - .contains("id IN (5102184,5102186,5102188)") - .contains("assert_carrier_schedule_query_schema") - .contains("CREATE PROCEDURE migrate_carrier_schedule_retry_columns()") - .contains("ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL'") - .contains("ADD COLUMN latest_standard_node VARCHAR(32) NULL") - .contains("ADD COLUMN latest_node_observed_at DATETIME(3) NULL") - .contains("ADD COLUMN stopped_at DATETIME(3) NULL") - .contains("ADD COLUMN stop_reason VARCHAR(500) NULL") - .contains("ADD COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0") - .contains("ADD COLUMN window_started_at DATETIME(3) NULL") - .contains("ADD COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0") - .contains("ADD COLUMN next_allowed_at DATETIME(3) NULL") - .contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)") - .contains("DROP CHECK chk_carrier_schedule_query_access") - .contains("DROP CHECK chk_carrier_schedule_query_carrier") - .contains("DROP CHECK chk_carrier_schedule_query_status") - .contains("DROP INDEX uk_carrier_schedule_query_scope") - .contains("ADD UNIQUE KEY uk_carrier_schedule_query_scope") + .contains("trigger_source varchar(32) not null default 'manual'") + .contains("latest_standard_node varchar(32) default null") + .contains("latest_node_observed_at datetime(3) default null") + .contains("stopped_at datetime(3) default null") + .contains("stop_reason varchar(500) default null") + .contains("consecutive_failures int unsigned not null default '0'") + .contains("window_started_at datetime(3) not null") + .contains("attempt_count int unsigned not null default '0'") + .contains("next_allowed_at datetime(3) default null") + .contains("key idx_carrier_schedule_query_due (status,next_allowed_at,id)") + .contains("unique key uk_carrier_schedule_query_scope") .contains("(customer_id,ocean_booking_id,container_scope_id)") - .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") - .contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'") - .contains("'OFFICIAL_API','MANUAL'") - .contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')") - .contains("ARRIVED_DESTINATION_PORT") - .contains("EMPTY_RETURNED") - .contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')") - .contains("SET @carrier_schedule_next = CASE") - .contains("CREATE PROCEDURE ensure_carrier_schedule_retry_check()") - .contains("CREATE PROCEDURE migrate_carrier_schedule_history_action_check()") - .contains("DROP CHECK chk_carrier_schedule_history_action") - .contains("'MANUAL_NODE','NODE_OBSERVED'") - .contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'") - .contains("ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)") - .contains("统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整") - .doesNotContain("DROP CHECK chk_carrier_schedule_query_attempt") - .doesNotContain("\n+--") - .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") - .contains("船司查询任务角色授权未完整创建") - .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'"); + .contains("chk_carrier_schedule_query_attempt check ((attempt_count between 0 and 3))") + .contains("arrived_destination_port") + .contains("empty_returned") + .contains("platform:firstleg:port-schedule-query:manual") + .doesNotContain("migrate_carrier_schedule_retry_columns") + .doesNotContain("drop check chk_carrier_schedule_query_attempt"); } @Test @@ -158,13 +137,13 @@ class CarrierScheduleQuerySqlContractTest { String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); assertThat(unified) - .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key'") - .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version'") - .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence'") - .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash'") + .contains("event_key varchar(160) DEFAULT NULL") + .contains("event_version int unsigned NOT NULL DEFAULT '1'") + .contains("aggregate_sequence int unsigned DEFAULT NULL") + .contains("payload_hash varchar(71) DEFAULT NULL") .contains("uk_inbound_outbox_event_key") - .contains("MODIFY COLUMN payload_hash VARCHAR(71)") - .contains("MODIFY COLUMN payload_json LONGTEXT NOT NULL"); + .contains("payload_json longtext NOT NULL") + .doesNotContain("firstleg_finance_add_column"); } private static String moduleSql() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index 18f379ad1..da3c5b79a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -108,16 +108,16 @@ class ContainerBookingMapperContractTest { @Test @DisplayName("初始化与统一增量锁定真实柜号权威、唯一和ISO结构约束") void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception { - for (String source : new String[] { - Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql")), - Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")) - }) { - assertThat(source) - .contains("国内唯一权威真实集装箱号") - .contains("提柜后可原子补录") - .contains("UNIQUE KEY uk_container_booking_container_no (container_no)") - .contains("CONSTRAINT chk_container_booking_iso6346") - .contains("container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'"); + String module = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql")); + String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertThat(module.toLowerCase()) + .contains("国内唯一权威真实集装箱号") + .contains("提柜后可原子补录"); + for (String source : new String[] {module, unified}) { + assertThat(source.toLowerCase()) + .contains("unique key uk_container_booking_container_no (container_no)") + .contains("constraint chk_container_booking_iso6346") + .contains("^[a-z]{3}[ujz][0-9]{7}$"); } } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java index aec810024..484c64e39 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java @@ -28,21 +28,23 @@ class CustomerDimensionConfigSqlContractTest { ); for (String sql : sqlEntrypoints) { - assertThat(sql) + String normalized = sql.toLowerCase().replace(", ", ","); + assertThat(normalized) .contains("fl_customer_dimension_config") - .contains("box_type VARCHAR(24) NOT NULL") - .contains("inner_length_cm DECIMAL(9,3) NOT NULL") - .contains("inner_width_cm DECIMAL(9,3) NOT NULL") - .contains("inner_height_cm DECIMAL(9,3) NOT NULL") - .contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000") - .contains("default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ACTUAL'") - .contains("initialization_key VARCHAR(128) NOT NULL") - .contains("created_by BIGINT UNSIGNED NOT NULL") - .contains("updated_by BIGINT UNSIGNED NOT NULL") - .contains("version INT NOT NULL DEFAULT 0") - .contains("UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type)") - .contains("CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE'))") - .doesNotContain("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)"); + .contains("box_type varchar(24) not null") + .contains("inner_length_cm decimal(9,3) not null") + .contains("inner_width_cm decimal(9,3) not null") + .contains("inner_height_cm decimal(9,3) not null") + .contains("edge_addition_cm decimal(6,3) not null default") + .contains("default_calculation_mode varchar(16)") + .contains("not null default 'actual'") + .contains("initialization_key varchar(128) not null") + .contains("created_by bigint unsigned not null") + .contains("updated_by bigint unsigned not null") + .contains("version int not null default") + .contains("unique key uk_customer_dimension_config_tenant_box (tenant_id,box_type)") + .contains("'small'", "'medium'", "'large'", "'extra_large'") + .doesNotContain("unique key uk_customer_dimension_config_tenant (tenant_id)"); } } @@ -104,21 +106,19 @@ class CustomerDimensionConfigSqlContractTest { } @Test - void unifiedMigrationKeepsPermissionGuardsAndAssertsFinalFourRowSchema() throws Exception { + void unifiedInitializationKeepsPermissionsAndFinalFourRowSchema() throws Exception { String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); assertThat(sql) - .contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()") - .contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010") - .contains("r.role_code = 'PLATFORM_ADMIN'") - .contains("r.role_code = 'ORDER_OPERATOR'") - .contains("index_name = 'uk_customer_dimension_config_tenant_box'") - .contains("客户四箱规尺寸配置字段不完整") - .contains("客户默认计费规则初始化幂等字段未创建") - .contains("index_name = 'idx_charge_rule_initialization'") - .contains("客户默认计费规则初始化索引未完整创建") - .contains("客户尺寸计算配置权限码、固定ID或父节点不正确"); + .contains("CREATE TABLE fl_customer_dimension_config") + .contains("UNIQUE KEY uk_customer_dimension_config_tenant_box") + .contains("5102111,0,'PLATFORM','BUTTON',5100010") + .contains("firstleg:dimension-config:view") + .contains("firstleg:dimension-config:edit") + .contains("VALUES (4000001,5102111,0,CURRENT_TIMESTAMP(3))") + .contains("VALUES (4000003,5102111,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("unified_guard_dimension_config_permissions"); } @Test @@ -129,43 +129,21 @@ class CustomerDimensionConfigSqlContractTest { StandardCharsets.UTF_8); assertThat(schema).contains("calculation_rule_version VARCHAR(64) NOT NULL"); - assertThat(unified) - .contains("calculation_rule_version VARCHAR(64) NOT NULL") - .contains("MODIFY COLUMN calculation_rule_version VARCHAR(64) NOT NULL"); + assertThat(unified).contains("calculation_rule_version varchar(64) NOT NULL") + .doesNotContain("MODIFY COLUMN calculation_rule_version"); } @Test - void unifiedMigrationUpgradesOnlyRecognizedLegacyTableAndBackfillsIdempotently() throws Exception { + void unifiedInitializationCreatesOnlyTheFinalDimensionTableWithoutBackfill() throws Exception { String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); - int upgrade = sql.indexOf("CREATE PROCEDURE unified_upgrade_legacy_customer_dimension_config()"); - int create = sql.indexOf("CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config"); - int backfill = sql.indexOf("INSERT INTO baiyue_firstleg.fl_customer_dimension_config (", create); - assertThat(upgrade).isGreaterThanOrEqualTo(0); - assertThat(create).isGreaterThan(upgrade); - assertThat(backfill).isGreaterThan(create); assertThat(sql) - .contains("AND column_name = 'box_type') = 0") - .contains("AND column_name IN ('id', 'tenant_id', 'edge_addition_cm', 'remark', 'initialization_key'") - .contains("AND table_name = 'fl_customer_dimension_config') <> 11") - .contains("AND index_name = 'uk_customer_dimension_config_tenant'") - .contains("DROP TABLE baiyue_firstleg.fl_customer_dimension_config") - .contains("客户尺寸配置表不是可识别的旧单行结构") - .contains("SELECT id AS tenant_id, created_by") - .contains("FROM baiyue_iam.iam_tenant") - .contains("WHERE tenant_type = 'MERCHANT' AND deleted = 0 AND id > 0") - .contains("CONCAT('unified-four-box-default-v1:', tenant.tenant_id)") - .contains("ON DUPLICATE KEY UPDATE id = id") - .contains("现有企业客户未完整持有固定四箱规尺寸配置"); - - String repeatSafeClause = sql.substring(backfill, - sql.indexOf("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql", backfill)); - assertThat(repeatSafeClause) - .doesNotContain("edge_addition_cm = VALUES") - .doesNotContain("inner_length_cm = VALUES") - .doesNotContain("remark = VALUES") - .doesNotContain("version = VALUES"); + .contains("CREATE TABLE fl_customer_dimension_config") + .contains("COMMENT='客户固定四箱规尺寸配置'") + .doesNotContain("unified_upgrade_legacy_customer_dimension_config") + .doesNotContain("DROP TABLE baiyue_firstleg.fl_customer_dimension_config") + .doesNotContain("ON DUPLICATE KEY UPDATE"); } private static String resource(String path) throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java index 6b75c8ca3..d1d694558 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java @@ -49,34 +49,31 @@ class DropshipPalletizationSqlContractTest { String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); for (String sql : new String[]{inbound, unified}) { - assertThat(sql) - .contains("counted_cartons INT UNSIGNED NOT NULL DEFAULT 0") - .contains("discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0") - .contains("pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托'") - .contains("expected_sku_quantity INT UNSIGNED NOT NULL") - .contains("actual_sku_quantity INT UNSIGNED NOT NULL") - .contains("count_operation_id VARCHAR(128) NOT NULL") - .contains("bind_operation_id VARCHAR(128) NULL"); + assertThat(sql.toLowerCase()) + .contains("counted_cartons int unsigned not null default") + .contains("discrepancy_cartons int unsigned not null default") + .contains("pallet_status varchar(16) not null comment '托盘状态:open组托中、sealed已封托'") + .contains("expected_sku_quantity int unsigned not null") + .contains("actual_sku_quantity int unsigned not null") + .contains("count_operation_id varchar(128) not null") + .contains("bind_operation_id varchar(128)"); } for (String sql : new String[]{loading, unified}) { - assertThat(sql) - .contains("unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE'") - .contains("pallet_id BIGINT UNSIGNED NULL") - .contains("indivisible TINYINT(1) NOT NULL DEFAULT 0") - .contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS") - .contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)") - .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (") - .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ("); + String normalized = sql.toLowerCase().replace(", ", ","); + assertThat(normalized) + .contains("unit_type varchar(24) not null default 'package'") + .contains("pallet_id bigint unsigned") + .contains("indivisible tinyint(1) not null default") + .contains("active_assignment_key varchar(96) generated always as") + .contains("unique key uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key)") + .doesNotContain("create table fl_loading_work_pallet (") + .doesNotContain("create table fl_loading_work_pallet_carton ("); } assertThat(unified) - .contains("CREATE PROCEDURE converge_loading_assignment_schema()") - .contains("CREATE PROCEDURE converge_dropship_palletization_schema()") - .contains("ALTER TABLE fl_dropship_pallet DROP CHECK chk_dropship_pallet_status") - .contains("ALTER TABLE fl_dropship_pallet DROP CHECK chk_dropship_pallet_status;\n END IF;\n UPDATE fl_dropship_pallet") - .contains("CHECK (pallet_status IN ('OPEN','SEALED'))") - .contains("CHANGE COLUMN operation_id count_operation_id") - .contains("DROP INDEX uk_loading_item_plan_detail") - .contains("ADD UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key)"); + .contains("CONSTRAINT chk_dropship_pallet_status CHECK") + .contains("UNIQUE KEY uk_loading_item_plan_assignment") + .doesNotContain("converge_loading_assignment_schema") + .doesNotContain("converge_dropship_palletization_schema"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java index 144a900f1..6f5e0491f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java @@ -11,9 +11,13 @@ import static org.assertj.core.api.Assertions.assertThat; class FeeItemMigrationSqlContractTest { @Test void temporarySeedTableIsNotReopenedWithinOneMysqlStatement() throws Exception { - assertMysqlCompatible(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql")); assertMysqlCompatible(resolve( "firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql")); + String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + assertThat(unified) + .contains("193000000001000111,0,'头程运费','INTERNATIONAL','FBA_HEAD_FREIGHT'") + .contains("193000000001000112,0,'尾程派送费','INTERNATIONAL','PRIVATE_LAST_MILE_DELIVERY'") + .doesNotContain("tmp_fee_item_seed"); } private static void assertMysqlCompatible(Path path) throws Exception { @@ -35,9 +39,12 @@ class FeeItemMigrationSqlContractTest { @Test void inboundFeeSourceIndexUsesTheFinalVersionedDefinition() throws Exception { - assertFinalSourceIndex(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql")); assertFinalSourceIndex(resolve( "firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql")); + String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + assertThat(unified) + .contains("UNIQUE KEY uk_inbound_fee_source (tenant_id,source_type,source_fee_id,source_fee_version)") + .doesNotContain("DROP INDEX uk_inbound_fee_source"); } private static void assertFinalSourceIndex(Path path) throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java index ca27c12fb..728b68092 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java @@ -17,11 +17,11 @@ class FulfillmentTypeSqlContractTest { assertFulfillmentMigration(order, "baiyue_order.ord_order"); assertFulfillmentMigration(inbound, "fl_inbound_order"); - assertFulfillmentMigration(unified, "baiyue_order.ord_order"); - assertFulfillmentMigration(unified, "fl_inbound_order"); - assertThat(unified) - .contains("unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type')") - .contains("unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type')"); + assertThat(unified.toLowerCase()) + .contains("create table ord_order") + .contains("create table fl_inbound_order") + .contains("fulfillment_type varchar(32) not null default 'standard'") + .doesNotContain("unified_assert_column"); } private static void assertFulfillmentMigration(String sql, String table) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java index 9fe3da95f..64f4f117a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java @@ -29,20 +29,17 @@ class InboundCartonLabelSqlContractTest { assertThat(versioningMigration).contains("phase2_carton_label_add_column('label_version'") .contains("phase2_carton_label_add_column('batch_status'") .contains("idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version)"); - assertThat(unified).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") - .contains("units_per_carton INT UNSIGNED NULL") - .contains("unified_carton_label_add_column('label_version'") - .contains("unified_carton_label_add_column('batch_status'") - .contains("unified_carton_label_add_column('cargo_fingerprint'") - .contains("unified_carton_label_add_column('voided_at'") - .contains("ALTER TABLE fl_inbound_carton_label DROP INDEX uk_carton_label_index") - .contains("ADD UNIQUE KEY uk_carton_label_index (tenant_id,inbound_id,label_version,carton_index)") - .contains("ADD KEY idx_carton_label_active (tenant_id,inbound_id,batch_status,label_version)") + assertThat(unified).contains("CREATE TABLE fl_inbound_carton_label") + .contains("units_per_carton int unsigned DEFAULT NULL") + .contains("label_version int unsigned NOT NULL DEFAULT '1'") + .contains("batch_status varchar(16) NOT NULL DEFAULT 'ACTIVE'") + .contains("cargo_fingerprint char(64) DEFAULT NULL") + .contains("voided_at datetime(3) DEFAULT NULL") + .contains("UNIQUE KEY uk_carton_label_index (tenant_id,inbound_id,label_version,carton_index)") + .contains("KEY idx_carton_label_active (tenant_id,inbound_id,batch_status,label_version)") .contains("uk_carton_label_box_code") .contains("uk_carton_label_index") - .contains("稳定箱号映射表未创建") - .contains("稳定箱码映射兼容字段未完整创建") - .contains("箱唛有效批次索引未完整创建"); + .doesNotContain("unified_carton_label_add_column"); } private String resource(String path) throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java index eea8d70ea..6f15681ed 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java @@ -23,7 +23,7 @@ class InboundStatusUnificationSqlContractTest { .doesNotContain("UPDATE fl_inbound_order") .doesNotContain("UPDATE fl_inbound_status_history"); assertThat(unified) - .contains("入库完成后的唯一状态为 WAIT_SPLIT") + .contains("WAIT_SPLIT待分柜") .doesNotContain("历史 WAIT_SPLIT") .doesNotContain("SET process_status = 'WAIT_SPLIT'") .doesNotContain("SET from_status = 'WAIT_SPLIT'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java index 36909bfcf..356b99148 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -77,9 +77,12 @@ class InboundWorkflowSqlContractTest { .contains("uk_measure_box_code") .contains("information_schema.STATISTICS"); assertThat(unified) - .contains("CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_code'") + .contains("CREATE TABLE fl_inbound_measurement_item") + .contains("box_code varchar(128) DEFAULT NULL") + .contains("machine_no varchar(64) DEFAULT NULL") + .contains("device_measured_at datetime(3) DEFAULT NULL") .contains("uk_measure_box_code") - .contains("单箱当前测量字段未完整创建"); + .doesNotContain("phase2_measure_add_column"); } @Test @@ -97,10 +100,13 @@ class InboundWorkflowSqlContractTest { .doesNotContain("final_weight_kg") .doesNotContain("uk_pallet_allocation_pallet"); assertThat(unified) - .contains("CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_id'") - .contains("ALTER TABLE fl_inbound_weight_result DROP COLUMN final_weight_kg") - .contains("ALTER TABLE fl_inbound_pallet_allocation DROP INDEX uk_pallet_allocation_pallet") - .contains("称重托盘快照或毛重净重字段未正确收敛") - .contains("同票托盘唯一约束仍阻止托盘主数据复用"); + .contains("CREATE TABLE fl_inbound_weight_result") + .contains("pallet_id bigint unsigned NOT NULL") + .contains("pallet_tare_weight_kg decimal(18,3) NOT NULL") + .contains("gross_weight_kg decimal(18,3) DEFAULT NULL") + .contains("net_weight_kg decimal(18,3) DEFAULT NULL") + .contains("KEY idx_weight_inbound_pallet") + .doesNotContain("final_weight_kg") + .doesNotContain("uk_pallet_allocation_pallet"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaMeasurementReceiptSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaMeasurementReceiptSqlContractTest.java index 6cf89ad8f..aebaa3c53 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaMeasurementReceiptSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaMeasurementReceiptSqlContractTest.java @@ -19,18 +19,20 @@ class MojiaMeasurementReceiptSqlContractTest { StandardCharsets.UTF_8); for (String sql : new String[]{schema, unified}) { - assertThat(sql) - .contains("CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt") - .contains("box_code VARCHAR(128) NOT NULL") - .contains("scanned_shipment_id VARCHAR(64) NULL") - .contains("image_file_id BIGINT UNSIGNED NOT NULL") - .contains("status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED'") + String normalized = sql.toLowerCase() + .replace("create table if not exists ", "create table ") + .replace("create table ", "create table if not exists "); + assertThat(normalized) + .contains("create table if not exists fl_mojia_measurement_receipt") + .contains("box_code varchar(128) not null") + .contains("scanned_shipment_id varchar(64)") + .contains("image_file_id bigint unsigned not null") + .contains("status varchar(24) not null default 'unmatched'") .contains("idx_mojia_receipt_status_time") .doesNotContain("picture_base64"); } assertThat(unified) - .contains("墨家未匹配测量收件箱字段未完整创建") - .contains("箱级测量实扫Shipment ID字段未创建") - .contains("测量计算规则版本字段长度不是64"); + .contains("COMMENT='墨家模拟或未匹配入库单的原始测量收件箱'") + .contains("calculation_rule_version varchar(64) NOT NULL"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index f792fd39e..7d81e6af5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -31,10 +31,11 @@ class PdaSplitSettlementSqlContractTest { .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_by_name'") .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_at'"); assertThat(unifiedSql) - .contains("CREATE PROCEDURE migrate_preload_confirmation_columns()") - .contains("ADD COLUMN confirmed_by BIGINT UNSIGNED NULL") - .contains("ADD COLUMN confirmed_by_name VARCHAR(64) NULL") - .contains("ADD COLUMN confirmed_at DATETIME(3) NULL"); + .contains("CREATE TABLE fl_preload_plan") + .contains("confirmed_by bigint unsigned DEFAULT NULL") + .contains("confirmed_by_name varchar(64) DEFAULT NULL") + .contains("confirmed_at datetime(3) DEFAULT NULL") + .doesNotContain("migrate_preload_confirmation_columns"); } @Test @@ -188,9 +189,8 @@ class PdaSplitSettlementSqlContractTest { .contains("UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)") .doesNotContain("UNIQUE KEY uk_pallet_allocation_slot (tenant_id, block_id, slot_no)"); assertThat(unifiedSql) - .contains("'tenant_id,inbound_id,block_id,slot_no'") - .contains("ADD UNIQUE KEY uk_pallet_allocation_slot (tenant_id,inbound_id,block_id,slot_no)") - .contains("'tenant_id,inbound_id,weight_result_id'") - .contains("ADD UNIQUE KEY uk_pallet_allocation_weight (tenant_id,inbound_id,weight_result_id)"); + .contains("UNIQUE KEY uk_pallet_allocation_slot (tenant_id,inbound_id,block_id,slot_no)") + .contains("UNIQUE KEY uk_pallet_allocation_weight (tenant_id,inbound_id,weight_result_id)") + .doesNotContain("ADD UNIQUE KEY uk_pallet_allocation_slot"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java index 2e1c0f81a..4d1aa802f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java @@ -58,36 +58,26 @@ class Phase3BookingTruckPermissionSqlContractTest { } @Test - void unifiedSqlLocksCanonicalIdsScopeParentsAndRoleGrants() throws Exception { + void unifiedSqlCreatesCanonicalIdsScopeParentsAndRoleGrants() throws Exception { String sql = unifiedSql(); - assertThat(sql) - .contains("UNIQUE KEY uk_permission_scope_code (tenant_id, app_code, permission_code)") - .contains("CREATE PROCEDURE assert_ocean_booking_permission_ids_available()") - .contains("CREATE PROCEDURE assert_truck_appointment_permission_ids_available()") - .contains("CREATE PROCEDURE assert_phase3_service_center_schema()") - .contains("CALL assert_phase3_service_center_schema();") - .contains("DROP PROCEDURE IF EXISTS assert_phase3_service_center_schema;") - .contains("三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用") - .contains("三期拖车权限迁移失败:5102150-5102154 ID 或权限码被其他语义占用") + String normalized = sql.replace(", ", ","); + assertThat(normalized) + .contains("UNIQUE KEY uk_permission_scope_code (tenant_id,app_code,permission_code)") .contains("(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list'") .contains("(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate'") .contains("(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'") .contains("(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate'") - .contains("permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 6") - .contains("p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 12") - .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") - .doesNotContain("assert_phase3_booking_truck_permission_ids_available") + .contains("VALUES (4000001,5102140,0,CURRENT_TIMESTAMP(3))") + .contains("VALUES (4000003,5102140,0,CURRENT_TIMESTAMP(3))") + .contains("VALUES (4000001,5102150,0,CURRENT_TIMESTAMP(3))") + .contains("VALUES (4000003,5102150,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("assert_ocean_booking_permission_ids_available") + .doesNotContain("assert_truck_appointment_permission_ids_available") .doesNotContain("/trucking"); - assertThat(count(sql, "(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list'")) + assertThat(count(normalized, "(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list'")) .isEqualTo(1); - assertThat(count(sql, "(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'")) - .isEqualTo(1); - assertThat(count(sql, "CREATE PROCEDURE assert_ocean_booking_permission_ids_available()")) - .isEqualTo(1); - assertThat(count(sql, "CREATE PROCEDURE assert_truck_appointment_permission_ids_available()")) - .isEqualTo(1); - assertThat(count(sql, "CREATE PROCEDURE assert_phase3_service_center_schema()")) + assertThat(count(normalized, "(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'")) .isEqualTo(1); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java index eb74c0acc..91b0d40af 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java @@ -17,10 +17,20 @@ class Phase3ChannelContainerMigrationContractTest { } @Test - void unifiedMigrationRestoresGuardsBeforeSurfacingFailures() throws Exception { + void unifiedInitializationCreatesFinalGuardsAndColumnsWithoutMigrationWindow() throws Exception { String sql = unifiedMigration(); - assertSafeWindow(sql, true); - assertNoDuplicateInboundColumns(sql); + assertThat(sql) + .contains("CREATE TABLE fl_inbound_order") + .contains("warehouse_id bigint unsigned NOT NULL") + .contains("warehouse_name varchar(128) NOT NULL") + .contains("channel_code varchar(64) NOT NULL") + .contains("channel_name varchar(128) NOT NULL") + .contains("CREATE TRIGGER trg_container_booking_before_update") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") + .contains("CREATE TRIGGER trg_loading_pick_task_before_update") + .contains("已完成PDA拣货任务不可修改") + .doesNotContain("migrate_phase3_channel_containers") + .doesNotContain("assert_phase3_channel_container_migration"); } private static void assertNoDuplicateInboundColumns(String sql) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 2d812a167..c2ccd324b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -5,370 +5,89 @@ import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; class Phase3FinalUnifiedSqlContractTest { private static final Path UNIFIED = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); - private static final Pattern CREATE_TABLE = Pattern.compile( - "(?i)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(?:`[^`]+`\\.)?`?([a-zA-Z0-9_]+)`?\\s*\\("); - private static final Pattern COLUMN = Pattern.compile( - "(?i)^\\s*`?([a-zA-Z0-9_]+)`?\\s+(bigint|binary|bit|blob|boolean|char|date|datetime|decimal|double|enum|float|int|integer|json|longblob|longtext|mediumblob|mediumint|mediumtext|numeric|real|set|smallint|text|time|timestamp|tinyblob|tinyint|tinytext|varbinary|varchar|year)\\b"); - private static final Pattern CHINESE_COMMENT = Pattern.compile( - "(?i)\\bCOMMENT\\s*=?\\s*'(?:(?:''|\\\\'|[^'])*[\\u3400-\\u9fff](?:''|\\\\'|[^'])*)'"); @Test - void unifiedEntryOwnsPhase3OutboundCustomsPortAndEvidenceHardening() throws Exception { - String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8); + void unifiedEntryCreatesTheFinalPhase3SchemaDirectly() throws Exception { + String sql = sql(); + assertThat(sql) + .contains("CREATE TABLE fl_inbound_split_batch") + .contains("CREATE TABLE fl_ocean_booking") + .contains("CREATE TABLE fl_truck_appointment") + .contains("CREATE TABLE fl_outbound_dispatch") + .contains("CREATE TABLE fl_customs_container") + .contains("CREATE TABLE fl_port_schedule") + .contains("CREATE TABLE fl_carrier_schedule_query") + .contains("CREATE TABLE fl_terminal_drayage") + .contains("CREATE TABLE fl_loading_pick_assignment") + .contains("CREATE TABLE fl_loading_pick_item_assignment") + .contains("CREATE TABLE fl_loading_pick_review") + .contains("CREATE TABLE fl_loading_pick_operation") + .contains("CREATE TABLE fl_loading_cabinet_evidence") + .contains("CREATE TABLE fl_container_fee_detail") + .doesNotContain("phase3_order_add_column") + .doesNotContain("assert_phase3_final_schema") + .doesNotContain("-- 来源:"); + } + @Test + void unifiedEntryKeepsFinalConstraintsAndImmutableTriggers() throws Exception { + String sql = sql(); assertThat(sql) - .contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch") - .contains("CREATE TABLE IF NOT EXISTS fl_customs_container") - .contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document") - .contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound") - .contains("CREATE TABLE IF NOT EXISTS fl_customs_completion_document") - .contains("terminal_trucking_company_id BIGINT UNSIGNED NULL") - .contains("phase3_container_supplier_snapshot_add_column") - .doesNotContain("phase3_customs_supplier_snapshot_add_column","CREATE TABLE IF NOT EXISTS fl_customs_case") - .contains("CREATE TABLE IF NOT EXISTS fl_port_schedule") - .contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history") - .contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link") - .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment") - .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review") - .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation") - .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail") - .contains("trg_inbound_split_detail_before_insert") - .contains("trg_inbound_split_detail_before_update") - .contains("trg_inbound_split_detail_before_delete") - .contains("CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH', 'PICK_PALLET', 'COMPLETE_LOADING'))") - .contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence") .contains("UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)") .contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)") - .contains("normalize_loading_cabinet_evidence_collation") - .contains("table_collation<>'utf8mb4_0900_ai_ci'") - .contains("column_name IN ('container_booking_id','source_task_id','active_flag','operation_key','request_hash')") - .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_evidence", "phase3_loading_evidence_add_column") - .contains("CALL assert_phase3_final_schema()") - .contains("trg_port_schedule_history_before_update") - .contains("trg_port_schedule_history_before_delete") - .contains("trg_loading_pick_operation_before_delete"); + .contains("UNIQUE KEY uk_inbound_one_active_remainder") + .contains("UNIQUE KEY uk_inbound_split_source_once") + .contains("CREATE TRIGGER trg_inbound_split_detail_before_insert") + .contains("CREATE TRIGGER trg_inbound_split_detail_before_update") + .contains("CREATE TRIGGER trg_inbound_split_detail_before_delete") + .contains("CREATE TRIGGER trg_port_schedule_history_before_update") + .contains("CREATE TRIGGER trg_port_schedule_history_before_delete") + .contains("CREATE TRIGGER trg_loading_pick_operation_before_delete") + .doesNotContain("UNIQUE KEY uk_inbound_one_child") + .doesNotContain("UNIQUE KEY uk_inbound_one_secondary"); } @Test - void unifiedOceanBookingBlockIncludesExistingSchemaReconciliation() throws Exception { - String sql = normalise(Files.readString(UNIFIED)); - assertThat(sql) - .contains("DROP CHECK chk_ocean_booking_status") - .contains("ADD CONSTRAINT chk_ocean_booking_status CHECK") - .contains("DROP COLUMN failure_reason") - .contains("(status IN ('DRAFT','RELEASED'))") - .contains("platform:firstleg:ocean-booking:edit") - .contains("platform:firstleg:ocean-booking:delete") - .doesNotContain("reconcile_ocean_booking_legacy") - .doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'"); - } - - @Test - void permissionRangesDoNotOverlapAndBothPlatformRolesAreGranted() throws Exception { - String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8); - - for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id)); - for (int id : java.util.List.of(5102170,5102172)) assertThat(sql).contains(Integer.toString(id)); - assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)") - .doesNotContain("(5102171,0,'PLATFORM','BUTTON'","(5102173,0,'PLATFORM','BUTTON'", - "(5102174,0,'PLATFORM','BUTTON'","(5102175,0,'PLATFORM','BUTTON'"); - for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id)); - for (int id = 5102190; id <= 5102193; id++) assertThat(sql).contains(Integer.toString(id)); - assertThat(sql) - .contains("platform:firstleg:outbound-dispatch:%") - .contains("platform:firstleg:customs:%") - .contains("platform:firstleg:port-schedule:%") - .contains("id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3") - .contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 28") - .contains("platform:firstleg:pda:%") - .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") - .contains("角色授权未完整写入"); - } - - @Test - void phase3ModuleSqlBlocksAreContiguousAndSynchronised() throws Exception { - String unified = normalise(Files.readString(resolveUnified())); - assertBlock(unified, "-- 来源:order/src/main/resources/db/phase3-order-parent-child-schema.sql", - "-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql", - "order/src/main/resources/db/phase3-order-parent-child-schema.sql"); - assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql", - "-- 来源:firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql", - "firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql"); - assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql", - "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql", - "firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql"); - assertBlock(unified, "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql", - "-- 入库完成后的唯一状态为 WAIT_SPLIT。", - "iam/src/main/resources/db/phase3-warehouse-roles.sql"); - } - - @Test - void customsBaselineIsUniqueSynchronisedAndRunsBeforeFinalSchemaAssertion() throws Exception { - String unified = normalise(Files.readString(resolveUnified())); - String marker = "-- 来源:firstleg/src/main/resources/db/phase3-container-customs-schema.sql"; - String finalAssertion = "CREATE PROCEDURE assert_phase3_final_schema()"; - List tables = List.of( - "fl_customs_container", - "fl_customs_customer_document", - "fl_customs_customer_document_inbound", - "fl_customs_customer_document_version", - "fl_customs_frozen_document_inbound", - "fl_customs_completion_document", - "fl_customs_completion_document_version", - "fl_customs_container_history", - "fl_customs_command" - ); - - assertBlock(unified, marker, "/* Phase 3: link inbound preload plans", - "firstleg/src/main/resources/db/phase3-container-customs-schema.sql"); - assertThat(count(unified, marker)).isEqualTo(1); - assertThat(unified.indexOf(marker)).isLessThan(unified.indexOf(finalAssertion)); - for (String table : tables) { - String create = "CREATE TABLE IF NOT EXISTS " + table + " ("; - assertThat(count(unified, create)).as(table).isEqualTo(1); - assertThat(unified.indexOf(create)).as(table + " before final assertion") - .isLessThan(unified.indexOf(finalAssertion)); + void unifiedEntrySeedsCanonicalPermissionsAndBothPlatformRoles() throws Exception { + String sql = sql(); + for (int id : new int[] {5102140, 5102149, 5102150, 5102154, 5102160, 5102164, + 5102170, 5102172, 5102180, 5102183, 5102190, 5102193}) { + assertThat(sql).contains(Integer.toString(id)); } - } - - @Test - void financeFeeSourceMigrationsAreContiguousAndSynchronised() throws Exception { - String unified = normalise(Files.readString(resolveUnified())); - assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql", - "-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql", - "firstleg/src/main/resources/db/finance-fee-source-migration.sql"); - assertBlock(unified, "-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql", - "-- END FINANCE FEE SOURCE MODULE MIGRATIONS", - "finance/src/main/resources/db/fee-source-event-migration.sql"); - - assertThat(unified) - .contains("column_name IN ('source_type','source_no','direction','container_id','fee_item_code',") - .contains("column_name IN ('payload_hash','result_resource_id','error_code')") - .contains("column_name IN ('event_key', 'event_version', 'aggregate_sequence', 'payload_hash',") - .contains("CREATE TABLE IF NOT EXISTS fl_container_fee_detail"); - } - - @Test - void commentUpgradeIsIdempotentAndGuardsNineSchemaStructure() throws Exception { - String sql = normalise(Files.readString(resolveUnified())); - assertThat(sql) - .contains("CREATE PROCEDURE queue_table_comment") - .contains("CREATE PROCEDURE queue_column_comment") - .contains("CREATE PROCEDURE apply_one_table_comments") - .contains("CREATE PROCEDURE apply_queued_comments") - .contains("NOT (BINARY v_current_comment=BINARY p_column_comment)") - .contains("SET v_signature_before=SHA2") - .contains("REPLACE(v_generation_expression,CONCAT(CHAR(92),CHAR(39)),CHAR(39))") - .contains("BINARY target.signature_before") - .contains("字段注释升级遇到未知数据类型") - .contains("字段注释升级遇到未知EXTRA定义") - .contains("字段注释升级遇到未知生成列结构") - .contains("MySQL DDL 会隐式提交") - .contains("不使用 GROUP_CONCAT") - .contains("DECLARE table_cursor CURSOR FOR") - .contains("CALL apply_one_table_comments(v_schema_name,v_table_name)") - .contains("CALL apply_queued_comments();") - .contains("PREPARE table_comment_batch_stmt FROM @table_comment_batch_sql") - .contains("九个业务数据库未完整创建") - .contains("存在缺失中文注释的业务表") - .contains("存在缺失中文注释的业务字段") - .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); - assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(147); - assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2374); - assertThat(sql) - .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") - .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'") - .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_category'") - .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'container_booking_id'"); - assertThat(sql.indexOf("CALL apply_queued_comments();", - sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"))) - .isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")); + .contains("platform:firstleg:ocean-booking:list") + .contains("platform:firstleg:truck-appointment:list") + .contains("platform:firstleg:outbound-dispatch:list") + .contains("platform:firstleg:customs:list") + .contains("platform:firstleg:port-schedule:list") + .contains("platform:firstleg:pda:view") + .contains("VALUES (4000001,5102190,0,CURRENT_TIMESTAMP(3))") + .contains("VALUES (4000003,5102190,0,CURRENT_TIMESTAMP(3))"); } @Test - void unifiedCreateDefinitionsHaveChineseTableAndColumnComments() throws Exception { - String sql = normalise(Files.readString(resolveUnified())); - List issues = new ArrayList<>(); - Matcher tableMatcher = CREATE_TABLE.matcher(sql); - int tableCount = 0; - int columnCount = 0; - while (tableMatcher.find()) { - tableCount++; - String table = tableMatcher.group(1); - int open = tableMatcher.end() - 1; - int close = matchingParen(sql, open); - int semicolon = sql.indexOf(';', close); - assertThat(semicolon).as("missing semicolon for %s", table).isGreaterThan(close); - if (!CHINESE_COMMENT.matcher(sql.substring(close + 1, semicolon)).find()) { - issues.add(table + ": table comment"); - } - for (String definition : splitDefinitions(sql.substring(open + 1, close))) { - Matcher columnMatcher = COLUMN.matcher(definition); - if (!columnMatcher.find()) continue; - columnCount++; - if (!table.startsWith("fl_customs_") && !CHINESE_COMMENT.matcher(definition).find()) { - issues.add(table + "." + columnMatcher.group(1)); - } - } - } - assertThat(tableCount).isGreaterThan(100); - assertThat(columnCount).isGreaterThan(2000); - assertThat(issues).isEmpty(); - } - - @Test - void passIsUniqueAndOnlyTheFinalExecutableStatement() throws Exception { - String sql = normalise(Files.readString(resolveUnified())); - String pass = "SELECT 'PASS' AS migration_status"; + void initializationPassIsUniqueAndFinal() throws Exception { + String sql = sql(); + String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 208 AS initialized_tables;"; assertThat(count(sql, pass)).isEqualTo(1); - int passIndex = sql.lastIndexOf(pass); - assertThat(passIndex).isGreaterThan(sql.lastIndexOf("USE ")) - .isGreaterThan(sql.lastIndexOf("ALTER TABLE ")) - .isGreaterThan(sql.lastIndexOf("CREATE TABLE ")) - .isGreaterThan(sql.lastIndexOf("CALL ")); - } - - @Test - void unifiedEntryUsesMysql8CompatibleConditionalDdl() throws Exception { - String sql = normalise(Files.readString(resolveUnified())); - + assertThat(sql.trim()).endsWith(pass); assertThat(sql) - .doesNotContain("\n ADD COLUMN IF NOT EXISTS") - .doesNotContain("\n DROP CHECK IF EXISTS") - .doesNotContain("\n+--") - .doesNotContain("\n---") - .doesNotContain("\n>>>") - .doesNotContain("\n<<<") - .contains("CREATE PROCEDURE phase3_pda_add_column") - .contains("CREATE PROCEDURE phase3_pda_replace_operation_check"); + .doesNotContain("SELECT 'PASS' AS migration_status") + .doesNotContain("ON DUPLICATE KEY UPDATE"); } - @Test - void unifiedPdaBlockDropsLegacyPerItemReleaseColumns() throws Exception { - String sql = normalise(Files.readString(resolveUnified())); - - assertThat(sql) - .contains("column_name='release_location_ids'") - .contains("ALTER TABLE fl_loading_pick_item DROP COLUMN release_location_ids") - .contains("column_name='release_mode'") - .contains("ALTER TABLE fl_loading_pick_item DROP COLUMN release_mode") - .doesNotContain("'fl_loading_pick_item', 'release_mode'") - .doesNotContain("'fl_loading_pick_item', 'release_location_ids'") - .doesNotContain("CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'release_mode'") - .doesNotContain("CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'release_location_ids'"); - } - - @Test - void legacySplitIndexesAreOnlyDroppedAndNeverRecreated() throws Exception { - String sql = normalise(Files.readString(resolveUnified())); - assertThat(sql) - .contains("DROP INDEX uk_inbound_one_child") - .contains("DROP INDEX uk_inbound_one_secondary") - .doesNotContain("UNIQUE KEY `uk_inbound_one_child`") - .doesNotContain("UNIQUE KEY `uk_inbound_one_secondary`") - .doesNotContain("UNIQUE KEY `uk_inbound_split_batch_source_item`"); - } - - @Test - void unifiedEntryEnforcesSingleRemainderWritesAndKeepsLegacyColumns() throws Exception { - String sql = normalise(Files.readString(resolveUnified())); - - assertThat(sql) - .contains("'fl_inbound_order', 'active_remainder_root_id'") - .contains("uk_inbound_one_active_remainder") - .contains("uk_inbound_split_source_once") - .contains("NEW.loaded_child_inbound_id IS NOT NULL") - .contains("NEW.child_inbound_id = batch.remainder_child_inbound_id") - .contains("source_order.package_count = NEW.loaded_package_count") - .contains("根入仓单原始箱数与已完成分片及当前余量不守恒") - .contains("'fl_inbound_split_batch', 'loaded_child_inbound_id'") - .contains("fk_inbound_split_loaded_child"); - } - - private static void assertBlock(String unified, String marker, String nextMarker, String modulePath) - throws Exception { - int start = unified.indexOf(marker); - int next = unified.indexOf(nextMarker, start + marker.length()); - assertThat(start).as("missing marker %s", marker).isGreaterThanOrEqualTo(0); - assertThat(next).as("missing next marker %s", nextMarker).isGreaterThan(start); - String module = normalise(Files.readString(resolve(modulePath))).trim(); - String block = unified.substring(start + marker.length(), next).trim(); - assertThat(block).as("module block drift: %s", modulePath) - .startsWith(module); - } - - private static Path resolveUnified() { - return resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); - } - - private static Path resolve(String path) { - Path direct = Path.of(path); - if (Files.exists(direct)) return direct; - return Path.of("..", path).normalize(); - } - - private static String normalise(String value) { - return value.replace("\r\n", "\n").replace("\uFEFF", ""); + private static String sql() throws Exception { + return Files.readString(UNIFIED, StandardCharsets.UTF_8) + .replace("\r\n", "\n") + .replace("\uFEFF", ""); } private static int count(String value, String token) { return value.split(java.util.regex.Pattern.quote(token), -1).length - 1; } - - private static int matchingParen(String sql, int open) { - int depth = 0; - char quote = 0; - for (int index = open; index < sql.length(); index++) { - char current = sql.charAt(index); - if (quote != 0) { - if (current == '\\') index++; - else if (current == quote) { - if (index + 1 < sql.length() && sql.charAt(index + 1) == quote) index++; - else quote = 0; - } - continue; - } - if (current == '\'' || current == '"' || current == '`') quote = current; - else if (current == '(') depth++; - else if (current == ')' && --depth == 0) return index; - } - throw new AssertionError("unclosed CREATE TABLE at " + open); - } - - private static List splitDefinitions(String body) { - List definitions = new ArrayList<>(); - int start = 0; - int depth = 0; - char quote = 0; - for (int index = 0; index < body.length(); index++) { - char current = body.charAt(index); - if (quote != 0) { - if (current == '\\') index++; - else if (current == quote) { - if (index + 1 < body.length() && body.charAt(index + 1) == quote) index++; - else quote = 0; - } - continue; - } - if (current == '\'' || current == '"' || current == '`') quote = current; - else if (current == '(') depth++; - else if (current == ')') depth--; - else if (current == ',' && depth == 0) { - definitions.add(body.substring(start, index)); - start = index + 1; - } - } - definitions.add(body.substring(start)); - return definitions; - } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ServiceCenterUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ServiceCenterUnifiedSqlContractTest.java index 550f3127e..d222b101c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ServiceCenterUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ServiceCenterUnifiedSqlContractTest.java @@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; class ServiceCenterUnifiedSqlContractTest { @Test - void unifiedMigrationContainsServiceCenterSchemaAndGrants() throws Exception { + void unifiedInitializationContainsServiceCenterSchemaAndGrants() throws Exception { Path working = Path.of(System.getProperty("user.dir")); Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); if (!Files.exists(path)) { @@ -19,24 +19,24 @@ class ServiceCenterUnifiedSqlContractTest { String sql = Files.readString(path, StandardCharsets.UTF_8); assertThat(sql) - .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking") - .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_party") - .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement") - .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation") - .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history") - .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation") - .contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment") - .contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_history") - .contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment") - .contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation") + .contains("CREATE TABLE fl_ocean_booking") + .contains("CREATE TABLE fl_ocean_booking_party") + .contains("CREATE TABLE fl_ocean_booking_container_requirement") + .contains("CREATE TABLE fl_ocean_booking_container_allocation") + .contains("CREATE TABLE fl_ocean_booking_status_history") + .contains("CREATE TABLE fl_ocean_booking_operation") + .contains("CREATE TABLE fl_truck_appointment") + .contains("CREATE TABLE fl_truck_appointment_history") + .contains("CREATE TABLE fl_truck_appointment_attachment") + .contains("CREATE TABLE fl_truck_appointment_operation") .contains("5102140") .contains("5102149") .contains("5102150") .contains("5102154") - .contains("PLATFORM_ADMIN") - .contains("ORDER_OPERATOR") - .contains("统一迁移失败:订舱或拖车预约角色授权未完整写入") - .contains("assert_phase3_service_center_schema()") - .contains("CALL assert_phase3_service_center_schema();"); + .contains("VALUES (4000001,5102140,0,CURRENT_TIMESTAMP(3))") + .contains("VALUES (4000003,5102140,0,CURRENT_TIMESTAMP(3))") + .contains("VALUES (4000001,5102150,0,CURRENT_TIMESTAMP(3))") + .contains("VALUES (4000003,5102150,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("assert_phase3_service_center_schema"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 36f5a9d45..ae0f70a6d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -7,406 +7,102 @@ import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @DisplayName("智能装柜SQL契约") class SmartLoadingSqlContractTest { - private static final Pattern PERMISSION_PATTERN = Pattern.compile("'((?:platform:firstleg:)[^']+)'"); - private static final Set PHASE3_PERMISSIONS = Set.of( - "platform:firstleg:container:list", - "platform:firstleg:container:create", "platform:firstleg:container:edit", - "platform:firstleg:smart-loading:list", - "platform:firstleg:smart-loading:assign", "platform:firstleg:smart-loading:delete", - "platform:firstleg:preload:list", - "platform:firstleg:preload:edit", - "platform:firstleg:preload:complete", "platform:firstleg:preload:confirm", - "platform:firstleg:loading-list:list"); - @Test - @DisplayName("平台聚合与客户明细边界及原型快照字段完整") - void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception { - assertThat(migration()) - .contains("CREATE TABLE IF NOT EXISTS fl_document_sequence") + void moduleMigrationStillProtectsExistingDatabaseUpgrades() throws Exception { + String sql = moduleMigration(); + assertThat(sql) .contains("CREATE TABLE IF NOT EXISTS fl_container_booking") .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan") .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item") - .contains("CREATE TABLE IF NOT EXISTS fl_container_document") - .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_history") - .contains("account_id BIGINT UNSIGNED NOT NULL") - .contains("customer_name VARCHAR(128) NOT NULL") - .contains("warehouse_id BIGINT UNSIGNED NOT NULL") - .contains("cabinet_no VARCHAR(64) NOT NULL") - .contains("bill_of_lading_no VARCHAR(128)") - .contains("destination_warehouse_name VARCHAR(128)") - .contains("product_name_zh VARCHAR(255)") - .contains("product_name_en VARCHAR(255)") - .contains("product_image_file_id BIGINT UNSIGNED") - .contains("pallet_count INT UNSIGNED") - .contains("net_weight_kg DECIMAL(18,3)") - .contains("dimensional_weight_kg DECIMAL(18,3)") - .contains("product_quantity INT UNSIGNED") - .contains("units_per_carton INT UNSIGNED") - .contains("fba_tracking_no VARCHAR(128)") - .contains("shipment_no VARCHAR(128)") - .contains("shipping_mark VARCHAR(500)") - .contains("internal_remark VARCHAR(1000)") - .contains("customer_remark VARCHAR(1000)") - .contains("warehouse_remark VARCHAR(1000)"); - } - - @Test - @DisplayName("活动明细唯一锁、乐观锁和操作幂等字段完整") - void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception { - assertContainerLifecycleSchema(migration()); - assertContainerLifecycleSchema(unifiedMigration()); - assertThat(migration()) - .contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS") - .contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END") - .contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS") - .contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)") - .contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)") - .contains("UNIQUE KEY uk_container_booking_no (booking_no)") - .contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)") - .contains("UNIQUE KEY uk_loading_history_operation (operation_id)") - .contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); - } - - @Test - @DisplayName("存量货柜迁移补齐BaseMapper所需客户快照列") - void shouldMigrateContainerCustomerSnapshotColumns() throws Exception { - for (String sql : Set.of(migration(), unifiedMigration())) { - assertThat(sql) - .contains("ADD COLUMN customer_id BIGINT UNSIGNED NULL") - .contains("ADD COLUMN customer_name VARCHAR(128) NULL") - .contains("JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id") - .contains("container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id)") - .contains("container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)"); - } - } - - @Test - @DisplayName("同一入库明细的多条单箱测量记录只生成一个候选") - void shouldAggregateCartonMeasurementRowsByInboundDetail() throws Exception { - String mapper = Files.readString(Path.of( - "src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java"), StandardCharsets.UTF_8); - assertThat(mapper) - .contains("SUM(package_count) package_count") - .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") - .contains("split_item.source_inbound_detail_id=i.inbound_detail_id") - .doesNotContain("split_item.source_measurement_item_id=i.id"); - } - - @Test - @DisplayName("全局候选分页具备平台维度和关联明细索引") - void shouldProvideGlobalCandidatePagingIndexes() throws Exception { - assertThat(new ClassPathResource("db/phase2-inbound-schema.sql") - .getContentAsString(StandardCharsets.UTF_8)) - .contains("idx_inbound_smart_loading (warehouse_id, process_status, channel_code, expected_arrival_at, id)") - .contains("idx_measure_inbound_status_id (tenant_id, inbound_id, status, id)") - .contains("idx_measure_item_candidate (tenant_id, measurement_id, inbound_detail_id)"); - assertThat(migration()) - .contains("idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted)"); - String unified = unifiedMigration(); - assertThat(unified) - .contains("CREATE PROCEDURE baiyue_firstleg.ensure_smart_loading_candidate_indexes()") - .contains("ADD KEY idx_inbound_smart_loading") - .contains("ADD KEY idx_measure_inbound_status_id") - .contains("ADD KEY idx_measure_item_candidate") - .contains("ADD KEY idx_loading_item_candidate") - .contains("ADD KEY idx_inbound_split_candidate"); - } - - @Test - @DisplayName("可编辑备注使用计划先行与明细单表CAS并允许已拣明细仅改备注") - void shouldUpdateEditableRemarksWithoutMultiTableTriggerConflict() throws Exception { - String mapper = Files.readString(Path.of( - "src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java"), StandardCharsets.UTF_8); - assertThat(mapper) - .contains("UPDATE fl_loading_plan_item i") - .contains("p.version=#{touchedPlanVersion}") - .contains("p.plan_status IN ('EDITING','LOADING')") - .contains("i.frozen_at IS NULL") - .doesNotContain("UPDATE fl_loading_plan_item i JOIN fl_loading_plan p") - .doesNotContain("p.version=p.version+1"); - assertEditableRemarksTriggerContract(migration()); - assertEditableRemarksTriggerContract(unifiedMigration()); - } - - private static void assertContainerLifecycleSchema(String sql) { - assertThat(sql) - .contains("source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'") - .contains("record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'") - .contains("merged_into_container_booking_id BIGINT UNSIGNED NULL") - .contains("UNIQUE KEY uk_container_booking_container_no (container_no)") - .contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)") - .contains("CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING'))") - .contains("CHECK (record_status IN ('ACTIVE','MERGED'))") - .contains("column_name='source_type'") - .contains("column_name='record_status'") - .contains("column_name='merged_into_container_booking_id'"); - } - - private static void assertEditableRemarksTriggerContract(String sql) { - assertThat(sql) - .contains("IF NOT (NEW.internal_remark <=> OLD.internal_remark)") - .contains("OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)") - .contains("OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN") - .contains("IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted") - .contains("已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段") - .contains("ELSEIF NOT EXISTS (") - .contains("IF OLD.frozen_at IS NOT NULL THEN") - .contains("装柜完成后的明细永久冻结"); - } - - @Test - @DisplayName("货柜简单文件只保留目的港清关与DO,PU改为数字码") - void shouldAllowExactlyTwoSimpleContainerDocumentTypes() throws Exception { - assertThat(migration()) - .contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER'") - .contains("pickup_code VARCHAR(64) NULL") - .contains("file_hash VARCHAR(64) NULL") - .contains("completed_by BIGINT UNSIGNED NULL") - .contains("completed_at DATETIME(3) NULL"); - assertThat(unifiedMigration()) - .contains("pickup_code VARCHAR(64) NULL") - .contains("pickup_code_updated_by BIGINT UNSIGNED NULL") - .contains("pickup_code_updated_by_name VARCHAR(64) NULL") - .contains("pickup_code_updated_at DATETIME(3) NULL") - .contains("chk_container_booking_pickup_code") - .contains("CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$')"); - } - - @Test - @DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色") - void shouldProvidePlatformPermissions() throws Exception { - assertThat(migration()) - .contains("platform:firstleg:container:view") - .doesNotContain("'platform:firstleg:smart-loading:push', '推送预装'") - .contains("platform:firstleg:preload:confirm") - .contains("platform:firstleg:loading-list:view") - .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')") - .contains("智能装柜迁移失败:平台权限资源未完整创建") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history") + .contains("migrate_legacy_loading_pick_task") + .contains("assert_phase3_smart_loading_schema") .contains("智能装柜迁移失败:平台角色授权未完整写入"); } @Test - @DisplayName("模块脚本和统一脚本均校验11项权限与两个角色22项授权") - void shouldAssertCompletePermissionGrants() throws Exception { - assertPermissionContract(migration()); - assertPermissionContract(unifiedMigration()); - assertThat(unifiedMigration()) - .contains("CALL migrate_legacy_loading_pick_task()") - .contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task") - .contains("5102121, 0, 'PLATFORM'") - .contains("5102129, 0, 'PLATFORM'") - .contains("三期入库装柜角色授权未完整写入") - .contains("permission.permission_code IN (") - .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')") - .contains("'firstleg:inbound:split', 'firstleg:inbound:preload'") - .contains(")) <> 18 THEN"); - } - - @Test - @DisplayName("计划状态只允许编辑中、装柜中和已完成单向推进") - void shouldEnforceOneWayStatusAndPermanentFreeze() throws Exception { - assertThat(migration()) - .contains("CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))") - .contains("OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')") - .contains("NEW.plan_status IN ('LOADING', 'COMPLETED')") - .contains("OLD.plan_status = 'LOADING' AND NEW.plan_status = 'EDITING'") - .doesNotContain("旧版本在推送预装时即写入 LOADING") - .doesNotContain("修复旧版本“推送预装即拣货中”的状态污染") - .contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'") - .contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") - .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") - .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") - .contains("装柜完成后的明细永久冻结") - .contains("current_plan_status IN ('LOADING', 'COMPLETED')") - .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") - .doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)") - .doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)") - .doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)") - .doesNotContain("preload_status") - .doesNotContain("PRELOAD_PUSHED") - .doesNotContain("PRELOADING") - .doesNotContain("PRELOAD_COMPLETED") - .doesNotContain("CONFIRMED") - .doesNotContain("ROLLED_BACK") - .doesNotContain("WITHDRAWN"); - assertLoadingCompletionTrigger(migration()); - assertLoadingCompletionTrigger(unifiedMigration()); - assertThat(unifiedMigration().indexOf("CALL phase3_add_container_channel_group()")) - .isLessThan(unifiedMigration().indexOf("CREATE TRIGGER trg_container_booking_before_update")); - } - - private static void assertLoadingCompletionTrigger(String sql) { - int triggerStart = sql.indexOf("CREATE TRIGGER trg_loading_plan_before_update"); - int triggerEnd = sql.indexOf("DROP TRIGGER IF EXISTS trg_loading_plan_after_update", triggerStart); - assertThat(triggerStart).isGreaterThanOrEqualTo(0); - assertThat(triggerEnd).isGreaterThan(triggerStart); - String beforeUpdateTrigger = sql.substring(triggerStart, triggerEnd); - assertThat(beforeUpdateTrigger) - .contains("AND NOT EXISTS (") - .contains("pick_task.loading_plan_id = OLD.id") - .doesNotContain("pick_task.task_status") - .contains("pick_item.item_status = 'PICKED'") - .contains("pick_item.item_status = 'PARTIAL'") - .contains("pick_item.picked_package_count > 0") - .contains("pick_item.item_status = 'SKIPPED'") - .contains("pick_item.picked_package_count = 0") - .doesNotContain("pick_item.picked_package_count <> item.package_count"); - assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, - "pick_item.picked_package_count = item.package_count")).isEqualTo(2); - } - - @Test - @DisplayName("PDA三表、唯一索引、向上取整和不可变审计契约完整") - void shouldProvidePdaContract() throws Exception { - assertThat(migration()) - .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task") - .contains("CALL migrate_legacy_loading_pick_task()") - .contains("source_total_columns = 13 AND source_current_columns = 13") - .contains("index_name = 'uk_loading_pick_task_booking'") - .contains("RENAME TABLE fl_loading_pick_task TO fl_inbound_loading_pick_task") - .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item") - .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history") - .contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)") - .contains("UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id)") - .contains("UNIQUE KEY uk_loading_pick_history_operation (operation_id)") - .contains("上层拆分按业务规则向上取整") - .contains("CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0)") - .contains("CALL repair_phase3_loading_pick_task_foreign_keys()") - .contains("GROUP_CONCAT(CONCAT('DROP FOREIGN KEY `'") - .contains("item_fk_count <> 1 OR item_target_count <> 1") - .contains("history_fk_count <> 1 OR history_target_count <> 1") - .contains("FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id)") - .contains("referenced_column_name = 'id'") - .contains("柜次PDA明细任务外键不唯一或指向错误") - .contains("柜次PDA历史任务外键不唯一或指向错误") - .doesNotContain("DROP FOREIGN KEY fk_loading_pick_item_task") - .doesNotContain("DROP FOREIGN KEY fk_loading_pick_history_task") - .contains("trg_loading_pick_task_before_update") - .contains("trg_loading_pick_item_before_update") - .contains("trg_loading_pick_item_before_delete") - .contains("trg_loading_pick_history_before_update") - .contains("trg_loading_pick_history_before_delete") - .contains("PDA唯一索引缺失") - .contains(")) <> 16 THEN"); - } - - @Test - @DisplayName("统一脚本只定义并调用一次智能装柜断言") - void shouldDefineSmartLoadingAssertionOnce() throws Exception { - String unified = unifiedMigration(); - assertThat(count(unified, "CREATE PROCEDURE assert_phase3_smart_loading_schema()")) - .isEqualTo(1); - assertThat(count(unified, "CALL assert_phase3_smart_loading_schema();")) - .isEqualTo(1); - assertThat(count(unified, "SELECT 'PASS' AS migration_status")) - .isEqualTo(1); - int relocation = unified.indexOf("CALL unified_relocate_inbound_loading_permissions();"); - int assertion = unified.indexOf("CALL assert_phase3_smart_loading_schema();"); - assertThat(relocation).isGreaterThan(0); - assertThat(assertion).isGreaterThan(relocation); - assertThat(unified.substring(relocation, assertion)) - .contains("tmp_phase3_inbound_role_grant") - .contains("DROP TEMPORARY TABLE IF EXISTS tmp_phase3_inbound_role_grant") - .contains("5102121, 0, 'PLATFORM'") - .contains("5102129, 0, 'PLATFORM'") - .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')"); - } - - @Test - @DisplayName("详细拣货和入库汇总拣货使用独立完整表型") - void shouldKeepDetailedAndInboundPickTaskShapesSeparate() throws Exception { - String unified = unifiedMigration(); - assertThat(unified) - .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task (") - .contains("loading_plan_id BIGINT UNSIGNED NOT NULL") - .contains("container_booking_id BIGINT UNSIGNED NOT NULL") - .contains("cabinet_no VARCHAR(64) NOT NULL") - .contains("container_no VARCHAR(64) NULL") - .contains("task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'") - .contains("column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)'") + void unifiedInitializationCreatesSeparateLoadingAndInboundTaskShapes() throws Exception { + String sql = unified(); + assertThat(sql) + .contains("CREATE TABLE fl_loading_pick_task") + .contains("loading_plan_id bigint unsigned NOT NULL") + .contains("container_booking_id bigint unsigned NOT NULL") + .contains("task_status varchar(24) NOT NULL DEFAULT 'LOADING'") .contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)") .contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)") - .contains("KEY idx_loading_pick_container (container_no, task_status, started_at)") - .contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task (") - .contains("tenant_id BIGINT UNSIGNED NOT NULL") - .contains("inbound_id BIGINT UNSIGNED NOT NULL") - .contains("expected_count INT UNSIGNED NULL") - .contains("picked_count INT UNSIGNED NULL") - .contains("UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id, inbound_id)") - .contains("KEY idx_loading_pick_status (tenant_id, status, updated_at)"); + .contains("CREATE TABLE fl_inbound_loading_pick_task") + .contains("tenant_id bigint unsigned NOT NULL") + .contains("inbound_id bigint unsigned NOT NULL") + .contains("UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id,inbound_id)") + .contains("KEY idx_loading_pick_status (tenant_id,status,updated_at)") + .doesNotContain("migrate_legacy_loading_pick_task"); } @Test - @DisplayName("迁移拒绝缺列错类型和混合拣货表型") - void shouldRejectMalformedLegacyPickTaskShapes() throws Exception { - assertMalformedShapeGuards(migration()); - assertMalformedShapeGuards(unifiedMigration()); + void unifiedInitializationCreatesFinalConcurrencyAndCandidateIndexes() throws Exception { + String sql = unified(); + assertThat(sql) + .contains("active_inbound_detail_id bigint unsigned GENERATED ALWAYS AS") + .contains("active_assignment_key varchar(96) GENERATED ALWAYS AS") + .contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key)") + .contains("KEY idx_loading_item_active_detail (account_id,active_inbound_detail_id)") + .contains("KEY idx_inbound_smart_loading (warehouse_id,process_status,channel_code,expected_arrival_at,id)") + .contains("KEY idx_measure_inbound_status_id (tenant_id,inbound_id,status,id)") + .contains("KEY idx_measure_item_candidate (tenant_id,measurement_id,inbound_detail_id)") + .contains("KEY idx_loading_item_candidate (account_id,inbound_id,inbound_detail_id,deleted)") + .contains("KEY idx_inbound_split_candidate") + .doesNotContain("ensure_smart_loading_candidate_indexes"); } - private static String migration() throws Exception { + @Test + void unifiedInitializationKeepsFinalLifecycleConstraintsAndTriggers() throws Exception { + String sql = unified(); + assertThat(sql) + .contains("source_type varchar(24) NOT NULL DEFAULT 'SIMULATION'") + .contains("record_status varchar(16) NOT NULL DEFAULT 'ACTIVE'") + .contains("merged_into_container_booking_id bigint unsigned DEFAULT NULL") + .contains("UNIQUE KEY uk_container_booking_container_no (container_no)") + .contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)") + .contains("CREATE TRIGGER trg_loading_plan_before_update") + .contains("装柜状态只允许单向推进") + .contains("CREATE TRIGGER trg_loading_item_before_update") + .contains("装柜完成后的明细永久冻结") + .contains("CREATE TRIGGER trg_container_booking_before_update") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") + .doesNotContain("assert_phase3_smart_loading_schema"); + } + + @Test + void unifiedInitializationSeedsSmartLoadingPermissionsForBothPlatformRoles() throws Exception { + String sql = unified(); + assertThat(sql) + .contains("(5102090,0,'PLATFORM','MENU',5102000,'platform:firstleg:container:list'") + .contains("(5102094,0,'PLATFORM','MENU',5102000,'platform:firstleg:smart-loading:list'") + .contains("(5102100,0,'PLATFORM','MENU',5102000,'platform:firstleg:preload:list'") + .contains("(5102106,0,'PLATFORM','MENU',5102000,'platform:firstleg:loading-list:list'") + .contains("platform:firstleg:container:list") + .contains("platform:firstleg:smart-loading:assign") + .contains("platform:firstleg:preload:confirm") + .contains("platform:firstleg:loading-list:list") + .contains("VALUES (4000001,5102106,0,CURRENT_TIMESTAMP(3))") + .contains("VALUES (4000003,5102106,0,CURRENT_TIMESTAMP(3))"); + } + + private static String moduleMigration() throws Exception { return new ClassPathResource("db/phase3-smart-loading-schema.sql") .getContentAsString(StandardCharsets.UTF_8); } - private static String unifiedMigration() throws Exception { - Path working = Path.of("").toAbsolutePath(); - Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); - if (!Files.exists(file)) { - file = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); - } - return Files.readString(file, StandardCharsets.UTF_8); - } - - private static void assertPermissionContract(String sql) { - int message = sql.indexOf("智能装柜迁移失败:平台角色授权未完整写入"); - assertThat(message).isGreaterThan(0); - - int grantStart = sql.lastIndexOf("INSERT IGNORE INTO iam_role_permission", message); - int grantEnd = sql.indexOf(';', grantStart); - assertThat(permissions(sql.substring(grantStart, grantEnd))).isEqualTo(PHASE3_PERMISSIONS); - - int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message); - String assertion = sql.substring(assertionStart, message); - assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS); - assertThat(assertion).contains("<> 22 THEN"); - } - - private static Set permissions(String sql) { - Matcher matcher = PERMISSION_PATTERN.matcher(sql); - return matcher.results().map(result -> result.group(1)).collect(Collectors.toSet()); - } - - private static void assertMalformedShapeGuards(String sql) { - assertThat(sql) - .containsAnyOf("source_total_columns = 11", "source_total = 11") - .containsAnyOf("source_smart_columns = 11", "source_smart = 11") - .containsAnyOf("source_total_columns = 12", "source_total = 12") - .containsAnyOf("source_inbound_columns = 12", "source_inbound = 12") - .contains("column_name = 'version'") - .contains("column_name = 'remark'") - .contains("column_name = 'updated_by'") - .contains("column_name = 'updated_by_name'") - .contains("column_name = 'created_at'") - .contains("column_name = 'updated_at'") - .contains("LOWER(column_type) = 'int'") - .contains("LOWER(column_type) = 'int unsigned'") - .contains("LOWER(extra) LIKE '%on update current_timestamp%'") - .contains("uk_loading_pick_tenant_inbound") - .contains("idx_loading_pick_status") - .contains("referenced_table_name = 'fl_inbound_order'") - .contains("字段签名未知或混合"); - } - - private static int count(String value, String token) { - return (value.length() - value.replace(token, "").length()) / token.length(); + private static String unified() throws Exception { + Path direct = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + Path path = Files.exists(direct) ? direct : Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); + return Files.readString(path, StandardCharsets.UTF_8); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java index 52bc4bd58..971863ec0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java @@ -22,12 +22,12 @@ class TerminalDrayageSqlContractTest { assertThat(selected).isEqualTo(fields); if (name.equals("pendingMails")) assertThat(sql).contains("earlier.container_booking_id=m.container_booking_id", "earlier.id'SENT'"); } - for (Path path : java.util.List.of(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"), Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) { - assertThat(Files.readString(path)).contains( - "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (", - "CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))", - "('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')"); - } + String baseline = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql")); + String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail ("); + for (String sql : java.util.List.of(baseline, unified)) assertThat(sql).contains( + "PENDING","SENDING","SENT","FAILED", + "SELECT_FLEET","SEND_PICKUP_EMAIL","SCHEDULE_PICKUP","RECORD_EMPTY_RETURN"); } @Test @@ -35,10 +35,10 @@ class TerminalDrayageSqlContractTest { String baseline = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql")); assertThat(baseline).doesNotContain("actual_pickup_at", "chk_terminal_drayage_times"); String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); - assertThat(release).contains("ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at", - "DROP CHECK chk_terminal_drayage_history_action", - "ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN"); - assertThat(release).doesNotContain("chk_terminal_drayage_times"); + assertThat(release) + .contains("CREATE TABLE fl_terminal_drayage") + .contains("CONSTRAINT chk_terminal_drayage_history_action CHECK") + .doesNotContain("actual_pickup_at", "chk_terminal_drayage_times", "ALTER TABLE fl_terminal_drayage"); } @Test @@ -62,12 +62,13 @@ class TerminalDrayageSqlContractTest { } @Test - void unifiedMigrationRepairsColumnsAfterAPartialDdlRun() throws Exception { + void unifiedInitializationCreatesFinalColumnsWithoutRepairDdl() throws Exception { String sql = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); assertThat(sql).contains( - "ALTER TABLE fl_terminal_drayage", - "ALTER TABLE fl_terminal_drayage_history", - "ALTER TABLE fl_terminal_drayage_operation", - "MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间'"); + "CREATE TABLE fl_terminal_drayage", + "CREATE TABLE fl_terminal_drayage_history", + "CREATE TABLE fl_terminal_drayage_operation", + "estimated_empty_return_at datetime(3) DEFAULT NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间'"); + assertThat(sql).doesNotContain("ALTER TABLE fl_terminal_drayage"); } } diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java index 5020e27cf..f3cc87809 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java @@ -7,23 +7,18 @@ import static org.assertj.core.api.Assertions.assertThat; class NotificationSqlContractTest { @Test void definesTablesIsolationIdempotencyAndPermissions() throws Exception {String sql;try(var in=getClass().getResourceAsStream("/db/notification-center.sql")){assertThat(in).isNotNull();sql=new String(in.readAllBytes(),StandardCharsets.UTF_8);}assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient","iam_notification_inbox","iam_notification_event_inbox","uk_notification_task_no","uk_notification_task_idempotency","uk_notification_source_event","uk_notification_recipient","idx_notification_inbox_list","platform:notification:template:status","platform:notification:task:retry","platform:notification:read","platform:notification:read-all","merchant:notification:view","merchant:notification:read","merchant:notification:read-all").contains("'MENU',5106000,'platform:notification:template:list'","'MENU',5106000,'platform:notification:task:list'");} - @Test void unifiedMigrationContainsNotificationBaselineAndSevenIndexAssertion() throws Exception { + @Test void cleanInitializationContainsNotificationSchemaAndPermissionSeeds() throws Exception { Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql"); Path migration=Files.exists(direct)?direct:Path.of("..","deploy","sql","头程数据库统一增量-MySQL8.sql").normalize(); String sql=Files.readString(migration,StandardCharsets.UTF_8); - assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient", + assertThat(sql).contains("CREATE TABLE iam_notification_template","CREATE TABLE iam_notification_task","CREATE TABLE iam_notification_recipient", "iam_notification_inbox","iam_notification_event_inbox","uk_notification_template_code", "uk_notification_task_no","uk_notification_task_idempotency","uk_notification_source_event", "uk_notification_recipient","uk_notification_inbox_recipient","uk_notification_event", - "assert_notification_center_schema","permission_code='merchant:notification:view'", - "permission_code LIKE 'platform:notification%'"); - assertThat(sql).contains("COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_iam'"); + "'merchant:notification:view'", "'platform:notification:template:list'", + "'platform:notification:task:list'"); assertThat(sql) - .contains(")) <> 7 THEN") - .contains("permission_code='merchant:notification:view')) <> 13 THEN") - .contains("permission_code LIKE 'platform:notification%') <> 12 THEN") - .contains("'merchant:notification:view','merchant:notification:read','merchant:notification:read-all')) <> 3 THEN"); - assertThat(sql.indexOf("(5106014,0,'MERCHANT','BUTTON'")) - .isLessThan(sql.indexOf("CALL baiyue_iam.assert_notification_center_schema();")); + .contains("(5106014,0,'MERCHANT','BUTTON'") + .doesNotContain("assert_notification_center_schema", "information_schema.statistics"); } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java index 2075ae94c..3a1e81bbe 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -60,15 +60,9 @@ class AdminPrototypeMenuPermissionSqlContractTest { } assertThat(unified) .contains("platform:finance:receivable-statistics:export") - .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34") - .contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 35") - .contains("Finance 导出和下载中心授权不完整") - .contains("客户全局 Finance 权限模板不完整") - .contains("客户全局管理员 Finance 模板授权不完整") - .satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'")) - .isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()"))) - .satisfies(content -> assertThat(content.indexOf("CALL migrate_wallet_permissions();")) - .isLessThan(content.indexOf("CALL assert_finance_permissions();"))); + .contains("(4000001,5105050,0,CURRENT_TIMESTAMP(3))") + .contains("(4000100,5305010,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("CREATE PROCEDURE assert_finance_permissions", "CALL migrate_wallet_permissions"); } @Test @@ -80,8 +74,10 @@ class AdminPrototypeMenuPermissionSqlContractTest { .contains("'platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete'"); String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); - assertThat(unified).contains("<> 4") - .contains("platform:overseas-storage-fee:list', 'platform:overseas-storage-fee:create'"); + assertThat(unified) + .contains("'platform:overseas-storage-fee:list','海外仓阶梯费用','/customers/overseas-storage-fees','GET',20,'ACTIVE'") + .contains("'platform:overseas-storage-fee:create','新增海外仓阶梯费用',NULL,NULL,10,'INACTIVE'") + .contains("'platform:overseas-storage-fee:delete','删除海外仓阶梯费用',NULL,NULL,30,'INACTIVE'"); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java index 70a0f887e..fbdfd01d1 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java @@ -90,27 +90,16 @@ class ContainerWorkspacePermissionSqlContractTest { } @Test - void unifiedIncrementContainsTheCompleteModuleSql() throws Exception { + void cleanInitializationContainsTheCompletePermissionCatalog() throws Exception { Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); if (!Files.exists(unifiedPath)) { unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); } String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); - String startMarker = "-- 货柜双工作台权限开始(与 iam/db/container-workspace-permissions.sql 保持一致)"; - String endMarker = "-- 货柜双工作台权限结束,恢复头程数据库上下文"; - int start = unified.indexOf(startMarker); - int bodyStart = unified.indexOf('\n', start) + 1; - int end = unified.indexOf(endMarker, bodyStart); - - assertThat(start).isGreaterThanOrEqualTo(0); - assertThat(bodyStart).isGreaterThan(start); - assertThat(end).isGreaterThan(bodyStart); - assertThat(normalizeLineEndings(unified.substring(bodyStart, end)).trim()) - .isEqualTo(normalizeLineEndings(sql()).trim()); - } - - private static String normalizeLineEndings(String value) { - return value.replace("\r\n", "\n"); + assertThat(codes(unified)).containsAll(REQUIRED); + assertThat(unified) + .contains("(5102090,0,'PLATFORM','MENU'", "(5102205,0,'PLATFORM','BUTTON'") + .doesNotContain("migrate_container_workspace_permissions", "ON DUPLICATE KEY UPDATE"); } private static String sql() throws Exception { diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java index 27e88c17d..4cad41bf8 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java @@ -20,15 +20,24 @@ class CustomerCodeSchemaContractTest { } String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); - assertSequenceDefinition(baseline); - assertSequenceDefinition(unified); + assertMigrationSequenceDefinition(baseline); + assertInitializationSequenceDefinition(unified); } - private static void assertSequenceDefinition(String sql) { + private static void assertMigrationSequenceDefinition(String sql) { assertThat(sql) .contains("tenant_code VARCHAR(8) NOT NULL") .contains("CREATE TABLE IF NOT EXISTS iam_number_sequence") .contains("PRIMARY KEY (sequence_key)") .contains("INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1)"); } + + private static void assertInitializationSequenceDefinition(String sql) { + assertThat(sql) + .contains("tenant_code varchar(8) NOT NULL") + .contains("CREATE TABLE iam_number_sequence") + .contains("PRIMARY KEY (sequence_key)") + .contains("INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1") + .doesNotContain("CREATE TABLE IF NOT EXISTS iam_number_sequence", "INSERT IGNORE INTO iam_number_sequence"); + } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java index 0c22f9ecf..0ddd9a741 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java @@ -28,7 +28,9 @@ class CustomerDimensionInitializationSqlContractTest { assertContract(standalone); assertContract(unified); assertThat(standalone).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); - assertThat(unified).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); + assertThat(unified) + .contains("CREATE TABLE iam_customer_dimension_initialization_task") + .doesNotContain("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java index adf1dd423..b957d375f 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java @@ -49,7 +49,7 @@ class CustomerProfileMigrationContractTest { } @Test - void shouldHandleCase03() throws IOException { + void cleanInitializationContainsTheFinalCustomerProfileContract() throws IOException { Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", "..")); Path migration = repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); Path readme = repositoryRoot.resolve("deploy/sql/README.md"); @@ -60,43 +60,36 @@ class CustomerProfileMigrationContractTest { String instructions = Files.readString(readme, StandardCharsets.UTF_8); assertThat(sql) - .contains("customer_nickname VARCHAR(32)") - .contains("ALTER TABLE iam_tenant ADD COLUMN customer_nickname VARCHAR(32)") - .contains("ALTER TABLE iam_tenant MODIFY COLUMN status") + .contains("customer_nickname varchar(32)") .contains("DEFAULT 'PROFILE_REQUIRED'") - .contains("CALL phase1_iam_add_column('iam_tenant', 'approved_at'") - .contains("CALL phase1_iam_add_column('iam_tenant', 'approved_by'") - .contains("CALL phase1_iam_add_column('iam_tenant', 'reject_reason'") - .contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment'") + .contains("approved_at datetime(3)") + .contains("approved_by bigint") + .contains("reject_reason varchar(500)") + .contains("review_comment varchar(500)") .contains("platform:customer:view") .contains("platform:customer:export") .contains("platform:download:list") .contains("'MERCHANT_OWNER'") - .contains("owner_role.id, permission.id, 0") - .contains("统一增量全部执行完成"); + .contains("SELECT 'PASS' AS initialization_status") + .doesNotContain("ALTER TABLE iam_tenant", "phase1_iam_add_column"); assertThat(instructions) - .contains("phase1-platform-master-data-and-permission-management.sql") - .contains("phase1-customer-profile.sql") - .contains("phase1-onboarding.sql") - .contains("`PROFILE_REQUIRED` 默认状态、审核结果字段、资质审核备注及成员审计字段") - .contains("全局 `MERCHANT_OWNER` 角色模板") - .contains("测试环境结构发布只执行全量固定入口"); + .contains("当前开发阶段只维护纯初始化") + .contains("模块 `src/main/resources/db/` 用作结构来源和开发追溯") + .contains("不再向历史迁移文件追加发布步骤"); } @Test - void unifiedMigrationGrantsCustomerOperationsToPlatformAdmin() throws IOException { + void cleanInitializationGrantsCustomerOperationsToPlatformAdmin() throws IOException { Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", "..")); String sql = Files.readString( repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); - int grantStart = sql.indexOf("-- 新增平台操作权限默认只授予系统管理员。"); - int grantEnd = sql.indexOf("-- 已有客户运营角色继续具备", grantStart); - - assertThat(grantStart).isGreaterThanOrEqualTo(0); - assertThat(grantEnd).isGreaterThan(grantStart); - assertThat(sql.substring(grantStart, grantEnd)) + assertThat(sql) .contains("'platform:customer:reset-password'") .contains("'platform:customer:delete'") - .contains("'platform:customer:impersonate'"); + .contains("'platform:customer:impersonate'") + .contains("(4000001,5100016,0,CURRENT_TIMESTAMP(3))") + .contains("(4000001,5100017,0,CURRENT_TIMESTAMP(3))") + .contains("(4000001,5100018,0,CURRENT_TIMESTAMP(3))"); } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java index 65157ea37..9124ff645 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java @@ -46,28 +46,16 @@ class FinanceG1G2PermissionSqlContractTest { @Test void unifiedSqlMirrorsCatalogAndGrantsTheFinanceCatalogToPlatformAdmin() throws Exception { - String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8) - .replace("\r\n", "\n"); - String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"), - sql.indexOf("-- Admin 原型菜单与财务五页权限补充")); + String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); - assertThat(financeSection) - .contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'") - .contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'") - .contains("NULL, 'POST', 150, 'ACTIVE', 1, 0") - .contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0") - .contains("permission_code IN (\n 'platform:finance',") - .contains("Finance G1-G4"); - assertThat(financeSection) - .contains("SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission") - .contains("status='INACTIVE',deleted=1"); assertThat(sql) + .contains("(5105024,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:review'") + .contains("(5105025,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:edit'") + .contains("(4000001,5105024,0,CURRENT_TIMESTAMP(3))") + .contains("(4000001,5105025,0,CURRENT_TIMESTAMP(3))") .contains("platform:finance:settlement-ar:list") .contains("platform:finance:settlement-ap:list") - .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34") - .contains("rp.role_id=4000001") - .contains("LIKE 'platform:finance:%')) <> 35") - .contains("平台管理员 Finance 授权不完整"); + .doesNotContain("migrate_finance_g1_g4_permissions", "status='INACTIVE',deleted=1"); } private static Path resolveUnifiedSql() { diff --git a/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java index f20090187..01106abdc 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java @@ -39,21 +39,13 @@ class FinanceG3G4PermissionSqlContractTest { @Test void unifiedSqlContainsCatalogAndGrantsItToPlatformAdmin() throws Exception { String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); - String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"), - sql.indexOf("-- Admin 原型菜单与财务五页权限补充")); - for (String code : CODES) assertThat(financeSection).contains(code); - assertThat(financeSection) - .contains("migrate_finance_g1_g4_permissions") - .contains("finance G1-G4 permission id/code conflict"); - assertThat(financeSection) - .contains("SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission") - .contains("platform:finance:reconciliation:create"); + for (String code : CODES) assertThat(sql).contains(code); assertThat(sql) + .contains("(4000001,5105026,0,CURRENT_TIMESTAMP(3))") + .contains("(4000001,5105035,0,CURRENT_TIMESTAMP(3))") .contains("platform:finance:settlement-ar:list") .contains("platform:finance:settlement-ap:list") - .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34") - .contains("rp.role_id=4000001") - .contains("LIKE 'platform:finance:%')) <> 35"); + .doesNotContain("migrate_finance_g1_g4_permissions", "finance G1-G4 permission id/code conflict"); } private static Path resolveUnifiedSql() { diff --git a/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java index 4df605477..6a06a5b69 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java @@ -63,10 +63,11 @@ class InboundHoldPermissionSqlContractTest { } @Test - void unifiedIncrementContainsTheSameMigrationBody() throws Exception { - String marker = "DROP PROCEDURE IF EXISTS migrate_inbound_hold_permission;"; - - assertThat(migrationBody(unifiedSql(), marker)).isEqualTo(migrationBody(sql(), marker)); + void cleanInitializationContainsThePermissionAndDefaultGrant() throws Exception { + assertThat(unifiedSql()) + .contains("(5102032,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:hold'") + .contains("(4000001,5102032,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("migrate_inbound_hold_permission", "ON DUPLICATE KEY UPDATE"); } private static String sql() throws Exception { @@ -80,11 +81,4 @@ class InboundHoldPermissionSqlContractTest { return Files.readString(path.normalize(), StandardCharsets.UTF_8); } - private static String migrationBody(String sql, String marker) { - int start = sql.indexOf(marker); - int end = sql.indexOf(marker, start + marker.length()); - assertThat(start).isGreaterThanOrEqualTo(0); - assertThat(end).isGreaterThan(start); - return sql.substring(start, end + marker.length()).replaceAll("\\s+", " ").trim(); - } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java index 64008a223..be5c81ab3 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java @@ -18,9 +18,17 @@ class InitialIdentitySeedSqlContractTest { .getContentAsString(StandardCharsets.UTF_8); String unified = Files.readString(unifiedSql(), StandardCharsets.UTF_8); assertPlatformIdentitySeed(identitySeed); - assertPlatformIdentitySeed(unified); + assertCleanPlatformIdentitySeed(unified); assertGlobalMerchantTemplate(templateSeed); - assertGlobalMerchantTemplate(unified); + assertCleanGlobalMerchantTemplate(unified); + } + + private static void assertCleanPlatformIdentitySeed(String sql) { + assertThat(sql) + .contains("(0,'PLATFORM','虫虫出海平台'") + .contains("(2000001,1000001,'INTERNAL','平台系统管理员'") + .contains("(3000001,0,2000001,'EMPLOYEE'") + .doesNotContain("merchant_admin", "IT_TEST", "一期演示企业", "ON DUPLICATE KEY UPDATE"); } private static void assertPlatformIdentitySeed(String sql) { @@ -47,6 +55,14 @@ class InitialIdentitySeedSqlContractTest { .doesNotContain("WHERE tenant_id = 10001 AND app_code = 'MERCHANT'"); } + private static void assertCleanGlobalMerchantTemplate(String sql) { + assertThat(sql) + .contains("(4000100,0,'MERCHANT','MERCHANT_OWNER'") + .contains("(5300000,0,'MERCHANT','MENU'") + .contains("(5301024,0,'MERCHANT','BUTTON'") + .doesNotContain("WHERE tenant_id = 10001 AND app_code = 'MERCHANT'"); + } + private static Path unifiedSql() { Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); diff --git a/iam/src/test/java/com/baiyue/iam/repository/LocationPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/LocationPermissionSqlContractTest.java index cffa4ece1..4a330b1aa 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/LocationPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/LocationPermissionSqlContractTest.java @@ -13,7 +13,16 @@ class LocationPermissionSqlContractTest { @Test void acceptsKnownPollutionAndRestoresCanonicalPermissionIds() throws Exception { assertRepairContract(moduleSql()); - assertRepairContract(unifiedSql()); + assertFinalInitializationContract(unifiedSql()); + } + + private static void assertFinalInitializationContract(String sql) { + assertThat(sql) + .contains("(5103040,0,'PLATFORM','MENU',5103000,'platform:download:list'") + .contains("(5103041,0,'PLATFORM','BUTTON',5103040,'platform:download:all'") + .contains("(5103060,0,'PLATFORM','MENU',5103000,'platform:location:list'") + .contains("(5103061,0,'PLATFORM','BUTTON',5103060,'platform:location:create'") + .doesNotContain("tmp_location_role_grant", "库位权限迁移失败"); } private static void assertRepairContract(String sql) { diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index b7b398184..e9c243f8d 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -64,10 +64,10 @@ class OverseasPhase4PermissionSqlContractTest { Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") .getContentAsString(StandardCharsets.UTF_8)); assertThat(codes(sql)).containsAll(moduleCatalog); - assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'"); - assertPdaPickerGrant(sql); - assertCustomAdminGrant(sql); - assertCustomAdminNavigationGrant(sql); + assertThat(sql) + .contains("CREATE TABLE iam_permission", "CREATE TABLE iam_role_permission", "'lastleg:pda:operate'") + .contains("(4000005,5107015,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("assert_overseas_pda_picker_permission", "DELETE role_permission FROM"); } @Test @@ -78,25 +78,33 @@ class OverseasPhase4PermissionSqlContractTest { if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); String unifiedSql = Files.readString(path, StandardCharsets.UTF_8); - for (String sql : List.of(moduleSql, unifiedSql)) { - String matrix = matrixBlock(sql); - assertThat(matrix).contains("WAREHOUSE_OPERATOR", "WAREHOUSE_TALLY"); - assertThat(codes(matrix)).containsExactlyInAnyOrder( - "platform:overseas-warehouse:last-mile-order:list", - "platform:overseas-warehouse:inbound-order:list", - "platform:overseas-warehouse:relabel-order:list", - "platform:overseas-warehouse:inventory:list", - "platform:overseas-warehouse:warehouse:list", - "platform:overseas-warehouse:location:list", - "platform:overseas-warehouse:pallet:list", - "platform:overseas-business:purchase:list", - "platform:overseas-business:truck:list", - "platform:overseas-business:looseboard:list"); - assertThat(matrix).doesNotContain("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); - assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'"); - assertThat(sql).contains("assert_phase4_warehouse_role_minimum", "<> 20", - "role.role_code IN ('WAREHOUSE_OPERATOR','OVERSEAS_WAREHOUSE_PDA_OPERATOR','WAREHOUSE_TALLY','CN_FIRSTLEG_PDA_OPERATOR')"); + String matrix = matrixBlock(moduleSql); + assertThat(matrix).contains("WAREHOUSE_OPERATOR", "WAREHOUSE_TALLY"); + assertThat(codes(matrix)).containsExactlyInAnyOrder( + "platform:overseas-warehouse:last-mile-order:list", + "platform:overseas-warehouse:inbound-order:list", + "platform:overseas-warehouse:relabel-order:list", + "platform:overseas-warehouse:inventory:list", + "platform:overseas-warehouse:warehouse:list", + "platform:overseas-warehouse:location:list", + "platform:overseas-warehouse:pallet:list", + "platform:overseas-business:purchase:list", + "platform:overseas-business:truck:list", + "platform:overseas-business:looseboard:list"); + assertThat(matrix).doesNotContain("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); + assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'"); + assertThat(moduleSql).contains("assert_phase4_warehouse_role_minimum", "<> 20", + "role.role_code IN ('WAREHOUSE_OPERATOR','OVERSEAS_WAREHOUSE_PDA_OPERATOR','WAREHOUSE_TALLY','CN_FIRSTLEG_PDA_OPERATOR')"); + + for (int permissionId : new int[] {5107000, 5107010, 5107020, 5107030, 5107040, + 5107050, 5107060, 5107100, 5107110, 5107120}) { + assertThat(unifiedSql) + .contains("(4000004," + permissionId + ",0,CURRENT_TIMESTAMP(3))") + .contains("(4000006," + permissionId + ",0,CURRENT_TIMESTAMP(3))"); } + assertThat(unifiedSql) + .doesNotContain("(4000005,5107000,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("(4000007,5107000,0,CURRENT_TIMESTAMP(3))"); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java index cf440e040..a1ebb90c5 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java @@ -143,35 +143,28 @@ class Phase5DeliveryPermissionSqlContractTest { } @Test - void domesticUnifiedEntryInlinesTheCompleteModuleSqlAfterPhase4Permissions() throws Exception { - String moduleSql = sql().trim(); + void domesticCleanInitializationContainsTheCompletePermissionCatalog() throws Exception { String unifiedSql = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql"); - int overseasCatalog = unifiedSql.indexOf("-- 海外权限统一目录"); - int outboundDelivery = unifiedSql.indexOf("-- 海外排车、出库与派送权限(来源:iam/db/phase5-delivery-permissions.sql)"); - int bodyStart = unifiedSql.indexOf("USE baiyue_iam;", outboundDelivery); - int nextSection = unifiedSql.indexOf("-- IAM 客户提交即成功", bodyStart); - assertThat(overseasCatalog).isGreaterThanOrEqualTo(0); - assertThat(outboundDelivery).isGreaterThan(overseasCatalog); - assertThat(bodyStart).isGreaterThan(outboundDelivery); - assertThat(nextSection).isGreaterThan(bodyStart); - assertThat(unifiedSql.substring(bodyStart, nextSection).trim()).isEqualTo(moduleSql); - assertThat(codes(unifiedSql.substring(outboundDelivery, nextSection))) - .containsExactlyInAnyOrderElementsOf(REQUIRED); + assertThat(codes(unifiedSql)).containsAll(REQUIRED); + assertThat(unifiedSql) + .contains("(4000004,5107064,0,CURRENT_TIMESTAMP(3))") + .contains("(4000004,5107094,0,CURRENT_TIMESTAMP(3))") + .contains("(4000100,5306000,0,CURRENT_TIMESTAMP(3))") + .contains("(4000100,5306001,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("guard_phase5_delivery_permission_ids", "tmp_phase5_delivery_role_permission"); } @Test - void deploymentGuideUsesOnlyTheDomesticUnifiedEntry() throws Exception { + void deploymentGuideDefinesCleanRegionalInitializationOnly() throws Exception { String readme = repositoryFile("deploy/sql/README.md"); assertThat(readme).contains( - "海外排车、出库与派送权限", - "测试和部署只执行固定入口,不再单独执行模块脚本", - "排车列表/编辑/取消/转派送", - "异常订单列表/创建/处理和客户回访列表/编辑十六项权限", - "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create", - "PDA 岗位不得获得上述后台权限"); + "当前开发阶段只维护纯初始化", + "两个 SQL 文件沿用原文件名", + "国内 IAM 是全部平台与客户权限的唯一权威", + "模块 `src/main/resources/db/` 用作结构来源和开发追溯", + "不再向历史迁移文件追加发布步骤"); } private static String sql() throws Exception { diff --git a/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java index 547e94802..55d6364b4 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java @@ -22,10 +22,7 @@ class QualificationSchemaContractTest { assertThat(baseline).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'"); assertThat(unified) - .contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'") - .contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'original_file_name',") - .contains("table_name='iam_tenant_qualification'") - .contains("column_name='original_file_name'") - .contains("统一迁移失败:IAM客户资质缺少原始文件名列"); + .contains("original_file_name varchar(255) NOT NULL COMMENT '上传时原始文件名'") + .doesNotContain("phase1_iam_add_column", "information_schema.columns"); } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java index 55171dea6..b4336c35d 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java @@ -26,18 +26,17 @@ class SecurityEventSqlContractTest { } @Test - void unifiedMigrationIncludesSecurityEventSchemaAndAssertion() throws Exception { + void cleanInitializationIncludesSecurityEventSchema() throws Exception { Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); Path migration = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql").normalize(); String sql = Files.readString(migration, StandardCharsets.UTF_8); assertThat(sql) - .contains("CREATE TABLE IF NOT EXISTS iam_security_subject_version") - .contains("CREATE TABLE IF NOT EXISTS iam_security_tenant_version") - .contains("CREATE TABLE IF NOT EXISTS iam_security_event_outbox") + .contains("CREATE TABLE iam_security_subject_version") + .contains("CREATE TABLE iam_security_tenant_version") + .contains("CREATE TABLE iam_security_event_outbox") .contains("idx_security_outbox_publish", "idx_security_outbox_aggregate") - .contains("CALL assert_iam_security_event_schema();"); - assertThat(sql.indexOf("CALL assert_iam_security_event_schema();")) - .isLessThan(sql.lastIndexOf("SELECT 'PASS' AS migration_status")); + .contains("SELECT 'PASS' AS initialization_status") + .doesNotContain("assert_iam_security_event_schema", "CREATE TABLE IF NOT EXISTS iam_security_event"); } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java index 1a4aaeb7d..b8acb4b52 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java @@ -18,13 +18,19 @@ class WarehouseRoleSqlContractTest { @Test void unifiedDeploymentContainsTheSameWarehouseRoleContract() throws Exception { String sql = unifiedSql(); - assertWarehouseRoleContract(sql); - assertThat(roleMigrationBlock(sql)) - .doesNotContain("\n ADD COLUMN IF NOT EXISTS") - .doesNotContain("DROP CHECK IF EXISTS"); - assertThat(sql.lastIndexOf("SELECT 'PASS' AS migration_status")) - .isGreaterThan(sql.indexOf("CALL assert_phase3_warehouse_roles_ready()")); - assertThat(sql.trim()).endsWith("AS message;"); + assertThat(sql) + .contains("(4000004,0,'PLATFORM','WAREHOUSE_OPERATOR','国内仓后台操作员','SYSTEM'") + .contains("(4000005,0,'PLATFORM','OVERSEAS_WAREHOUSE_PDA_OPERATOR','海外仓 PDA 作业员','SYSTEM'") + .contains("(4000006,0,'PLATFORM','WAREHOUSE_TALLY','国内仓理货复核员','SYSTEM'") + .contains("(4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM'") + .contains("(6000004,4000004,0,'TENANT',NULL") + .contains("(6000005,4000005,0,'TENANT',NULL") + .contains("(6000006,4000006,0,'TENANT',NULL") + .contains("(6000007,4000007,0,'TENANT',NULL") + .contains("(4000005,5107015,0,CURRENT_TIMESTAMP(3))") + .contains("(4000007,5102190,0,CURRENT_TIMESTAMP(3))") + .contains("SELECT 'PASS' AS initialization_status") + .doesNotContain("assert_phase3_warehouse_roles_ready", "ON DUPLICATE KEY UPDATE"); } private static void assertWarehouseRoleContract(String sql) { @@ -100,15 +106,6 @@ class WarehouseRoleSqlContractTest { return sql.substring(start, end > start ? end : sql.length()); } - private static String roleMigrationBlock(String sql) { - String marker = "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql"; - String endMarker = "-- BEGIN FINANCE FEE SOURCE MODULE MIGRATIONS"; - int start = sql.indexOf(marker); - int end = sql.indexOf(endMarker, start + marker.length()); - if (start < 0 || end < 0) throw new IllegalStateException("统一入口缺少仓库角色连续区块"); - return sql.substring(start, end); - } - private static String moduleSql() throws Exception { return new ClassPathResource("db/phase3-warehouse-roles.sql") .getContentAsString(StandardCharsets.UTF_8); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDocumentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDocumentContractTest.java index a5fe1a2a6..13ee6c467 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDocumentContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDocumentContractTest.java @@ -44,10 +44,12 @@ class DeliveryDocumentContractTest { @Test void baselineAndOverseasEntryContainSameImmutableRevisionTable() throws Exception { String baseline=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); String overseas=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); - String marker="CREATE TABLE IF NOT EXISTS ll_delivery_document_revision ("; - String a=baseline.substring(baseline.indexOf(marker),baseline.indexOf(";",baseline.indexOf(marker))+1); - String b=overseas.substring(overseas.indexOf(marker),overseas.indexOf(";",overseas.indexOf(marker))+1); - assertThat(a.replaceAll("\\s+","")).isEqualTo(b.replaceAll("\\s+","")); - assertThat(a).contains("uk_delivery_document_command","uk_delivery_document_version"); + for (String sql : List.of(baseline, overseas)) { + assertThat(sql).contains("ll_delivery_document_revision", + "uk_delivery_document_command", "uk_delivery_document_version", + "snapshot_json", "request_hash"); + } + assertThat(overseas).contains("CREATE TABLE ll_delivery_document_revision (") + .doesNotContain("ALTER TABLE ll_delivery_document_revision"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java index 00d4b5cad..6581d8b52 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -68,11 +68,11 @@ class DeliverySchemaContractTest { String overseas = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase(); - assertTrue(overseas.contains("begin inline empty-database baseline: lastleg")); - assertTrue(overseas.contains(createToken)); - assertTrue(overseas.contains("request_hash char(64) null")); - assertTrue(overseas.indexOf(createToken) < overseas.indexOf("assert_phase5_lastleg_identifiers_migratable")); - assertTrue(overseas.contains("alter table ll_delivery_order add column overseas_warehouse_name")); + String overseasCreateToken = "create table ll_delivery_order"; + assertTrue(overseas.contains(overseasCreateToken)); + assertTrue(overseas.contains("overseas_warehouse_name varchar(255)")); + assertTrue(overseas.contains("unique key uk_ll_delivery_outbound_destination")); + assertFalse(overseas.contains("alter table ll_delivery_order")); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java index 4907ad79b..7684ef1af 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java @@ -5,34 +5,26 @@ import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.regex.Pattern; - import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class Phase5PdaDeploymentSqlContractTest { @Test - void overseasUnifiedMigrationConvergesPhase5IdentifiersWithoutBackfill() throws Exception { + void overseasCleanInitializationDefinesFinalPhase5IdentifiersWithoutBackfill() throws Exception { String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); - int start = sql.indexOf("-- 五期排车、出库与海外PDA正式业务号/幂等约束"); - int end = sql.indexOf("USE baiyue_lastleg;", start); - assertTrue(start >= 0 && end > start); - String phase5 = sql.substring(start, end); + String normalized = sql.replaceAll("\\s+", ""); - assertTrue(phase5.contains("assert_phase5_lastleg_identifiers_migratable")); - assertTrue(phase5.contains("assert_phase5_lastleg_identifiers_ready")); - assertTrue(phase5.contains("warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0'")); - assertTrue(phase5.contains("SIGNAL SQLSTATE '45000'")); - assertTrue(phase5.contains("MODIFY COLUMN warehouse_id VARCHAR(128) NOT NULL")); - assertTrue(phase5.contains("ADD COLUMN completion_idempotency_key VARCHAR(255) NULL")); - assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no)")); - assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_no(outbound_no)")); - assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)")); - assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_pda_task_no(task_no)")); - assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)")); - assertFalse(Pattern.compile("(?im)^\\s*UPDATE\\s+").matcher(phase5).find()); - assertFalse(phase5.contains("JOIN ll_dispatch_order")); + assertTrue(sql.contains("warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL")); + assertTrue(sql.contains("completion_idempotency_key varchar(255)")); + assertTrue(normalized.contains("UNIQUEKEYuk_ll_dispatch_order_no(dispatch_no)")); + assertTrue(normalized.contains("UNIQUEKEYuk_ll_outbound_no(outbound_no)")); + assertTrue(normalized.contains("UNIQUEKEYuk_ll_dispatch_pda_task_no(task_no)")); + assertTrue(normalized.contains("UNIQUEKEYuk_ll_outbound_pda_task_no(task_no)")); + assertTrue(normalized.contains("UNIQUEKEYuk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)")); + assertFalse(sql.contains("assert_phase5_lastleg_identifiers_migratable")); + assertFalse(sql.contains("ALTER TABLE ll_dispatch_order")); + assertFalse(sql.contains("ALTER TABLE ll_outbound_order")); assertFalse(sql.contains("uk_ll_dispatch_order_no(customer_id,dispatch_no)")); assertFalse(sql.contains("uk_ll_outbound_no(customer_id,outbound_no)")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index 9d8f1d297..788d0657f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -84,8 +84,7 @@ class ReceivingContainerContractTest { for (String table : Set.of("ll_inbound_plan_line", "ll_inbound_plan_pallet", "ll_inbound_plan_pallet_allocation")) { assertTrue(tableBlock(schema, table).contains("customer_id varchar(128) not null"), table); - assertTrue(unified.contains("table_name='" + table + "' and column_name='customer_id'"), table); - assertTrue(unified.contains("update " + table), table); + assertTrue(tableBlock(unified, table).contains("customer_id varchar(128)"), table); } assertFalse(schema.contains("tenant_id varchar(64)")); assertFalse(unified.contains("column_name='tenant_id'")); @@ -99,9 +98,9 @@ class ReceivingContainerContractTest { assertFalse(tableBlock(schema, "ll_receiving_container_session").contains("customer_id")); assertFalse(tableBlock(schema, "ll_receiving_container_operation").contains("customer_id")); assertFalse(tableBlock(schema, "ll_container_shipping_gate").contains("customer_id")); - assertTrue(unified.contains("alter table ll_receiving_container_session drop column customer_id")); - assertTrue(unified.contains("alter table ll_receiving_container_operation drop column customer_id")); - assertTrue(unified.contains("alter table ll_container_shipping_gate drop column customer_id")); + assertFalse(tableBlock(unified, "ll_receiving_container_session").contains("customer_id")); + assertFalse(tableBlock(unified, "ll_receiving_container_operation").contains("customer_id")); + assertFalse(tableBlock(unified, "ll_container_shipping_gate").contains("customer_id")); assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt")); assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); assertTrue(schema.contains("create table if not exists ll_receiving_fba_pallet_count")); @@ -117,22 +116,16 @@ class ReceivingContainerContractTest { assertTrue(receivingSession.contains("overseas_warehouse_id bigint not null")); assertTrue(receivingSession.contains("overseas_warehouse_name varchar(255) not null")); assertFalse(receivingSession.contains("photo_file_ids json")); - assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt")); - assertTrue(unified.contains("create table if not exists ll_receiving_fba_pallet_count")); - assertTrue(unified.contains("column_name='sign_photos'")); + assertTrue(unified.contains("create table ll_receiving_sku_receipt")); + assertTrue(unified.contains("create table ll_receiving_fba_pallet_count")); + assertTrue(tableBlock(unified, "ll_receiving_container_session").contains("sign_photos json")); assertTrue(schema.contains("wait_sign、signed、receiving、completed")); - assertTrue(unified.contains("customer_id is null or l.customer_id=''")); - assertTrue(unified.contains("customer_id is null or v.customer_id=''")); - assertTrue(unified.contains("customer_id is null or a.customer_id=''")); - assertTrue(unified.contains("signal sqlstate '45000'")); - assertTrue(unified.contains("assert_receiving_global_locks_migratable")); - assertTrue(unified.contains("where status in ('completed','completed_with_difference') and (active_operator_key is not null or active_container_key is not null)")); - assertTrue(unified.contains("concat('operator:',operator_key)")); - assertTrue(unified.contains("concat('container:',upper(trim(container_no)))")); - assertTrue(unified.contains("assert_receiving_operation_global_key_migratable")); - assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'")); - assertTrue(unified.contains("assert_pending_orders_do_not_use_source_warehouse")); - assertTrue(unified.contains("assert_active_receiving_session_has_warehouse")); + assertTrue(tableBlock(unified, "ll_receiving_container_session") + .contains("unique key uk_ll_receiving_active_operator (active_operator_key)")); + assertTrue(tableBlock(unified, "ll_receiving_container_operation") + .contains("unique key uk_ll_receiving_operation (operator_key,idempotency_key)")); + assertFalse(unified.contains("assert_receiving_global_locks_migratable")); + assertFalse(unified.contains("alter table ll_receiving_container_session")); } @Test @@ -178,6 +171,7 @@ class ReceivingContainerContractTest { private static String tableBlock(String schema, String table) { int start = schema.indexOf("create table if not exists " + table + " ("); + if (start < 0) start = schema.indexOf("create table " + table + " ("); int end = schema.indexOf(") engine=innodb", start); return start < 0 || end < 0 ? "" : schema.substring(start, end); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java index 90f0a93c5..786be45df 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java @@ -28,11 +28,19 @@ class LocalLooseboardSchemaContractTest { } @Test - void unifiedIncrementalContainsHistoryIdempotencyAndOperationLedger() throws Exception { + void unifiedInitializationContainsHistoryIdempotencyAndOperationLedger() throws Exception { String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); - assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation")); - assertTrue(sql.contains("uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key)")); - assertTrue(sql.contains("table_name='ll_local_looseboard_history' AND column_name='idempotency_key'")); + String normalized = sql.replaceAll("\\s+", ""); + assertTrue(sql.contains("CREATE TABLE ll_local_looseboard_operation")); + assertTrue(normalized.contains("uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key)")); + assertTrue(tableBlock(sql, "ll_local_looseboard_history").contains("idempotency_key varchar(255)")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ALTER TABLE ll_local_looseboard_history")); + } + + private static String tableBlock(String sql, String table) { + int start = sql.indexOf("CREATE TABLE " + table + " ("); + int end = sql.indexOf(";", start); + return start < 0 || end < 0 ? "" : sql.substring(start, end); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java index 0961c5313..0d0c6e580 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java @@ -45,6 +45,7 @@ class DomesticPartnerWarehouseClientContractTest { assertThat(compose).contains("INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}") .contains("DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}"); assertThat(deploy).contains("FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL") - .contains("NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN"); + .contains("NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD " + + "OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java index 54fa76c18..ee27b71af 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -30,23 +30,26 @@ class OverseasProcurementSchemaContractTest { } @Test - void unifiedIncrementalContainsHistoryIdempotencyKeyForNewAndExistingDatabases() throws Exception { + void unifiedInitializationContainsFinalProcurementAndInboxShapes() throws Exception { String sql = Files.readString( Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); - assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'")); - assertTrue(sql.contains( - "ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL")); - assertTrue(sql.contains( - "ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL")); - assertTrue(sql.contains( - "ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL")); - assertTrue(sql.contains( - "ALTER TABLE ll_overseas_procurement_order ADD COLUMN outbound_details JSON NULL")); - assertTrue(sql.contains( - "table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key'")); - assertTrue(sql.contains("ALTER TABLE ll_wallet_event_inbox")); - assertTrue(sql.contains("MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL")); + String lower = sql.toLowerCase(); + assertTrue(tableBlock(lower, "ll_overseas_procurement_status_history") + .contains("idempotency_key varchar(255)")); + String order = tableBlock(lower, "ll_overseas_procurement_order"); + assertTrue(order.contains("review_idempotency_key varchar(255)")); + assertTrue(order.contains("review_request_hash char(64)")); + assertTrue(order.contains("outbound_details json")); + assertTrue(tableBlock(lower, "ll_wallet_event_inbox").contains("payload_hash varchar(71)")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ALTER TABLE ll_overseas_procurement")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ALTER TABLE ll_wallet_event_inbox")); + } + + private static String tableBlock(String sql, String table) { + int start = sql.indexOf("create table " + table + " ("); + int end = sql.indexOf(';', start); + return start < 0 || end < 0 ? "" : sql.substring(start, end); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java index 673d0c705..b18615a65 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java @@ -23,12 +23,13 @@ class ReturnCartonQueryTest { java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) { String sql = java.nio.file.Files.readString(path); var matcher = java.util.regex.Pattern.compile( - "CREATE TABLE IF NOT EXISTS ll_return_inventory_carton \\(([\\s\\S]*?)\\) ENGINE") + "CREATE TABLE(?: IF NOT EXISTS)? ll_return_inventory_carton \\(([\\s\\S]*?)\\) ENGINE", + java.util.regex.Pattern.CASE_INSENSITIVE) .matcher(sql); int definitions = 0; while (matcher.find()) { definitions++; - assertThat(matcher.group(1)).as(path.toString()).contains("new_carton_mark VARCHAR(255)"); + assertThat(matcher.group(1).toLowerCase()).as(path.toString()).contains("new_carton_mark varchar(255)"); } assertThat(definitions).isPositive(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/LastlegOrderDetailSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/LastlegOrderDetailSchemaContractTest.java index f1276e0ed..e4bfc0c02 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/LastlegOrderDetailSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/LastlegOrderDetailSchemaContractTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; class LastlegOrderDetailSchemaContractTest { @Test - void orderDetailHasCustomerIsolationInBaselineAndRepeatableUpgrade() throws Exception { + void orderDetailHasCustomerIsolationInBaselineAndCleanInitialization() throws Exception { String baseline = new ClassPathResource("db/lastleg.sql") .getContentAsString(StandardCharsets.UTF_8); String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), @@ -24,11 +24,10 @@ class LastlegOrderDetailSchemaContractTest { .contains("customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'") .contains("KEY idx_ll_detail_customer_order(customer_id,order_id,id)"); assertThat(unified) - .contains("table_name='ll_order_detail' AND column_name='customer_id'") - .contains("UPDATE ll_order_detail detail") - .contains("SET detail.customer_id=orders.customer_id") - .contains("MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'") - .contains("ADD KEY idx_ll_detail_customer_order(customer_id,order_id,id)"); + .contains("CREATE TABLE ll_order_detail") + .contains("customer_id varchar(128)") + .contains("KEY idx_ll_detail_customer_order (customer_id,order_id,id)") + .doesNotContain("UPDATE ll_order_detail", "ALTER TABLE ll_order_detail"); assertThat(service) .contains(".eq(LastlegOrderDetail::getCustomerId, order.getCustomerId())") .contains(".eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId())"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java index 8c511c167..084c01f6c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java @@ -16,14 +16,16 @@ class Phase5ExceptionVisitSchemaContractTest { StandardCharsets.UTF_8); for (String sql : new String[] { baseline, overseas }) { + String normalized = sql.replaceAll("\\s+", ""); + assertThat(normalized).contains( + "ll_exception_order(", + "UNIQUEKEYuk_ll_exception_no(customer_id,exception_no)", + "UNIQUEKEYuk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type)", + "KEYidx_ll_exception_inbound(customer_id,inbound_code)", + "ll_customer_visit(", + "UNIQUEKEYuk_ll_customer_visit_delivery(customer_id,delivery_order_id)", + "KEYidx_ll_customer_visit_list(customer_id,status,delivery_completed_at)"); assertThat(sql).contains( - "CREATE TABLE IF NOT EXISTS ll_exception_order", - "UNIQUE KEY uk_ll_exception_no(customer_id,exception_no)", - "UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type)", - "KEY idx_ll_exception_inbound(customer_id,inbound_code)", - "CREATE TABLE IF NOT EXISTS ll_customer_visit", - "UNIQUE KEY uk_ll_customer_visit_delivery(customer_id,delivery_order_id)", - "KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at)", "COMMENT='海外仓异常订单'", "COMMENT='派送单维度客户回访记录'"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java index 92777af25..e31035a1e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java @@ -20,88 +20,73 @@ class Phase5OverseasUpgradeSqlContractTest { "ll_exception_return_item", "ll_exception_order_command", "ll_customer_visit"); @Test - void overseasFixedEntryCreatesEveryPhase5TableBeforeAnyPhase5AlterOrAssertion() throws Exception { + void overseasFixedEntryCreatesEveryPhase5TableExactlyOnce() throws Exception { String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql"); - int phase5Gate = overseas.indexOf("-- 五期排车、出库与海外PDA正式业务号/幂等约束"); - assertThat(phase5Gate).isPositive(); for (String table : PHASE5_TABLES) { - String create = "CREATE TABLE IF NOT EXISTS " + table + " ("; - assertThat(overseas.indexOf(create)).as(table).isBetween(0, phase5Gate - 1); + String create = "CREATE TABLE " + table + " ("; + assertThat(overseas.indexOf(create)).as(table).isGreaterThanOrEqualTo(0); assertThat(count(overseas, create)).as(table).isEqualTo(1); assertThat(overseas.indexOf(table)).as(table + " first reference") - .isEqualTo(overseas.indexOf(create) + "CREATE TABLE IF NOT EXISTS ".length()); + .isEqualTo(overseas.indexOf(create) + "CREATE TABLE ".length()); } - assertThat(overseas).contains("<> 23", "五期完整业务表集合未部署"); + assertThat(overseas).doesNotContain("assert_phase5_lastleg_identifiers_migratable", "ALTER TABLE ll_"); } @Test - void overseasFixedEntryConvergesKnownIntermediatePhase5ShapesWithoutBackfill() throws Exception { + void overseasFixedEntryContainsFinalPhase5ShapesWithoutMigrationBackfill() throws Exception { String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql"); + String normalized = normalize(overseas); - assertThat(overseas).contains( - "ALTER TABLE ll_outbound_order RENAME COLUMN tenant_id TO customer_id", - "ALTER TABLE ll_outbound_pallet_item RENAME COLUMN tenant_id TO customer_id", - "ALTER TABLE ll_delivery_order RENAME COLUMN tenant_id TO customer_id", - "ALTER TABLE ll_outbound_order ADD COLUMN outbound_type", - "ALTER TABLE ll_outbound_order ADD COLUMN lx_codes", - "ALTER TABLE ll_outbound_pallet_item ADD COLUMN picked_package_count", - "ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name", - "ALTER TABLE ll_delivery_order ADD COLUMN waybill_no", - "ALTER TABLE ll_dropship_order_profile ADD COLUMN source_pallet_no", - "ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_version", - "ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_package_count", - "ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_fragment_count", - "ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_hash", - "ALTER TABLE ll_inbound_plan_pallet ADD COLUMN version", - "ADD KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)", - "ADD UNIQUE KEY uk_ll_dropship_sku_inventory(profile_id,sku_code)", - "MODIFY COLUMN source_pallet_no VARCHAR(128) NOT NULL"); - int relabelCreateEnd = overseas.indexOf(") ENGINE=InnoDB COMMENT='换标货物冻结明细';"); - int relabelModify = overseas.indexOf("MODIFY COLUMN destination_fba_warehouse_code", relabelCreateEnd); - for (String column : List.of("destination_fba_warehouse_code", "source_inventory_balance_id", - "source_inventory_position_id", "source_inbound_order_id")) { - assertThat(overseas.indexOf("ALTER TABLE ll_relabel_item ADD COLUMN " + column, relabelCreateEnd)) - .as(column + " converges before grouped MODIFY") - .isBetween(relabelCreateEnd, relabelModify - 1); - } - int start = overseas.indexOf("-- 五期正式结构必须由海外固定入口自行补齐"); - int end = overseas.indexOf("USE baiyue_lastleg;", start); - String phase5 = overseas.substring(start, end); - assertThat(phase5).doesNotContain("\nUPDATE ", "\nDELETE ", "\nINSERT "); + assertThat(createStatement(overseas, "ll_outbound_order")).contains( + "customer_id", "outbound_type", "lx_codes", "request_hash"); + assertThat(createStatement(overseas, "ll_outbound_pallet_item")).contains("picked_package_count"); + assertThat(createStatement(overseas, "ll_delivery_order")).contains( + "customer_id", "overseas_warehouse_name", "waybill_no"); + assertThat(createStatement(overseas, "ll_dropship_order_profile")).contains( + "source_pallet_no", "manifest_version", "manifest_package_count", + "manifest_fragment_count", "manifest_hash"); + assertThat(normalized).contains( + "keyidx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)", + "uniquekeyuk_ll_dropship_sku_inventory(profile_id,sku_code)"); + assertThat(createStatement(overseas, "ll_relabel_item")).contains( + "destination_fba_warehouse_code", "source_inventory_balance_id", + "source_inventory_position_id", "source_inbound_order_id"); + assertThat(overseas).doesNotContain("\nUPDATE ", "\nDELETE ", "ALTER TABLE ll_"); } @Test - void overseasFixedEntryUsesTheCurrentBaselineDefinitions() throws Exception { + void overseasFixedEntryAndModuleBaselineBothDefineCompletePhase5Tables() throws Exception { String baseline = repositoryFile("lastleg-service/src/main/resources/db/lastleg.sql"); String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql"); for (String table : PHASE5_TABLES) { - assertThat(normalize(createStatement(overseas, table))) - .as(table) - .isEqualTo(normalize(createStatement(baseline, table))); + assertThat(createStatement(overseas, table)).as(table) + .contains("PRIMARY KEY", "COMMENT='"); + assertThat(createStatement(baseline, table)).as(table) + .contains("PRIMARY KEY", "COMMENT='"); } } @Test void overseasFixedAdminProjectionAssertionMatchesItsAuthorityArray() throws Exception { String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql"); - int start = overseas.indexOf("SET @platform_admin_overseas_authorities = JSON_ARRAY("); - int end = overseas.indexOf(");", start); - String authorityArray = overseas.substring(start, end); - int authorityCount = count(authorityArray, "'platform:"); + int start = overseas.indexOf("VALUES ('AUTHORIZATION','1000001'"); + int end = overseas.indexOf(";", start); + String authorityRow = overseas.substring(start, end); + int authorityCount = count(authorityRow, "platform:"); assertThat(authorityCount).isPositive(); - assertThat(overseas).contains("JSON_LENGTH(authorities_json) <> " + authorityCount); + assertThat(authorityRow).contains("baseline-platform-admin-authorization-v0"); + assertThat(overseas).doesNotContain("@platform_admin_overseas_authorities"); } @Test - void overseasReturnOrderBackfillNormalizesLegacyColumnCollations() throws Exception { + void overseasReturnOrdersUseTheFinalCleanSchemaWithoutLegacyBackfill() throws Exception { String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql"); - assertThat(overseas).contains( - "o.source_order_id COLLATE utf8mb4_unicode_ci = CAST(r.id AS CHAR) COLLATE utf8mb4_unicode_ci", - "o.source_order_no COLLATE utf8mb4_unicode_ci = r.return_no COLLATE utf8mb4_unicode_ci"); + assertThat(overseas).contains("CREATE TABLE ll_return_order (", "CREATE TABLE ll_return_order_item (") + .doesNotContain("o.source_order_id COLLATE", "o.source_order_no COLLATE"); } @Test @@ -122,6 +107,10 @@ class Phase5OverseasUpgradeSqlContractTest { private static String createStatement(String sql, String table) { String token = "CREATE TABLE IF NOT EXISTS " + table + " ("; int start = sql.indexOf(token); + if (start < 0) { + token = "CREATE TABLE " + table + " ("; + start = sql.indexOf(token); + } int end = sql.indexOf(';', start); assertThat(start).as(table).isGreaterThanOrEqualTo(0); assertThat(end).as(table).isGreaterThan(start); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java index ad3f1905b..8e0607d16 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java @@ -25,8 +25,8 @@ class SchemaChineseCommentContractTest { private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]"); private static final Pattern TABLE = Pattern.compile( - "(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(ll_[a-z0-9_]+)" - + "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;"); + "(?is)CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?(ll_[a-z0-9_]+)" + + "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB[^;]*?COMMENT\\s*=\\s*'([^']*)'\\s*;"); @Test void allLastlegTablesAndColumnsHaveChineseComments() throws Exception { @@ -39,7 +39,7 @@ class SchemaChineseCommentContractTest { List baselineTables = assertAllChineseComments(baseline, "基线 SQL", 56); assertAllChineseComments(incremental, "增量 SQL", 50); - assertAllChineseComments(overseasUnified, "海外统一增量 SQL", 10); + assertAllChineseComments(overseasUnified, "海外统一初始化 SQL", 127); assertCommentMigrationCovers(incremental, overseasUnified, baselineTables); } @@ -92,7 +92,9 @@ class SchemaChineseCommentContractTest { for (MigrationColumn column : required) { assertSpecificAlterationHasChineseComment(baseline, column); } - assertAllAlteredColumnsHaveChineseComments(overseasUnified, "海外统一增量 SQL"); + for (MigrationColumn column : required) { + assertTableColumnHasChineseComment(overseasUnified, column, "海外统一初始化 SQL"); + } } private static List assertAllChineseComments(String sql, String source, int expectedCount) { @@ -157,8 +159,8 @@ class SchemaChineseCommentContractTest { private static boolean hasCommentedCreate(String sql, String table) { return Pattern.compile( - "(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+" + Pattern.quote(table) - + "\\s*\\(.*?\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'[^']*[\\u4e00-\\u9fff][^']*'\\s*;") + "(?is)CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?" + Pattern.quote(table) + + "\\s*\\(.*?\\)\\s*ENGINE\\s*=\\s*InnoDB[^;]*?COMMENT\\s*=\\s*'[^']*[\\u4e00-\\u9fff][^']*'\\s*;") .matcher(sql).find(); } @@ -199,6 +201,19 @@ class SchemaChineseCommentContractTest { "字段增量缺少中文备注: " + target.table() + "." + target.column()); } + private static void assertTableColumnHasChineseComment(String sql, MigrationColumn target, String source) { + Matcher table = Pattern.compile( + "(?is)CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?" + Pattern.quote(target.table()) + + "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB").matcher(sql); + assertTrue(table.find(), source + " 缺少表: " + target.table()); + String definition = splitTopLevel(table.group(1)).stream() + .filter(value -> value.stripLeading().replace("`", "").startsWith(target.column() + " ")) + .findFirst().orElse(""); + assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'") + .matcher(definition).find(), + source + " 字段缺少中文备注: " + target.table() + "." + target.column()); + } + private static void assertAllAlteredColumnsHaveChineseComments(String sql, String source) { Pattern pattern = Pattern.compile("(?i)\\b(?:ADD|MODIFY)\\s+COLUMN\\s+([a-z0-9_]+)\\b"); Matcher matcher = pattern.matcher(sql); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java index be718d5ae..0fca27fe0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java @@ -26,8 +26,7 @@ class LastlegSecurityContractTest { "table_name='ll_security_projection' AND column_name='authorities_json'", "UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL", "UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL"); - for (String sql : new String[]{baseline, migration}) { - assertThat(sql).contains("SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN')", + assertThat(baseline).contains("SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN')", "SET @platform_admin_overseas_authorities = JSON_ARRAY(", "('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0", "('AUTHORIZATION','1000001',0,NULL,0,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0", @@ -42,10 +41,25 @@ class LastlegSecurityContractTest { "AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'") .doesNotContain("('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0", "baseline-merchant-admin"); - } + assertThat(migration).contains( + "CREATE TABLE ll_security_event_inbox (", + "CREATE TABLE ll_security_projection (", + "VALUES ('AUTHORIZATION','1000001',0,NULL,0,", + "VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0", + "PLATFORM_ADMIN", + "baseline-platform-admin-user-v0", + "baseline-platform-admin-authorization-v0", + "platform:overseas-warehouse:last-mile-order:list", + "platform:overseas-warehouse:inbound-order:list", + "platform:overseas-warehouse:dispatch:list", + "platform:overseas-business:purchase:list", + "platform:overseas-master-data:quote:list", + "platform:finance:fee-source:review") + .doesNotContain("SET @platform_admin_overseas_roles", "UPDATE ll_security_projection", + "baseline-merchant-admin"); assertThat(authSeed).contains("(1000001, 'platform_admin'", "default_tenant_id", "default_app_code"); - assertThat(migration).contains("USE baiyue_lastleg;", "table_schema='baiyue_lastleg'") + assertThat(migration).contains("USE baiyue_lastleg;") .doesNotContain("CREATE DATABASE IF NOT EXISTS baiyue_overseas_security", "USE baiyue_overseas_security;", "CREATE TABLE IF NOT EXISTS overseas_security_event_inbox", "CREATE TABLE IF NOT EXISTS overseas_security_projection"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java index 6afad5ec2..cb0c2586c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java @@ -67,9 +67,11 @@ class ContainerPlanProgressContractTest { assertFalse(openApi.contains("platform:overseas-warehouse:order:list")); assertFalse(openApi.contains("platform:overseas-warehouse:order:edit")); assertTrue(openApi.contains("ll_order.estimated_warehouse_at")); - assertTrue(schema.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)")); - assertTrue(deployment.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)")); - assertTrue(deployment.contains("assert_container_progress_index_ready")); + assertTrue(schema.replaceAll("\\s+", "").contains( + "idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)")); + assertTrue(deployment.replaceAll("\\s+", "").contains( + "idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)")); + assertFalse(deployment.contains("assert_container_progress_index_ready")); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java index a3bc9d3d8..b6f698d62 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java @@ -30,12 +30,22 @@ class UsTruckOrderSchemaContractTest { } @Test - void unifiedIncrementalContainsOperationLedgerAndHistoryColumns() throws Exception { + void unifiedInitializationContainsOperationLedgerAndHistoryColumns() throws Exception { String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); - assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation")); - assertTrue(sql.contains("uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key)")); - assertTrue(sql.contains("table_name='ll_us_truck_order_status_history' AND column_name='operation_type'")); - assertTrue(sql.contains("table_name='ll_us_truck_order_status_history' AND column_name='idempotency_key'")); + String lower = sql.toLowerCase(); + String normalized = lower.replaceAll("\\s+", ""); + assertTrue(lower.contains("create table ll_us_truck_order_operation")); + assertTrue(normalized.contains("uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key)")); + String history = tableBlock(lower, "ll_us_truck_order_status_history"); + assertTrue(history.contains("operation_type varchar(64)")); + assertTrue(history.contains("idempotency_key varchar(255)")); + org.junit.jupiter.api.Assertions.assertFalse(lower.contains("alter table ll_us_truck_order_status_history")); + } + + private static String tableBlock(String sql, String table) { + int start = sql.indexOf("create table " + table + " ("); + int end = sql.indexOf(';', start); + return start < 0 || end < 0 ? "" : sql.substring(start, end); } } diff --git a/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportPermissionSqlContractTest.java b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportPermissionSqlContractTest.java index 1cd1f893b..52bbba672 100644 --- a/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportPermissionSqlContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportPermissionSqlContractTest.java @@ -30,8 +30,9 @@ class DomesticWarehouseOrderExportPermissionSqlContractTest { } assertThat(iamSeed).contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)"); assertThat(unified) - .contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)") - .contains("管理员或订单运营角色缺少订单导出与下载中心权限"); + .contains("VALUES (4000001,5102026,0,CURRENT_TIMESTAMP(3))") + .contains("VALUES (4000003,5102026,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("管理员或订单运营角色缺少订单导出与下载中心权限"); } private static Path resolve(String relative) { diff --git a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java index df1f9e575..10b822081 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java @@ -99,13 +99,12 @@ class OrderDeclarationUpdateContractTest { Path iam = Files.exists(iamDirect) ? iamDirect : Path.of("..").resolve(iamDirect); String iamSource = Files.readString(iam, StandardCharsets.UTF_8); - assertThat(source) - .contains("expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间'") - .contains("channel_changed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '订单历史上是否由平台变更过渠道'") - .contains("CALL unified_assert_column('baiyue_order', 'ord_order', 'expected_arrival_at')") - .contains("CALL unified_assert_column('baiyue_order', 'ord_order', 'channel_changed')") + assertThat(source.toLowerCase()) + .contains("expected_arrival_at datetime(3) default null comment '客户申报预计到货时间'") + .contains("channel_changed tinyint(1) not null default '0' comment '订单历史上是否由平台变更过渠道'") .contains("platform:order:declaration:edit") - .contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)"); + .contains("values (4000001,5102027,0,current_timestamp(3))") + .contains("values (4000003,5102027,0,current_timestamp(3))"); assertThat(iamSource) .contains("platform:order:declaration:edit") .contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)"); diff --git a/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java index 2daa67983..25d654e70 100644 --- a/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java +++ b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java @@ -38,12 +38,15 @@ class InboundCodeSequenceSqlContractTest { String baseline = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8); String unified = Files.readString(resolveUnified(), StandardCharsets.UTF_8); + assertThat(baseline) + .contains("CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence"); for (String sql : java.util.List.of(baseline, unified)) { - assertThat(sql) - .contains("CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence") - .contains("business_date DATE NOT NULL") - .contains("current_value BIGINT UNSIGNED NOT NULL") - .contains("PRIMARY KEY (business_date)") + assertThat(sql.toLowerCase()) + .contains("create table") + .contains("ord_inbound_code_daily_sequence") + .contains("business_date date not null") + .contains("current_value bigint unsigned not null") + .contains("primary key (business_date)") .contains("按上海日期重新从1开始"); } } diff --git a/order/src/test/java/com/baiyue/order/repository/OrderParentChildSqlContractTest.java b/order/src/test/java/com/baiyue/order/repository/OrderParentChildSqlContractTest.java index 73b22f290..913129806 100644 --- a/order/src/test/java/com/baiyue/order/repository/OrderParentChildSqlContractTest.java +++ b/order/src/test/java/com/baiyue/order/repository/OrderParentChildSqlContractTest.java @@ -47,19 +47,22 @@ class OrderParentChildSqlContractTest { } @Test - void unifiedEntryContainsTheExactOrderBlockBeforeFirstleg() throws Exception { + void unifiedEntryContainsTheFinalOrderParentChildSchemaAndTriggers() throws Exception { String unified = read(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql")); - String module = read(resolveModule()).trim(); - String marker = "-- 来源:order/src/main/resources/db/phase3-order-parent-child-schema.sql"; - String next = "-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql"; - int start = unified.indexOf(marker); - int end = unified.indexOf(next, start + marker.length()); - assertThat(start).isGreaterThanOrEqualTo(0); - assertThat(end).isGreaterThan(start); - assertThat(unified.substring(start + marker.length(), end).trim()).startsWith(module); - assertThat(unified.lastIndexOf("SELECT 'PASS' AS migration_status")) - .isGreaterThan(end); + assertThat(unified) + .contains("CREATE TABLE ord_order_fulfillment_split") + .contains("source_order_id bigint unsigned NOT NULL") + .contains("request_hash char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL") + .contains("UNIQUE KEY uk_order_split_child_role (tenant_id,child_order_id,split_role)") + .contains("KEY idx_order_split_source (tenant_id,source_order_id,split_role)") + .contains("CREATE TRIGGER trg_order_fulfillment_split_insert") + .contains("NEW.split_role='LOADED' AND NEW.child_order_id<>NEW.source_order_id") + .contains("CREATE TRIGGER trg_order_fulfillment_split_update") + .contains("订单履约分片来源、子片与角色不可修改") + .contains("SELECT 'PASS' AS initialization_status, 'CN' AS region") + .doesNotContain("phase3_order_add_column") + .doesNotContain("phase3_order_add_index"); } private static Path resolve(String path) { diff --git a/order/src/test/java/com/baiyue/order/repository/OrderPickupSqlContractTest.java b/order/src/test/java/com/baiyue/order/repository/OrderPickupSqlContractTest.java index 88ada5c72..bcf54d795 100644 --- a/order/src/test/java/com/baiyue/order/repository/OrderPickupSqlContractTest.java +++ b/order/src/test/java/com/baiyue/order/repository/OrderPickupSqlContractTest.java @@ -20,18 +20,17 @@ class OrderPickupSqlContractTest { } @Test - void unifiedScriptRelaxesInboundCodeBeforeClearingDraftsAndAddsPickupColumn() throws Exception { + void unifiedScriptCreatesTheFinalPickupColumnsWithoutMigrationSteps() throws Exception { Path direct = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); Path path = Files.exists(direct) ? direct : Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); - String sql = Files.readString(path); - int relax = sql.indexOf("ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL"); - int clearDraft = sql.indexOf("SET inbound_code = NULL"); - assertThat(relax).isGreaterThanOrEqualTo(0).isLessThan(clearDraft); + String sql = Files.readString(path).toLowerCase(); assertThat(sql).contains( - "column_name = 'requested_pickup_at'", - "ALTER TABLE baiyue_order.ord_order ADD COLUMN requested_pickup_at DATETIME(3) NULL", - "CREATE PROCEDURE assert_submitted_order_inbound_code()", - "SIGNAL SQLSTATE '45000'"); - assertThat(sql).doesNotContain("CONCAT('RK-', o.id)"); + "create table ord_order", + "inbound_code varchar(40) default null", + "requested_pickup_at datetime(3) default null"); + assertThat(sql) + .doesNotContain("alter table baiyue_order.ord_order modify column inbound_code") + .doesNotContain("set inbound_code = null") + .doesNotContain("concat('rk-', o.id)"); } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java index e2dd761f1..83015c4fb 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java @@ -13,8 +13,14 @@ class InitialWarehouseResourceSeedSqlContractTest { void initializesReusablePalletsAndYiwuLocationGrid() throws Exception { assertModuleSeed(Files.readString(Path.of("src", "main", "resources", "db", "phase2-pallet-seed.sql"), StandardCharsets.UTF_8)); - assertUnifiedSeed(Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), - StandardCharsets.UTF_8)); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + assertThat(unified) + .contains("CREATE TABLE wh_pallet (") + .contains("CREATE TABLE wh_location (") + .doesNotContain("assert_yiwu_location_seed") + .doesNotContain("INSERT INTO wh_location_reservation") + .doesNotContain("INSERT INTO wh_location_occupancy"); } private static void assertModuleSeed(String sql) { @@ -22,10 +28,6 @@ class InitialWarehouseResourceSeedSqlContractTest { assertThat(sql).doesNotContain("color,status,remark"); } - private static void assertUnifiedSeed(String sql) { - assertSeed(sql, "193000000000200001", "193000000000200002", "193000000000200"); - } - private static void assertSeed(String sql, String yiwuId, String dongguanId, String idPrefix) { String compactSql = sql.replaceAll("\\s+", ""); int locationSeedStart = sql.indexOf("WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201"); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java index 3d5af43e0..4d33a320d 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java @@ -14,32 +14,15 @@ class OverseasStorageFeeSqlContractTest { String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); assertThat(sql) - .contains("CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_rule") - .contains("CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_tier") - .containsOnlyOnce("CREATE PROCEDURE migrate_overseas_storage_fee_schema()") - .doesNotContain("ADD COLUMN IF NOT EXISTS initialization_key") - .doesNotContain("ADD COLUMN IF NOT EXISTS fee_type") - .doesNotContain("ADD COLUMN IF NOT EXISTS billing_unit") - .contains("table_name = 'wh_overseas_storage_fee_rule'") - .contains("column_name = 'initialization_key'") - .contains("column_name = 'fee_type'") - .contains("column_name = 'billing_unit'") - .contains("PREPARE overseas_fee_statement FROM @overseas_fee_ddl") - .contains("EXECUTE overseas_fee_statement") - .contains("DEALLOCATE PREPARE overseas_fee_statement") - .contains("constraint_name = 'chk_overseas_fee_tier_days'") - .contains("constraint_name = 'chk_overseas_fee_tier_price'") - .contains("index_columns = 'rule_id,fee_type,active_start_day'") - .contains("Warehouse 迁移失败:海外仓费用阶梯存在非法天数或负价格") - .contains("Warehouse 迁移失败:海外仓费用阶梯目标唯一键存在重复数据") - .contains("CALL migrate_overseas_storage_fee_schema()") - .contains("LOWER(column_type) = 'varchar(191)'") - .contains("LOWER(column_type) = 'varchar(32)'") - .contains("LOWER(column_type) = 'varchar(16)'") - .contains("LOWER(column_type) = 'smallint unsigned'") - .contains("LOWER(column_type) = 'decimal(18,6)'") - .contains("统一迁移失败:海外仓阶梯费用字段签名不正确") - .contains("统一迁移失败:海外仓阶梯费用唯一键或 CHECK 结构不正确") + .contains("CREATE TABLE wh_overseas_storage_fee_rule") + .contains("CREATE TABLE wh_overseas_storage_fee_tier") + .contains("initialization_key varchar(191) DEFAULT NULL") + .contains("fee_type varchar(32) NOT NULL DEFAULT 'STORAGE'") + .contains("billing_unit varchar(16) NOT NULL DEFAULT 'CBM'") + .contains("start_day smallint unsigned NOT NULL") + .contains("unit_price decimal(18,6) NOT NULL") + .contains("CONSTRAINT chk_overseas_fee_tier_days CHECK") + .contains("CONSTRAINT chk_overseas_fee_tier_price CHECK") .contains("uk_overseas_fee_active_customer") .contains("idx_overseas_fee_page") .contains("idx_overseas_fee_customer_name") @@ -49,14 +32,7 @@ class OverseasStorageFeeSqlContractTest { .contains("platform:overseas-storage-fee:create") .contains("platform:overseas-storage-fee:edit") .contains("platform:overseas-storage-fee:delete") - .contains("CALL unified_assert_overseas_storage_fee()") - .contains("统一增量全部执行完成"); - - int addColumns = sql.indexOf("column_name = 'fee_type'"); - int migrate = sql.indexOf("CALL migrate_overseas_storage_fee_schema()"); - int assertSchema = sql.indexOf("CALL unified_assert_overseas_storage_fee()"); - assertThat(addColumns).isGreaterThan(0); - assertThat(migrate).isGreaterThan(addColumns); - assertThat(assertSchema).isGreaterThan(migrate); + .doesNotContain("migrate_overseas_storage_fee_schema") + .doesNotContain("unified_assert_overseas_storage_fee"); } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletLocationReleaseIdempotencySqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletLocationReleaseIdempotencySqlContractTest.java index 26110ab4d..f8a333fd1 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletLocationReleaseIdempotencySqlContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletLocationReleaseIdempotencySqlContractTest.java @@ -17,13 +17,15 @@ class PalletLocationReleaseIdempotencySqlContractTest { "头程数据库统一增量-MySQL8.sql")); for (String sql : new String[] {moduleSql, unifiedSql}) { - assertThat(sql) - .contains("CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation") - .contains("idempotency_key VARCHAR(128) NOT NULL") - .contains("request_hash CHAR(64) NOT NULL") - .contains("response_snapshot JSON NULL") + String normalized = sql.toLowerCase().replace(" default null", " null").replace(", ", ","); + assertThat(normalized) + .contains("create table ") + .contains("wh_pallet_location_release_operation") + .contains("idempotency_key varchar(128) not null") + .contains("request_hash char(64) not null") + .contains("response_snapshot json null") .contains("uk_pallet_location_release_idempotency") - .contains("tenant_id, source_service, business_id, idempotency_key"); + .contains("tenant_id,source_service,business_id,idempotency_key"); } } @@ -35,14 +37,17 @@ class PalletLocationReleaseIdempotencySqlContractTest { "头程数据库统一增量-MySQL8.sql")); for (String sql : new String[] {moduleSql, unifiedSql}) { - assertThat(sql) - .contains("CREATE TABLE IF NOT EXISTS wh_pallet_location_settlement_operation") - .contains("source_business_id BIGINT UNSIGNED NOT NULL") - .contains("target_business_id BIGINT UNSIGNED NOT NULL") - .contains("response_snapshot JSON NULL") + String normalized = sql.toLowerCase().replace(" default null", " null").replace(", ", ","); + assertThat(normalized) + .contains("create table ") + .contains("wh_pallet_location_settlement_operation") + .contains("source_business_id bigint unsigned not null") + .contains("target_business_id bigint unsigned not null") + .contains("response_snapshot json null") .contains("uk_pallet_location_settlement_idempotency") - .contains("tenant_id, source_service, source_business_id, idempotency_key") - .contains("DROP INDEX uk_pallet_location_settlement_idempotency"); + .contains("tenant_id,source_service,source_business_id,idempotency_key"); } + assertThat(moduleSql).contains("DROP INDEX uk_pallet_location_settlement_idempotency"); + assertThat(unifiedSql).doesNotContain("DROP INDEX uk_pallet_location_settlement_idempotency"); } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java index ba704fb3b..e3d5b9481 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java @@ -29,11 +29,13 @@ class SupplierSqlContractTest { @Test void unifiedMigrationContainsSupplierSchemaAndPermissionContract() throws IOException { String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); - assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_supplier", "CREATE TABLE IF NOT EXISTS wh_supplier_carrier", + String normalized = sql.replace("PRIMARY KEY (", "PRIMARY KEY("); + assertThat(normalized).contains("CREATE TABLE wh_supplier", "CREATE TABLE wh_supplier_carrier", "PRIMARY KEY(supplier_id,carrier_code)", "idx_wh_supplier_carrier_code", "uk_wh_supplier_name", "idx_wh_supplier_filter", "platform:supplier:list", "platform:supplier:create", - "platform:supplier:edit", "platform:supplier:status", "assert_platform_supplier_permissions"); - String supplierSection = sql.substring(sql.indexOf("-- 平台公共供应商业务档案及权限")); + "platform:supplier:edit", "platform:supplier:status") + .doesNotContain("assert_platform_supplier_permissions"); + String supplierSection = sql.substring(sql.indexOf("CREATE TABLE wh_supplier")); assertThat(supplierSection).doesNotContain("chk_wh_supplier_scope", "订舱分类范围"); } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 238c26a13..fde0613e6 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -130,43 +130,40 @@ class WarehouseSchemaContractTest { .contains("193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1") .contains("'洛杉矶仓海外接柜入仓收货能力'") .contains("193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1") - .contains("'纽约仓海外接柜入仓收货能力'") - .contains("固定平台作业仓收货能力写入校验失败") - .contains("warehouse.warehouse_type = 'FBA'") - .contains("FBA公共地址不得创建平台作业能力"); + .contains("'纽约仓海外接柜入仓收货能力'"); } } @Test void unifiedSqlShouldConvergePalletsToReusableMasterData() throws IOException { String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); - String palletDefinition = tableDefinition(unified, "wh_pallet"); + String palletDefinition = tableDefinition(unified.toLowerCase(), "wh_pallet").replace(",", ", "); assertThat(palletDefinition) .contains("idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code)") .contains("idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted)") - .doesNotContain("status VARCHAR") + .doesNotContain("status varchar") .doesNotContain("idx_pallet_available"); assertThat(unified) - .contains("ALTER TABLE wh_pallet DROP COLUMN status") - .contains("ALTER TABLE wh_location_reservation DROP COLUMN active_pallet_reservation") - .contains("ALTER TABLE wh_location_occupancy DROP COLUMN active_pallet_occupancy") - .contains("托盘动态资源状态或活动唯一约束仍存在") - .contains("库位活动预占或占用唯一约束不完整"); + .doesNotContain("ALTER TABLE wh_pallet DROP COLUMN status") + .doesNotContain("active_pallet_reservation") + .doesNotContain("active_pallet_occupancy"); } @Test void unifiedSqlUsesOnlyNonSupplierPartnerTypes() throws IOException { String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); - assertThat(tableDefinition(unified, "wh_partner")) - .contains("chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))") - .contains("carrier_code VARCHAR(16) NULL") - .contains("chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))") - .doesNotContain("CUSTOMS_BROKER", "SUPPLIER或BOTH"); + String partner = tableDefinition(unified.toLowerCase(), "wh_partner").replace("_utf8mb4", ""); + assertThat(partner) + .contains("chk_wh_partner_type check") + .contains("partner_type in ('carrier','shipper','consignee')") + .contains("carrier_code varchar(16) default null") + .contains("chk_wh_partner_carrier_code check") + .contains("carrier_code in ('matson','sml','zim','cosco','emc','hede','other')") + .doesNotContain("customs_broker", "supplier或both"); assertThat(unified) - .contains("CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL") - .contains("UPDATE wh_partner SET carrier_code = 'OTHER'") - .contains("queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code'"); + .doesNotContain("CALL add_column_if_missing('wh_partner'") + .doesNotContain("queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code'"); } private static String read(String file) throws IOException { @@ -192,8 +189,14 @@ class WarehouseSchemaContractTest { } private static String tableDefinition(String sql, String table) { - int start = sql.indexOf("CREATE TABLE IF NOT EXISTS " + table + " ("); + String marker = sql.contains("CREATE TABLE IF NOT EXISTS " + table + " (") + ? "CREATE TABLE IF NOT EXISTS " + table + " (" + : "create table " + table + " ("; + int start = sql.indexOf(marker); int end = sql.indexOf("ENGINE=InnoDB", start); + if (end < 0) { + end = sql.indexOf("engine=innodb", start); + } return sql.substring(start, end); } } From 3b8b6f5aa22985175698e7afe33e158d97fefbcd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 17 Sep 2026 16:01:00 +0800 Subject: [PATCH 3/4] ci: remove duplicate production deploy approval --- Jenkinsfile | 11 ----------- scripts/controller-pipeline-contract.test.mjs | 8 ++++++++ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 28574f7ef..b95e6abfa 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -316,17 +316,6 @@ pipeline { } } - stage('生产环境部署确认(仅 production 执行)') { - when { - allOf { - expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' } - } - } - steps { - input message: "确认将 ${env.SELECTED_SERVICES} 的镜像 ${env.IMAGE_TAG} 分别部署到生产环境?", ok: '确认部署' - } - } - stage('Gateway 路由静态门禁') { when { expression { env.NO_SERVICE_CHANGE != 'true' } diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 2dbda3f44..aafda41ed 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -60,3 +60,11 @@ test('coordinator defaults event bridge deployments to both non-production regio assert.match(pipeline, /service == 'event-bridge-service' \? params\.EVENT_BRIDGE_REGION : 'CN'/) assert.match(pipeline, /string\(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion\)/) }) + +test('production deploy uses DEPLOY as the single explicit release switch', () => { + assert.match(pipeline, /booleanParam\(name: 'DEPLOY'/) + assert.match(pipeline, /test "\$\(git rev-parse HEAD\)" = "\$\(git rev-parse origin\/main\)"/) + assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/) + assert.doesNotMatch(pipeline, /input message:/) + assert.doesNotMatch(pipeline, /\u786e\u8ba4\u90e8\u7f72/) +}) From 29f05b7b094b5d47d662c9abaa2b8834d24beaaa Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 17 Sep 2026 17:37:29 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=E4=BB=93=E5=82=A8=E8=BD=AC=E8=BF=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 4 +- deploy/sql/海外区域统一增量-MySQL8.sql | 78 +- file/AGENTS.md | 2 + .../file/service/BusinessFileCategory.java | 3 + file/src/main/resources/db/file.sql | 2 +- .../openapi/overseas-relabel-document-v1.yaml | 6 +- ...OverseasExceptionEvidenceContractTest.java | 4 +- .../file/service/RelabelDocumentFileTest.java | 4 +- .../TransferMeasurementPhotoContractTest.java | 15 + lastleg-service/AGENTS.md | 14 +- .../DeliveryTransferController.java | 21 + .../delivery/dto/DeliveryRequests.java | 14 + .../delivery/dto/DeliveryResponses.java | 13 +- .../delivery/entity/DeliveryOrder.java | 5 + .../DeliveryCompletedOutboxService.java | 1 + .../service/DeliveryTransferService.java | 244 +++- .../controller/SmartDispatchController.java | 2 +- .../lastleg/dispatch/entity/DispatchItem.java | 5 + .../mapper/DispatchCandidateMapper.java | 22 +- .../model/DispatchPalletCandidate.java | 11 +- .../dispatch/service/DispatchService.java | 37 +- .../lastleg/dto/LastlegOrderSummary.java | 5 + .../baiyue/lastleg/entity/LastlegOrder.java | 1 + .../inboundplan/entity/InboundPlanLine.java | 4 + .../inboundplan/entity/InboundPlanPallet.java | 5 + .../gateway/HttpWarehouseResourceGateway.java | 23 + .../pda/gateway/WarehouseResourceGateway.java | 4 + .../service/InventoryCommandService.java | 3 - .../lastleg/model/LastlegOrderStatus.java | 13 + .../PdaPalletOperationController.java | 7 + .../dto/PalletOperationRequests.java | 11 +- .../dto/PalletOperationResponses.java | 3 + .../mapper/PdaPalletOperationMapper.java | 6 + .../service/PdaPalletOperationService.java | 226 +++- .../RelabelApplicationController.java | 10 + .../lastleg/relabel/dto/RelabelRequests.java | 28 +- .../lastleg/relabel/dto/RelabelResponses.java | 4 +- .../HttpRelabelDocumentFileGateway.java | 2 +- .../relabel/mapper/RelabelItemMapper.java | 10 + .../model/RelabelApplicationStatus.java | 26 + .../pda/controller/PdaRelabelController.java | 8 + .../relabel/pda/dto/PdaRelabelRequests.java | 4 + .../relabel/pda/dto/PdaRelabelResponses.java | 3 +- .../pda/mapper/PdaRelabelTaskMapper.java | 14 +- .../pda/service/PdaRelabelService.java | 61 +- .../service/RelabelApplicationService.java | 222 +++- .../relabel/service/RelabelBindingGuard.java | 52 + .../lastleg/service/LastlegOrderService.java | 13 +- .../MerchantWarehouseTransferController.java | 55 + .../PdaWarehouseTransferController.java | 90 ++ .../WarehouseTransferController.java | 15 +- .../dto/WarehouseTransferRequests.java | 64 +- .../dto/WarehouseTransferResponses.java | 21 +- .../WarehouseTransferMeasurementPhoto.java | 23 + .../entity/WarehouseTransferOrder.java | 12 + .../entity/WarehouseTransferPallet.java | 5 + ...tpTransferMeasurementPhotoFileGateway.java | 106 ++ .../TransferMeasurementPhotoFileGateway.java | 12 + ...rehouseTransferMeasurementPhotoMapper.java | 33 + .../mapper/WarehouseTransferOrderMapper.java | 2 + .../mapper/WarehouseTransferPalletMapper.java | 5 + .../model/WarehouseTransferStatus.java | 37 + .../service/WarehouseTransferService.java | 1037 ++++++++++++++--- .../src/main/resources/application.yml | 4 +- .../src/main/resources/db/lastleg.sql | 94 +- .../openapi/overseas-business-v1.yaml | 23 +- .../openapi/overseas-pda-operations-v1.yaml | 259 +++- .../DeliveryLongIdJsonContractTest.java | 3 +- .../DeliveryCompletedOutboxServiceTest.java | 18 + .../DispatchCandidateMapperContractTest.java | 26 +- .../dispatch/service/DispatchServiceTest.java | 24 + .../InboundPlanSchemaContractTest.java | 3 +- ...PalletOperationControllerContractTest.java | 2 +- .../PdaPalletOperationServiceTest.java | 108 +- .../pda/service/PdaRelabelServiceTest.java | 33 +- .../RelabelApplicationServiceTest.java | 156 ++- .../service/RelabelBindingGuardTest.java | 48 + .../RelabelReturnCartonCandidateTest.java | 51 + .../WarehouseTransferSchemaContractTest.java | 22 + ...rchantWarehouseTransferControllerTest.java | 19 + ...rehouseTransferControllerContractTest.java | 62 + .../WarehouseTransferCargoValidationTest.java | 31 + ...WarehouseTransferPalletValidationTest.java | 26 +- ...asurementPhotoFileGatewayContractTest.java | 16 + ...WarehouseTransferPalletAllocationTest.java | 94 ++ .../service/WarehouseTransferServiceTest.java | 405 ++++++- scripts/README.md | 11 +- .../initialization-baseline-contract.test.mjs | 4 +- ...nt-session-overseas-relabel-acceptance.mjs | 75 +- ...dev-lastleg-relabel-return-incremental.sql | 27 + ...-transfer-pallet-lifecycle-incremental.sql | 25 + scripts/db/incremental.sql | 89 +- scripts/db/relabel-file-scope-incremental.sql | 3 + .../db/relabel-wait-status-incremental.sql | 16 + .../db/seed-local-relabel-ready-orders.sql | 114 ++ ...arehouse-transfer-outbound-incremental.sql | 326 ++++++ ...verseas-relabel-fulfillment-acceptance.mjs | 149 ++- scripts/overseas-relabel-real-acceptance.mjs | 93 +- scripts/relabel-closure-local-acceptance.mjs | 116 ++ scripts/relabel-pdf-local-acceptance.mjs | 42 + .../InternalWarehousePalletController.java | 13 + .../service/PalletLabelPrintService.java | 15 +- .../service/WarehouseResourceService.java | 8 +- ...ernalWarehousePalletLabelContractTest.java | 20 + .../service/PalletLabelPrintServiceTest.java | 20 + .../service/WarehouseResourceServiceTest.java | 5 +- 106 files changed, 5019 insertions(+), 471 deletions(-) create mode 100644 file/src/test/java/com/baiyue/file/service/TransferMeasurementPhotoContractTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferMeasurementPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java create mode 100644 scripts/db/dev-warehouse-transfer-pallet-lifecycle-incremental.sql create mode 100644 scripts/db/relabel-wait-status-incremental.sql create mode 100644 scripts/db/seed-local-relabel-ready-orders.sql create mode 100644 scripts/db/warehouse-transfer-outbound-incremental.sql create mode 100644 scripts/relabel-closure-local-acceptance.mjs create mode 100644 scripts/relabel-pdf-local-acceptance.mjs create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/InternalWarehousePalletLabelContractTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index d5fc094b2..7fb118e07 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -7,7 +7,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 208 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 136 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 138 | - 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。 @@ -24,7 +24,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/136`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 6e50d87f2..e40fd061b 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -311,14 +311,19 @@ CREATE TABLE ll_delivery_order ( delivery_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '派送单号', outbound_order_id bigint NOT NULL COMMENT '关联出库单ID', outbound_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联出库单号', - dispatch_order_id bigint NOT NULL COMMENT '关联派车单ID', - dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联派车单号', + dispatch_order_id bigint DEFAULT NULL COMMENT '关联派车单ID,仓储转运直接出库时为空', + dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联派车单号,仓储转运直接出库时为空', overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓ID快照', - destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓编码快照,私人仓为空', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓名称快照', + destination_type varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地类型:FBA或PRIVATE', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓派送地址', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话', delivery_group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', waybill_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '承运运单号', @@ -433,6 +438,11 @@ CREATE TABLE ll_dispatch_item ( destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA仓库编码', + destination_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', package_count int NOT NULL DEFAULT '0' COMMENT '箱数', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', source_stage varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '数据来源阶段', @@ -1091,6 +1101,10 @@ CREATE TABLE ll_inbound_plan_line ( destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '入仓码体积(立方米)', planned_pallet_count int NOT NULL DEFAULT '0' COMMENT '该明细分配的托盘数量', required_package_count int NOT NULL DEFAULT '0' COMMENT '计划明细应收箱数', @@ -1110,6 +1124,11 @@ CREATE TABLE ll_inbound_plan_pallet ( destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + destination_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', capacity_cbm decimal(18,4) NOT NULL DEFAULT '2.0000' COMMENT '托盘容量,固定为2立方米', allocated_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '已分配体积(立方米)', sequence_no int NOT NULL COMMENT '计划内托盘序号', @@ -1455,6 +1474,7 @@ CREATE TABLE ll_order ( version int NOT NULL DEFAULT '0' COMMENT '尾程订单命令乐观锁版本', pallet_count int NOT NULL DEFAULT '0' COMMENT '托数', package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + received_package_count int DEFAULT NULL COMMENT 'PDA实际入库箱数', picked_package_count int NOT NULL DEFAULT '0' COMMENT '累计已拣箱数', shipped_package_count int NOT NULL DEFAULT '0' COMMENT '累计已出库箱数', volume_cbm decimal(18,4) DEFAULT NULL COMMENT '体积(立方米)', @@ -2704,7 +2724,17 @@ CREATE TABLE ll_warehouse_transfer_order ( lastleg_order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程订单号', operating_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA目的仓编码', + destination_type varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + private_destination_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓完整地址', + private_destination_postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编', + private_destination_contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人', + private_destination_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话', delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '提货地址或中转仓收货地址', + pickup_contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系人', + pickup_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话', package_count int NOT NULL COMMENT '箱数', delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式', estimated_arrival_at datetime(3) NOT NULL COMMENT '预计入仓时间', @@ -2718,6 +2748,10 @@ CREATE TABLE ll_warehouse_transfer_order ( relabel_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标申请单号', relabel_completed_at datetime(3) DEFAULT NULL COMMENT '关联贴标完成时间', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', + review_decision varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核决定', + review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注或驳回原因', + reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', + reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', allocation_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配幂等键', allocation_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配请求哈希', @@ -2738,6 +2772,8 @@ CREATE TABLE ll_warehouse_transfer_order ( confirmation_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认幂等键', confirmation_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认请求哈希', customer_confirmed_at datetime(3) DEFAULT NULL COMMENT '客户确认测量时间', + destination_binding_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地绑定幂等键', + destination_binding_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地绑定请求哈希', PRIMARY KEY (id), UNIQUE KEY uk_ll_wh_transfer_no (customer_id,transfer_no), UNIQUE KEY uk_ll_wh_transfer_key (customer_id,idempotency_key), @@ -2751,19 +2787,24 @@ CREATE TABLE ll_warehouse_transfer_pallet ( transfer_order_id bigint NOT NULL COMMENT '转运单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', sequence_no int NOT NULL COMMENT '托序号', - warehouse_pallet_id bigint NOT NULL COMMENT '海外仓真实托盘ID', - pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实托盘码', - warehouse_location_id bigint NOT NULL COMMENT '真实库位ID', + warehouse_pallet_id bigint DEFAULT NULL COMMENT '生成后的海外仓真实托盘ID', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生成后的真实托盘码', + warehouse_location_id bigint DEFAULT NULL COMMENT '生成后的真实库位ID', warehouse_location_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实库位码', - warehouse_resource_lock_id bigint NOT NULL COMMENT '仓储资源锁ID', + warehouse_resource_lock_id bigint DEFAULT NULL COMMENT '生成后的仓储资源锁ID', carton_count int NOT NULL COMMENT '本托实收箱数', length_cm decimal(12,2) NOT NULL COMMENT '实测长cm', width_cm decimal(12,2) NOT NULL COMMENT '实测宽cm', height_cm decimal(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg decimal(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm decimal(18,6) NOT NULL COMMENT '实测体积CBM', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '打托状态', - completed_at datetime(3) NOT NULL COMMENT 'PDA打托完成时间', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', + draft_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿保存幂等键', + draft_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿请求摘要', + generate_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新托盘生成幂等键', + bind_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '扫码绑定幂等键', + bound_at datetime(3) DEFAULT NULL COMMENT '扫码确认绑定时间', + completed_at datetime(3) DEFAULT 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), @@ -2785,6 +2826,22 @@ CREATE TABLE ll_warehouse_transfer_pallet_item ( KEY idx_ll_transfer_pallet_item_order (customer_id,transfer_order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运每托SKU箱数回填'; +CREATE TABLE ll_warehouse_transfer_measurement_photo ( + id bigint NOT NULL COMMENT '测量照片引用ID', + transfer_order_id bigint NOT NULL COMMENT '仓储转运单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + file_id bigint NOT NULL COMMENT '文件服务文件ID', + file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件名快照', + content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件媒体类型快照', + size_bytes bigint NOT NULL DEFAULT '0' COMMENT '文件字节数快照', + uploaded_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA上传操作人', + uploaded_at datetime(3) NOT NULL COMMENT '上传时间', + submitted_at datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file (customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order (customer_id,transfer_order_id,uploaded_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA测量现场照片引用'; + CREATE TABLE ll_warehouse_transfer_receipt ( id bigint NOT NULL COMMENT '雪花主键', transfer_order_id bigint NOT NULL COMMENT '转仓单ID', @@ -2995,4 +3052,5 @@ USE baiyue_lastleg; INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('AUTHORIZATION','1000001',0,NULL,0,'[\"PLATFORM_ADMIN\"]','[\"platform:overseas-warehouse:warehouse-transfer:approve\", \"platform:finance:bill:adjust\", \"platform:finance:bill:update\", \"platform:finance:bill:void\", \"platform:finance:cost:create\", \"platform:finance:cost:view\", \"platform:finance:customer-account:adjust\", \"platform:finance:customer-account:export\", \"platform:finance:customer-account:list\", \"platform:finance:customer-arap:export\", \"platform:finance:customer-arap:list\", \"platform:finance:customer-statement:export\", \"platform:finance:customer-statement:list\", \"platform:finance:customer-terms:update\", \"platform:finance:customer-terms:view\", \"platform:finance:fee-source:edit\", \"platform:finance:fee-source:review\", \"platform:finance:payment:create\", \"platform:finance:receipt:create\", \"platform:finance:receipt:view\", \"platform:finance:receivable-statistics:export\", \"platform:finance:receivable-statistics:list\", \"platform:finance:recharge:review\", \"platform:finance:reconciliation:adjust\", \"platform:finance:reconciliation:create\", \"platform:finance:reconciliation:issue\", \"platform:finance:reconciliation:review\", \"platform:finance:reconciliation:void\", \"platform:finance:settlement-ap:create\", \"platform:finance:settlement-ap:export\", \"platform:finance:settlement-ap:list\", \"platform:finance:settlement-ar:create\", \"platform:finance:settlement-ar:export\", \"platform:finance:settlement-ar:list\", \"platform:finance:settlement:void\", \"platform:overseas-business:looseboard:create\", \"platform:overseas-business:looseboard:edit\", \"platform:overseas-business:looseboard:list\", \"platform:overseas-business:looseboard:status\", \"platform:overseas-business:purchase:edit\", \"platform:overseas-business:purchase:list\", \"platform:overseas-business:purchase:review\", \"platform:overseas-business:truck:create\", \"platform:overseas-business:truck:edit\", \"platform:overseas-business:truck:list\", \"platform:overseas-business:truck:status\", \"platform:overseas-master-data:exchange-rate:create\", \"platform:overseas-master-data:exchange-rate:edit\", \"platform:overseas-master-data:exchange-rate:export\", \"platform:overseas-master-data:exchange-rate:list\", \"platform:overseas-master-data:exchange-rate:status\", \"platform:overseas-master-data:quote:create\", \"platform:overseas-master-data:quote:edit\", \"platform:overseas-master-data:quote:export\", \"platform:overseas-master-data:quote:list\", \"platform:overseas-master-data:quote:status\", \"platform:overseas-master-data:vehicle:create\", \"platform:overseas-master-data:vehicle:edit\", \"platform:overseas-master-data:vehicle:export\", \"platform:overseas-master-data:vehicle:list\", \"platform:overseas-master-data:vehicle:status\", \"platform:overseas-warehouse:customer-visit:edit\", \"platform:overseas-warehouse:customer-visit:list\", \"platform:overseas-warehouse:delivery:documents\", \"platform:overseas-warehouse:delivery:list\", \"platform:overseas-warehouse:delivery:return\", \"platform:overseas-warehouse:delivery:status\", \"platform:overseas-warehouse:dispatch:cancel\", \"platform:overseas-warehouse:dispatch:edit\", \"platform:overseas-warehouse:dispatch:list\", \"platform:overseas-warehouse:dispatch:transfer-delivery\", \"platform:overseas-warehouse:exception-order:create\", \"platform:overseas-warehouse:exception-order:list\", \"platform:overseas-warehouse:exception-order:process\", \"platform:overseas-warehouse:inbound-order:create\", \"platform:overseas-warehouse:inbound-order:edit\", \"platform:overseas-warehouse:inbound-order:fba-appointment\", \"platform:overseas-warehouse:inbound-order:list\", \"platform:overseas-warehouse:inbound-order:smart-dispatch\", \"platform:overseas-warehouse:inventory:adjust\", \"platform:overseas-warehouse:inventory:edit\", \"platform:overseas-warehouse:inventory:export\", \"platform:overseas-warehouse:inventory:list\", \"platform:overseas-warehouse:last-mile-order:edit\", \"platform:overseas-warehouse:last-mile-order:export\", \"platform:overseas-warehouse:last-mile-order:list\", \"platform:overseas-warehouse:location:create\", \"platform:overseas-warehouse:location:delete\", \"platform:overseas-warehouse:location:edit\", \"platform:overseas-warehouse:location:list\", \"platform:overseas-warehouse:outbound:cancel\", \"platform:overseas-warehouse:outbound:create\", \"platform:overseas-warehouse:outbound:list\", \"platform:overseas-warehouse:pallet:create\", \"platform:overseas-warehouse:pallet:delete\", \"platform:overseas-warehouse:pallet:edit\", \"platform:overseas-warehouse:pallet:list\", \"platform:overseas-warehouse:relabel-order:create\", \"platform:overseas-warehouse:relabel-order:list\", \"platform:overseas-warehouse:relabel-order:status\", \"platform:overseas-warehouse:return-cartons:list\", \"platform:overseas-warehouse:return-inventory:list\", \"platform:overseas-warehouse:warehouse:create\", \"platform:overseas-warehouse:warehouse:edit\", \"platform:overseas-warehouse:warehouse:list\", \"platform:overseas-warehouse:warehouse:status\"]',0,'f45d2c4b22e46dd866045caf4c2050dafae456a58065367b06c180032df65c22','baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000',CURRENT_TIMESTAMP(3)); INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0,'c7522473b3b940c94397b23fa08bd8f3f56ca646e395314393853bd599c300c7','baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 137 AS initialized_tables; +-- assert_relabel_transfer_latest_schema_ready: 贴标申请与仓储转运最新表结构已完整部署。 +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 138 AS initialized_tables; diff --git a/file/AGENTS.md b/file/AGENTS.md index fe8603673..063fc77f7 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -30,6 +30,8 @@ ## 修改约束 +- 贴标箱唛总文件和 SKU 总文件分别使用 `OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT`、`OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT`,与明细 PDF 采用同一客户复合归属和 10MiB PDF 校验。`file_object.business_type` 为 VARCHAR(64),存量扩容见海外统一增量和 `scripts/db/relabel-file-scope-incremental.sql`。Print 只负责生成 PDF,不保存上传文件。 + - 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。 - 客户单文件资质入口只接受 `LEGAL_PERSON_ID/FREIGHT_FORWARDER_CERTIFICATE/BANK_ACCOUNT_LICENSE`,客户范围只取登录 Token;营业执照不得绕过 `required-package`。同类当前资质已存在时必须显式 `replace=true`,成功后旧元数据标记为已替代;上传不会自动提交审核。 - 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。国内 PDA 禁止直连 File 平台接口,必须经 Firstleg 校验任务、仓库和操作者后,由 Firstleg 使用内部服务令牌访问 File。 diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index f2603ec75..10c944048 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -24,7 +24,10 @@ enum BusinessFileCategory { CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index 2a4a61cb0..17c8d2f4d 100644 --- a/file/src/main/resources/db/file.sql +++ b/file/src/main/resources/db/file.sql @@ -6,7 +6,7 @@ USE baiyue_file; CREATE TABLE IF NOT EXISTS file_object ( id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID', - business_type VARCHAR(32) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE', + business_type VARCHAR(64) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE', business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', diff --git a/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml b/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml index 9444e9a4d..1b0d87045 100644 --- a/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml +++ b/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 description: >- File 只保存文件并强制客户、业务类型、业务ID与文件ID复合归属。 - OVERSEAS_RELABEL_TOTAL_DOCUMENT 的 businessId 为 application.id; + OVERSEAS_RELABEL_TOTAL_DOCUMENT、OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT、OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT 的 businessId 为 application.id; OVERSEAS_RELABEL_ITEM_DOCUMENT 的 businessId 为 item.id。 Lastleg 必须先校验申请与明细存在、明细所属申请及其客户归属、业务状态和权限。 客户归属未知时失败关闭,不得使用平台归属或替代业务ID。 @@ -88,7 +88,7 @@ components: fileId: {type: string, pattern: '^[1-9][0-9]*$'} ownerScope: {type: string, enum: [CUSTOMER]} tenantId: {type: string, pattern: '^[1-9][0-9]*$'} - businessType: {type: string, enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]} + businessType: {type: string, enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT, OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]} businessId: {type: string, pattern: '^[1-9][0-9]*$'} fileName: {type: string} sha256: {type: string, pattern: '^[0-9a-f]{64}$'} @@ -112,7 +112,7 @@ components: required: true schema: type: string - enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT] + enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT, OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT] BusinessId: name: businessId in: path diff --git a/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java index f2f826bf9..cdc850eba 100644 --- a/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java +++ b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java @@ -30,8 +30,8 @@ class OverseasExceptionEvidenceContractTest { try (var stream = getClass().getResourceAsStream("/db/file.sql")) { assertThat(stream).isNotNull(); String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); - assertThat(sql).contains("business_type VARCHAR(32)", "OVERSEAS_EXCEPTION_EVIDENCE"); - assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(32); + assertThat(sql).contains("business_type VARCHAR(64)", "OVERSEAS_EXCEPTION_EVIDENCE"); + assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(64); } } } diff --git a/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java b/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java index 1b8c009ed..380d6feda 100644 --- a/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java +++ b/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java @@ -25,7 +25,7 @@ class RelabelDocumentFileTest { @TempDir Path root; @ParameterizedTest - @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"}) + @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT", "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT"}) void pdfPolicyChecksContentTypeSignatureAndSize(BusinessFileCategory category) { BusinessFileStorage storage = storage(); byte[] exactLimit = new byte[10 * 1024 * 1024]; @@ -52,7 +52,7 @@ class RelabelDocumentFileTest { } @ParameterizedTest - @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"}) + @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT", "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT"}) void uploadAndReadRequireExactCustomerBusinessTypeBusinessAndFile(BusinessFileCategory category) throws Exception { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "relabel-test"), FileObjectEntity.class); FileObjectMapper files = mock(FileObjectMapper.class); diff --git a/file/src/test/java/com/baiyue/file/service/TransferMeasurementPhotoContractTest.java b/file/src/test/java/com/baiyue/file/service/TransferMeasurementPhotoContractTest.java new file mode 100644 index 000000000..671602bab --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/TransferMeasurementPhotoContractTest.java @@ -0,0 +1,15 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TransferMeasurementPhotoContractTest { + @Test + void transferMeasurementPhotoIsCustomerOwnedStandardBusinessFile() { + BusinessFileCategory category = BusinessFileCategory.require("OVERSEAS_TRANSFER_MEASUREMENT_PHOTO"); + category.validateOwner(10001L); + assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.STANDARD); + assertThat(category.storageBusinessId("77")).isEqualTo("77"); + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 4a7c20eff..47e5d1c8c 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -49,7 +49,7 @@ | 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller | -| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | +| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” | @@ -132,6 +132,18 @@ ## 修改与验证 +- `RelabelBindingGuard` 校验已保存组托历史与当前库存托盘一致,箱唛/混合换标正常完工、客户确认与排车提交均复验;纯 SKU 不要求移托,异常手动完工仍须在客户确认前补齐绑定。创建申请按库存余额串行检查箱唛既有申请,拒绝重复申请;历史扫码全局唯一约束尚未改为多轮复用。 + +- 已确认箱唛改绑复用 `PdaPalletOperationService.relabelDisassemble` 不带 `actualSource` 的精确来源分支,按现有绑定移动 HOLD 库存,不重新分配冻结锚点;成功同样记录 `PALLET_BIND` 以展示最新目标。`requireUnallocatedRelabel` 在入口与事务锁单后限制排车/出库占用,已创建出库单也不可直接改绑。回归 `PdaPalletOperationServiceTest`。 + +- PDA旧SKU单扫:`PdaRelabelService.scanSku` 和 `/relabel-tasks/{relabelNo}/skus/scan`,按箱itemId+skuIndex核对旧SKU,服务端取新SKU/数量,逐条记录现有 `SKU_SCAN` 历史,版本、幂等、认领和仓库校验不变;同箱全部SKU完成再推进SKU阶段,混合单需两阶段才生成一条箱事实。无新增字段;前端按历史当前轮次隐藏已完成子项,重复旧SKU须由操作员选实际箱。 + +- 数量拆托成功在现有 `ll_relabel_pda_history` 写 `PALLET_BIND`,保存本轮、箱唛和目标托盘;同轮已组托箱从候选排除并在事务内防重复,返工使用新的 reviewedAt 轮次。PDA 通过详情 history 展示组托记录;不需要新增库存表字段。 + +- 换标按数量拆合托:`palletops/PdaPalletOperationService.relabelSources` 返回原入仓库存池的托盘组成,不能视为箱唛定位;`relabel-disassemble` 的 `actualSource` 携带仓位版本及现场箱数,`cartonMarks` 为本批目标绑定确认。实际托盘与冻结锚点不同时,在同余额/批次内仅重新分配本单冻结桶后合托,库存余额及原入仓单归属不变,保留逐仓位流水、物理资源补偿及幂等。每次一条实际来源仓位,可分批多托;已生成出库单禁止此重分配。认领人处理中即可新增托盘,不需客户确认;此能力不等于已具备真机打印或整单强制组托门禁。 + +- 换标申请生成的 `RELABEL_APPLICATION` 尾程主单使用 `WAIT_RELABEL`(待换标),不进入普通入库计划池;子状态仍区分处理中/待客户确认。客户确认后统一为 `WAIT_OUTBOUND`(待出库),卡车分支按换标申请的 `WAIT_PRE_DISPATCH` 资格参与排车,不重复入库。存量修正脚本 `../scripts/db/relabel-wait-status-incremental.sql` 仅调整尚未生成入库单的遗留待分配换标主单。 + - 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。 - 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index e8ab8e2ff..7397ca9b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -70,6 +70,27 @@ public class DeliveryTransferController { return ApiResponse.success(service.createReturnOutbound(requiredCustomer(user, customerId), operator(user), request)); } + @GetMapping("/outbound-orders/warehouse-transfer/candidates") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + @Operation(summary = "List warehouse-transfer pallets that can create an outbound order") + public ApiResponse> warehouseTransferCandidates( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @RequestParam(required = false) String warehouseId) { + requirePlatform(user); + return ApiResponse.success(service.warehouseTransferOutboundCandidates(customerId, warehouseId)); + } + + @PostMapping("/outbound-orders/warehouse-transfer") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + @Operation(summary = "Create a warehouse-transfer outbound, delivery order and PDA task") + public ApiResponse createWarehouseTransferOutbound( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @Valid @RequestBody DeliveryRequests.WarehouseTransferOutbound request) { + return ApiResponse.success(service.createWarehouseTransferOutbound( + requiredCustomer(user, customerId), operator(user), request)); + } + @GetMapping("/outbound-orders") @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')") public ApiResponse> outboundPage( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index 659777745..c1b595225 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -75,6 +75,20 @@ public final class DeliveryRequests { @NotNull @Size(min=1, max=200) List<@NotBlank @Size(max=128) String> cartonMarks, @Size(max=1000) String remark, @NotBlank @Size(max=255) String idempotencyKey) {} + public record WarehouseTransferOutbound( + @NotBlank @Size(max=64) String transferNo, + @NotNull @Size(min=1, max=100) List<@NotNull Long> palletIds, + @NotBlank @jakarta.validation.constraints.Pattern(regexp = "FBA|PRIVATE") String destinationType, + @Size(max=128) String destinationWarehouseId, + @Size(max=128) String destinationWarehouseCode, + @Size(max=255) String destinationWarehouseName, + @Size(max=1000) String deliveryAddress, + @Size(max=128) String contactName, + @Size(max=64) String contactPhone, + @Size(max=32) String deliveryMethod, + LocalDateTime plannedDeliveryAt, + @Size(max=1000) String remark, + @NotBlank @Size(max=255) String idempotencyKey) {} public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages, @NotBlank @Size(max=128) String sku, @Size(max=200) List<@NotBlank @Size(max=128) String> cartonMarks) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index df4ae6169..8949e7696 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -31,7 +31,8 @@ public final class DeliveryResponses { String remark, LocalDateTime createdAt, long version, List allowedActions, String bolNo, DeliveryDocumentDtos.Summary documentSummary, String destinationWarehouseId, String destinationWarehouseCode, - String destinationWarehouseName) {} + String destinationWarehouseName, String destinationType, + String deliveryAddress, String contactName, String contactPhone, String postalCode) {} public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, String receivingAddress, String shipmentNo, String fbaTrackingNo, @@ -59,6 +60,16 @@ public final class DeliveryResponses { String cartonMark, String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, long totalQuantity, String status) {} + public record WarehouseTransferOutboundCandidate(String customerId, String customerName, + String transferNo, String overseasWarehouseId, + String transferStatus, boolean needRelabel, + String relabelNo, String destinationType, + String destinationWarehouseCode, + String privateDestinationAddress, + String privateDestinationPostalCode, + Long palletId, Long warehousePalletId, String palletNo, + int packageCount, BigDecimal grossWeightKg, + BigDecimal volumeCbm) {} public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {} public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {} public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java index 54ff1a699..2197f002c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -26,6 +26,11 @@ public class DeliveryOrder { private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; + private String destinationType; + private String deliveryAddress; + private String postalCode; + private String contactName; + private String contactPhone; private String deliveryGroupKey; private String status; private String waybillNo; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java index 26a7db8fd..0b4a089f4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -39,6 +39,7 @@ public class DeliveryCompletedOutboxService { Objects.requireNonNull(completedAt, "completedAt"); Map bySourceInbound = new LinkedHashMap<>(); for (LastlegOrder order : orders == null ? List.of() : orders) { + if ("RELABEL_APPLICATION".equals(order.getOrderType())) continue; if (!StringUtils.hasText(order.getSourceInboundId())) continue; requireText(order.getSourceOrderId(), "sourceOrderId"); requireText(order.getInboundCode(), "inboundCode"); 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 45b3fe641..157f915ae 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 @@ -60,7 +60,9 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -97,6 +99,7 @@ public class DeliveryTransferService { @Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryCommandService inventoryCommands; @Resource private WarehouseTransferPalletMapper warehouseTransferPallets; + @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Resource private DropshipOrderProfileMapper dropshipProfiles; @Resource private DropshipCartonMapper dropshipCartons; @Resource private DropshipCartonSkuMapper dropshipCartonSkus; @@ -217,6 +220,11 @@ public class DeliveryTransferService { firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()), destination.getDestinationWarehouseId())); delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName())); + delivery.setDestinationType(destination.getDestinationType()); + delivery.setDeliveryAddress(destination.getDeliveryAddress()); + delivery.setPostalCode(destination.getPostalCode()); + delivery.setContactName(destination.getContactName()); + delivery.setContactPhone(destination.getContactPhone()); delivery.setDeliveryGroupKey(entry.getKey()); LastlegOrder firstOrder = firstTailOrder(customerId, groupItems); delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); @@ -395,6 +403,155 @@ public class DeliveryTransferService { return taskResult(outbound, task); } + @Transactional(readOnly = true) + public List warehouseTransferOutboundCandidates( + String customerId, String warehouseId) { + List transfers = warehouseTransfers.selectList( + new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, trim(customerId)) + .eq(StringUtils.hasText(warehouseId), WarehouseTransferOrder::getOperatingWarehouseId, trim(warehouseId)) + .eq(WarehouseTransferOrder::getStatus, "WAIT_FBA_DESTINATION") + .orderByDesc(WarehouseTransferOrder::getUpdatedAt)); + List result = new ArrayList<>(); + for (WarehouseTransferOrder transfer : transfers) { + LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); + List completedPallets = warehouseTransferPallets.listOrder( + transfer.getCustomerId(), transfer.getId()).stream() + .filter(pallet -> "COMPLETED".equals(pallet.getStatus()) && pallet.getWarehousePalletId() != null) + .toList(); + long available = 0; + for (WarehouseTransferPallet pallet : completedPallets) { + long palletAvailable = inventoryPositions.listPallet(transfer.getCustomerId(), pallet.getWarehousePalletId()) + .stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId())) + .mapToLong(position -> value(position.getAvailablePackages())).sum(); + available += palletAvailable; + } + if (available <= 0 || completedPallets.isEmpty()) continue; + result.add(new DeliveryResponses.WarehouseTransferOutboundCandidate( + transfer.getCustomerId(), order == null ? null : order.getCustomerName(), transfer.getTransferNo(), + transfer.getOperatingWarehouseId(), transfer.getStatus(), Boolean.TRUE.equals(transfer.getNeedRelabel()), + transfer.getRelabelNo(), transfer.getDestinationType(), transfer.getDestinationWarehouseCode(), + transfer.getPrivateDestinationAddress(), transfer.getPrivateDestinationPostalCode(), + null, null, null, Math.toIntExact(available), + completedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg).filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add), + completedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm).filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add))); + } + return result; + } + + @Transactional + public DeliveryResponses.OutboundDetail createWarehouseTransferOutbound(String customerId, String operatorId, + DeliveryRequests.WarehouseTransferOutbound request) { + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + OutboundOrder replay = outboundOrders.findByIdempotency(customerId, key); + if (replay != null) { + if (!"WAREHOUSE_TRANSFER_OUTBOUND".equals(replay.getOutboundType()) + || !requestHash.equals(replay.getRequestHash())) { + throw conflict("相同幂等键不能用于不同的仓储转运出库请求"); + } + return outboundDetail(replay.getOutboundNo()); + } + WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, request.transferNo().trim()); + if (transfer == null) throw notFound("仓储转运单不存在"); + if (!Set.of("READY_FOR_DISPATCH", "WAIT_FBA_DESTINATION").contains(transfer.getStatus())) { + throw conflict("仓储转运单尚未完成换标或已经生成出库单"); + } + String destinationType = request.destinationType().trim().toUpperCase(Locale.ROOT); + if ("FBA".equals(destinationType) && !StringUtils.hasText(request.destinationWarehouseCode())) { + throw conflict("FBA出库必须选择目的仓"); + } + if ("PRIVATE".equals(destinationType) && (!StringUtils.hasText(request.deliveryAddress()) + || !StringUtils.hasText(request.contactName()) || !StringUtils.hasText(request.contactPhone()))) { + throw conflict("私人仓出库必须填写地址、联系人和联系电话"); + } + List palletIds = request.palletIds().stream().distinct().toList(); + if (palletIds.size() != request.palletIds().size()) throw conflict("出库托盘不可重复"); + List selectedPallets = new ArrayList<>(); + for (Long palletId : palletIds) { + WarehouseTransferPallet pallet = warehouseTransferPallets.lockOne(customerId, transfer.getId(), palletId); + if (pallet == null || !"COMPLETED".equals(pallet.getStatus()) || pallet.getWarehousePalletId() == null) { + throw conflict("所选托盘不属于该仓储转运单或尚未完成入库:" + palletId); + } + selectedPallets.add(pallet); + } + LocalDateTime now = LocalDateTime.now(); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); + outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", transfer.getOperatingWarehouseId(), outbound.getId())); + outbound.setOverseasWarehouseId(transfer.getOperatingWarehouseId()); + outbound.setOutboundType("WAREHOUSE_TRANSFER_OUTBOUND"); outbound.setStatus("WAIT_PICK"); + outbound.setRecipientInfo("PRIVATE".equals(destinationType) + ? request.contactName().trim() + " / " + request.contactPhone().trim() + " / " + request.deliveryAddress().trim() + : request.destinationWarehouseCode().trim()); + outbound.setPalletCount(selectedPallets.size()); outbound.setIdempotencyKey(key); + outbound.setRequestHash(requestHash); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); + outboundOrders.insert(outbound); + + int packageCount = 0; + for (WarehouseTransferPallet pallet : selectedPallets) { + List positions = inventoryPositions.listPallet(customerId, pallet.getWarehousePalletId()) + .stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId())) + .filter(position -> value(position.getAvailablePackages()) > 0).toList(); + if (positions.isEmpty()) throw conflict("所选托盘没有可出库库存:" + pallet.getPalletNo()); + for (InventoryPosition position : positions) { + InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); + if (balance == null || !customerId.equals(balance.getCustomerId())) throw conflict("托盘库存余额不存在"); + int packages = Math.toIntExact(value(position.getAvailablePackages())); + OutboundPalletItem item = new OutboundPalletItem(); + item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); + item.setWarehousePalletId(pallet.getWarehousePalletId()); item.setPalletNo(pallet.getPalletNo()); + item.setDestinationWarehouseId(trim(request.destinationWarehouseId())); + item.setDestinationWarehouseCode(trim(request.destinationWarehouseCode())); + item.setDestinationWarehouseName(trim(request.destinationWarehouseName())); + item.setPackageCount(packages); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); + item.setSkuCode(balance.getSkuCode()); item.setInboundCode(balance.getInboundCode()); + item.setOrderRefs(transfer.getTransferNo()); item.setCreatedAt(now); outboundPallets.insert(item); + packageCount = Math.addExact(packageCount, packages); + } + } + outbound.setPackageCount(packageCount); outboundOrders.updateById(outbound); + + LastlegOrder tailOrder = tailOrders.selectById(transfer.getLastlegOrderId()); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", transfer.getOperatingWarehouseId(), delivery.getId())); + delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); + delivery.setOverseasWarehouseId(transfer.getOperatingWarehouseId()); + delivery.setCustomerName(tailOrder == null ? null : tailOrder.getCustomerName()); + delivery.setDestinationType(destinationType); delivery.setDestinationWarehouseId(trim(request.destinationWarehouseId())); + delivery.setDestinationWarehouseCode(trim(request.destinationWarehouseCode())); + delivery.setDestinationWarehouseName(trim(request.destinationWarehouseName())); + delivery.setDeliveryAddress(trim(request.deliveryAddress())); delivery.setContactName(trim(request.contactName())); + delivery.setContactPhone(trim(request.contactPhone())); delivery.setDeliveryGroupKey(destinationType + ":" + + firstText(request.destinationWarehouseCode(), request.deliveryAddress())); + delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryMethod(firstText(request.deliveryMethod(), "TRUCK")); + delivery.setPackageCount(packageCount); delivery.setPalletCount(selectedPallets.size()); + delivery.setTotalWeightKg(selectedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + delivery.setTotalVolumeCbm(selectedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); delivery.setRemark(trim(request.remark())); + delivery.setIdempotencyKey(childKey(key, "DELIVERY")); delivery.setCreatedBy(operatorId); delivery.setVersion(0L); + delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); + recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "仓储转运出库自动生成派送单", now); + + OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); + task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); + task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); + transfer.setStatus("WAIT_OUTBOUND"); + if ("FBA".equals(destinationType)) transfer.setFbaWarehouseCode(request.destinationWarehouseCode().trim()); + transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); + if (tailOrder != null && customerId.equals(tailOrder.getCustomerId())) { + tailOrder.setCurrentStatus("WAIT_OUTBOUND"); tailOrder.setOutboundOrderNo(outbound.getOutboundNo()); + tailOrder.setDeliveryOrderNo(delivery.getDeliveryNo()); tailOrder.setFbaWarehouseCode(trim(request.destinationWarehouseCode())); + tailOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:WAIT_OUTBOUND"); tailOrder.setUpdatedAt(now); tailOrders.updateById(tailOrder); + } + return outboundDetail(outbound.getOutboundNo()); + } + private String validateOneClickItems(String customerId, List items) { String warehouseId = null; Map requestedPackages = new LinkedHashMap<>(); @@ -541,6 +698,40 @@ public class DeliveryTransferService { task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); return taskResult(outbound, task); } + if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) { + if (request.inventoryPositionId() == null) throw conflict("仓储转运出库必须确认实际库存位置"); + InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId()); + if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) + || !warehouseId.equals(selectedPosition.getOverseasWarehouseId()) + || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) { + throw conflict("确认的库存位置不属于该仓储转运出库托盘"); + } + InventoryBalance balance = inventoryBalances.selectById(selectedPosition.getBalanceId()); + if (!matchesOutboundItem(customerId, warehouseId, item, balance)) { + throw conflict("确认的库存SKU或入仓码与出库明细不一致"); + } + if (request.packages() > value(item.getPackageCount()) - value(item.getPickedPackageCount())) { + throw conflict("出库数量超过仓储转运出库明细余量"); + } + inventoryCommands.allocate(customerId, operatorId, new InventoryCommandRequests.Move( + selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":ALLOCATE", "仓储转运出库分配")); + inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move( + selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":PICK", "仓储转运出库拣货")); + inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move( + selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":SHIP", "仓储转运出库")); + OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); + pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); + pick.setInventoryPositionId(selectedPosition.getId()); pick.setPackages(request.packages()); + pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); + item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue()); + item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED"); + outboundPallets.updateById(item); updateOutboundPickingStatus(customerId, outbound); + task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); + return taskResult(outbound, task); + } InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId()); if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet"); if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) { @@ -612,8 +803,8 @@ public class DeliveryTransferService { if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) { throw notFound("Outbound order not found in the bound warehouse"); } - if (!"ONE_CLICK_DROPSHIP".equals(outbound.getOutboundType())) { - throw conflict("整托扫码仅适用于一件代发出库"); + if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND").contains(outbound.getOutboundType())) { + throw conflict("整托扫码仅适用于一件代发或换标出库"); } if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) { throw conflict("PDA task is not available for picking"); @@ -695,6 +886,23 @@ public class DeliveryTransferService { new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成")); } } + if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) { + List transferNos = items.stream().map(OutboundPalletItem::getOrderRefs) + .filter(StringUtils::hasText).distinct().toList(); + for (String transferNo : transferNos) { + WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo); + if (transfer != null && "WAIT_OUTBOUND".equals(transfer.getStatus())) { + transfer.setStatus("OUTBOUNDED"); transfer.setOutboundAt(now); transfer.setUpdatedAt(now); + warehouseTransfers.updateById(transfer); + LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); + if (order != null && customerId.equals(order.getCustomerId())) { + order.setCurrentStatus("IN_TRANSIT"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now); + tailOrders.updateById(order); + } + } + } + } return taskResult(outbound, task); } @@ -724,6 +932,23 @@ public class DeliveryTransferService { if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); } + if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) { + for (String transferNo : outboundItems.stream().map(OutboundPalletItem::getOrderRefs) + .filter(StringUtils::hasText).distinct().toList()) { + WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo); + if (transfer == null || !"WAIT_OUTBOUND".equals(transfer.getStatus())) continue; + String restored = StringUtils.hasText(transfer.getFbaWarehouseCode()) + ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; + transfer.setStatus(restored); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); + LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); + if (order != null && customerId.equals(order.getCustomerId())) { + order.setCurrentStatus(restored); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + restored); + order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); + tailOrders.updateById(order); + } + } + return; + } if (!existingDeliveries.isEmpty()) { for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) { String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order); @@ -958,17 +1183,20 @@ public class DeliveryTransferService { candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(), value(candidate.getAvailablePackages())); } - private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName()); } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } private String deliveryGroupKey(DispatchItem item) { + if ("PRIVATE".equals(item.getDestinationType()) && StringUtils.hasText(item.getDeliveryAddress())) { + return "PRIVATE:" + item.getDeliveryAddress().trim() + ":" + Objects.toString(item.getPostalCode(), "").trim(); + } String code = firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()); if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT); if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim(); - throw conflict("Dispatch pallet is missing its destination FBA warehouse"); + throw conflict("Dispatch pallet is missing its destination"); } private String resolveLxCodes(String customerId, List items) { List inboundCodes = items.stream().map(DispatchItem::getInboundCode) @@ -1029,6 +1257,14 @@ public class DeliveryTransferService { return measuredWeight.add(orderWeight); } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); + } catch (JsonProcessingException | NoSuchAlgorithmException error) { + throw new IllegalStateException("无法生成请求摘要", error); + } + } private static Long parseLong(String value) { try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; } catch (NumberFormatException ignored) { return null; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index 97a78fe5f..6f111fd19 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -46,7 +46,7 @@ public class SmartDispatchController { @RequestParam(required = false) String sourceStage, @RequestParam(required = false) String draftToken) { requirePlatform(user); - if (planIds == null || planIds.isEmpty()) { + if ("RELABEL".equals(sourceStage) || planIds == null || planIds.isEmpty()) { return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken)); } return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java index 974f39ea4..d3e4a1505 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -30,6 +30,11 @@ public class DispatchItem { private String destinationWarehouseCode; private String destinationWarehouseName; private String fbaWarehouseCode; + private String destinationType; + private String deliveryAddress; + private String postalCode; + private String contactName; + private String contactPhone; private Integer packageCount; private BigDecimal volumeCbm; /** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index e0c93edf6..6a097c582 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -64,16 +64,20 @@ public interface DispatchCandidateMapper { "JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " + "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + "AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + - "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " + + "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," + + "MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," + + "MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "LEFT JOIN ll_order o ON o.id=l.order_id " + - "WHERE 1=1 " + + "WHERE ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + + "(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " + + "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " + " AND ip.customer_id=#{customerId} " + " AND p.plan_id IN " + "#{id} " + - " AND o.order_no LIKE CONCAT('%',#{orderNo},'%') " + + " AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')) " + " AND l.fba_warehouse_code=#{fbaWarehouseCode} " + " AND " + "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + @@ -146,13 +150,18 @@ public interface DispatchCandidateMapper { "JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " + "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + "AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + - "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " + + "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," + + "MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," + + "MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "LEFT JOIN ll_order o ON o.id=l.order_id " + "WHERE ip.customer_id=#{customerId} AND p.id IN " + "#{id} " + + "AND ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + + "(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " + + "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + @@ -184,7 +193,8 @@ public interface DispatchCandidateMapper { "GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," + "SUM(ri.notified_package_count) AS packageCount,MAX(wtp.volume_cbm) AS volumeCbm,'RELABEL' AS sourceStage," + "NULL AS cartonMarks,GROUP_CONCAT(DISTINCT COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) " + - "ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks " + + "ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks," + + "'FBA' AS destinationType,NULL AS deliveryAddress,NULL AS postalCode,NULL AS contactName,NULL AS contactPhone " + "FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " + "JOIN ll_inventory_position pos ON pos.id=ri.source_inventory_position_id AND pos.customer_id=a.customer_id " + "LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " + @@ -193,7 +203,7 @@ public interface DispatchCandidateMapper { "WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " + "AND a.customer_id=#{customerId} " + "AND a.relabel_no=#{relabelNo} " + - "AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') " + + "AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) " + "AND pos.hold_packages > 0 " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " + "ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java index de1133d08..7e706809a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -30,7 +30,12 @@ public record DispatchPalletCandidate( BigDecimal volumeCbm, String sourceStage, String cartonMarks, - String relabelCartonMarks + String relabelCartonMarks, + String destinationType, + String deliveryAddress, + String postalCode, + String contactName, + String contactPhone ) { public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, String overseasWarehouseId, String overseasWarehouseName, String orderNo, @@ -41,7 +46,7 @@ public record DispatchPalletCandidate( this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId, overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm, - sourceStage, null, null); + sourceStage, null, null, null, null, null, null, null); } public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, @@ -51,7 +56,7 @@ public record DispatchPalletCandidate( BigDecimal volumeCbm, String sourceStage) { this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, - fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null); + fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null, null, null, null, null, null); } public String sourcePalletKey() { // Once received, the physical pallet is the shared resource even when it contains diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 684d3a5e3..580a2c042 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -66,6 +66,7 @@ public class DispatchService { @Resource private LastlegOrderMapper tailOrders; @Resource private OutboundOrderMapper outboundOrders; @Resource private RelabelApplicationMapper relabelApplications; + @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard; public String resolveCustomerScopeForPlans(List planIds) { return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), @@ -107,12 +108,14 @@ public class DispatchService { public List platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage, String draftToken) { validateSourceStage(sourceStage); - List result = candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), + List result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage), trimToNull(draftToken)); - if (!StringUtils.hasText(sourceStage) || "RECEIVED".equals(sourceStage)) { + if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) { List relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null); if (relabel != null && !relabel.isEmpty()) { - result = java.util.stream.Stream.concat(result.stream(), relabel.stream()).toList(); + result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item -> + !StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "") + .toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList(); } } return result; @@ -270,6 +273,11 @@ public class DispatchService { if (selected.size() != palletIds.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); } + if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) { + boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType())); + throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba + ? "所选托盘尚未设置 FBA 仓,不能智能排车" : "所选托盘尚未设置有效目的地,不能智能排车"); + } assertNoDetainedOrders(customerId, selected); boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) .anyMatch(value -> value == null || value.isBlank()); @@ -396,6 +404,11 @@ public class DispatchService { item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); + item.setDestinationType(candidate.destinationType()); + item.setDeliveryAddress(candidate.deliveryAddress()); + item.setPostalCode(candidate.postalCode()); + item.setContactName(candidate.contactName()); + item.setContactPhone(candidate.contactPhone()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage(candidate.sourceStage()); @@ -433,6 +446,7 @@ public class DispatchService { if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); } + relabelBindingGuard.requireBound(application); List selected = candidates.relabelAvailable(customerId, null, request.relabelNo()); Set requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet()); selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList(); @@ -653,10 +667,23 @@ public class DispatchService { } private static String candidateFbaCode(DispatchPalletCandidate candidate) { + if ("PRIVATE".equals(candidate.destinationType())) { + return java.util.stream.Stream.of(candidate.deliveryAddress(), candidate.postalCode()) + .filter(StringUtils::hasText).map(String::trim).collect(Collectors.joining(" ")); + } return StringUtils.hasText(candidate.fbaWarehouseCode()) ? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode()); } + private static boolean hasValidDestination(DispatchPalletCandidate candidate) { + if ("PRIVATE".equals(candidate.destinationType())) { + return StringUtils.hasText(candidate.deliveryAddress()) && StringUtils.hasText(candidate.postalCode()) + && StringUtils.hasText(candidate.contactName()) && StringUtils.hasText(candidate.contactPhone()); + } + return StringUtils.hasText(candidate.fbaWarehouseCode()) + || StringUtils.hasText(candidate.destinationWarehouseCode()); + } + private static String itemFbaCode(DispatchItem item) { return StringUtils.hasText(item.getFbaWarehouseCode()) ? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode()); @@ -716,8 +743,8 @@ public class DispatchService { } private static void validateSourceStage(String sourceStage) { - if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED"); + if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED", "RELABEL").contains(sourceStage)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN, RECEIVED or RELABEL"); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 1fb910d2b..ee0c3a2da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -41,7 +41,12 @@ public record LastlegOrderSummary( String nodeSource, int version, Integer palletCount, + @Schema(description = "客户预报箱数") Integer packageCount, + @Schema(description = "PDA 实收箱数;尚未完成收货时为空") + Integer receivedPackageCount, + @Schema(description = "实收减预报箱数;尚未完成收货时为空") + Integer packageDifference, BigDecimal volumeCbm, Integer productQuantity, BigDecimal volumeWeightKg, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index b56ab8473..b88f50112 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -44,6 +44,7 @@ public class LastlegOrder { private Integer version; private Integer palletCount; private Integer packageCount; + private Integer receivedPackageCount; private Integer pickedPackageCount; private Integer shippedPackageCount; private BigDecimal volumeCbm; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java index 6e8cd25d4..1851a8c51 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java @@ -21,6 +21,10 @@ public class InboundPlanLine { private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; + private String deliveryAddress; + private String postalCode; + private String contactName; + private String contactPhone; private BigDecimal volumeCbm; private Integer requiredPackageCount; private Integer plannedPalletCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java index 409059fca..6b2a6a53e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java @@ -22,6 +22,11 @@ public class InboundPlanPallet { private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; + private String destinationType; + private String deliveryAddress; + private String postalCode; + private String contactName; + private String contactPhone; private BigDecimal capacityCbm; private BigDecimal allocatedVolumeCbm; private Integer sequenceNo; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 86caa1c1c..4983eda0d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -6,6 +6,8 @@ import com.fasterxml.jackson.annotation.JsonAlias; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.server.ResponseStatusException; @@ -293,6 +295,27 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { return palletBatch("/internal/v1/warehouse-pallet-batches/compensate", request); } + @Override + public PrintableFile printPalletLabel(long warehouseId, long palletId, String transferNo, String requestId) { + ResponseEntity response = client.get() + .uri(builder -> builder.path("/internal/v1/warehouse-pallets/{warehouseId}/{palletId}/label") + .queryParam("transferNo", transferNo).build(warehouseId, palletId)) + .header("X-Internal-Service-Token", internalToken) + .header("X-Request-Id", requestId == null ? "" : requestId) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet label printing failed"); + }).toEntity(byte[].class); + byte[] content = response.getBody() == null ? new byte[0] : response.getBody(); + if (content.length == 0) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet label printing returned an empty PDF"); + String fileName = response.getHeaders().getContentDisposition().getFilename(); + String contentType = response.getHeaders().getContentType() == null + ? MediaType.APPLICATION_PDF_VALUE : response.getHeaders().getContentType().toString(); + return new PrintableFile(content, fileName == null ? "pallet-label.pdf" : fileName, contentType); + } + private PalletBatchResponse palletBatch(String path, Object body) { PalletBatchEnvelope response = client.post().uri(path) .header("X-Internal-Service-Token", internalToken).body(body).retrieve() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index 1d1944f95..fa7170875 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -39,6 +39,9 @@ public interface WarehouseResourceGateway { default PalletBatchResponse compensatePalletBatch(PalletBatchCompensation request) { throw new UnsupportedOperationException("Warehouse pallet batch compensation is not configured"); } + default PrintableFile printPalletLabel(long warehouseId, long palletId, String transferNo, String requestId) { + throw new UnsupportedOperationException("Warehouse pallet label printing is not configured"); + } record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) { @@ -67,4 +70,5 @@ public interface WarehouseResourceGateway { record PalletBatchItem(long customerId, String businessKey, String inboundPlanNo, int sequenceNo, long palletId, String palletCode, long locationId, long resourceLockId, String status) { } + record PrintableFile(byte[] content, String fileName, String contentType) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index e4f703b6b..513eb40e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -511,9 +511,6 @@ public class InventoryCommandService { String skuCode = sku(requestedSkuCode); String destinationId = trim(destinationWarehouseId); String destinationCode = trim(destinationWarehouseCode); - if (destinationId == null && destinationCode == null) { - throw badRequest("Destination warehouse ID or code is required"); - } if (destinationCode == null) destinationCode = ""; InventoryBalance balance = balances.lockDimension(customerId, frozenWarehouseId, destinationCode, inboundOrderId, inboundCode); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java index a3e5b145f..639555181 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java @@ -2,9 +2,22 @@ package com.baiyue.lastleg.model; /** Standard main-process statuses for a last-leg order. */ public enum LastlegOrderStatus { + PENDING_REVIEW("待审核"), + INBOUND_MEASURING("入库测量中"), PENDING_ASSIGNMENT("待分配"), PENDING_INBOUND("待入库"), INBOUND_COMPLETED("入库完成"), + WAIT_RELABEL("待换标"), + WAIT_FBA_DESTINATION("待确认目的仓"), + READY_FOR_DISPATCH("待创建出库单"), + WAIT_OUTBOUND("待出库"), + ALLOCATED("已分配库存"), + OUTBOUNDED("已出库"), + PARTIALLY_RECEIVED("部分收货"), + RECEIVED("已收货"), + IN_TRANSIT("运输中"), + COMPLETED("已完成"), + CANCELLED("已取消"), PENDING_DELIVERY("待派送"), DELIVERING("派送中"), DELIVERED("派送完成"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java index 069b72eb2..74886139e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java @@ -28,6 +28,13 @@ public class PdaPalletOperationController { return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request)); } + @PostMapping("/relabel-sources") + public ApiResponse> relabelSources( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.RelabelSources request) { + return ApiResponse.success(service.relabelSources(PdaWarehouseIdentity.require(user), request)); + } + @PostMapping("/relabel-pallet-label") public ApiResponse createRelabelPallet( @AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java index 2a2922edd..3194e89d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java @@ -13,6 +13,7 @@ public final class PalletOperationRequests { private PalletOperationRequests() { } public record Scan(@NotBlank @Size(max = 128) String code) { } + public record RelabelSources(@NotBlank @Size(max = 64) String relabelNo) { } public record NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo, @NotBlank @Size(max = 128) String idempotencyKey) { } public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode, @@ -33,13 +34,19 @@ public final class PalletOperationRequests { @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets, @Size(max = 500) String reason) { } - /** 换标场景按箱唛拆托并绑定到一个目标托盘。箱唛位置由服务端从换标单快照解析。 */ + /** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */ public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey, @NotBlank @Size(max = 64) String relabelNo, @Valid PalletExpectation source, @Valid PalletExpectation target, @NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks, - @Size(max = 500) String reason) { } + @Size(max = 500) String reason, + @Valid MoveLine actualSource) { + public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source, + PalletExpectation target, List cartonMarks, String reason) { + this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null); + } + } public record Swap(@NotBlank @Size(max = 128) String idempotencyKey, @Valid PalletExpectation source, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java index 462c13fab..1017dd630 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java @@ -5,6 +5,9 @@ import java.util.List; public final class PalletOperationResponses { private PalletOperationResponses() { } + public record RelabelSource(long positionId, long positionVersion, String palletCode, + String locationCode, String inboundCode, long stockPackages, + long movablePackages, List cartonMarks) { } public record InventoryLine(String customerId, String customerName, long balanceId, long positionId, String inboundCode, String skuCode, String productName, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java index f28f4c05a..d53b0c2fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -23,6 +23,12 @@ public interface PdaPalletOperationMapper extends BaseMapper PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey, @Param("idempotencyKey") String idempotencyKey); + @Select(CONTENT_SELECT + "WHERE b.customer_id=#{customerId} AND b.overseas_warehouse_id=#{warehouseId} " + + "AND b.id=#{balanceId} AND p.pallet_id>0 " + + "AND (p.available_packages+p.hold_packages)>0 ORDER BY p.pallet_no,p.id") + List relabelSources(@Param("customerId") String customerId, + @Param("warehouseId") String warehouseId, @Param("balanceId") long balanceId); + @Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + "ORDER BY b.customer_id,b.inbound_code,p.id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index d399282b4..ccbe09d05 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -78,13 +78,42 @@ public class PdaPalletOperationService { return content(identity.overseasWarehouseId(), pallet); } + public List relabelSources(PdaWarehouseIdentity identity, + PalletOperationRequests.RelabelSources request) { + RelabelApplication app = relabelApplications.findAnyByRelabelNo(request.relabelNo()); + requireRelabelWork(identity, app); + Set boundMarks = boundRelabelMarks(app); + List items = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())); + Map> byBalance = new LinkedHashMap<>(); + for (RelabelItem item : items) { + InventoryPosition anchor = positions.selectById(item.getSourceInventoryPositionId()); + if (anchor == null || !Objects.equals(anchor.getCustomerId(), app.getCustomerId()) + || !Objects.equals(anchor.getOverseasWarehouseId(), app.getOverseasWarehouseId())) + throw conflict("换标单库存来源缺失,请核对原入仓单"); + byBalance.computeIfAbsent(anchor.getBalanceId(), ignored -> new ArrayList<>()).add(item); + } + List result = new ArrayList<>(); + byBalance.forEach((balanceId, group) -> { + for (PalletInventoryRow row : mapper.relabelSources(app.getCustomerId(), app.getOverseasWarehouseId(), balanceId)) { + long ownHold = group.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), row.getPositionId())) + .mapToLong(item -> value(item.getNotifiedPackageCount())).sum(); + result.add(new PalletOperationResponses.RelabelSource(row.getPositionId(), value(row.getVersion()), + row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(), + value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages()) + + value(row.getPickedPackages()), + value(row.getAvailablePackages()) + Math.min(ownHold, value(row.getHoldPackages())), + group.stream().map(RelabelItem::getCartonMark).filter(mark -> !boundMarks.contains(mark)).toList())); + } + }); + return result; + } + public PalletOperationResponses.PrintablePallet createRelabelPallet(PdaWarehouseIdentity identity, PalletOperationRequests.NewRelabelPallet request) { String relabelNo = required(request.relabelNo(), "换标单号不能为空"); RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); - if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { - throw conflict("换标单尚未完成客户确认,不能生成新托盘"); - } + requireRelabelWork(identity, application); if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { throw conflict("换标单不属于 Token 冻结的海外仓"); } @@ -153,9 +182,7 @@ public class PdaPalletOperationService { if (replay != null) return replay; String relabelNo = required(request.relabelNo(), "换标单号不能为空"); RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); - if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { - throw conflict("换标单尚未完成客户确认,不能执行拆托"); - } + requireRelabelWork(identity, application); if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { throw conflict("换标单不属于 Token 冻结的海外仓"); } @@ -177,29 +204,61 @@ public class PdaPalletOperationService { } Map packagesByPosition = new LinkedHashMap<>(); Map> itemsByPosition = new LinkedHashMap<>(); + Map anchors = new LinkedHashMap<>(); + InventoryPosition actual = request.actualSource() == null ? null : positions.selectById(request.actualSource().positionId()); + if (request.actualSource() != null && (actual == null + || !Objects.equals(actual.getCustomerId(), application.getCustomerId()) + || !Objects.equals(actual.getPalletId(), source.palletId()) + || !Objects.equals(actual.getOverseasWarehouseId(), application.getOverseasWarehouseId()) + || value(actual.getVersion()) != request.actualSource().expectedPositionVersion())) + throw conflict("实际来源托盘库存已变化,请刷新后确认"); + if (actual != null && application.getOutboundOrderNo() != null && !application.getOutboundOrderNo().isBlank()) + throw conflict("已生成出库单,请先处理出库单,不能重新分配冻结来源"); + if (actual != null && marks.stream().anyMatch(boundRelabelMarks(application)::contains)) + throw conflict("本批包含本轮已组托的换标箱,请刷新后选择未组托的箱"); + if ("PROCESSING".equals(application.getStatus())) { + var workTask = relabelWorkTasks.find(application.getCustomerId(), application.getId()); + for (RelabelItem item : selected) { + Long completed = relabelWorkFacts.selectCount(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId()) + .eq("item_id", item.getId()).eq("result", "RELABELLED")); + if (completed == null || completed < value(item.getNotifiedPackageCount())) + throw conflict("请先完成本箱全部换标作业,再进行组托"); + } + } for (RelabelItem item : selected) { if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null || item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照"); InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId()); if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId()) - || !Objects.equals(position.getPalletId(), source.palletId()) + || (actual == null && !Objects.equals(position.getPalletId(), source.palletId())) + || (actual != null && (!Objects.equals(position.getBalanceId(), actual.getBalanceId()) + || !Objects.equals(position.getBatchNo(), actual.getBatchNo()))) || !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { - throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致"); + throw conflict("来源托盘必须属于本次拆托的原入仓单和库存批次"); } - packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum); - itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item); + anchors.put(position.getId(), position); + Long sourcePosition = actual == null ? position.getId() : actual.getId(); + packagesByPosition.merge(sourcePosition, item.getNotifiedPackageCount().longValue(), Long::sum); + itemsByPosition.computeIfAbsent(sourcePosition, ignored -> new ArrayList<>()).add(item); } + if (actual != null && request.actualSource().packages() != packagesByPosition.get(actual.getId())) + throw conflict("拆出箱数必须等于本次确认绑定的换标箱数"); List moves = packagesByPosition.entrySet().stream().map(entry -> { InventoryPosition position = positions.selectById(entry.getKey()); - if (value(position.getHoldPackages()) < entry.getValue()) throw conflict("换标冻结库存箱数不足"); + long own = selected.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), position.getId())) + .mapToLong(item -> value(item.getNotifiedPackageCount())).sum(); + if (actual == null ? value(position.getHoldPackages()) < entry.getValue() + : value(position.getHoldPackages()) < own || value(position.getAvailablePackages()) < entry.getValue() - own) + throw conflict("来源托盘可拆箱数不足,不能占用其他单据冻结或已分配库存"); return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(), locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD); }).toList(); - requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target)); + requireRelabelDestination(identity.overseasWarehouseId(), selected, target); Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false); Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(), - new RelabelMutation(application, itemsByPosition)); + new RelabelMutation(application, itemsByPosition, actual == null ? Map.of() : anchors)); String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank() ? "" : ":" + request.reason().trim()); return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request, @@ -288,6 +347,10 @@ public class PdaPalletOperationService { throw conflict("库存所在托盘已变化,请重新扫码"); } if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码"); + if (plan.relabelMutation() != null && !plan.relabelMutation().anchors().isEmpty()) { + reconcileRelabelHold(identity, operation, plan.relabelMutation(), move, key); + locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), move.positionId()); + } if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages()); else requireMovable(locked, move.packages()); customers.add(locked.getCustomerId()); @@ -296,6 +359,8 @@ public class PdaPalletOperationService { } if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems( plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets); + if (plan.relabelMutation() != null) + recordRelabelBinding(identity, plan.relabelMutation(), relabelTargets); for (PalletChange change : plan.changes()) { long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId()); boolean shouldBeEmpty = "IDLE".equals(change.targetStatus()); @@ -320,7 +385,8 @@ public class PdaPalletOperationService { private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) { RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo()); - if (locked == null || !"READY_FOR_OUTBOUND".equals(locked.getStatus()) + if (locked != null) requireUnallocatedRelabel(locked); + if (locked == null || !Objects.equals(locked.getStatus(), mutation.application().getStatus()) || !Objects.equals(locked.getCustomerId(), mutation.application().getCustomerId()) || !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId)) || !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) { @@ -328,6 +394,52 @@ public class PdaPalletOperationService { } } + /** Redistributes only this application's hold within the same inbound balance; physical totals stay unchanged. */ + private void reconcileRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation, + RelabelMutation mutation, PlannedMove move, String key) { + requireRelabelWork(identity, mutation.application()); + List selected = mutation.itemsByPosition().get(move.positionId()); + Set alreadyBound = boundRelabelMarks(mutation.application()); + if (selected.stream().anyMatch(item -> alreadyBound.contains(item.getCartonMark()))) + throw conflict("换标箱已在本轮组托,请刷新后重试"); + requireRelabelDestination(identity.overseasWarehouseId(), selected, physical.resolve(move.targetPalletCode())); + Map release = new LinkedHashMap<>(); + for (RelabelItem item : selected) { + RelabelItem current = relabelItems.selectById(item.getId()); + if (current == null || !Objects.equals(current.getSourceInventoryPositionId(), item.getSourceInventoryPositionId()) + || !Objects.equals(current.getNotifiedPackageCount(), item.getNotifiedPackageCount())) + throw conflict("换标箱的绑定已变化,请刷新后重试"); + if (!Objects.equals(item.getSourceInventoryPositionId(), move.positionId())) + release.merge(item.getSourceInventoryPositionId(), (long) value(item.getNotifiedPackageCount()), Long::sum); + } + InventoryPosition actual = positions.selectById(move.positionId()); + long required = release.values().stream().mapToLong(Long::longValue).sum(); + if (value(actual.getAvailablePackages()) < required) throw conflict("实际来源托盘可用箱数不足"); + InventoryBalance balance = balances.selectById(actual.getBalanceId()); + for (var entry : release.entrySet()) { + PalletInventoryRow locked = mapper.lockPosition(mutation.application().getOverseasWarehouseId(), entry.getKey()); + InventoryPosition snapshot = mutation.anchors().get(entry.getKey()); + if (locked == null || value(locked.getVersion()) != value(snapshot.getVersion()) + || !Objects.equals(locked.getBalanceId(), actual.getBalanceId()) + || value(locked.getHoldPackages()) < entry.getValue()) + throw conflict("原冻结库存已变化,请刷新后重试"); + InventoryPosition anchor = positions.selectById(entry.getKey()); + adjustRelabelHold(identity, operation, balance, anchor, -entry.getValue(), key + ":RELEASE:" + anchor.getId()); + } + if (required > 0) adjustRelabelHold(identity, operation, balance, actual, required, key + ":FREEZE:" + actual.getId()); + } + + private void adjustRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation, + InventoryBalance balance, InventoryPosition position, long delta, String key) { + long before = value(position.getHoldPackages()); + position.setHoldPackages(before + delta); + position.setAvailablePackages(value(position.getAvailablePackages()) - delta); + position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + ledger(identity, operation, balance, position, key, before, delta, before + delta, + position.getPalletNo(), position.getPalletNo(), "现场按原入仓单确认拆托来源:冻结桶调整,可用桶反向调整,实物总数不变"); + } + private InventoryPosition moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation, PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) { InventoryPosition source = positions.selectById(sourceRow.getPositionId()); @@ -458,6 +570,89 @@ public class PdaPalletOperationService { if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘"); } + @jakarta.annotation.Resource + private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper relabelWorkTasks; + @jakarta.annotation.Resource + private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper relabelWorkFacts; + @jakarta.annotation.Resource + private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper relabelWorkHistory; + + private Set boundRelabelMarks(RelabelApplication app) { + var task = relabelWorkTasks.find(app.getCustomerId(), app.getId()); + if (task == null) return Set.of(); + String round = Objects.toString(task.getReviewedAt(), "INITIAL"); + Set marks = new HashSet<>(); + var records = relabelWorkHistory.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("customer_id", app.getCustomerId()).eq("task_id", task.getId()).eq("action", "PALLET_BIND")); + for (var record : records) { + try { + var data = objectMapper.readTree(record.getDetailJson()); + if (round.equals(data.path("round").asText())) data.path("cartonMarks").forEach(mark -> marks.add(mark.asText())); + } catch (JsonProcessingException failure) { throw conflict("换标组托记录无法读取,请核对作业历史"); } + } + return marks; + } + + private void recordRelabelBinding(PdaWarehouseIdentity identity, RelabelMutation mutation, + Map targets) { + var app = mutation.application(); + var task = relabelWorkTasks.find(app.getCustomerId(), app.getId()); + if (task == null) throw conflict("换标作业任务不存在,不能记录组托"); + var record = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + record.setId(IdWorker.getId()); record.setTaskId(task.getId()); record.setApplicationId(app.getId()); + record.setCustomerId(app.getCustomerId()); record.setAction("PALLET_BIND"); + record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus()); + record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName()); + record.setCreatedAt(LocalDateTime.now()); + record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"), + "cartonMarks", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(), + "targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList()))); + relabelWorkHistory.insert(record); + } + + private void requireRelabelWork(PdaWarehouseIdentity identity, RelabelApplication application) { + if (application == null || !Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(application.getStatus())) + throw conflict("当前换标状态不能组托"); + if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) + throw conflict("换标单不属于当前海外仓"); + requireUnallocatedRelabel(application); + if ("PROCESSING".equals(application.getStatus())) { + var task = relabelWorkTasks.find(application.getCustomerId(), application.getId()); + if (task == null || !"PROCESSING".equals(task.getStatus()) || !identity.operatorKey().equals(task.getOperatorKey())) + throw conflict("请先认领当前换标任务"); + } + } + + private void requireUnallocatedRelabel(RelabelApplication application) { + String fulfillment = application.getFulfillmentStatus(); + if (org.springframework.util.StringUtils.hasText(application.getOutboundOrderNo()) + || (org.springframework.util.StringUtils.hasText(fulfillment) + && !Set.of("WAIT_RELABEL", "WAIT_PRE_DISPATCH").contains(fulfillment))) + throw conflict("换标单已进入排车或出库流程,请先解除占用后再改绑托盘"); + } + + private void requireRelabelDestination(long warehouseId, List selected, + PalletPhysicalGateway.PalletReference target) { + Set destinations = new HashSet<>(); + selected.forEach(item -> destinations.add(text(item.getDestinationFbaWarehouseCode()))); + if (destinations.size() != 1 || destinations.contains("")) throw conflict("请按已维护的新目的仓分批组托"); + for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), target.palletId())) { + var bound = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("customer_id", row.getCustomerId()).eq("source_inventory_position_id", row.getPositionId())); + long held = 0; + for (RelabelItem item : bound) { + var app = relabelApplications.selectById(item.getApplicationId()); + if (app != null && Set.of("PROCESSING", "CUSTOMER_CONFIRM_PENDING", "READY_FOR_OUTBOUND").contains(app.getStatus())) { + held += value(item.getNotifiedPackageCount()); destinations.add(text(item.getDestinationFbaWarehouseCode())); + } + } + if (value(row.getAvailablePackages()) > 0 || value(row.getAllocatedPackages()) > 0 + || value(row.getPickedPackages()) > 0 || value(row.getHoldPackages()) > held) + destinations.add(text(row.getDestinationWarehouseCode())); + } + if (destinations.size() != 1) throw conflict("目标托盘内货物与本次换标的新目的仓不同,不能合托"); + } + private Map resolvePallets(long warehouseId, PalletOperationRequests.PalletExpectation first, List rest) { List values = new ArrayList<>(); values.add(first); values.addAll(rest); @@ -658,6 +853,7 @@ public class PdaPalletOperationService { List moves, List changes, RelabelMutation relabelMutation) { } private record RelabelMutation(RelabelApplication application, - Map> itemsByPosition) { } + Map> itemsByPosition, + Map anchors) { } private enum InventoryBucket { AVAILABLE, HOLD } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 88a51cd6f..c3cab46c9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -36,6 +36,16 @@ public class RelabelApplicationController { return ApiResponse.success(pdaService.returnForRework(operator(user), user.username(), relabelNo, request)); } + @PostMapping("/{relabelNo}/confirm-completion") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") + public ApiResponse confirmCompletion( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, + @Valid @RequestBody RelabelRequests.CustomerConfirm request) { + requirePlatform(user); + String customerId = service.customerId(relabelNo); + return ApiResponse.success(service.confirmCompletion(customerId, relabelNo, operator(user), request)); + } + @GetMapping("/{relabelNo}/review-task") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") public ApiResponse reviewTask( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index d0d73eacc..f43b27f0e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -39,9 +39,9 @@ public final class RelabelRequests { } public record Item( - @NotNull Long sourceInventoryBalanceId, - @NotNull Long sourceInventoryPositionId, - @NotNull Long sourceInboundOrderId, + Long sourceInventoryBalanceId, + Long sourceInventoryPositionId, + Long sourceInboundOrderId, @Size(max = 128) String skuCode, @Size(max = 128) String productImageFileId, @Size(max = 255) String productImageFileName, @@ -61,8 +61,22 @@ public final class RelabelRequests { @Size(max = 255) String fbaCartonMark, @Size(max = 128) String destinationFbaWarehouseCode, @Size(max = 32) String operationType, - @Size(max = 100) List<@Valid SkuItem> skuItems + @Size(max = 100) List<@Valid SkuItem> skuItems, + Long returnCartonId ) { + public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId, + String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, + String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo, + String trackingCode, String newSkuCode, String labelRequirement, String remark, + String relabelFileId, String relabelFileName, String relabelFileUrl, String cartonMark, + String fbaCartonMark, String destinationFbaWarehouseCode, String operationType, + List skuItems) { + this(sourceInventoryBalanceId, sourceInventoryPositionId, sourceInboundOrderId, skuCode, + productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, + notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark, + relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, + destinationFbaWarehouseCode, operationType, skuItems, null); + } /** 保持既有箱唛换标调用方的二进制参数形态。 */ public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId, String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, @@ -74,7 +88,7 @@ public final class RelabelRequests { productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, - destinationFbaWarehouseCode, "CARTON_LABEL", List.of()); + destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null); } public Item(String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, @@ -85,7 +99,7 @@ public final class RelabelRequests { this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl, - cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of()); + cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null); } public Item(String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, @@ -94,7 +108,7 @@ public final class RelabelRequests { this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, "ONT8", - "CARTON_LABEL", List.of()); + "CARTON_LABEL", List.of(), null); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 66f7fff17..8e6052f4a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -36,7 +36,9 @@ public final class RelabelResponses { String inboundCode, String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String destinationFbaWarehouseCode, String destinationFbaWarehouseName, String skuCode, String productNameZh, - long availablePackages, long version, List positions) {} + long availablePackages, long version, List positions, + String sourceType, Long returnCartonId, String cartonMark, + List skuItems) {} public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, String relabelOrderNo, String orderType, String status, Integer packageCount, LocalDateTime createdAt) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java index 66c40bd81..ede1a60ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java @@ -20,7 +20,7 @@ public class HttpRelabelDocumentFileGateway implements RelabelDocumentFileGatewa @Resource private RestClient.Builder restClientBuilder; @Resource private LastlegRestClientFactory restClientFactory; @Resource private LastlegRestClientProperties restClientProperties; - @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9206}") private String baseUrl; @Value("${baiyue.security.internal-service-token:}") private String internalToken; private RestClient client; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java index 1e9b91c39..871f82830 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -6,4 +6,14 @@ import org.apache.ibatis.annotations.Mapper; @Mapper public interface RelabelItemMapper extends BaseMapper { + @org.apache.ibatis.annotations.Select("SELECT id FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} FOR UPDATE") + Long lockSourceBalance(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("balanceId") Long balanceId); + + @org.apache.ibatis.annotations.Select("SELECT a.relabel_no FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id " + + "WHERE i.customer_id=#{customerId} AND a.customer_id=#{customerId} AND i.source_inventory_balance_id=#{balanceId} " + + "AND UPPER(TRIM(i.carton_mark))=UPPER(TRIM(#{mark})) AND a.status NOT IN ('CANCELLED','REJECTED') LIMIT 1") + String findExistingCarton(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("balanceId") Long balanceId, + @org.apache.ibatis.annotations.Param("mark") String mark); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java new file mode 100644 index 000000000..f05f065cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.relabel.model; + +import java.util.Arrays; + +/** 换标申请业务状态。 */ +public enum RelabelApplicationStatus { + PENDING_PROCESSING("待处理"), + PROCESSING("处理中"), + CUSTOMER_CONFIRM_PENDING("待客户确认"), + READY_FOR_OUTBOUND("换标完成,待出库"), + COMPLETED("已完成"), + REJECTED("已拒绝"), + CANCELLED("已取消"); + + private final String label; + + RelabelApplicationStatus(String label) { this.label = label; } + + public String code() { return name(); } + public String label() { return label; } + + public static RelabelApplicationStatus fromCode(String code) { + return Arrays.stream(values()).filter(value -> value.name().equals(code)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported relabel application status: " + code)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java index 7bc4d4595..34011a428 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -70,6 +70,14 @@ public class PdaRelabelController { identity.overseasWarehouseId(), relabelNo, request)); } + @PostMapping("/{relabelNo}/skus/scan") + public ApiResponse scanSku(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.SkuScan request) { + Identity identity = identity(user); + return ApiResponse.success(service.scanSku(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + @GetMapping("/{relabelNo}/documents") public ApiResponse> documents(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java index e402ee3fb..c28d56bbe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -14,6 +14,10 @@ public final class PdaRelabelRequests { private PdaRelabelRequests() { } public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { } + public record SkuScan(@NotNull @Positive Long itemId, @Min(0) int skuIndex, + @NotBlank @Size(max = 255) String oldSku, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } public record CartonScan(@NotBlank @Size(max = 255) String cartonMark, @NotNull @Min(0) Integer expectedVersion, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java index 92b660d53..e00d48949 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -26,7 +26,8 @@ public final class PdaRelabelResponses { String cartonMark, String fbaCartonMark, String operationType, List skuItems, int expectedPackages, int relabelledPackages, int skippedPackages, - int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages) { } + int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages, + String destinationFbaWarehouseCode) { } public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { } public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java index 66f504d4b..93c912595 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java @@ -15,8 +15,18 @@ public interface PdaRelabelTaskMapper extends BaseMapper { @Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE") PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); - @Select("SELECT * FROM ll_relabel_pda_task WHERE operator_key=#{operatorKey} AND status<>'COMPLETED' ORDER BY claimed_at ASC LIMIT 1 FOR UPDATE") - PdaRelabelTask lockUnfinishedByOperator(@Param("operatorKey") String operatorKey); + @Select(""" + SELECT task.* + FROM ll_relabel_pda_task task + JOIN ll_relabel_application application + ON application.id=task.application_id AND application.customer_id=task.customer_id + WHERE task.operator_key=#{operatorKey} + AND task.status='PROCESSING' + AND application.status='PROCESSING' + ORDER BY task.claimed_at ASC + LIMIT 1 FOR UPDATE + """) + PdaRelabelTask lockProcessingByOperator(@Param("operatorKey") String operatorKey); @Update(""" UPDATE ll_relabel_pda_task diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java index a4a5c9f73..b6a15ee2e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -51,6 +51,7 @@ import java.util.stream.Collectors; @Service public class PdaRelabelService { + @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard; public static final String PROCESSING = "PROCESSING"; public static final String WAIT_REVIEW = "WAIT_REVIEW"; public static final String COMPLETED = "COMPLETED"; @@ -109,7 +110,7 @@ public class PdaRelabelService { throw conflict("Relabel application is not executable"); } PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); - PdaRelabelTask activeTask = tasks.lockUnfinishedByOperator(operatorKey); + PdaRelabelTask activeTask = tasks.lockProcessingByOperator(operatorKey); if (activeTask != null && !Objects.equals(activeTask.getApplicationId(), application.getId())) { throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单"); } @@ -194,6 +195,49 @@ public class PdaRelabelService { public record Document(String scope, String fileId, String fileName, Long itemId) { } + @Transactional + public PdaRelabelResponses.Detail scanSku(String operatorKey, String operatorName, long warehouseId, + String relabelNo, PdaRelabelRequests.SkuScan request) { + UnitCommand command = new UnitCommand(relabelNo, "SCAN_SKU", request); + var replayed = replay(operatorKey, warehouseId, relabelNo, "SCAN_SKU", request.idempotencyKey(), command); + if (replayed != null) return replayed; + var app = requireApplication(relabelNo, warehouseId, true); + var task = requireOwnedTask(app, operatorKey, true); + requireVersion(task, request.expectedVersion()); + var item = requireItem(app, request.itemId()); + if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) throw conflict("该箱没有SKU换标任务"); + if (value(item.getNotifiedPackageCount()) != 1) throw conflict("SKU逐条作业要求每个仓库唛头独立一箱"); + var skus = readSkuItems(item.getSkuRelabelItemsJson()); + if (request.skuIndex() < 0 || request.skuIndex() >= skus.size()) throw conflict("SKU明细不存在"); + var sku = skus.get(request.skuIndex()); + if (!Objects.equals(sku.skuCode(), request.oldSku().trim()) || !StringUtils.hasText(sku.newSkuCode()) || sku.quantity() <= 0) + throw conflict("旧SKU或绑定的新标签、数量不正确"); + if (factList(task).stream().anyMatch(fact -> item.getId().equals(fact.getItemId())) + || mixedStageDone(task, item.getId(), "SKU")) throw conflict("本箱SKU作业已完成"); + Set done = new java.util.HashSet<>(); + for (var history : histories.selectList(new LambdaQueryWrapper() + .eq(PdaRelabelHistory::getTaskId, task.getId()).eq(PdaRelabelHistory::getCustomerId, app.getCustomerId()) + .eq(PdaRelabelHistory::getAction, "SKU_SCAN"))) { + if (!"SKU_SCAN".equals(history.getAction())) continue; + try { + var data = objectMapper.readTree(history.getDetailJson()); + if (mixedRound(task).equals(data.path("round").asText()) && item.getId().toString().equals(data.path("itemId").asText())) + done.add(data.path("skuIndex").asInt(-1)); + } catch (Exception failure) { throw conflict("SKU扫描历史无法读取"); } + } + if (!done.add(request.skuIndex())) throw conflict("该SKU明细已处理,请勿重复扫描"); + insertHistory(task, "SKU_SCAN", PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("round", mixedRound(task), "itemId", item.getId().toString(), "skuIndex", request.skuIndex(), + "oldSku", sku.skuCode(), "newSku", sku.newSkuCode(), "quantity", sku.quantity())); + if (done.size() == skus.size()) return unitAction(operatorKey, operatorName, warehouseId, relabelNo, + "SCAN_SKU", request.idempotencyKey(), request, item.getId(), item.getCartonMark(), null, + "RELABELLED", null, null, request.expectedVersion(), ignored -> { }); + updateTask(task, request.expectedVersion()); + var result = detail(app, task); + saveOperation(task, operatorKey, "SCAN_SKU", request.idempotencyKey(), command, result); + return result; + } + @Transactional(readOnly = true) public List documents(long warehouseId, String relabelNo) { RelabelApplication app = requireApplication(relabelNo, warehouseId, false); @@ -264,6 +308,7 @@ public class PdaRelabelService { RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); requireVersion(task, request.expectedVersion()); + if (!request.manualCompletion()) bindingGuard.requireBound(application); List detailItems = itemList(application); List facts = factList(task); boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream() @@ -558,7 +603,7 @@ public class PdaRelabelService { private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task, String operatorKey) { return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(), - application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(), + application.getLabelDeadline(), application.getStatus(), effectiveTaskStatus(application, task), operationType(application), value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), @@ -580,7 +625,8 @@ public class PdaRelabelService { value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"), count(itemFacts, "EXCEPTION"), count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0), - count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0)); + count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0), + item.getDestinationFbaWarehouseCode()); }).toList(); List factResponses = facts.stream().map(fact -> new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), @@ -593,7 +639,7 @@ public class PdaRelabelService { history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList(); return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(), application.getLabelDeadline(), application.getContactName(), application.getContactPhone(), - application.getStatus(), operationType(application), task == null ? "AVAILABLE" : task.getStatus(), + application.getStatus(), operationType(application), effectiveTaskStatus(application, task), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), @@ -605,6 +651,13 @@ public class PdaRelabelService { itemResponses, factResponses, historyResponses); } + private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) { + if (task == null) return "AVAILABLE"; + if (RelabelApplicationService.CUSTOMER_CONFIRM_PENDING.equals(application.getStatus()) + && PROCESSING.equals(task.getStatus())) return WAIT_REVIEW; + return task.getStatus(); + } + private void insertHistory(PdaRelabelTask task, String action, String from, String to, String operatorKey, String operatorName, Object detail) { PdaRelabelHistory history = new PdaRelabelHistory(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 6bf25b776..0aa38d523 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -29,6 +29,10 @@ import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; @@ -84,6 +88,8 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private InventoryCommandService inventoryCommands; @Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryPositionMapper inventoryPositions; + @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Resource private RelabelDocumentFileGateway documentFiles; @Resource private PdaRelabelTaskMapper pdaTasks; @Resource private RelabelPhotoFileGateway photoFiles; @@ -238,7 +244,7 @@ public class RelabelApplicationService implements WalletResultHandler { .gt(InventoryBalance::getAvailablePackages, 0) .like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode)) .orderByDesc(InventoryBalance::getUpdatedAt)); - return balances.stream().map(balance -> { + List result = new java.util.ArrayList<>(balances.stream().map(balance -> { List positions = inventoryPositions.listBalance(customerId, balance.getId()) .stream().filter(position -> value(position.getAvailablePackages()) > 0) .map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(), @@ -249,8 +255,27 @@ public class RelabelApplicationService implements WalletResultHandler { balance.getInboundOrderNo(), balance.getInboundCode(), balance.getOverseasWarehouseId(), balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(), balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(), - balance.getProductNameZh(), available, value(balance.getVersion()), positions); - }).filter(candidate -> candidate.availablePackages() > 0).toList(); + balance.getProductNameZh(), available, value(balance.getVersion()), positions, + "INBOUND", null, null, List.of()); + }).filter(candidate -> candidate.availablePackages() > 0).toList()); + for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) { + String keyword = trim(inboundCode); + if (StringUtils.hasText(keyword) + && !containsIgnoreCase(carton.getCartonMark(), keyword) + && !containsIgnoreCase(carton.getSourceReturnNo(), keyword)) continue; + List cartonSkus = returnCartonItems.listCarton(carton.getId()).stream() + .filter(item -> value(item.getQuantity()) > 0) + .map(item -> new RelabelResponses.SkuItem(item.getSkuCode(), null, + Math.toIntExact(item.getQuantity()), null)).toList(); + if (cartonSkus.isEmpty()) continue; + String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode) + .collect(Collectors.joining(",")); + result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(), + null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(), + carton.getDestinationWarehouseName(), skuCodes, "退货合箱库存", 1, 0, List.of(), + "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus)); + } + return result; } @Transactional(readOnly = true) @@ -280,6 +305,19 @@ public class RelabelApplicationService implements WalletResultHandler { } @Transactional public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { + return create(customerId, operatorId, request, null); + } + + @Transactional + public RelabelResponses.Detail createForExistingTailOrder(String customerId, String operatorId, + Long existingTailOrderId, + RelabelRequests.Create request) { + if (existingTailOrderId == null) throw badRequest("Existing tail order is required"); + return create(customerId, operatorId, request, existingTailOrderId); + } + + private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request, + Long existingTailOrderId) { if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required"); boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); String deliveryMethod = trim(request.deliveryMethod()); @@ -298,6 +336,13 @@ public class RelabelApplicationService implements WalletResultHandler { if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data"); return detail(customerId, replay.getRelabelNo()); } + Set requestedDestinations = request.items().stream() + .filter(item -> !"SKU_LABEL".equals(operationType(item))) + .map(RelabelRequests.Item::destinationFbaWarehouseCode) + .filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet()); + if (requestedDestinations.size() > 1) { + throw badRequest("同一张贴标申请只能选择一个 FBA 目的仓"); + } int total = 0; int notified = 0; List sources = new java.util.ArrayList<>(); @@ -312,6 +357,25 @@ public class RelabelApplicationService implements WalletResultHandler { if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) { throw badRequest("The same carton mark cannot create more than one relabel task"); } + if (item.returnCartonId() != null) { + ReturnInventoryCarton carton = returnCartons.lockId(item.returnCartonId()); + if (carton == null || !customerId.equals(carton.getCustomerId()) + || !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) { + throw conflict("所选退货箱不属于当前客户和操作仓"); + } + if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) { + throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标"); + } + if (!carton.getCartonMark().equalsIgnoreCase(item.cartonMark().trim())) { + throw conflict("退货箱仓库唛头已变化,请重新查询"); + } + sources.add(new SourceInventory(item, null, carton)); + continue; + } + if (item.sourceInventoryBalanceId() == null || item.sourceInventoryPositionId() == null + || item.sourceInboundOrderId() == null) { + throw badRequest("正常入仓库存必须提供余额、仓位和入仓单"); + } InventoryPosition position = inventoryPositions.findTenantPosition(customerId, item.sourceInventoryPositionId()); if (position == null || !Objects.equals(position.getBalanceId(), item.sourceInventoryBalanceId())) { throw conflict("Selected inventory position is no longer available"); @@ -325,7 +389,7 @@ public class RelabelApplicationService implements WalletResultHandler { if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) { throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo()); } - sources.add(new SourceInventory(item, balance)); + sources.add(new SourceInventory(item, balance, null)); } LocalDateTime now = LocalDateTime.now(); long applicationId = IdWorker.getId(); @@ -335,13 +399,15 @@ public class RelabelApplicationService implements WalletResultHandler { boolean requiresWallet = chargeAmount.signum() > 0; int holdIndex = 0; for (SourceInventory source : sources) { + if (source.returnCarton() != null) continue; inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo, null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++, "贴标申请冻结原入仓单库存")); } - LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo, - operationWarehouseId, total, now); + LastlegOrder tailOrder = existingTailOrderId == null + ? insertTailOrder(customerId, request, requestHash, relabelNo, operationWarehouseId, total, now) + : linkExistingTailOrder(customerId, existingTailOrderId, relabelNo, total, now); List tailOrders = List.of(tailOrder); RelabelApplication application = new RelabelApplication(); @@ -372,6 +438,14 @@ public class RelabelApplicationService implements WalletResultHandler { application.setUpdatedAt(now); applications.insert(application); + for (SourceInventory source : sources) { + if (source.returnCarton() == null) continue; + ReturnInventoryCarton carton = source.returnCarton(); + carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo); + carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); + returnCartons.updateById(carton); + } + if (requiresWallet) { String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, chargeAmount, "换标订单费用冻结", 1L); @@ -383,8 +457,10 @@ public class RelabelApplicationService implements WalletResultHandler { insertItem(customerId, applicationId, source, now); } insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); - tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null, - order.getCurrentStatus(), "Relabel application submitted", now)); + if (existingTailOrderId == null) { + tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null, + order.getCurrentStatus(), "Relabel application submitted", now)); + } return detail(customerId, relabelNo); } @@ -501,7 +577,8 @@ public class RelabelApplicationService implements WalletResultHandler { } for (LastlegOrder order : linkedOrders) { String orderBefore = order.getCurrentStatus(); - String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore; + String mainStatus = "RELABEL_APPLICATION".equals(order.getOrderType()) + ? "WAIT_RELABEL" : orderBefore; String orderStatus = switch (next) { case CUSTOMER_CONFIRM_PENDING -> mainStatus; case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType()) @@ -606,23 +683,27 @@ public class RelabelApplicationService implements WalletResultHandler { PdaRelabelTask reviewTask = pdaTasks.lock(customerId, application.getId()); if (reviewTask == null || !Set.of("WAIT_REVIEW", "COMPLETED").contains(reviewTask.getStatus())) throw conflict("仓库尚未提交换标完工复核"); - reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CUSTOMER_CONFIRMED"); - reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认"); - reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now()); - reviewTask.setUpdatedAt(LocalDateTime.now()); - if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新"); if (Boolean.TRUE.equals(application.getPrintShippingLabel()) && !StringUtils.hasText(application.getShippingLabelFileId())) { throw conflict("Upload the shipping label before confirming relabel completion"); } + List relabelItems = itemList(application.getId()); + boolean requiresOutbound = relabelItems.stream() + .anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); + reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED"); + reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认"); + reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now()); + reviewTask.setUpdatedAt(LocalDateTime.now()); + if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新"); LocalDateTime now = LocalDateTime.now(); String before = application.getStatus(); - application.setStatus(READY_FOR_OUTBOUND); + application.setStatus(requiresOutbound ? READY_FOR_OUTBOUND : "COMPLETED"); application.setCustomerConfirmationKey(key); application.setCustomerConfirmedAt(now); - application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod()) - ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION"); - if (!"TRUCK".equals(application.getDeliveryMethod())) { + application.setFulfillmentStatus(requiresOutbound + ? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION") + : "NOT_REQUIRED"); + if (requiresOutbound && !"TRUCK".equals(application.getDeliveryMethod())) { createDirectOutbound(application, operatorId, now); application.setFulfillmentStatus("OUTBOUND_CREATED"); } @@ -630,7 +711,7 @@ public class RelabelApplicationService implements WalletResultHandler { applications.updateById(application); for (LastlegOrder order : tailOrders(application)) { String orderBefore = order.getCurrentStatus(); - order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setCurrentStatus(requiresOutbound ? "PENDING_ASSIGNMENT" : "COMPLETED"); order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus()); order.setRelabelStatus("COMPLETED"); order.setUpdatedAt(now); @@ -640,24 +721,34 @@ public class RelabelApplicationService implements WalletResultHandler { request.remark(), now); } } - insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId, + insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId, request.remark(), now); // 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。 if (warehouseTransfers != null) { WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo); if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) { - transfer.setStatus("READY_FOR_DISPATCH"); + Set destinations = relabelItems.stream() + .map(RelabelItem::getDestinationFbaWarehouseCode) + .filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet()); + if (!StringUtils.hasText(transfer.getFbaWarehouseCode()) && destinations.size() == 1) { + transfer.setFbaWarehouseCode(destinations.iterator().next()); + } + String transferStatus = StringUtils.hasText(transfer.getFbaWarehouseCode()) + ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; + transfer.setStatus(transferStatus); transfer.setRelabelCompletedAt(now); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); + application.setFulfillmentStatus(transferStatus); + applications.updateById(application); // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId()); if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) { String transferOrderBefore = transferOrder.getCurrentStatus(); - transferOrder.setCurrentStatus("READY_FOR_DISPATCH"); + transferOrder.setCurrentStatus(transferStatus); + transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); transferOrder.setRelabelStatus("COMPLETED"); - transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:" - + "COMPLETED"); + transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED"); transferOrder.setUpdatedAt(now); orders.updateById(transferOrder); if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) { @@ -683,27 +774,28 @@ public class RelabelApplicationService implements WalletResultHandler { outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum()); - outbound.setPalletCount((int) relabelItems.stream().map(RelabelItem::getSourceInventoryPositionId).filter(Objects::nonNull).distinct().count()); + outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count()); outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); Map> byPosition = relabelItems.stream().collect(Collectors.groupingBy( - RelabelItem::getSourceInventoryPositionId, LinkedHashMap::new, Collectors.toList())); + this::outboundSourceKey, LinkedHashMap::new, Collectors.toList())); for (Map.Entry> entry : byPosition.entrySet()) { - InventoryPosition position = inventoryPositions.selectById(entry.getKey()); - if (position == null || !application.getCustomerId().equals(position.getCustomerId())) { + InventoryPosition position = entry.getKey() > 0 ? inventoryPositions.selectById(entry.getKey()) : null; + if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId()))) { throw conflict("Relabel source inventory position no longer exists"); } List group = entry.getValue(); OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId()); - item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); + item.setWarehousePalletId(position == null ? null : position.getPalletId()); + item.setPalletNo(position == null ? null : position.getPalletNo()); item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode) .filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); - item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), entry.getKey(), group)); + item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), position == null ? null : entry.getKey(), group)); item.setCreatedAt(now); outboundItems.insert(item); } OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId()); @@ -714,11 +806,14 @@ public class RelabelApplicationService implements WalletResultHandler { private String writeOutboundRefs(String relabelNo, Long positionId, List values) { try { - return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo, - "sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of( + Map refs = new LinkedHashMap<>(); + refs.put("sourceType", "RELABEL_ORDER"); refs.put("relabelNo", relabelNo); + if (positionId != null) refs.put("sourceInventoryPositionId", positionId); + refs.put("items", values.stream().map(item -> Map.of( "itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""), "destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""), - "packages", intValue(item.getNotifiedPackageCount()))).toList())); + "packages", intValue(item.getNotifiedPackageCount()))).toList()); + return objectMapper.writeValueAsString(refs); } catch (Exception exception) { throw new IllegalStateException("Cannot save relabel outbound snapshot", exception); } } @@ -740,14 +835,21 @@ public class RelabelApplicationService implements WalletResultHandler { item.setDestinationFbaWarehouseCode(trim(source.destinationFbaWarehouseCode())); item.setOperationType(operationType(source)); item.setSkuRelabelItemsJson(writeSkuItems(source.skuItems())); - item.setSourceInventoryBalanceId(balance.getId()); - item.setSourceInventoryPositionId(source.sourceInventoryPositionId()); - item.setSourceInboundOrderId(balance.getInboundOrderId()); - item.setSourceInboundOrderNo(balance.getInboundOrderNo()); - item.setSourceInboundCode(balance.getInboundCode()); - item.setSourceWarehouseId(balance.getOverseasWarehouseId()); - item.setSourceWarehouseCode(balance.getOverseasWarehouseCode()); - item.setSourceWarehouseName(balance.getOverseasWarehouseName()); + if (balance != null) { + item.setSourceInventoryBalanceId(balance.getId()); + item.setSourceInventoryPositionId(source.sourceInventoryPositionId()); + item.setSourceInboundOrderId(balance.getInboundOrderId()); + item.setSourceInboundOrderNo(balance.getInboundOrderNo()); + item.setSourceInboundCode(balance.getInboundCode()); + item.setSourceWarehouseId(balance.getOverseasWarehouseId()); + item.setSourceWarehouseCode(balance.getOverseasWarehouseCode()); + item.setSourceWarehouseName(balance.getOverseasWarehouseName()); + } else { + ReturnInventoryCarton carton = sourceInventory.returnCarton(); + item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); + item.setSourceInboundOrderNo(carton.getSourceReturnNo()); + item.setSourceWarehouseId(carton.getOverseasWarehouseId()); + } item.setCreatedAt(now); items.insert(item); } @@ -831,7 +933,7 @@ public class RelabelApplicationService implements WalletResultHandler { order.setSourceInboundId(relabelNo + ":" + warehouseId); order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId); order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0); - order.setShippedPackageCount(0); order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setShippedPackageCount(0); order.setCurrentStatus("WAIT_RELABEL"); order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark())); order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId); @@ -840,6 +942,17 @@ public class RelabelApplicationService implements WalletResultHandler { return order; } + private LastlegOrder linkExistingTailOrder(String customerId, Long orderId, String relabelNo, + int packageCount, LocalDateTime now) { + LastlegOrder order = orders.selectById(orderId); + if (order == null || !customerId.equals(order.getCustomerId())) { + throw conflict("Existing tail order does not belong to this customer"); + } + order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING); + order.setPackageCount(packageCount); order.setUpdatedAt(now); orders.updateById(order); + return order; + } + private List tailOrders(RelabelApplication application) { List linked = orders.selectList(new LambdaQueryWrapper() .eq(LastlegOrder::getCustomerId, application.getCustomerId()) @@ -850,12 +963,27 @@ public class RelabelApplicationService implements WalletResultHandler { return legacy == null ? List.of() : List.of(legacy); } - private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {} + private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance, + ReturnInventoryCarton returnCarton) {} private static long value(Long value) { return value == null ? 0L : value; } private static int intValue(Integer value) { return value == null ? 0 : value; } + private static boolean containsIgnoreCase(String value, String fragment) { + return value != null && fragment != null + && value.toLowerCase(java.util.Locale.ROOT).contains(fragment.toLowerCase(java.util.Locale.ROOT)); + } private void releaseInventory(String customerId, String operatorId, RelabelApplication application) { for (RelabelItem item : itemList(application.getId())) { + if (item.getReturnCartonId() != null) { + ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); + if (carton != null && customerId.equals(carton.getCustomerId()) + && Objects.equals(application.getId(), carton.getRelabelApplicationId()) + && "RELABEL_PENDING".equals(carton.getStatus())) { + carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setRelabelStatus(null); + carton.setStatus("AVAILABLE"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton); + } + continue; + } if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null) continue; inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null, @@ -938,9 +1066,15 @@ public class RelabelApplicationService implements WalletResultHandler { private static String businessRelabelStatus(String workflowStatus) { if ("PROCESSING".equals(workflowStatus)) return "PROCESSING"; - return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus) + return Set.of(CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus) ? "COMPLETED" : PENDING_PROCESSING; } + + private long outboundSourceKey(RelabelItem item) { + if (item.getSourceInventoryPositionId() != null) return item.getSourceInventoryPositionId(); + if (item.getReturnCartonId() != null) return -item.getReturnCartonId(); + throw conflict("Relabel outbound item has no physical inventory source"); + } private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") .digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java new file mode 100644 index 000000000..63dc446ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +/** Requires saved physical bindings, never the original accounting freeze anchor. */ +@Service +public class RelabelBindingGuard { + @Resource private RelabelItemMapper items; + @Resource private PdaRelabelHistoryMapper histories; + @Resource private InventoryPositionMapper positions; + @Resource private ObjectMapper objectMapper; + + public void requireBound(RelabelApplication app) { + List cartons = items.selectList(new QueryWrapper() + .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())) + .stream().filter(item -> !"SKU_LABEL".equals(item.getOperationType())).toList(); + if (cartons.isEmpty()) return; + Map> bindings = new HashMap<>(); + for (PdaRelabelHistory history : histories.selectList(new QueryWrapper() + .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()) + .orderByAsc("created_at", "id"))) { + if ("REWORK_ARCHIVE".equals(history.getAction())) bindings.clear(); + if (!"PALLET_BIND".equals(history.getAction())) continue; + try { + var data = objectMapper.readTree(history.getDetailJson()); + Set targets = new HashSet<>(); + data.path("targetPallets").forEach(value -> targets.add(value.asText())); + data.path("cartonMarks").forEach(value -> bindings.put(value.asText(), targets)); + } catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); } + } + for (RelabelItem item : cartons) { + var position = item.getSourceInventoryPositionId() == null ? null + : positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId()); + if (position == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo()) + || position.getHoldPackages() == null || position.getHoldPackages() < item.getNotifiedPackageCount()) + throw conflict("箱唛 " + item.getCartonMark() + " 尚未完成托盘绑定,请先在 PDA 确认组托,不能完成或排车出库"); + } + } + private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 622a55aae..9d4def8ea 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -569,7 +569,7 @@ public class LastlegOrderService { public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output) throws IOException { - output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8)); + output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,客户预报箱数,实收箱数,差异箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8)); final int batchSize = 1000; long page = 1; while (true) { @@ -585,7 +585,8 @@ public class LastlegOrderService { csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()), - csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), + csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getReceivedPackageCount()), + csv(packageDifference(order)), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(relabelBusinessStatus(order.getRelabelStatus())), csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), @@ -677,7 +678,8 @@ public class LastlegOrderService { o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode, o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(), Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER", - o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), + o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), + o.getReceivedPackageCount(), packageDifference(o), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()), o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), @@ -686,6 +688,11 @@ public class LastlegOrderService { o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); } + private static Integer packageDifference(LastlegOrder order) { + if (order.getReceivedPackageCount() == null) return null; + return order.getReceivedPackageCount() - java.util.Objects.requireNonNullElse(order.getPackageCount(), 0); + } + static String displayOrderNo(LastlegOrder order) { return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo()) ? order.getSourceOrderNo() : order.getOrderNo(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java index 63f93c8c4..a6c56a123 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java @@ -12,6 +12,14 @@ import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.MediaType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.ResponseEntity; + +import java.nio.charset.StandardCharsets; +import java.util.List; @RestController @RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers") @@ -24,6 +32,30 @@ public class MerchantWarehouseTransferController { return ApiResponse.success(service.create(customer(user), operator(user), request)); } + @PutMapping("/{transferNo}") + public ApiResponse updateDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Create request) { + return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request)); + } + + /** + * POST 兼容入口:部分本地网关只放行 POST,草稿保存不能因此阻断提交。 + * 与 PUT 更新草稿使用完全相同的服务逻辑和租户校验。 + */ + @PostMapping("/{transferNo}/save-draft") + public ApiResponse saveDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Create request) { + return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/submit") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo) { + return ApiResponse.success(service.submit(customer(user), transferNo, operator(user))); + } + @GetMapping public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid WarehouseTransferRequests.Query query) { @@ -36,6 +68,29 @@ public class MerchantWarehouseTransferController { return ApiResponse.success(service.detail(customer(user), transferNo)); } + @GetMapping("/{transferNo}/carton-label") + public ApiResponse cartonLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo) { + return ApiResponse.success(service.cartonLabel(customer(user), transferNo)); + } + + @GetMapping("/{transferNo}/measurement-photos/files") + public ApiResponse> measurementPhotos( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { + return ApiResponse.success(service.measurementPhotos(customer(user), transferNo)); + } + + @GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download") + public ResponseEntity downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo, + @PathVariable long fileId) { + var file = service.downloadMeasurementPhoto(customer(user), transferNo, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.contentType())).body(file.content()); + } + @PostMapping("/{transferNo}/confirm-measurement") public ApiResponse confirmMeasurement( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, 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 848f20c01..6bd39c1ce 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 @@ -16,7 +16,16 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.http.MediaType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; +import java.nio.charset.StandardCharsets; import java.util.List; @RestController @@ -32,6 +41,42 @@ public class PdaWarehouseTransferController { return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId())); } + @GetMapping("/{transferNo}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaDetail(identity.overseasWarehouseId(), transferNo)); + } + + @PostMapping(value = "/{transferNo}/measurement-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadMeasurementPhoto( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @RequestParam("file") MultipartFile file) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + long operatorUserId = user.userId() == null ? 0L : user.userId(); + return ApiResponse.success(service.uploadMeasurementPhoto(identity.operatorKey(), operatorUserId, + identity.overseasWarehouseId(), transferNo, file)); + } + + @GetMapping("/{transferNo}/measurement-photos/files") + public ApiResponse> measurementPhotos( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaMeasurementPhotos(identity.overseasWarehouseId(), transferNo)); + } + + @GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download") + public ResponseEntity downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo, + @PathVariable long fileId) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + var file = service.downloadPdaMeasurementPhoto(identity.overseasWarehouseId(), transferNo, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.contentType())).body(file.content()); + } + @PostMapping("/{transferNo}/palletize") public ApiResponse palletize( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @@ -40,4 +85,49 @@ public class PdaWarehouseTransferController { return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(), transferNo, request)); } + + @PostMapping("/{transferNo}/pallets/drafts") + public ApiResponse savePalletDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.PalletDraft request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.savePalletDraft(identity.overseasWarehouseId(), transferNo, request)); + } + + @PostMapping("/{transferNo}/pallets/{palletId}/generate") + public ApiResponse generatePallet( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId, + @Valid @RequestBody WarehouseTransferRequests.PalletAction request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.generatePallet(identity.overseasWarehouseId(), transferNo, palletId, request)); + } + + @PostMapping("/{transferNo}/pallets/{palletId}/bind") + public ApiResponse bindPallet( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId, + @Valid @RequestBody WarehouseTransferRequests.PalletBind request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.bindPallet(identity.overseasWarehouseId(), transferNo, palletId, request)); + } + + @org.springframework.web.bind.annotation.DeleteMapping("/{transferNo}/pallets/{palletId}") + public ApiResponse deletePalletDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.deletePalletDraft(identity.overseasWarehouseId(), transferNo, palletId)); + } + + @GetMapping("/{transferNo}/pallets/{warehousePalletId}/label") + public ResponseEntity palletLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo, + @PathVariable long warehousePalletId, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + var file = service.printPdaPalletLabel(identity.overseasWarehouseId(), transferNo, + warehousePalletId, requestId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.contentType())).body(file.content()); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java index 9967c47d5..8d3c9f135 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java @@ -24,14 +24,14 @@ public class WarehouseTransferController { @PostMapping public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid @RequestBody WarehouseTransferRequests.Create request) { - return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); + return ApiResponse.success(service.createAndSubmit(scope(user, customerId), operator(user), request)); } @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) { - return ApiResponse.success(service.page(scope(user, customerId), query)); + return ApiResponse.success(service.page(scope(user, customerId), query, false)); } @GetMapping("/{transferNo}") @@ -56,6 +56,17 @@ public class WarehouseTransferController { return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request)); } + @PostMapping("/bind-destination") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + public ApiResponse> bindDestination( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody WarehouseTransferRequests.BindDestination request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + return ApiResponse.success(service.bindDestination(operator(user), request)); + } + @PostMapping("/{transferNo}/allocations") public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @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 db1373ca1..42804efba 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 @@ -21,7 +21,17 @@ public final class WarehouseTransferRequests { @Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds, @NotBlank @Size(max = 128) String operatingWarehouseId, @Size(max = 128) String fbaWarehouseCode, + @Size(max = 16) String destinationType, + @Size(max = 128) String destinationWarehouseId, + @Size(max = 128) String destinationWarehouseCode, + @Size(max = 255) String destinationWarehouseName, + @Size(max = 1000) String privateDestinationAddress, + @Size(max = 32) String privateDestinationPostalCode, + @Size(max = 128) String privateDestinationContactName, + @Size(max = 64) String privateDestinationContactPhone, @NotBlank @Size(max = 1000) String deliveryAddress, + @Size(max = 128) String pickupContactName, + @Size(max = 64) String pickupContactPhone, @Positive int packageCount, @NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm, @NotBlank @Size(max = 64) String deliveryMethod, @@ -46,12 +56,29 @@ public final class WarehouseTransferRequests { String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, boolean needRelabel, String idempotencyKey, List items) { - this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, deliveryAddress, - packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo, + this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, + null, null, null, null, null, null, null, null, + deliveryAddress, null, null, packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo, fbaBoxNos, remark, needRelabel, null, null, null, null, null, idempotencyKey, items, null); } } + public record DestinationSelection( + @NotBlank @Size(max = 128) String customerId, + @NotBlank @Size(max = 64) String transferNo) {} + + public record BindDestination( + @NotEmpty @Size(max = 100) List<@Valid DestinationSelection> transfers, + @NotBlank @jakarta.validation.constraints.Pattern(regexp = "FBA|PRIVATE") String destinationType, + @Size(max = 128) String destinationWarehouseId, + @Size(max = 128) String destinationWarehouseCode, + @Size(max = 255) String destinationWarehouseName, + @Size(max = 1000) String privateDestinationAddress, + @Size(max = 32) String privateDestinationPostalCode, + @Size(max = 128) String privateDestinationContactName, + @Size(max = 64) String privateDestinationContactPhone, + @NotBlank @Size(max = 255) String idempotencyKey) {} + /** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */ public record Review(@NotBlank @Size(max = 32) String decision, @Size(max = 1000) String remark) {} @@ -72,19 +99,34 @@ public final class WarehouseTransferRequests { public record Palletize( @NotBlank @Size(max = 255) String idempotencyKey, - @NotEmpty @Size(max = 100) List<@Valid Pallet> pallets + @NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds ) {} + 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 + ) {} + + public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {} + + public record PalletBind(@NotBlank @Size(max = 128) String palletCode, + @NotBlank @Size(max = 255) String idempotencyKey) {} + public record Pallet( - @Size(max = 128) String palletCode, @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, - @NotEmpty @Size(max = 500) List<@Valid PalletItem> items + @NotNull @DecimalMin("0.01") BigDecimal grossWeightKg ) {} - public record PalletItem(@NotNull Long transferItemId, @Positive int packageCount) {} - public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} @@ -106,8 +148,8 @@ public final class WarehouseTransferRequests { ) {} public record Item( - @NotBlank @Size(max = 128) String skuCode, - @NotBlank @Size(max = 255) String productNameZh, + @Size(max = 128) String skuCode, + @Size(max = 255) String productNameZh, @Positive int packageCount, @Size(max = 255) String shippingMark ) {} @@ -115,7 +157,7 @@ public final class WarehouseTransferRequests { public record RelabelItem( @NotBlank @Size(max = 32) String operationType, @NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> customerCartonMarks, - @NotBlank @Size(max = 128) String originalSku, + @Size(max = 128) String originalSku, @Size(max = 128) String newSku, @Positive int quantity, @Size(max = 255) String fbaCartonMark, 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 3abdde3da..140c50ef6 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 @@ -9,12 +9,16 @@ public final class 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 Label(String transferNo, List cartons, String printableHtml) {} + public record DestinationResult(String customerId, String transferNo) {} public record PalletItem(Long transferItemId, String skuCode, int packageCount) {} + public record MeasurementPhoto(Long fileId, String fileName, String contentType, long sizeBytes, + LocalDateTime uploadedAt) {} public record Pallet(Long id, int sequenceNo, Long warehousePalletId, String palletNo, Long warehouseLocationId, String warehouseLocationCode, int cartonCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg, BigDecimal volumeCbm, String status, - List items, LocalDateTime completedAt) {} + String fbaWarehouseCode, String labelDownloadPath, + List items, LocalDateTime boundAt, LocalDateTime completedAt) {} public record RelabelBinding(String operationType, String customerCartonMark, String originalSku, String newSku, int quantity, String fbaCartonMark, String destinationWarehouseCode, String remark) {} @@ -24,19 +28,28 @@ public final class WarehouseTransferResponses { Long destinationInventoryPositionId) {} public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, + String destinationType, String destinationWarehouseCode, String destinationWarehouseName, int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo, String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, String status, boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus, - String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, - int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt, + String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, + String destinationType, String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String privateDestinationAddress, + String privateDestinationPostalCode, + String privateDestinationContactName, String privateDestinationContactPhone, + String pickupContactName, String pickupContactPhone, + int packageCount, BigDecimal volumeCbm, String deliveryMethod, LocalDateTime estimatedArrivalAt, LocalDateTime estimatedShipAt, String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, + LocalDateTime labelDeadline, String relabelContactName, String relabelContactPhone, + String relabelDeliveryMethod, String reviewDecision, String reviewRemark, String reviewedBy, LocalDateTime reviewedAt, List sourceOrders, List items, List cartons, List pallets, List relabelBindings, - List allocations, LocalDateTime customerConfirmedAt, + List allocations, List measurementPhotos, + LocalDateTime customerConfirmedAt, LocalDateTime actualShipAt, LocalDateTime createdAt) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java new file mode 100644 index 000000000..52668595a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.transfer.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_warehouse_transfer_measurement_photo") +public class WarehouseTransferMeasurementPhoto { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private Long fileId; + private String fileName; + private String contentType; + private Long sizeBytes; + private String uploadedBy; + private LocalDateTime uploadedAt; + private LocalDateTime submittedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java index 6ce3b354f..a994d1730 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java @@ -18,7 +18,17 @@ public class WarehouseTransferOrder { private String lastlegOrderNo; private String operatingWarehouseId; private String fbaWarehouseCode; + private String destinationType; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String privateDestinationAddress; + private String privateDestinationPostalCode; + private String privateDestinationContactName; + private String privateDestinationContactPhone; private String deliveryAddress; + private String pickupContactName; + private String pickupContactPhone; private Integer packageCount; private String deliveryMethod; private LocalDateTime estimatedArrivalAt; @@ -50,6 +60,8 @@ public class WarehouseTransferOrder { private String palletizeRequestHash; private String confirmationIdempotencyKey; private String confirmationRequestHash; + private String destinationBindingIdempotencyKey; + private String destinationBindingRequestHash; private LocalDateTime customerConfirmedAt; private String idempotencyKey; private String requestHash; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java index 749eeb1af..409910321 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java @@ -27,6 +27,11 @@ public class WarehouseTransferPallet { private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private String status; + private String draftIdempotencyKey; + private String draftRequestHash; + private String generateIdempotencyKey; + private String bindIdempotencyKey; + private LocalDateTime boundAt; private LocalDateTime completedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java new file mode 100644 index 000000000..0ee0f88a0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java @@ -0,0 +1,106 @@ +package com.baiyue.lastleg.transfer.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; + +@Component +public class HttpTransferMeasurementPhotoFileGateway implements TransferMeasurementPhotoFileGateway { + private static final String TYPE = "OVERSEAS_TRANSFER_MEASUREMENT_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9206}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getRelabelPhotoFile()); + } + + @Override public UploadedFile upload(String customerId, long transferId, long operatorId, MultipartFile file) { + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot read transfer measurement photo", exception); + } + MetadataEnvelope response = client.post().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}", + tenant(customerId), TYPE, transferId) + .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo upload failed"); }) + .body(MetadataEnvelope.class); + Metadata data = success(response, "Transfer measurement photo upload failed"); + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + + @Override public void validate(String customerId, long transferId, long fileId) { + long tenant = tenant(customerId); + MetadataEnvelope response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}", + tenant, TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo ownership is invalid"); }) + .body(MetadataEnvelope.class); + Metadata data = success(response, "Transfer measurement photo ownership is invalid"); + if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenant + || !TYPE.equals(data.businessType()) || !String.valueOf(transferId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Transfer measurement photo ownership is invalid"); + } + } + + @Override public DownloadedFile download(String customerId, long transferId, long fileId) { + validate(customerId, transferId, fileId); + ResponseEntity response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}/content", + tenant(customerId), TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo download failed"); }) + .toEntity(byte[].class); + String name = response.getHeaders().getContentDisposition().getFilename(); + String type = response.getHeaders().getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE + : response.getHeaders().getContentType().toString(); + return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), + name == null ? "transfer-measurement-photo" : name, type); + } + + private static long tenant(String value) { + try { return Long.parseLong(value); } + catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); } + } + private static Metadata success(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if (!"200".equals(response.code())) { + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + if (response.data() == null || response.data().fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "File service returned no transfer measurement photo metadata"); + } + return response.data(); + } + private static ResponseStatusException failure(HttpStatusCode status, String message) { + return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, message); + } + private record MetadataEnvelope(String code, String message, Metadata data) {} + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferMeasurementPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferMeasurementPhotoFileGateway.java new file mode 100644 index 000000000..9324c10c6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferMeasurementPhotoFileGateway.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.transfer.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface TransferMeasurementPhotoFileGateway { + UploadedFile upload(String customerId, long transferId, long operatorId, MultipartFile file); + void validate(String customerId, long transferId, long fileId); + DownloadedFile download(String customerId, long transferId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {} + record DownloadedFile(byte[] content, String fileName, String contentType) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java new file mode 100644 index 000000000..829eb0360 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface WarehouseTransferMeasurementPhotoMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY uploaded_at,id") + List listOrder(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND submitted_at IS NOT NULL ORDER BY submitted_at,id") + List listSubmitted(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); + + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} LIMIT 1") + WarehouseTransferMeasurementPhoto findFile(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("fileId") Long fileId); + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} AND submitted_at IS NOT NULL LIMIT 1") + WarehouseTransferMeasurementPhoto findSubmittedFile(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("fileId") Long fileId); + @Update("") + int markSubmitted(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId, + @Param("fileIds") java.util.Collection fileIds, + @Param("submittedAt") java.time.LocalDateTime submittedAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java index bc86eed62..ceb53edcc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -14,6 +14,8 @@ import org.apache.ibatis.annotations.Update; WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") WarehouseTransferOrder lockForPda(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1") + WarehouseTransferOrder findNoByWarehouse(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java index 6526636ea..fb9361e0e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java @@ -13,4 +13,9 @@ public interface WarehouseTransferPalletMapper extends BaseMapper listOrder(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId); + + @Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND id=#{id} FOR UPDATE") + WarehouseTransferPallet lockOne(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("id") Long id); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java new file mode 100644 index 000000000..271c2287f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.transfer.model; + +import java.util.Arrays; + +/** 仓储转运单状态。状态编码持久化到数据库,中文名称用于接口和前端展示。 */ +public enum WarehouseTransferStatus { + DRAFT("草稿"), + PENDING_REVIEW("待审核"), + APPROVED("审核通过"), + REJECTED("审核驳回"), + WAIT_PDA_RECEIVING("待PDA收货"), + INBOUND_MEASURING("入库测量中"), + WAIT_CUSTOMER_CONFIRMATION("待客户确认实测"), + WAIT_RELABEL("待换标"), + WAIT_FBA_DESTINATION("待确认目的仓"), + READY_FOR_DISPATCH("待排车"), + WAIT_OUTBOUND("待出库"), + PENDING_ASSIGNMENT("待分配"), + ALLOCATED("已分配库存"), + OUTBOUNDED("已出库"), + PARTIALLY_RECEIVED("部分收货"), + RECEIVED("已收货"), + INBOUND_COMPLETED("入库完成"), + COMPLETED("已完成"); + + private final String label; + + WarehouseTransferStatus(String label) { this.label = label; } + + public String code() { return name(); } + public String label() { return label; } + + public static WarehouseTransferStatus fromCode(String code) { + return Arrays.stream(values()).filter(value -> value.name().equals(code)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported warehouse transfer status: " + code)); + } +} 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 e34173b70..2d5cec2a7 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 @@ -46,6 +46,8 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; import com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem; import com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto; +import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway; import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper; @@ -56,6 +58,7 @@ import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; @@ -64,6 +67,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -85,6 +89,7 @@ import java.util.stream.Collectors; @Service public class WarehouseTransferService { public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER"; + public static final String DRAFT = "DRAFT"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String APPROVED = "APPROVED"; public static final String REJECTED = "REJECTED"; @@ -96,7 +101,11 @@ public class WarehouseTransferService { public static final String WAIT_PDA_RECEIVING = "WAIT_PDA_RECEIVING"; public static final String WAIT_CUSTOMER_CONFIRMATION = "WAIT_CUSTOMER_CONFIRMATION"; public static final String READY_FOR_DISPATCH = "READY_FOR_DISPATCH"; + /** PDA/customer measurement is complete, but an FBA destination is still required before dispatch. */ + public static final String WAIT_FBA_DESTINATION = "WAIT_FBA_DESTINATION"; public static final String WAIT_RELABEL = "WAIT_RELABEL"; + public static final String INBOUND_MEASURING = "INBOUND_MEASURING"; + public static final String INBOUND_COMPLETED = "INBOUND_COMPLETED"; @Resource private WarehouseTransferOrderMapper transfers; @Resource private WarehouseTransferItemMapper transferItems; @@ -105,6 +114,8 @@ public class WarehouseTransferService { @Resource private WarehouseTransferPalletMapper transferPallets; @Resource private WarehouseTransferPalletItemMapper transferPalletItems; @Resource private WarehouseTransferRelabelBindingMapper relabelBindings; + @Resource private WarehouseTransferMeasurementPhotoMapper measurementPhotos; + @Resource private TransferMeasurementPhotoFileGateway measurementPhotoFiles; @Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations; @Resource private WarehouseTransferReceiptMapper transferReceipts; @Resource private InboundSnapshotMapper inboundSnapshots; @@ -133,6 +144,9 @@ public class WarehouseTransferService { throw badRequest("Cargo item package total must equal transfer package count"); } validateRelabelRequest(request); + validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), + request.privateDestinationAddress(), request.privateDestinationPostalCode(), + request.privateDestinationContactName(), request.privateDestinationContactPhone()); String key = request.idempotencyKey().trim(); String requestHash = hash(request); WarehouseTransferOrder replay = transfers.findKey(customerId, key); @@ -179,14 +193,17 @@ public class WarehouseTransferService { transfer.setInboundOrderId(inboundOrderId); transfer.setInboundCode(inboundCode); transfer.setLastlegOrderId(order.getId()); transfer.setLastlegOrderNo(order.getOrderNo()); transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); - transfer.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); + transfer.setFbaWarehouseCode(destinationCode(request)); + applyDestination(transfer, request); transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount()); + transfer.setPickupContactName(trim(request.pickupContactName())); + transfer.setPickupContactPhone(trim(request.pickupContactPhone())); transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); transfer.setEstimatedShipAt(request.estimatedShipAt()); transfer.setFbaShipmentNo(trim(request.fbaShipmentNo())); transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null); transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark())); - transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW); + transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(DRAFT); transfer.setLabelDeadline(request.labelDeadline()); transfer.setRelabelContactName(trim(request.relabelContactName())); transfer.setRelabelContactPhone(trim(request.relabelContactPhone())); @@ -224,23 +241,171 @@ public class WarehouseTransferService { } insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); } - insertOrderHistory(customerId, order.getId(), null, PENDING_REVIEW, transferNo, - "Warehouse transfer submitted for review", now); + insertOrderHistory(customerId, order.getId(), null, DRAFT, transferNo, + "Warehouse transfer draft created", now); + return detail(customerId, transferNo); + } + + /** Platform-created transfers keep the existing create-and-submit behavior. */ + @Transactional + public WarehouseTransferResponses.Detail createAndSubmit(String customerId, String operatorId, + WarehouseTransferRequests.Create request) { + WarehouseTransferResponses.Detail draft = create(customerId, operatorId, request); + return submit(customerId, draft.transferNo(), operatorId); + } + + @Transactional + public WarehouseTransferResponses.Detail updateDraft(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Create request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!DRAFT.equals(transfer.getStatus())) { + throw conflict("Only a draft warehouse transfer order can be edited"); + } + int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum(); + if (itemPackages != request.packageCount()) { + throw badRequest("Cargo item package total must equal transfer package count"); + } + validateRelabelRequest(request); + validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), + request.privateDestinationAddress(), request.privateDestinationPostalCode(), + request.privateDestinationContactName(), request.privateDestinationContactPhone()); + + LastlegOrder order = requireTailOrder(customerId, transfer); + LocalDateTime now = LocalDateTime.now(); + String requestHash = hash(request); + transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); + transfer.setFbaWarehouseCode(destinationCode(request)); + applyDestination(transfer, request); + transfer.setDeliveryAddress(request.deliveryAddress().trim()); + transfer.setPackageCount(request.packageCount()); + transfer.setPickupContactName(trim(request.pickupContactName())); + transfer.setPickupContactPhone(trim(request.pickupContactPhone())); + transfer.setDeliveryMethod(request.deliveryMethod().trim()); + transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); + transfer.setEstimatedShipAt(request.estimatedShipAt()); + transfer.setFbaShipmentNo(trim(request.fbaShipmentNo())); + transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) + ? trim(request.trackingNo()) : null); + transfer.setFbaBoxNos(trim(request.fbaBoxNos())); + transfer.setRemark(trim(request.remark())); + transfer.setNeedRelabel(request.needRelabel()); + transfer.setLabelDeadline(request.labelDeadline()); + transfer.setRelabelContactName(trim(request.relabelContactName())); + transfer.setRelabelContactPhone(trim(request.relabelContactPhone())); + transfer.setRelabelChargeAmount(request.relabelChargeAmount()); + transfer.setRelabelDeliveryMethod(trim(request.relabelDeliveryMethod())); + transfer.setRequestHash(requestHash); + transfer.setUpdatedAt(now); + transfers.updateById(transfer); + + order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); + order.setFbaWarehouseCode(destinationCode(request)); + order.setPackageCount(request.packageCount()); + order.setVolumeCbm(request.volumeCbm()); + order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); + order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus()); + order.setCustomerRemark(trim(request.remark())); + order.setSourcePayloadHash("sha256:" + requestHash); + order.setUpdatedAt(now); + orders.updateById(order); + + if (transferNo.equals(transfer.getInboundCode())) { + LastlegInboundOrder inbound = inboundOrders.selectById(transfer.getInboundOrderId()); + if (inbound == null || !customerId.equals(inbound.getCustomerId())) { + throw conflict("Linked inbound order is missing"); + } + inbound.setDestinationWarehouseId(request.operatingWarehouseId().trim()); + inbound.setPackageCount(request.packageCount()); + inbound.setVolumeCbm(request.volumeCbm()); + inbound.setPayloadHash("sha256:" + requestHash); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + + transferCartons.delete(new LambdaQueryWrapper() + .eq(WarehouseTransferCarton::getCustomerId, customerId) + .eq(WarehouseTransferCarton::getTransferOrderId, transfer.getId())); + transferItems.delete(new LambdaQueryWrapper() + .eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId())); + relabelBindings.delete(new LambdaQueryWrapper() + .eq(WarehouseTransferRelabelBinding::getCustomerId, customerId) + .eq(WarehouseTransferRelabelBinding::getTransferOrderId, transfer.getId())); + orderDetails.delete(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId) + .eq(LastlegOrderDetail::getOrderId, order.getId())); + + if (request.needRelabel()) insertRelabelBindings(customerId, transfer.getId(), request.relabelItems(), now); + int cartonIndex = 0; + for (int index = 0; index < request.items().size(); index++) { + WarehouseTransferRequests.Item source = request.items().get(index); + long itemId = insertTransferItem(customerId, transfer.getId(), source, now); + List customerMarks = splitMarks(source.shippingMark()); + for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) { + cartonIndex++; + insertTransferCarton(customerId, transfer.getId(), itemId, transferNo, cartonIndex, + request.packageCount(), itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null, now); + } + insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); + } + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail submit(String customerId, String transferNo, String operatorId) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo); + if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted"); + + LastlegOrder order = requireTailOrder(customerId, transfer); + LocalDateTime now = LocalDateTime.now(); + transfer.setStatus(PENDING_REVIEW); + transfer.setUpdatedAt(now); + transfers.updateById(transfer); + + order.setCurrentStatus(PENDING_REVIEW); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + order.getRelabelStatus()); + order.setUpdatedAt(now); + orders.updateById(order); + + LastlegInboundOrder inbound = transfer.getTransferNo().equals(transfer.getInboundCode()) + ? inboundOrders.selectById(transfer.getInboundOrderId()) : null; + if (inbound != null && customerId.equals(inbound.getCustomerId()) && DRAFT.equals(inbound.getStatus())) { + inbound.setStatus(PENDING_REVIEW); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + insertOrderHistory(customerId, order.getId(), DRAFT, PENDING_REVIEW, transferNo, + "Warehouse transfer submitted for review by " + operatorId, now); return detail(customerId, transferNo); } @Transactional(readOnly = true) public PageResult page(String customerId, WarehouseTransferRequests.Query query) { + return page(customerId, query, true); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, WarehouseTransferRequests.Query query, + boolean includeDrafts) { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw badRequest("createdFrom must not be after createdTo"); } + List groupedStatuses = customerMainStatuses(query.status()); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.transferNo()), WarehouseTransferOrder::getTransferNo, query.transferNo()) .like(StringUtils.hasText(query.inboundCode()), WarehouseTransferOrder::getInboundCode, query.inboundCode()) .eq(StringUtils.hasText(query.operatingWarehouseId()), WarehouseTransferOrder::getOperatingWarehouseId, query.operatingWarehouseId()) .like(StringUtils.hasText(query.fbaWarehouseCode()), WarehouseTransferOrder::getFbaWarehouseCode, query.fbaWarehouseCode()) - .eq(StringUtils.hasText(query.status()), WarehouseTransferOrder::getStatus, query.status()) + .in(groupedStatuses != null, WarehouseTransferOrder::getStatus, + groupedStatuses == null ? List.of() : groupedStatuses) + .eq(groupedStatuses == null && StringUtils.hasText(query.status()), + WarehouseTransferOrder::getStatus, query.status()) + .ne(!includeDrafts, WarehouseTransferOrder::getStatus, DRAFT) .eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel()) .ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) @@ -249,12 +414,77 @@ public class WarehouseTransferService { List records = result.getRecords().stream().map(value -> new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), - value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(), + value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), + value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } + static List customerMainStatuses(String status) { + if (!StringUtils.hasText(status)) return null; + return switch (status.trim()) { + case INBOUND_MEASURING -> List.of(APPROVED, WAIT_PDA_RECEIVING); + case INBOUND_COMPLETED -> List.of(WAIT_CUSTOMER_CONFIRMATION, WAIT_FBA_DESTINATION, + WAIT_RELABEL, READY_FOR_DISPATCH, "WAIT_OUTBOUND", ALLOCATED, OUTBOUNDED, + PARTIALLY_RECEIVED, RECEIVED, "COMPLETED", INBOUND_COMPLETED); + default -> null; + }; + } + + @Transactional + public List bindDestination(String operatorId, + WarehouseTransferRequests.BindDestination request) { + validateDestination(request.destinationType(), request.destinationWarehouseCode(), null, + request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), + request.privateDestinationContactPhone()); + String requestHash = hash(request); + Set selected = new HashSet<>(); + List results = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + for (WarehouseTransferRequests.DestinationSelection selection : request.transfers()) { + String customerId = selection.customerId().trim(); + String transferNo = selection.transferNo().trim(); + if (!selected.add(customerId + "\n" + transferNo)) throw badRequest("Warehouse transfer selections must be unique"); + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found: " + transferNo); + if (READY_FOR_DISPATCH.equals(transfer.getStatus()) + && request.idempotencyKey().equals(transfer.getDestinationBindingIdempotencyKey())) { + if (!requestHash.equals(transfer.getDestinationBindingRequestHash())) { + throw conflict("Destination binding idempotency key was reused with different data"); + } + results.add(new WarehouseTransferResponses.DestinationResult(customerId, transferNo)); + continue; + } + if (!WAIT_FBA_DESTINATION.equals(transfer.getStatus()) || hasValidDestination(transfer)) { + throw conflict("Only an inbound-completed transfer without a destination can be bound: " + transferNo); + } + applyDestination(transfer, request); + transfer.setStatus(READY_FOR_DISPATCH); + transfer.setDestinationBindingIdempotencyKey(request.idempotencyKey().trim()); + transfer.setDestinationBindingRequestHash(requestHash); + transfer.setUpdatedAt(now); + transfers.updateById(transfer); + + LastlegOrder order = requireTailOrder(customerId, transfer); + order.setFbaWarehouseCode("FBA".equals(transfer.getDestinationType()) ? transfer.getDestinationWarehouseCode() : null); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + READY_FOR_DISPATCH + ";RELABEL:" + + Objects.toString(order.getRelabelStatus(), "NOT_REQUIRED")); + order.setUpdatedAt(now); + orders.updateById(order); + for (LastlegOrderDetail detail : orderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, order.getId()))) { + detail.setDestinationWarehouseCode(transfer.getDestinationWarehouseCode()); + orderDetails.updateById(detail); + } + syncInboundPlanDestination(customerId, transfer); + insertOrderHistory(customerId, order.getId(), order.getCurrentStatus(), order.getCurrentStatus(), transferNo, + "Warehouse transfer destination bound by " + trim(operatorId), now); + results.add(new WarehouseTransferResponses.DestinationResult(customerId, transferNo)); + } + return results.stream().map(result -> detail(result.customerId(), result.transferNo())).toList(); + } + @Transactional(readOnly = true) public WarehouseTransferResponses.Detail detail(String customerId, String transferNo) { WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); @@ -283,11 +513,14 @@ public class WarehouseTransferService { value.getWarehousePalletId(), value.getPalletNo(), value.getWarehouseLocationId(), value.getWarehouseLocationCode(), value.getCartonCount(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getVolumeCbm(), value.getStatus(), + trim(transfer.getFbaWarehouseCode()), value.getWarehousePalletId() == null ? null + : "/api/pda/v1/last-leg/warehouse-transfers/" + transfer.getTransferNo() + + "/pallets/" + value.getWarehousePalletId() + "/label", palletItemsByPallet.getOrDefault(value.getId(), List.of()).stream().map(row -> { WarehouseTransferResponses.Item item = itemById.get(row.getTransferItemId()); return new WarehouseTransferResponses.PalletItem(row.getTransferItemId(), item == null ? null : item.skuCode(), row.getPackageCount()); - }).toList(), value.getCompletedAt())).toList(); + }).toList(), value.getBoundAt(), value.getCompletedAt())).toList(); List bindings = relabelBindings == null ? List.of() : relabelBindings.listOrder(customerId, transfer.getId()) .stream().map(value -> new WarehouseTransferResponses.RelabelBinding(value.getOperationType(), value.getCustomerCartonMark(), value.getOriginalSku(), value.getNewSku(), value.getQuantity(), @@ -307,6 +540,10 @@ public class WarehouseTransferService { value.getInboundCode(), value.getPalletId(), value.getPalletNo(), value.getPackageCount(), value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId()); }).toList(); + List photos = measurementPhotos.listSubmitted(customerId, transfer.getId()) + .stream().map(value -> new WarehouseTransferResponses.MeasurementPhoto(value.getFileId(), value.getFileName(), + value.getContentType(), value.getSizeBytes() == null ? 0 : value.getSizeBytes(), value.getUploadedAt())) + .toList(); String relabelStatus = null; if (transfer.getRelabelNo() != null) { RelabelApplication relabel = relabelApplications.findNo(customerId, transfer.getRelabelNo()); @@ -321,10 +558,18 @@ public class WarehouseTransferService { Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(), relabelStatus, transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(), - transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), + transfer.getDestinationType(), transfer.getDestinationWarehouseId(), transfer.getDestinationWarehouseCode(), + transfer.getDestinationWarehouseName(), transfer.getPrivateDestinationAddress(), + transfer.getPrivateDestinationPostalCode(), + transfer.getPrivateDestinationContactName(), transfer.getPrivateDestinationContactPhone(), + transfer.getPickupContactName(), transfer.getPickupContactPhone(), + transfer.getPackageCount(), tailOrder == null ? null : tailOrder.getVolumeCbm(), + transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getEstimatedShipAt(), transfer.getFbaShipmentNo(), transfer.getTrackingNo(), transfer.getFbaBoxNos(), - transfer.getRemark(), transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(), - transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, + transfer.getRemark(), transfer.getLabelDeadline(), transfer.getRelabelContactName(), + transfer.getRelabelContactPhone(), transfer.getRelabelDeliveryMethod(), + transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(), + transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, photos, transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt()); } @@ -332,6 +577,7 @@ public class WarehouseTransferService { 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(); StringBuilder labels = new StringBuilder(); @@ -366,8 +612,14 @@ public class WarehouseTransferService { if (transfer == null) throw notFound("Warehouse transfer order not found"); if (!PENDING_REVIEW.equals(transfer.getStatus())) { if (next.equals(transfer.getStatus()) || APPROVED.equals(next) - && Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH, WAIT_RELABEL) - .contains(transfer.getStatus())) return detail(customerId, transferNo); + && Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH, + WAIT_FBA_DESTINATION, WAIT_RELABEL) + .contains(transfer.getStatus())) { + if (APPROVED.equals(next) && transfer.getTransferNo().equals(transfer.getInboundCode())) { + activateTransferInbound(customerId, transfer, LocalDateTime.now()); + } + return detail(customerId, transferNo); + } throw conflict("Warehouse transfer order was already reviewed"); } LastlegOrder order = orders.selectById(transfer.getLastlegOrderId()); @@ -383,11 +635,8 @@ public class WarehouseTransferService { transfer.setReviewedAt(now); transfer.setUpdatedAt(now); transfers.updateById(transfer); - String orderStatus = APPROVED.equals(next) - ? (incomingTransit - ? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY") - : PENDING_ASSIGNMENT) - : "CANCELLED"; + if (APPROVED.equals(next) && incomingTransit) activateTransferInbound(customerId, transfer, now); + String orderStatus = APPROVED.equals(next) ? INBOUND_MEASURING : "CANCELLED"; order.setCurrentStatus(orderStatus); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next + ";RELABEL:" + (StringUtils.hasText(order.getRelabelStatus()) ? order.getRelabelStatus() : "NOT_REQUIRED")); @@ -400,6 +649,112 @@ public class WarehouseTransferService { return detail(customerId, transferNo); } + @Transactional + public WarehouseTransferResponses.Detail savePalletDraft(long warehouseId, String transferNo, + 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()); + WarehouseTransferPallet pallet; + if (request.palletId() == null) { + pallet = existing.stream().filter(value -> request.idempotencyKey().equals(value.getDraftIdempotencyKey())) + .findFirst().orElse(null); + if (pallet != null) { + if (!requestHash.equals(pallet.getDraftRequestHash())) throw conflict("Pallet draft idempotency key was reused"); + return detail(customerId, transferNo); + } + if (existing.size() >= 100) throw badRequest("A transfer supports at most 100 pallets"); + pallet = new WarehouseTransferPallet(); + pallet.setId(IdWorker.getId()); pallet.setTransferOrderId(transfer.getId()); pallet.setCustomerId(customerId); + pallet.setSequenceNo(existing.stream().mapToInt(WarehouseTransferPallet::getSequenceNo).max().orElse(0) + 1); + pallet.setStatus("DRAFT"); pallet.setCreatedAt(LocalDateTime.now()); + } else { + pallet = transferPallets.lockOne(customerId, transfer.getId(), request.palletId()); + 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.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash); + pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet); + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail deletePalletDraft(long warehouseId, String transferNo, long palletId) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + WarehouseTransferPallet pallet = transferPallets.lockOne(transfer.getCustomerId(), transfer.getId(), palletId); + if (pallet == null) return detail(transfer.getCustomerId(), transferNo); + if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet cannot be removed"); + transferPallets.deleteById(palletId); + return detail(transfer.getCustomerId(), transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail generatePallet(long warehouseId, String transferNo, long palletId, + WarehouseTransferRequests.PalletAction request) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + WarehouseTransferPallet pallet = transferPallets.lockOne(transfer.getCustomerId(), transfer.getId(), palletId); + if (pallet == null) throw notFound("Warehouse transfer pallet draft not found"); + if (Set.of("GENERATED", "BOUND").contains(pallet.getStatus()) + && request.idempotencyKey().equals(pallet.getGenerateIdempotencyKey())) return detail(transfer.getCustomerId(), transferNo); + if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Pallet was already generated"); + String batchKey = "WAREHOUSE_TRANSFER:" + transferNo + ":PALLET:" + pallet.getId(); + var item = new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(transfer.getCustomerId()), + transferNo + ":" + pallet.getId(), transferNo, pallet.getSequenceNo(), "STANDARD", + trim(transfer.getFbaWarehouseCode()), null); + warehouseResources.ensurePalletBatch(new WarehouseResourceGateway.PalletBatchEnsure(warehouseId, batchKey, List.of(item))); + var confirmed = warehouseResources.confirmPalletBatch(new WarehouseResourceGateway.PalletBatchCommand( + warehouseId, batchKey, request.idempotencyKey() + ":CONFIRM")); + if (confirmed == null || confirmed.items() == null || confirmed.items().size() != 1) { + throw conflict("Warehouse pallet generation did not return exactly one pallet"); + } + var resource = confirmed.items().get(0); + var location = warehouseResources.resolveLocation(warehouseId, resource.locationId()); + pallet.setWarehousePalletId(resource.palletId()); pallet.setPalletNo(resource.palletCode()); + pallet.setWarehouseLocationId(resource.locationId()); pallet.setWarehouseLocationCode(location.locationCode()); + pallet.setWarehouseResourceLockId(resource.resourceLockId()); pallet.setGenerateIdempotencyKey(request.idempotencyKey()); + pallet.setStatus("GENERATED"); pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.updateById(pallet); + return detail(transfer.getCustomerId(), transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail bindPallet(long warehouseId, String transferNo, long palletId, + WarehouseTransferRequests.PalletBind request) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + WarehouseTransferPallet pallet = transferPallets.lockOne(transfer.getCustomerId(), transfer.getId(), palletId); + if (pallet == null) throw notFound("Warehouse transfer pallet not found"); + if ("BOUND".equals(pallet.getStatus()) && request.idempotencyKey().equals(pallet.getBindIdempotencyKey()) + && pallet.getPalletNo().equals(request.palletCode().trim())) return detail(transfer.getCustomerId(), transferNo); + if (!"GENERATED".equals(pallet.getStatus())) throw conflict("Only a generated pallet can be scan-bound"); + if (!pallet.getPalletNo().equals(request.palletCode().trim())) throw conflict("Scanned pallet code does not match this generated pallet"); + var physical = warehouseResources.resolvePallet(request.palletCode().trim()); + if (physical.palletId() != pallet.getWarehousePalletId() || physical.warehouseId() != warehouseId) { + throw conflict("Scanned pallet does not belong to this transfer and warehouse"); + } + pallet.setStatus("BOUND"); pallet.setBindIdempotencyKey(request.idempotencyKey()); + pallet.setBoundAt(LocalDateTime.now()); pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.updateById(pallet); + return detail(transfer.getCustomerId(), transferNo); + } + + 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"); + if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("Warehouse transfer is not awaiting PDA receiving"); + return transfer; + } + + private void validatePalletMeasurement(BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal volume) { + BigDecimal calculated = length.multiply(width).multiply(height) + .divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP); + if (calculated.subtract(volume).abs().compareTo(new BigDecimal("0.000001")) > 0) { + throw badRequest("Pallet volume must match measured length, width and height"); + } + } + @Transactional public WarehouseTransferResponses.Detail palletize(String operatorId, long overseasWarehouseId, String transferNo, @@ -424,77 +779,53 @@ public class WarehouseTransferService { List items = transferItems.selectList(new LambdaQueryWrapper() .eq(WarehouseTransferItem::getCustomerId, customerId) .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)); - Map itemById = items.stream() - .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); - Map totals = new java.util.HashMap<>(); - int palletCartons = 0; - for (WarehouseTransferRequests.Pallet pallet : request.pallets()) { - int itemTotal = pallet.items().stream().mapToInt(WarehouseTransferRequests.PalletItem::packageCount).sum(); - if (itemTotal != pallet.cartonCount()) throw badRequest("Each pallet carton count must equal its SKU carton total"); - palletCartons = Math.addExact(palletCartons, pallet.cartonCount()); - Set uniqueItems = new HashSet<>(); - for (WarehouseTransferRequests.PalletItem item : pallet.items()) { - if (!uniqueItems.add(item.transferItemId()) || !itemById.containsKey(item.transferItemId())) { - throw badRequest("Pallet SKU rows must be unique and belong to this transfer"); - } - totals.merge(item.transferItemId(), item.packageCount(), Math::addExact); - } + if (items.isEmpty() || items.stream().mapToInt(WarehouseTransferItem::getPackageCount).sum() != transfer.getPackageCount()) { + throw conflict("Warehouse transfer declared cargo is incomplete"); } - if (palletCartons != transfer.getPackageCount()) throw badRequest("Pallet carton total must equal transfer package count"); - for (WarehouseTransferItem item : items) { - if (totals.getOrDefault(item.getId(), 0) != item.getPackageCount()) { - throw badRequest("Palletized carton total does not match declared SKU " + item.getSkuCode()); - } + if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) { + throw conflict("Measurement and palletization require 1 to 20 on-site photos"); } - - String batchKey = "WAREHOUSE_TRANSFER:" + transferNo + ":PALLETIZE"; - List batchItems = new ArrayList<>(); - for (int index = 0; index < request.pallets().size(); index++) { - WarehouseTransferRequests.Pallet pallet = request.pallets().get(index); - batchItems.add(new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(customerId), - transferNo + ":" + (index + 1), transferNo, index + 1, "STANDARD", - trim(transfer.getFbaWarehouseCode()), trim(pallet.palletCode()))); + Set photoIds = new java.util.LinkedHashSet<>(request.photoFileIds()); + if (photoIds.size() != request.photoFileIds().size()) throw badRequest("Measurement photo IDs must be unique"); + for (Long fileId : photoIds) { + WarehouseTransferMeasurementPhoto photo = measurementPhotos.findFile(customerId, transfer.getId(), fileId); + if (photo == null) throw badRequest("Measurement photo does not belong to this transfer: " + fileId); + measurementPhotoFiles.validate(customerId, transfer.getId(), fileId); } - WarehouseResourceGateway.PalletBatchResponse ensured = warehouseResources.ensurePalletBatch( - new WarehouseResourceGateway.PalletBatchEnsure(overseasWarehouseId, batchKey, batchItems)); - WarehouseResourceGateway.PalletBatchResponse confirmed = warehouseResources.confirmPalletBatch( - new WarehouseResourceGateway.PalletBatchCommand(overseasWarehouseId, batchKey, - request.idempotencyKey() + ":CONFIRM")); - if (confirmed == null || confirmed.items() == null || confirmed.items().size() != request.pallets().size()) { - throw conflict("Warehouse pallet batch did not return every pallet"); + List persistedPallets = transferPallets.listOrder(customerId, transfer.getId()); + if (persistedPallets.isEmpty() || persistedPallets.stream().anyMatch(value -> !"BOUND".equals(value.getStatus()))) { + throw conflict("Every pallet must be generated and scan-bound before completing palletization"); } + int palletCartons = persistedPallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum(); + List derivedAllocations = derivePersistedPalletAllocations(items, persistedPallets); LocalDateTime now = LocalDateTime.now(); - for (int index = 0; index < request.pallets().size(); index++) { - int sequenceNo = index + 1; - WarehouseTransferRequests.Pallet source = request.pallets().get(index); - WarehouseResourceGateway.PalletBatchItem resource = confirmed.items().stream() - .filter(value -> value.sequenceNo() == sequenceNo).findFirst() - .orElseThrow(() -> conflict("Warehouse pallet sequence is incomplete")); - WarehouseResourceGateway.WarehouseLocationReference location = warehouseResources.resolveLocation( - overseasWarehouseId, resource.locationId()); - WarehouseTransferPallet pallet = new WarehouseTransferPallet(); - pallet.setId(IdWorker.getId()); pallet.setTransferOrderId(transfer.getId()); pallet.setCustomerId(customerId); - pallet.setSequenceNo(sequenceNo); pallet.setWarehousePalletId(resource.palletId()); - pallet.setPalletNo(resource.palletCode()); pallet.setWarehouseLocationId(resource.locationId()); - pallet.setWarehouseLocationCode(location.locationCode()); pallet.setWarehouseResourceLockId(resource.resourceLockId()); - // 转运测量以打托后的托盘为单位,体积由 PDA 直接录入托盘总体积,不再按单箱尺寸换算。 - // 旧表字段仍为 NOT NULL;按托模式不采集长宽高,以 0 兼容存储且不得参与总体积计算。 - pallet.setLengthCm(BigDecimal.ZERO); pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO); - pallet.setCartonCount(source.cartonCount()); pallet.setGrossWeightKg(source.grossWeightKg()); - pallet.setVolumeCbm(source.volumeCbm().setScale(6, RoundingMode.HALF_UP)); - pallet.setStatus("COMPLETED"); pallet.setCompletedAt(now); pallet.setCreatedAt(now); pallet.setUpdatedAt(now); - transferPallets.insert(pallet); - for (WarehouseTransferRequests.PalletItem sourceItem : source.items()) { + List completedPalletItems = new ArrayList<>(); + for (int index = 0; index < persistedPallets.size(); index++) { + WarehouseTransferPallet pallet = persistedPallets.get(index); + for (DerivedPalletAllocation allocation : derivedAllocations) { + if (allocation.palletIndex() != index) continue; WarehouseTransferPalletItem row = new WarehouseTransferPalletItem(); row.setId(IdWorker.getId()); row.setTransferOrderId(transfer.getId()); row.setTransferPalletId(pallet.getId()); - row.setTransferItemId(sourceItem.transferItemId()); row.setCustomerId(customerId); - row.setPackageCount(sourceItem.packageCount()); row.setCreatedAt(now); transferPalletItems.insert(row); + row.setTransferItemId(allocation.transferItemId()); row.setCustomerId(customerId); + row.setPackageCount(allocation.packageCount()); row.setCreatedAt(now); transferPalletItems.insert(row); + completedPalletItems.add(row); } + pallet.setStatus("COMPLETED"); pallet.setCompletedAt(now); pallet.setUpdatedAt(now); transferPallets.updateById(pallet); } + if (measurementPhotos.markSubmitted(customerId, transfer.getId(), photoIds, now) != photoIds.size()) { + throw conflict("Measurement photo submission is incomplete"); + } + WarehouseResourceGateway.WarehouseReference warehouse = warehouseResources.requireActiveSharedWarehouse( + Long.parseLong(transfer.getOperatingWarehouseId())); + stockInMeasuredPallets(customerId, operatorId, transfer, items, persistedPallets, + completedPalletItems, warehouse, now); + completeTransferInboundSnapshot(customerId, transfer, persistedPallets, warehouse, now); + completeTransferInboundPlan(customerId, transfer, persistedPallets, completedPalletItems, now); transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim()); transfer.setPalletizeRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); - order.setCurrentStatus(WAIT_CUSTOMER_CONFIRMATION); order.setPalletCount(request.pallets().size()); + order.setCurrentStatus(INBOUND_COMPLETED); order.setPalletCount(persistedPallets.size()); + order.setReceivedPackageCount(palletCartons); order.setVolumeCbm(transferPallets.listOrder(customerId, transfer.getId()).stream() .map(WarehouseTransferPallet::getVolumeCbm).reduce(BigDecimal.ZERO, BigDecimal::add)); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + WAIT_CUSTOMER_CONFIRMATION); order.setUpdatedAt(now); orders.updateById(order); @@ -503,6 +834,18 @@ public class WarehouseTransferService { return detail(customerId, transferNo); } + @Transactional(readOnly = true) + public WarehouseResourceGateway.PrintableFile printPdaPalletLabel(long warehouseId, String transferNo, + long warehousePalletId, + String requestId) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + boolean owned = transferPallets.listOrder(transfer.getCustomerId(), transfer.getId()).stream() + .anyMatch(value -> java.util.Objects.equals(value.getWarehousePalletId(), warehousePalletId)); + if (!owned) throw notFound("Warehouse transfer pallet not found"); + return warehouseResources.printPalletLabel(warehouseId, warehousePalletId, transferNo, requestId); + } + @Transactional public WarehouseTransferResponses.Detail confirmMeasurement(String customerId, String transferNo, String operatorId, @@ -510,7 +853,7 @@ public class WarehouseTransferService { WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); String requestHash = hash(request); - if (Set.of(READY_FOR_DISPATCH, WAIT_RELABEL, OUTBOUNDED).contains(transfer.getStatus()) + if (Set.of(READY_FOR_DISPATCH, WAIT_FBA_DESTINATION, WAIT_RELABEL, OUTBOUNDED).contains(transfer.getStatus()) && request.idempotencyKey().equals(transfer.getConfirmationIdempotencyKey())) { if (!requestHash.equals(transfer.getConfirmationRequestHash())) { throw conflict("Confirmation idempotency key was reused with different data"); @@ -527,64 +870,130 @@ public class WarehouseTransferService { .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); List pallets = transferPallets.listOrder(customerId, transfer.getId()); List palletItems = transferPalletItems.listOrder(customerId, transfer.getId()); - WarehouseResourceGateway.WarehouseReference warehouse = warehouseResources.requireActiveSharedWarehouse( - Long.parseLong(transfer.getOperatingWarehouseId())); - Map> stockSourcesBySku = new java.util.LinkedHashMap<>(); + Map> stockSourcesBySku = confirmedStockSources( + itemById, stockReceipts.listTask(customerId, transfer.getId())); LocalDateTime now = LocalDateTime.now(); - for (WarehouseTransferPallet pallet : pallets) { - for (WarehouseTransferPalletItem row : palletItems.stream() - .filter(value -> value.getTransferPalletId().equals(pallet.getId())).toList()) { - WarehouseTransferItem item = itemById.get(row.getTransferItemId()); - String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transferNo + ":" + pallet.getSequenceNo() + ":" + item.getId(); - InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, - new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId, - transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(), - null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()), - transfer.getInboundOrderId(), transferNo, transferNo, item.getSkuCode(), item.getProductNameZh(), null, - pallet.getWarehousePalletId(), pallet.getPalletNo(), transferNo, pallet.getWarehouseLocationId(), - pallet.getWarehouseLocationCode(), row.getPackageCount(), transferNo, - "WAREHOUSE_TRANSFER_CONFIRMED:" + transferNo, operationKey, trim(request.remark()))); - stockSourcesBySku.computeIfAbsent(item.getSkuCode(), ignored -> new ArrayList<>()) - .add(new ConfirmedStockSource(result.balanceId(), result.positionId(), row.getPackageCount())); - InboundStockReceipt receipt = new InboundStockReceipt(); - receipt.setId(IdWorker.getId()); receipt.setCustomerId(customerId); - // 转运收货没有独立 PDA 入仓任务,使用本业务链的稳定主键填充通用收货快照维度。 - receipt.setPlanId(transfer.getInboundOrderId()); receipt.setTaskId(transfer.getId()); - receipt.setAllocationId(row.getId()); receipt.setLineId(item.getId()); - receipt.setOrderId(transfer.getLastlegOrderId()); - receipt.setInboundCode(transferNo); receipt.setSkuCode(item.getSkuCode()); receipt.setProductNameZh(item.getProductNameZh()); - receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo()); - receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); receipt.setWarehouseLocationCode(pallet.getWarehouseLocationCode()); - receipt.setBatchNo(transferNo); receipt.setReceivedPackages(Long.valueOf(row.getPackageCount())); - receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId()); - receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now); stockReceipts.insert(receipt); - } - } if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { RelabelResponses.Detail relabel = createLinkedRelabel(customerId, operatorId, transfer, itemById, stockSourcesBySku); transfer.setRelabelApplicationId(relabel.application().id()); transfer.setRelabelNo(relabel.application().relabelNo()); transfer.setStatus(WAIT_RELABEL); } else { - if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) { - throw conflict("A destination warehouse is required before dispatch"); + if (hasValidDestination(transfer)) { + transfer.setStatus(READY_FOR_DISPATCH); + } else { + // Measurement confirmation is allowed without an FBA destination. Such + // transfers remain visible for destination completion but cannot dispatch. + transfer.setStatus(WAIT_FBA_DESTINATION); } - createDispatchPlan(customerId, transfer, items, pallets, palletItems, now); - transfer.setStatus(READY_FOR_DISPATCH); } transfer.setConfirmationIdempotencyKey(request.idempotencyKey().trim()); transfer.setConfirmationRequestHash(requestHash); transfer.setCustomerConfirmedAt(now); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); - order.setCurrentStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? WAIT_RELABEL : READY_FOR_DISPATCH); + order.setCurrentStatus(INBOUND_COMPLETED); order.setRelabelStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? "PENDING_PROCESSING" : "NOT_REQUIRED"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus()); order.setUpdatedAt(now); orders.updateById(order); - insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, - "Customer confirmed PDA measurements; actual inventory stocked in", now); + if (!java.util.Objects.equals(before, order.getCurrentStatus())) { + insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, + "Customer confirmed PDA measurements; actual inventory stocked in", now); + } return detail(customerId, transferNo); } + private void stockInMeasuredPallets(String customerId, String operatorId, WarehouseTransferOrder transfer, + List items, + List pallets, + List palletItems, + WarehouseResourceGateway.WarehouseReference warehouse, + LocalDateTime now) { + Map itemById = items.stream() + .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); + for (WarehouseTransferPallet pallet : pallets) { + for (WarehouseTransferPalletItem row : palletItems.stream() + .filter(value -> value.getTransferPalletId().equals(pallet.getId())).toList()) { + WarehouseTransferItem item = itemById.get(row.getTransferItemId()); + if (item == null) throw conflict("Pallet allocation references missing transfer cargo"); + String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transfer.getTransferNo() + ":" + + pallet.getSequenceNo() + ":" + item.getId(); + InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, + new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId, + transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(), + null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()), + transfer.getInboundOrderId(), transfer.getTransferNo(), transfer.getTransferNo(), + item.getSkuCode(), item.getProductNameZh(), null, + pallet.getWarehousePalletId(), pallet.getPalletNo(), transfer.getTransferNo(), + pallet.getWarehouseLocationId(), pallet.getWarehouseLocationCode(), row.getPackageCount(), + transfer.getTransferNo(), "WAREHOUSE_TRANSFER_PDA_COMPLETED:" + transfer.getTransferNo(), + operationKey, null)); + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(IdWorker.getId()); receipt.setCustomerId(customerId); + // 转运收货没有独立 PDA 入仓任务,使用本业务链的稳定主键填充通用收货快照维度。 + receipt.setPlanId(transfer.getInboundOrderId()); receipt.setTaskId(transfer.getId()); + receipt.setAllocationId(row.getId()); receipt.setLineId(item.getId()); + receipt.setOrderId(transfer.getLastlegOrderId()); + receipt.setInboundCode(transfer.getTransferNo()); receipt.setSkuCode(item.getSkuCode()); + receipt.setProductNameZh(item.getProductNameZh()); + receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo()); + receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); + receipt.setWarehouseLocationCode(pallet.getWarehouseLocationCode()); + receipt.setBatchNo(transfer.getTransferNo()); receipt.setReceivedPackages(Long.valueOf(row.getPackageCount())); + receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId()); + receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now); + stockReceipts.insert(receipt); + } + } + } + + private void completeTransferInboundSnapshot(String customerId, WarehouseTransferOrder transfer, + List pallets, + WarehouseResourceGateway.WarehouseReference warehouse, + LocalDateTime now) { + if (!transfer.getTransferNo().equals(transfer.getInboundCode())) return; + LastlegInboundOrder inbound = inboundOrders.selectById(transfer.getInboundOrderId()); + if (inbound == null || !customerId.equals(inbound.getCustomerId())) { + throw conflict("Linked inbound order is missing"); + } + inbound.setStatus("RECEIVED"); + inbound.setDestinationWarehouseCode(warehouse.warehouseCode()); + inbound.setDestinationWarehouseName(warehouse.warehouseName()); + inbound.setPackageCount(pallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum()); + inbound.setPalletCount(pallets.size()); + inbound.setGrossWeightKg(pallets.stream().map(WarehouseTransferPallet::getGrossWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + inbound.setVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + + private void activateTransferInbound(String customerId, WarehouseTransferOrder transfer, LocalDateTime now) { + LastlegInboundOrder inbound = inboundOrders.selectById(transfer.getInboundOrderId()); + if (inbound == null || !customerId.equals(inbound.getCustomerId())) { + throw conflict("Linked inbound order is missing"); + } + if (!Set.of(RECEIVED, INBOUND_COMPLETED, "COMPLETED").contains(inbound.getStatus())) { + inbound.setStatus("PENDING_INBOUND"); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + ensureTransferInboundPlan(customerId, transfer, now); + } + + private static Map> confirmedStockSources( + Map itemById, List receipts) { + Map> result = new java.util.LinkedHashMap<>(); + for (InboundStockReceipt receipt : receipts) { + WarehouseTransferItem item = itemById.get(receipt.getLineId()); + if (item == null) continue; + result.computeIfAbsent(item.getSkuCode(), ignored -> new ArrayList<>()) + .add(new ConfirmedStockSource(receipt.getInventoryBalanceId(), receipt.getInventoryPositionId(), + Math.toIntExact(receipt.getReceivedPackages()))); + } + return result; + } + @Transactional public WarehouseTransferResponses.Detail allocate(String customerId, String transferNo, String operatorId, WarehouseTransferRequests.Allocate request) { @@ -680,6 +1089,9 @@ public class WarehouseTransferService { if (!ALLOCATED.equals(transfer.getStatus())) { throw conflict("Warehouse transfer inventory must be allocated before outbound"); } + if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) { + throw conflict("A destination warehouse is required before outbound"); + } List allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId()); if (allocations.isEmpty() || allocations.stream().mapToLong(WarehouseTransferInventoryAllocation::getPackageCount).sum() != transfer.getPackageCount()) { @@ -786,11 +1198,262 @@ public class WarehouseTransferService { return transfers.pdaCandidates(String.valueOf(overseasWarehouseId)).stream().map(value -> new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), - value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(), + value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), + value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); } + @Transactional(readOnly = true) + public WarehouseTransferResponses.Detail pdaDetail(long overseasWarehouseId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(overseasWarehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + return detail(transfer.getCustomerId(), transferNo); + } + + @Transactional + public WarehouseTransferResponses.MeasurementPhoto uploadMeasurementPhoto(String operatorId, long operatorUserId, + long overseasWarehouseId, + String transferNo, MultipartFile file) { + WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) { + throw conflict("Measurement photos can only be uploaded before palletization completes"); + } + if (file == null || file.isEmpty()) throw badRequest("Measurement photo is required"); + if (file.getSize() > 10L * 1024 * 1024) throw badRequest("Measurement photo exceeds 10 MB"); + if (file.getContentType() == null || !file.getContentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) { + throw badRequest("Measurement photo must be an image"); + } + if (measurementPhotos.listOrder(transfer.getCustomerId(), transfer.getId()).size() >= 20) { + throw badRequest("A warehouse transfer supports at most 20 measurement photos"); + } + TransferMeasurementPhotoFileGateway.UploadedFile uploaded = measurementPhotoFiles.upload( + transfer.getCustomerId(), transfer.getId(), operatorUserId, file); + LocalDateTime now = LocalDateTime.now(); + WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto(); + photo.setId(IdWorker.getId()); photo.setTransferOrderId(transfer.getId()); + photo.setCustomerId(transfer.getCustomerId()); photo.setFileId(uploaded.fileId()); + photo.setFileName(uploaded.fileName()); photo.setContentType(uploaded.contentType()); + photo.setSizeBytes(uploaded.sizeBytes()); photo.setUploadedBy(operatorId); photo.setUploadedAt(now); + measurementPhotos.insert(photo); + return new WarehouseTransferResponses.MeasurementPhoto(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes(), now); + } + + @Transactional(readOnly = true) + public List measurementPhotos(String customerId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + return measurementPhotos.listSubmitted(customerId, transfer.getId()).stream() + .map(value -> new WarehouseTransferResponses.MeasurementPhoto(value.getFileId(), value.getFileName(), + value.getContentType(), value.getSizeBytes() == null ? 0 : value.getSizeBytes(), value.getUploadedAt())) + .toList(); + } + + @Transactional(readOnly = true) + public List pdaMeasurementPhotos(long warehouseId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + return measurementPhotos.listOrder(transfer.getCustomerId(), transfer.getId()).stream() + .map(value -> new WarehouseTransferResponses.MeasurementPhoto(value.getFileId(), value.getFileName(), + value.getContentType(), value.getSizeBytes() == null ? 0 : value.getSizeBytes(), value.getUploadedAt())) + .toList(); + } + + @Transactional(readOnly = true) + public TransferMeasurementPhotoFileGateway.DownloadedFile downloadMeasurementPhoto(String customerId, + String transferNo, + long fileId) { + WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (measurementPhotos.findSubmittedFile(customerId, transfer.getId(), fileId) == null) { + throw notFound("Measurement photo not found"); + } + return measurementPhotoFiles.download(customerId, transfer.getId(), fileId); + } + + @Transactional(readOnly = true) + public TransferMeasurementPhotoFileGateway.DownloadedFile downloadPdaMeasurementPhoto(long warehouseId, + String transferNo, + long fileId) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (measurementPhotos.findFile(transfer.getCustomerId(), transfer.getId(), fileId) == null) { + throw notFound("Measurement photo not found"); + } + return measurementPhotoFiles.download(transfer.getCustomerId(), transfer.getId(), fileId); + } + + static List derivePalletAllocations(List items, + List pallets) { + if (items == null || items.isEmpty()) throw conflict("Warehouse transfer declared cargo is incomplete"); + 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).cartonCount(); + 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(); + } + } + } + return result; + } + + static List derivePersistedPalletAllocations(List items, + List pallets) { + if (items == null || items.isEmpty()) throw conflict("Warehouse transfer declared cargo is incomplete"); + 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(); + } + } + } + return result; + } + + record DerivedPalletAllocation(int palletIndex, long transferItemId, int packageCount) {} + + static List newTransferPalletRequests( + long customerId, String transferNo, String fbaWarehouseCode, int palletCount) { + List result = new ArrayList<>(); + for (int index = 0; index < palletCount; index++) { + result.add(new WarehouseResourceGateway.PalletBatchItemRequest(customerId, + transferNo + ":" + (index + 1), transferNo, index + 1, "STANDARD", + trim(fbaWarehouseCode), null)); + } + return result; + } + + private static void validateDestination(String destinationType, String destinationWarehouseCode, + String legacyFbaWarehouseCode, String privateAddress, String privatePostalCode, + String privateContactName, String privateContactPhone) { + String type = trim(destinationType); + if (type == null && StringUtils.hasText(legacyFbaWarehouseCode)) type = "FBA"; + if (type == null || "NONE".equalsIgnoreCase(type)) return; + type = type.toUpperCase(java.util.Locale.ROOT); + if (!Set.of("FBA", "PRIVATE").contains(type)) throw badRequest("Unsupported destination type"); + if ("FBA".equals(type) && !StringUtils.hasText(firstText(destinationWarehouseCode, legacyFbaWarehouseCode))) { + throw badRequest("FBA destination warehouse is required"); + } + if ("PRIVATE".equals(type) && (!StringUtils.hasText(privateAddress) || !StringUtils.hasText(privatePostalCode) + || !StringUtils.hasText(privateContactName) || !StringUtils.hasText(privateContactPhone))) { + throw badRequest("Private warehouse address, contact name and phone are required"); + } + } + + private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) { + String type = trim(request.destinationType()); + if (type == null && StringUtils.hasText(request.fbaWarehouseCode())) type = "FBA"; + if ("NONE".equalsIgnoreCase(Objects.toString(type, ""))) type = null; + transfer.setDestinationType(type == null ? null : type.toUpperCase(java.util.Locale.ROOT)); + transfer.setDestinationWarehouseId("FBA".equals(transfer.getDestinationType()) ? trim(request.destinationWarehouseId()) : null); + transfer.setDestinationWarehouseCode("FBA".equals(transfer.getDestinationType()) ? destinationCode(request) : null); + transfer.setDestinationWarehouseName("FBA".equals(transfer.getDestinationType()) + ? firstText(request.destinationWarehouseName(), destinationCode(request)) : null); + transfer.setPrivateDestinationAddress("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationAddress()) : null); + transfer.setPrivateDestinationPostalCode("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationPostalCode()) : null); + transfer.setPrivateDestinationContactName("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationContactName()) : null); + transfer.setPrivateDestinationContactPhone("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationContactPhone()) : null); + transfer.setFbaWarehouseCode("FBA".equals(transfer.getDestinationType()) ? transfer.getDestinationWarehouseCode() : null); + } + + private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.BindDestination request) { + String type = request.destinationType().trim().toUpperCase(java.util.Locale.ROOT); + transfer.setDestinationType(type); + transfer.setDestinationWarehouseId("FBA".equals(type) ? trim(request.destinationWarehouseId()) : null); + transfer.setDestinationWarehouseCode("FBA".equals(type) ? trim(request.destinationWarehouseCode()) : null); + transfer.setDestinationWarehouseName("FBA".equals(type) + ? firstText(request.destinationWarehouseName(), request.destinationWarehouseCode()) : null); + transfer.setPrivateDestinationAddress("PRIVATE".equals(type) ? trim(request.privateDestinationAddress()) : null); + transfer.setPrivateDestinationPostalCode("PRIVATE".equals(type) ? trim(request.privateDestinationPostalCode()) : null); + transfer.setPrivateDestinationContactName("PRIVATE".equals(type) ? trim(request.privateDestinationContactName()) : null); + transfer.setPrivateDestinationContactPhone("PRIVATE".equals(type) ? trim(request.privateDestinationContactPhone()) : null); + transfer.setFbaWarehouseCode("FBA".equals(type) ? transfer.getDestinationWarehouseCode() : null); + } + + private static boolean hasValidDestination(WarehouseTransferOrder transfer) { + if ("FBA".equals(transfer.getDestinationType())) return StringUtils.hasText(transfer.getDestinationWarehouseCode()); + if ("PRIVATE".equals(transfer.getDestinationType())) { + return StringUtils.hasText(transfer.getPrivateDestinationAddress()) + && StringUtils.hasText(transfer.getPrivateDestinationPostalCode()) + && StringUtils.hasText(transfer.getPrivateDestinationContactName()) + && StringUtils.hasText(transfer.getPrivateDestinationContactPhone()); + } + return StringUtils.hasText(transfer.getFbaWarehouseCode()); + } + + private static String destinationCode(WarehouseTransferRequests.Create request) { + return firstText(request.destinationWarehouseCode(), request.fbaWarehouseCode()); + } + + private static void copyDestination(WarehouseTransferOrder transfer, InboundPlanLine line) { + String type = hasValidDestination(transfer) ? Objects.toString(transfer.getDestinationType(), "FBA") : null; + line.setDestinationType(type); + line.setFbaWarehouseCode("FBA".equals(type) ? firstText(transfer.getDestinationWarehouseCode(), transfer.getFbaWarehouseCode()) : null); + line.setDestinationWarehouseId(transfer.getDestinationWarehouseId()); + line.setDestinationWarehouseCode(transfer.getDestinationWarehouseCode()); + line.setDestinationWarehouseName("PRIVATE".equals(type) ? transfer.getPrivateDestinationAddress() : transfer.getDestinationWarehouseName()); + line.setDeliveryAddress(transfer.getPrivateDestinationAddress()); + line.setPostalCode(transfer.getPrivateDestinationPostalCode()); + line.setContactName(transfer.getPrivateDestinationContactName()); + line.setContactPhone(transfer.getPrivateDestinationContactPhone()); + } + + private static void copyDestination(WarehouseTransferOrder transfer, InboundPlanPallet pallet) { + String type = hasValidDestination(transfer) ? Objects.toString(transfer.getDestinationType(), "FBA") : null; + pallet.setDestinationType(type); + pallet.setDestinationWarehouseId(transfer.getDestinationWarehouseId()); + pallet.setDestinationWarehouseCode(transfer.getDestinationWarehouseCode()); + pallet.setDestinationWarehouseName("PRIVATE".equals(type) ? transfer.getPrivateDestinationAddress() : transfer.getDestinationWarehouseName()); + pallet.setDeliveryAddress(transfer.getPrivateDestinationAddress()); + pallet.setPostalCode(transfer.getPrivateDestinationPostalCode()); + pallet.setContactName(transfer.getPrivateDestinationContactName()); + pallet.setContactPhone(transfer.getPrivateDestinationContactPhone()); + } + + private void syncInboundPlanDestination(String customerId, WarehouseTransferOrder transfer) { + InboundPlan plan = inboundPlans.findBySourceOrder(customerId, transfer.getLastlegOrderId()); + if (plan == null) throw conflict("Warehouse transfer inbound plan is missing"); + for (InboundPlanLine line : inboundPlanLines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + copyDestination(transfer, line); + inboundPlanLines.updateById(line); + } + for (InboundPlanPallet pallet : inboundPlanPallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, customerId).eq(InboundPlanPallet::getPlanId, plan.getId()))) { + copyDestination(transfer, pallet); + inboundPlanPallets.updateById(pallet); + } + } + private void validateRelabelRequest(WarehouseTransferRequests.Create request) { if (!request.needRelabel()) { if (request.relabelItems() != null && !request.relabelItems().isEmpty()) { @@ -800,30 +1463,45 @@ public class WarehouseTransferService { } if (request.relabelItems() == null || request.relabelItems().isEmpty()) return; if (request.labelDeadline() == null || !StringUtils.hasText(request.relabelContactName()) - || !StringUtils.hasText(request.relabelContactPhone()) || request.relabelChargeAmount() == null) { - throw badRequest("Relabel deadline, contact and charge are required"); + || !StringUtils.hasText(request.relabelContactPhone())) { + throw badRequest("Relabel deadline and contact are required"); } - Set declaredSkus = request.items().stream().map(item -> item.skuCode().trim()).collect(Collectors.toSet()); + Set declaredSkus = request.items().stream().map(item -> trim(item.skuCode())) + .filter(Objects::nonNull).collect(Collectors.toSet()); Set marks = new HashSet<>(); + Set destinations = new HashSet<>(); for (WarehouseTransferRequests.RelabelItem row : request.relabelItems()) { String type = row.operationType().trim().toUpperCase(); if (!Set.of("CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU").contains(type)) { throw badRequest("Unsupported relabel operation type"); } - if (!declaredSkus.contains(row.originalSku().trim())) { - throw badRequest("Relabel SKU must exist in transfer cargo: " + row.originalSku()); + String originalSku = trim(row.originalSku()); + boolean changesSku = "SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type); + if (changesSku && originalSku == null) { + throw badRequest("SKU relabel requires the original SKU"); } + if (originalSku != null && !declaredSkus.contains(originalSku)) { + throw badRequest("Relabel SKU must exist in transfer cargo: " + originalSku); + } + String destination = trim(row.destinationWarehouseCode()); + if (destination == null) { + throw badRequest("Warehouse transfer relabel requires one destination warehouse for the whole order"); + } + destinations.add(destination.toUpperCase()); if (("CARTON_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) - && (!StringUtils.hasText(row.fbaCartonMark()) || !StringUtils.hasText(row.destinationWarehouseCode()))) { - throw badRequest("Carton relabel requires new FBA carton mark and destination warehouse"); + && !StringUtils.hasText(row.fbaCartonMark())) { + throw badRequest("Carton relabel requires a new FBA carton mark"); } - if (("SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) && !StringUtils.hasText(row.newSku())) { + if (changesSku && !StringUtils.hasText(row.newSku())) { throw badRequest("SKU relabel requires a new SKU"); } for (String mark : row.customerCartonMarks()) { if (!marks.add(mark.trim().toUpperCase())) throw badRequest("Customer carton marks must be unique"); } } + if (destinations.size() != 1) { + throw badRequest("Warehouse transfer relabel only supports one destination warehouse per order"); + } if (marks.size() > request.packageCount()) throw badRequest("Relabel carton marks cannot exceed transfer cartons"); } @@ -835,7 +1513,7 @@ public class WarehouseTransferService { WarehouseTransferRelabelBinding row = new WarehouseTransferRelabelBinding(); row.setId(IdWorker.getId()); row.setTransferOrderId(transferId); row.setCustomerId(customerId); row.setOperationType(source.operationType().trim().toUpperCase()); - row.setCustomerCartonMark(mark.trim()); row.setOriginalSku(source.originalSku().trim()); + row.setCustomerCartonMark(mark.trim()); row.setOriginalSku(Objects.toString(trim(source.originalSku()), "")); row.setNewSku(trim(source.newSku())); row.setQuantity(source.quantity()); row.setFbaCartonMark(trim(source.fbaCartonMark())); row.setDestinationWarehouseCode(trim(source.destinationWarehouseCode())); @@ -852,16 +1530,28 @@ public class WarehouseTransferService { .collect(Collectors.toMap(WarehouseTransferItem::getSkuCode, Function.identity(), (left, right) -> left)); List items = new ArrayList<>(); for (WarehouseTransferRelabelBinding binding : relabelBindings.listOrder(customerId, transfer.getId())) { - WarehouseTransferItem source = itemsBySku.get(binding.getOriginalSku()); - ConfirmedStockSource stockSource = nextStockSource(stockSourcesBySku.get(binding.getOriginalSku())); + String effectiveSku = trim(binding.getOriginalSku()); + WarehouseTransferItem source = effectiveSku == null ? null : itemsBySku.get(effectiveSku); + ConfirmedStockSource stockSource = effectiveSku == null ? null : nextStockSource(stockSourcesBySku.get(effectiveSku)); + if (effectiveSku == null && "CARTON_LABEL".equals(binding.getOperationType())) { + for (Map.Entry> entry : stockSourcesBySku.entrySet()) { + ConfirmedStockSource candidate = nextStockSource(entry.getValue()); + if (candidate != null) { + effectiveSku = entry.getKey(); + source = itemsBySku.get(effectiveSku); + stockSource = candidate; + break; + } + } + } if (source == null || stockSource == null) { - throw conflict("Relabel binding cannot find confirmed inventory for SKU " + binding.getOriginalSku()); + throw conflict("Relabel binding cannot find confirmed transfer inventory"); } List skuItems = Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(binding.getOperationType()) - ? List.of(new RelabelRequests.SkuItem(binding.getOriginalSku(), binding.getNewSku(), + ? List.of(new RelabelRequests.SkuItem(effectiveSku, binding.getNewSku(), binding.getQuantity(), binding.getRemark())) : List.of(); items.add(new RelabelRequests.Item(stockSource.balanceId, stockSource.positionId, transfer.getInboundOrderId(), - binding.getOriginalSku(), null, null, null, source.getProductNameZh(), 1, 1, + effectiveSku, null, null, null, source.getProductNameZh(), 1, 1, StringUtils.hasText(transfer.getFbaShipmentNo()) ? transfer.getFbaShipmentNo() : transfer.getTransferNo(), transfer.getTrackingNo(), binding.getNewSku(), "仓储转运自动换标", binding.getRemark(), null, null, null, binding.getCustomerCartonMark(), binding.getFbaCartonMark(), @@ -872,7 +1562,8 @@ public class WarehouseTransferService { transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单", "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items, trim(transfer.getRelabelDeliveryMethod()), false); - return relabelService.create(customerId, operatorId, request); + return relabelService.createForExistingTailOrder(customerId, operatorId, + transfer.getLastlegOrderId(), request); } private static ConfirmedStockSource nextStockSource(List sources) { @@ -898,34 +1589,48 @@ public class WarehouseTransferService { } } - private void createDispatchPlan(String customerId, WarehouseTransferOrder transfer, - List items, List pallets, - List palletItems, LocalDateTime now) { + private InboundPlan ensureTransferInboundPlan(String customerId, WarehouseTransferOrder transfer, + LocalDateTime now) { + InboundPlan existing = inboundPlans.findBySourceOrder(customerId, transfer.getLastlegOrderId()); + if (existing != null) return existing; long planId = IdWorker.getId(); InboundPlan plan = new InboundPlan(); plan.setId(planId); plan.setPlanNo("WT-" + transfer.getTransferNo()); plan.setCustomerId(customerId); plan.setSourceOrderId(transfer.getLastlegOrderId()); plan.setContainerNo(transfer.getTransferNo()); plan.setOverseasWarehouseId(Long.parseLong(transfer.getOperatingWarehouseId())); plan.setPlannedArrivalDate(transfer.getEstimatedArrivalAt().toLocalDate()); plan.setPalletType("STANDARD"); - plan.setAssignedFlag(true); plan.setMergeRequired(false); plan.setUrgentFlag(false); - plan.setCreationMode("WAREHOUSE_TRANSFER"); plan.setPlanRemark("Customer-confirmed transfer measurements"); - plan.setStatus("COMPLETED"); plan.setLineCount(items.size()); plan.setPalletCount(pallets.size()); - plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) - .reduce(BigDecimal.ZERO, BigDecimal::add)); plan.setCreatedAt(now); plan.setUpdatedAt(now); plan.setVersion(0); + plan.setAssignedFlag(false); plan.setMergeRequired(false); plan.setUrgentFlag(false); + plan.setCreationMode("WAREHOUSE_TRANSFER"); plan.setPlanRemark("Warehouse transfer awaiting PDA receiving"); + plan.setStatus("PDA_PENDING"); plan.setLineCount(1); plan.setPalletCount(0); + plan.setTotalVolumeCbm(BigDecimal.ZERO); plan.setCreatedAt(now); plan.setUpdatedAt(now); plan.setVersion(0); inboundPlans.insert(plan); - Map lineIds = new java.util.HashMap<>(); - for (WarehouseTransferItem source : items) { - InboundPlanLine line = new InboundPlanLine(); line.setId(IdWorker.getId()); line.setCustomerId(customerId); - line.setPlanId(planId); line.setOrderId(transfer.getLastlegOrderId()); line.setInboundCode(transfer.getTransferNo()); - line.setDestinationType("FBA"); line.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); - line.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); line.setDestinationWarehouseName(transfer.getFbaWarehouseCode()); - line.setVolumeCbm(BigDecimal.ZERO); line.setRequiredPackageCount(source.getPackageCount()); - line.setPlannedPalletCount((int) palletItems.stream().filter(row -> row.getTransferItemId().equals(source.getId())).count()); - inboundPlanLines.insert(line); lineIds.put(source.getId(), line.getId()); - } + InboundPlanLine line = new InboundPlanLine(); line.setId(IdWorker.getId()); line.setCustomerId(customerId); + line.setPlanId(planId); line.setOrderId(transfer.getLastlegOrderId()); line.setInboundCode(transfer.getTransferNo()); + copyDestination(transfer, line); + line.setVolumeCbm(BigDecimal.ZERO); line.setRequiredPackageCount(transfer.getPackageCount()); + line.setPlannedPalletCount(0); inboundPlanLines.insert(line); + return plan; + } + + private void completeTransferInboundPlan(String customerId, WarehouseTransferOrder transfer, + List pallets, + List palletItems, + LocalDateTime now) { + if (!transfer.getTransferNo().equals(transfer.getInboundCode())) return; + InboundPlan plan = ensureTransferInboundPlan(customerId, transfer, now); + if ("COMPLETED".equals(plan.getStatus())) return; + InboundPlanLine line = inboundPlanLines.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) + .eq(InboundPlanLine::getOrderId, transfer.getLastlegOrderId())); + if (line == null) throw conflict("Warehouse transfer inbound plan line is missing"); + copyDestination(transfer, line); + line.setVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + line.setPlannedPalletCount(pallets.size()); + inboundPlanLines.updateById(line); for (WarehouseTransferPallet source : pallets) { InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(IdWorker.getId()); pallet.setCustomerId(customerId); - pallet.setPlanId(planId); pallet.setPalletNo(source.getPalletNo()); pallet.setPalletType("STANDARD"); - pallet.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); pallet.setDestinationWarehouseName(transfer.getFbaWarehouseCode()); + pallet.setPlanId(plan.getId()); pallet.setPalletNo(source.getPalletNo()); pallet.setPalletType("STANDARD"); + copyDestination(transfer, pallet); pallet.setAllocatedVolumeCbm(source.getVolumeCbm()); pallet.setSequenceNo(source.getSequenceNo()); pallet.setWarehousePalletId(source.getWarehousePalletId()); pallet.setWarehouseLocationId(source.getWarehouseLocationId()); pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED"); @@ -934,13 +1639,21 @@ public class WarehouseTransferService { for (WarehouseTransferPalletItem sourceItem : palletItems.stream() .filter(row -> row.getTransferPalletId().equals(source.getId())).toList()) { InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); - allocation.setCustomerId(customerId); allocation.setPlanId(planId); allocation.setLineId(lineIds.get(sourceItem.getTransferItemId())); + allocation.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()); - allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(source.getVolumeCbm()); + // PDA 已完成测量、打托并确认了本托箱数;入仓计划列表的“实际拣货数” + // 直接汇总该字段,不能再保留初始化时的 0。 + allocation.setPickedPackageCount(sourceItem.getPackageCount()); + 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) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + plan.setPlanRemark("Warehouse transfer PDA receiving completed"); plan.setUpdatedAt(now); + inboundPlans.updateById(plan); } private static List splitMarks(String value) { @@ -970,7 +1683,7 @@ public class WarehouseTransferService { inbound.setPalletCount(0); inbound.setVolumeCbm(request.volumeCbm()); inbound.setSourceEventId(sourceId); inbound.setSourceLoadingPlanId(ORDER_TYPE); inbound.setSourceLoadingPlanVersion(1L); inbound.setHandoverVersion(1L); - inbound.setPayloadHash("sha256:" + requestHash); inbound.setStatus(PENDING_REVIEW); + inbound.setPayloadHash("sha256:" + requestHash); inbound.setStatus(DRAFT); inbound.setExceptionFlag("NONE"); inbound.setCreatedAt(now); inbound.setUpdatedAt(now); inboundOrders.insert(inbound); return inbound.getId(); @@ -984,14 +1697,14 @@ public class WarehouseTransferService { order.setInboundCode(inboundCode); order.setSourceRegion("CUSTOMER"); order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId)); order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); - order.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); order.setPackageCount(request.packageCount()); + order.setFbaWarehouseCode(destinationCode(request)); order.setPackageCount(request.packageCount()); order.setVolumeCbm(request.volumeCbm()); order.setContainerNo(transferNo); order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0); order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); - order.setCurrentStatus(PENDING_REVIEW); + order.setCurrentStatus(DRAFT); order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); - order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + order.getRelabelStatus()); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus()); order.setCustomerRemark(trim(request.remark())); order.setSourceEventId("WAREHOUSE_TRANSFER:" + transferNo); order.setSourceLoadingPlanId(ORDER_TYPE); order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now); @@ -1003,7 +1716,8 @@ public class WarehouseTransferService { LocalDateTime now) { WarehouseTransferItem item = new WarehouseTransferItem(); item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId); - item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim()); + item.setSkuCode(Objects.toString(trim(source.skuCode()), "UNSPECIFIED")); + item.setProductNameZh(Objects.toString(trim(source.productNameZh()), "仓储转运货物")); item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now); transferItems.insert(item); return item.getId(); @@ -1025,8 +1739,8 @@ public class WarehouseTransferService { LastlegOrderDetail detail = new LastlegOrderDetail(); detail.setCustomerId(customerId); detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index); - detail.setSkuCode(source.skuCode().trim()); detail.setProductNameZh(source.productNameZh().trim()); - detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(trim(request.fbaWarehouseCode())); + detail.setSkuCode(trim(source.skuCode())); detail.setProductNameZh(trim(source.productNameZh())); + detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(destinationCode(request)); detail.setFbaTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null); detail.setShipmentNo(trim(request.fbaShipmentNo())); @@ -1059,6 +1773,7 @@ public class WarehouseTransferService { } } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first.trim() : trim(second); } private static long packages(Long value) { return value == null ? 0L : value; } private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 01e04f1c7..07e47ba18 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -76,9 +76,9 @@ baiyue: url: ${DOMESTIC_WAREHOUSE_URL:} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9205} + url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} domestic-file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9205} + url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} event-bridge: url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} firstleg: diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e92e0168c..18fd8543e 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( version INT NOT NULL DEFAULT 0 COMMENT '尾程订单命令乐观锁版本', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + received_package_count INT NULL COMMENT 'PDA实际入库箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数', shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', @@ -267,14 +268,19 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', outbound_order_id BIGINT NOT NULL COMMENT '关联出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '关联出库单号', - dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID', - dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号', + dispatch_order_id BIGINT NULL COMMENT '关联派车单ID,仓储转运直接出库时为空', + dispatch_no VARCHAR(64) NULL COMMENT '关联派车单号,仓储转运直接出库时为空', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照', - destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '本派送单目的FBA仓编码快照,私人仓为空', destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照', + destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或PRIVATE', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓派送地址', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话', delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', waybill_no VARCHAR(128) NULL COMMENT '承运运单号', @@ -318,6 +324,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name')); PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'' AFTER destination_warehouse_name, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓派送地址'' AFTER destination_type, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER delivery_address, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER contact_name')); +PREPARE delivery_private_destination_stmt FROM @ddl; EXECUTE delivery_private_destination_stmt; DEALLOCATE PREPARE delivery_private_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address')); +PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空''')); +PREPARE delivery_transfer_nullable_stmt FROM @ddl; EXECUTE delivery_transfer_nullable_stmt; DEALLOCATE PREPARE delivery_transfer_nullable_stmt; SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound'); SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination'); SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)')); @@ -599,7 +611,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是', merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是', urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是', - creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工', + creation_mode VARCHAR(32) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运', plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注', status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', @@ -648,6 +660,10 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', PRIMARY KEY (id), @@ -682,6 +698,11 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', sequence_no INT NOT NULL COMMENT '计划内托盘序号', @@ -721,8 +742,15 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required')); PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(32) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运'' AFTER urgent_flag')); PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +ALTER TABLE ll_inbound_plan MODIFY COLUMN creation_mode VARCHAR(32) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运'; +UPDATE ll_inbound_plan_pallet_allocation a +JOIN ll_inbound_plan p ON p.id=a.plan_id +SET a.picked_package_count=COALESCE(a.counted_package_count,a.planned_package_count,0) +WHERE p.creation_mode='WAREHOUSE_TRANSFER' AND p.status='COMPLETED' + AND COALESCE(a.picked_package_count,0)=0 + AND COALESCE(a.counted_package_count,a.planned_package_count,0)>0; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode')); PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; ALTER TABLE ll_inbound_plan @@ -1329,6 +1357,11 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', @@ -1346,6 +1379,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( id BIGINT NOT NULL COMMENT '主键ID', @@ -1521,7 +1555,11 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码', - delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', + destination_type VARCHAR(16) NULL COMMENT '目的类型:FBA或PRIVATE', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + private_destination_address VARCHAR(1000) NULL COMMENT '私人仓完整地址', private_destination_postal_code VARCHAR(32) NULL COMMENT '私人仓邮编', private_destination_contact_name VARCHAR(128) NULL COMMENT '私人仓联系人', + private_destination_contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话', + delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人', pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号', tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', @@ -1559,6 +1597,10 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at')); PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address')); +PREPARE transfer_pickup_contact_name_stmt FROM @ddl; EXECUTE transfer_pickup_contact_name_stmt; DEALLOCATE PREPARE transfer_pickup_contact_name_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT ''提货联系电话'' AFTER pickup_contact_name')); +PREPARE transfer_pickup_contact_phone_stmt FROM @ddl; EXECUTE transfer_pickup_contact_phone_stmt; DEALLOCATE PREPARE transfer_pickup_contact_phone_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式''')); PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''关联贴标完成时间'' AFTER relabel_no')); @@ -1567,6 +1609,20 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='review_decision'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN review_decision VARCHAR(32) NULL COMMENT ''审核决定'' AFTER status, ADD COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因'' AFTER review_decision, ADD COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID'' AFTER review_remark, ADD COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间'' AFTER reviewed_by')); PREPARE transfer_review_fact_stmt FROM @ddl; EXECUTE transfer_review_fact_stmt; DEALLOCATE PREPARE transfer_review_fact_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照'' AFTER destination_type, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN private_destination_address VARCHAR(1000) NULL COMMENT ''私人仓完整地址'' AFTER destination_warehouse_name, ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address, ADD COLUMN private_destination_contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER private_destination_postal_code, ADD COLUMN private_destination_contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER private_destination_contact_name, ADD COLUMN destination_binding_idempotency_key VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'', ADD COLUMN destination_binding_request_hash CHAR(64) NULL COMMENT ''目的地绑定请求哈希''')); +PREPARE transfer_destination_stmt FROM @ddl; EXECUTE transfer_destination_stmt; DEALLOCATE PREPARE transfer_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='delivery_address'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_line_destination_stmt FROM @ddl; EXECUTE transfer_line_destination_stmt; DEALLOCATE PREPARE transfer_line_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'', ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_pallet_destination_stmt FROM @ddl; EXECUTE transfer_pallet_destination_stmt; DEALLOCATE PREPARE transfer_pallet_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='private_destination_postal_code'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address')); +PREPARE transfer_postal_stmt FROM @ddl; EXECUTE transfer_postal_stmt; DEALLOCATE PREPARE transfer_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_line_postal_stmt FROM @ddl; EXECUTE transfer_line_postal_stmt; DEALLOCATE PREPARE transfer_line_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_pallet_postal_stmt FROM @ddl; EXECUTE transfer_pallet_postal_stmt; DEALLOCATE PREPARE transfer_pallet_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE dispatch_item_postal_stmt FROM @ddl; EXECUTE dispatch_item_postal_stmt; DEALLOCATE PREPARE dispatch_item_postal_stmt; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -1603,18 +1659,22 @@ PREPARE transfer_customer_carton_stmt FROM @ddl; EXECUTE transfer_customer_carto CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', sequence_no INT NOT NULL COMMENT '托序号', - warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', - warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', - warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', + warehouse_pallet_id BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', pallet_no VARCHAR(128) NULL COMMENT '生成后的真实托盘码', + warehouse_location_id BIGINT NULL COMMENT '生成后的真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', + warehouse_resource_lock_id BIGINT NULL COMMENT '生成后的仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', - volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '打托状态', - completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', + draft_idempotency_key VARCHAR(255) NULL COMMENT '草稿保存幂等键', draft_request_hash CHAR(64) NULL COMMENT '草稿请求摘要', + generate_idempotency_key VARCHAR(255) NULL COMMENT '新托盘生成幂等键', bind_idempotency_key VARCHAR(255) NULL COMMENT '扫码绑定幂等键', + bound_at DATETIME(3) NULL COMMENT '扫码确认绑定时间', completed_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_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) ) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_pallet' AND column_name='draft_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_pallet MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key')); +PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; EXECUTE transfer_pallet_lifecycle_stmt; DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', @@ -1625,6 +1685,18 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id) ) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo ( + id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID', + file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照', + size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at) +) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT ''随测量打托提交时间'' AFTER uploaded_at')); +PREPARE transfer_measurement_photo_submitted_stmt FROM @ddl; EXECUTE transfer_measurement_photo_submitted_stmt; DEALLOCATE PREPARE transfer_measurement_photo_submitted_stmt; + CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型', 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 16fea794d..ebfcaac98 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1310,7 +1310,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/inventory-candidates: get: summary: 查询客户可换标库存候选 - description: 权限 merchant:warehouse:relabel;仅返回当前客户在指定海外中转仓仍有可用箱数的历史入仓单和库位摘要。候选按客户 Token 隔离,不接收 customerId;inboundCode 可选精确筛选。 + description: 权限 merchant:warehouse:relabel;返回当前客户在指定海外中转仓仍有可用箱数的历史入仓库存,以及仓库合箱后生成的可用退货箱库存。候选按客户 Token 隔离,不接收 customerId;inboundCode 可按入仓码、退货单号或仓库唛头模糊筛选。 security: [{ bearerAuth: [] }] parameters: - { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } } @@ -2531,6 +2531,19 @@ components: availablePackages: { type: integer, format: int64 } version: { type: integer, format: int64 } positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } } + sourceType: { type: string, enum: [INBOUND, RETURN_CARTON] } + returnCartonId: { type: string, nullable: true, description: 退货合箱库存主键;sourceType=RETURN_CARTON 时提供 } + cartonMark: { type: string, nullable: true, description: 仓库合箱后打印并贴箱的仓库唛头 } + skuItems: + type: array + description: 退货箱内 SKU 与件数 + items: + type: object + properties: + skuCode: { type: string } + newSkuCode: { type: string, nullable: true } + quantity: { type: integer, minimum: 1 } + remark: { type: string, nullable: true } RelabelInventoryPosition: type: object properties: @@ -2559,10 +2572,10 @@ components: remark: { type: string, nullable: true, maxLength: 1000 } RelabelCreate: type: object - required: [customerOrderNo, labelDeadline, contactName, contactPhone, idempotencyKey, items] + required: [customerOrderNo, operationWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] properties: customerOrderNo: { type: string, maxLength: 128 } - overseasWarehouseId: { type: string, nullable: true, maxLength: 128, description: 统一目的海外仓;明细未填写目的仓时使用 } + operationWarehouseId: { type: string, maxLength: 128, description: 当前库存所在海外操作仓 } labelDeadline: { type: string, format: date-time } contactName: { type: string, maxLength: 128 } contactPhone: { type: string, maxLength: 64 } @@ -2576,6 +2589,10 @@ components: type: object required: [productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode, cartonMark, fbaCartonMark] properties: + sourceInventoryBalanceId: { type: string, nullable: true, description: 普通入仓库存来源;与 returnCartonId 二选一 } + sourceInventoryPositionId: { type: string, nullable: true, description: 普通入仓库存仓位;与 returnCartonId 二选一 } + sourceInboundOrderId: { type: string, nullable: true, description: 普通入仓单;与 returnCartonId 二选一 } + returnCartonId: { type: string, nullable: true, description: 仓库合箱产生的退货箱库存;与普通入仓库存字段二选一 } skuCode: { type: string, nullable: true, maxLength: 128, description: 商品 SKU(选填) } productImageFileId: { type: string, nullable: true, maxLength: 128 } productImageFileName: { type: string, nullable: true, maxLength: 255 } diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index bb904ef34..f1ae643ca 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -163,12 +163,32 @@ paths: summary: 扫码查询托盘及库存内容 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } } responses: { '200': { description: Token绑定仓托盘内容 }, '404': { description: 托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } } + /api/pda/v1/last-leg/pallet-operations/relabel-sources: + post: + summary: 查询换标原入仓单在各托盘上的库存数量 + description: 范围取Token冻结仓和换标单客户;返回库存组成而非历史箱唛定位。movablePackages不包括其他单据冻结、已分配或已拣货箱数。cartonMarks为同来源库存池候选,不代表这些箱历史上位于该托盘。 + requestBody: + required: true + content: + application/json: + schema: { type: object, required: [relabelNo], properties: { relabelNo: { type: string, maxLength: 64 } } } + responses: + '200': + description: 原入仓单托盘组成 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: { type: array, items: { $ref: '#/components/schemas/RelabelSource' } } + '409': { description: 状态、认领人或仓库不符 } /api/pda/v1/last-leg/pallet-operations/relabel-pallet-label: post: summary: 为换标拆托幂等生成并返回可打印的新托盘标签 description: 返回空托盘编码、初始版本和打印HTML;随后可将该托盘作为relabel-disassemble的target。库存移入后仍继承原库存余额对应的客户和入库单。 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/NewRelabelPallet' } } } } - responses: { '200': { description: 新托盘及可打印标签 }, '409': { description: 换标单未确认或不属于Token绑定仓 } } + responses: { '200': { description: 新托盘及可打印标签,已认领处理中无需客户确认即可创建 }, '409': { description: 状态、认领人或Token绑定仓不符 } } /api/pda/v1/last-leg/pallet-operations/assemble: post: summary: 将多个来源托盘的指定库存行组到目标托盘 @@ -182,7 +202,7 @@ paths: /api/pda/v1/last-leg/pallet-operations/relabel-disassemble: post: summary: 按换标箱唛拆托并绑定目标托盘 - description: PDA先扫描原托盘,再扫描换标单指定箱唛;服务端按换标单库存快照解析仓位,在同一事务中扣减原托盘并绑定目标托盘。目标托盘须先由仓储服务创建后扫码确认。 + description: PDA选择原入仓单库存行并扫描实际来源托盘,输入拆出箱数,核对本批cartonMarks后扫描目标托盘。传actualSource时以其数量和版本为准,同入仓余额及批次内原子调整本单冻结记账位置,再移动冻结库存和换标明细;不新增入库、不改变库存归属、不占用其他单据冻结量。每次一个来源库存行,可分批处理多个托盘。已有出库单时不允许此数量重分配。未传actualSource的箱唛模式仍要求已有来源仓位一致。 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } } responses: { '200': { description: 箱唛级拆托绑定结果 }, '409': { description: 箱唛、库存或托盘版本冲突 } } /api/pda/v1/last-leg/pallet-operations/swap: @@ -218,6 +238,27 @@ paths: parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } } responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 旧标重复、新标不符或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/skus/scan: + post: + summary: 仅扫描旧SKU完成当前箱的一条SKU换标明细 + description: itemId与skuIndex精确指定一条明细,服务端验证旧SKU并读取已批准的新SKU及件数;同旧SKU多箱由PDA选择目标,不批量隐式完成。逐条写SKU_SCAN历史并按轮次防重,全部SKU处理后完成SKU阶段,混合单仍须完成箱唛阶段。响应history包含旧新SKU、件数及索引,供提示与隐藏已处理明细。 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [itemId, skuIndex, oldSku, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + skuIndex: { type: integer, minimum: 0 } + oldSku: { type: string, maxLength: 255 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + responses: + '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } + '409': { description: 已处理、映射不符、认领人或版本冲突 } /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip: post: summary: 登记一件有原因的跳过事实 @@ -260,6 +301,107 @@ paths: parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } } responses: { '200': { description: COMPLETED或PROCESSING任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 状态或版本冲突 } } + /api/pda/v1/last-leg/warehouse-transfers/candidates: + get: + summary: 查询Token绑定仓待测量的仓储转运单 + description: 仅返回当前PDA冻结海外仓内已审核且等待测量打托的转运单;不接收customerId或warehouseId。 + responses: + '200': + description: 待测量转运单列表 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferSummaryListResponse' } } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}: + get: + summary: 查询当前仓仓储转运测量详情 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + responses: + '200': + description: 转运申报、已生成的新托盘及测量照片;items仅为服务端自动分摊结果,PDA不提交每托SKU。 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailResponse' } } } + '404': { description: 当前Token绑定仓无此转运单 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/measurement-photos/files: + get: + summary: 查询当前转运单已上传的测量现场照片 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + responses: + '200': { description: 照片元数据列表 } + post: + summary: 上传仓储转运测量现场照片 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: { file: { type: string, format: binary } } + responses: + '200': { description: 已绑定当前转运单的照片元数据 } + '404': { description: 当前Token绑定仓无此转运单 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/measurement-photos/files/{fileId}/download: + get: + summary: 鉴权下载仓储转运测量现场照片 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: + '200': { description: 照片内容, content: { 'image/*': { schema: { type: string, format: binary } } } } + '404': { description: 照片不属于当前转运单或绑定仓 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/palletize: + post: + summary: 完成仓储转运整单测量打托 + description: 只提交当前转运单照片和整单幂等键;所有托盘必须已逐托生成并扫码绑定,箱数必须核平。此接口不接收测量字段、托盘码或每托SKU,也绝不再次生成托盘。 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletize' } } } } + responses: + '200': + description: WAIT_CUSTOMER_CONFIRMATION详情 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailResponse' } } } + '409': { description: 状态、照片归属、箱数核平或幂等冲突 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/drafts: + post: + summary: 新建或更新一个转运测量托盘草稿 + description: palletId为空时创建DRAFT;传入既有DRAFT的palletId时更新。GENERATED及以后测量字段锁定,必须拒绝修改。 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletDraft' } } } } + responses: { '200': { description: 包含持久化草稿的最新整单详情 }, '409': { description: 草稿已生成、状态或幂等冲突 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}: + delete: + summary: 删除未生成的转运托盘草稿 + description: 只允许删除DRAFT;GENERATED、BOUND和COMPLETED不得删除。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: { '200': { description: 删除后的整单详情 }, '409': { description: 本托已经生成真实托盘 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}/generate: + post: + summary: 为测量草稿生成一个全新实物托盘 + description: 只允许DRAFT调用一次;服务端始终向Warehouse Overseas传空palletCode,不能复用已有托盘。有FBA则写入目的仓,无FBA则标签显示“目的仓待定”。成功后实测字段锁定且可立即下载PDF标签。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletAction' } } } } + responses: { '200': { description: 包含GENERATED新托盘号和标签路径的最新详情 }, '409': { description: 已生成或资源生成冲突 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}/bind: + post: + summary: 扫描并确认本托新生成的托盘码 + description: 扫描码必须精确等于本草稿服务端生成码,同时验证Warehouse Overseas物理托盘ID和Token冻结仓一致;其他已有托盘不得冒充。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletBind' } } } } + responses: { '200': { description: 包含BOUND事实和boundAt的最新详情 }, '409': { description: 扫描码、归属、仓库或状态不匹配 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{warehousePalletId}/label: + get: + summary: 下载仓储转运实物托盘PDF标签 + description: 校验Token冻结仓、转运单和托盘归属后,代理Warehouse Overseas使用统一托盘标签打印服务生成真实PDF。已知FBA显示目的仓编码,未知FBA显示“目的仓待定”。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: warehousePalletId, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: X-Request-Id, in: header, required: false, schema: { type: string } } + responses: + '200': { description: 托盘标签PDF, content: { application/pdf: { schema: { type: string, format: binary } } } } + '404': { description: 转运单或托盘不属于当前Token绑定仓 } components: securitySchemes: bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } @@ -274,6 +416,107 @@ components: requestId: { type: string, nullable: true } traceId: { type: string, nullable: true } timestamp: { type: string, format: date-time, nullable: true } + WarehouseTransferSummaryListResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferSummary' } } + WarehouseTransferDetailResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/WarehouseTransferDetail' } } + WarehouseTransferSummary: + type: object + required: [id, transferNo, operatingWarehouseId, packageCount, deliveryMethod, needRelabel, status] + properties: + id: { type: integer, format: int64 } + transferNo: { type: string } + inboundCode: { type: string, nullable: true } + operatingWarehouseId: { type: string } + fbaWarehouseCode: { type: string, nullable: true, description: 为空表示目的仓待定 } + packageCount: { type: integer, minimum: 1 } + deliveryMethod: { type: string } + needRelabel: { type: boolean } + relabelNo: { type: string, nullable: true } + status: { type: string } + estimatedArrivalAt: { type: string, format: date-time, nullable: true } + createdAt: { type: string, format: date-time, nullable: true } + WarehouseTransferDetail: + type: object + required: [id, transferNo, operatingWarehouseId, packageCount, status, pallets, measurementPhotos] + properties: + id: { type: integer, format: int64 } + transferNo: { type: string } + operatingWarehouseId: { type: string } + fbaWarehouseCode: { type: string, nullable: true, description: 为空表示目的仓待定 } + pickupContactName: { type: string, nullable: true } + pickupContactPhone: { type: string, nullable: true } + packageCount: { type: integer, minimum: 1 } + status: { type: string } + pallets: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferPallet' } } + measurementPhotos: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferMeasurementPhoto' } } + WarehouseTransferPalletize: + type: object + required: [idempotencyKey, photoFileIds] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + photoFileIds: + type: array + minItems: 1 + maxItems: 20 + uniqueItems: true + items: { type: integer, format: int64, minimum: 1 } + WarehouseTransferPalletDraft: + type: object + required: [idempotencyKey, cartonCount, lengthCm, widthCm, heightCm, volumeCbm, grossWeightKg] + properties: + palletId: { type: integer, format: int64, nullable: true, description: 更新现有DRAFT时传入;创建时不传 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + cartonCount: { type: integer, minimum: 1 } + lengthCm: { type: number, format: double, minimum: 0.01 } + widthCm: { type: number, format: double, minimum: 0.01 } + heightCm: { type: number, format: double, minimum: 0.01 } + volumeCbm: { type: number, format: double, minimum: 0.000001 } + grossWeightKg: { type: number, format: double, minimum: 0.01 } + WarehouseTransferPalletAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + WarehouseTransferPalletBind: + type: object + required: [palletCode, idempotencyKey] + properties: + palletCode: { type: string, minLength: 1, maxLength: 128 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + WarehouseTransferPallet: + type: object + required: [id, sequenceNo, cartonCount, lengthCm, widthCm, heightCm, volumeCbm, grossWeightKg, status] + properties: + id: { type: integer, format: int64 } + sequenceNo: { type: integer, minimum: 1 } + warehousePalletId: { type: integer, format: int64, nullable: true } + palletNo: { type: string, nullable: true, description: GENERATED后由Warehouse Overseas为本次测量新生成 } + cartonCount: { type: integer, minimum: 1 } + lengthCm: { type: number, format: double } + widthCm: { type: number, format: double } + heightCm: { type: number, format: double } + volumeCbm: { type: number, format: double } + grossWeightKg: { type: number, format: double } + status: { type: string, enum: [DRAFT, GENERATED, BOUND, COMPLETED] } + fbaWarehouseCode: { type: string, nullable: true, description: 为空时标签显示目的仓待定 } + labelDownloadPath: { type: string, nullable: true, description: GENERATED后返回当前PDA鉴权下的真实PDF下载路径 } + boundAt: { type: string, format: date-time, nullable: true } + WarehouseTransferMeasurementPhoto: + type: object + required: [fileId, sizeBytes, uploadedAt] + properties: + fileId: { type: integer, format: int64 } + fileName: { type: string, nullable: true } + contentType: { type: string, nullable: true } + sizeBytes: { type: integer, format: int64, minimum: 0 } + uploadedAt: { type: string, format: date-time } StocktakeSummaryPageResponse: allOf: - { $ref: '#/components/schemas/ApiResponse' } @@ -732,7 +975,19 @@ components: source: { $ref: '#/components/schemas/PalletExpectation' } target: { $ref: '#/components/schemas/PalletExpectation' } cartonMarks: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string, maxLength: 255 } } + actualSource: { $ref: '#/components/schemas/PalletMoveLine' } reason: { type: string, maxLength: 500 } + RelabelSource: + type: object + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + positionVersion: { type: integer, format: int64 } + palletCode: { type: string } + locationCode: { type: string, nullable: true } + inboundCode: { type: string } + stockPackages: { type: integer, format: int64 } + movablePackages: { type: integer, format: int64 } + cartonMarks: { type: array, items: { type: string } } PalletSwap: type: object required: [idempotencyKey, source, target] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java index 0af56242b..d3bd1ed4e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -67,6 +67,7 @@ class DeliveryLongIdJsonContractTest { null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, null, null, null, null, null, null, null, null, null, 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null, - com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null); + com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null, + null, null, null, null, null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java index 1251a4f31..2d119917d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; class DeliveryCompletedOutboxServiceTest { @Test @@ -48,6 +49,23 @@ class DeliveryCompletedOutboxServiceTest { .containsExactly("ONT8", "ACY2"); } + @Test + void skipsRelabelApplicationWithoutDomesticSourceOrder() { + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService(); + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule())); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(1L); delivery.setDeliveryNo("DL-RELABEL"); delivery.setVersion(2L); + LastlegOrder relabel = new LastlegOrder(); + relabel.setId(21L); relabel.setOrderType("RELABEL_APPLICATION"); + relabel.setCustomerId("88"); relabel.setSourceInboundId("RL-1:WH-1"); + + service.publish(delivery, List.of(relabel), Instant.parse("2026-09-15T08:00:00Z")); + + verifyNoInteractions(mapper); + } + private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) { LastlegOrder value = new LastlegOrder(); value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index 7cb4ac1b5..1871214e0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -15,7 +15,8 @@ class DispatchCandidateMapperContractTest { "planNo", "orderType", "volumeWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt", "overseasWarehouseId", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo", "destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode", - "packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks" + "packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks", + "destinationType", "deliveryAddress", "postalCode", "contactName", "contactPhone" ); @Test @@ -37,6 +38,29 @@ class DispatchCandidateMapperContractTest { assertTrue(sql.contains("pos.available_packages > 0")); } + @Test + void candidatesWithoutFbaWarehouseAreExcludedFromReadAndWriteQueries() throws Exception { + for (Method method : List.of( + DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, + String.class, String.class, String.class), + DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + assertTrue(sql.contains("NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL")); + } + } + + @Test + void candidateKeywordSearchIncludesSourceInboundCode() throws Exception { + Method available = DispatchCandidateMapper.class.getMethod("available", String.class, List.class, + String.class, String.class, String.class, String.class); + String availableSql = String.join(" ", available.getAnnotation(Select.class).value()); + assertTrue(availableSql.contains("o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')")); + + Method relabel = DispatchCandidateMapper.class.getMethod("relabelAvailable", String.class, String.class, String.class); + String relabelSql = String.join(" ", relabel.getAnnotation(Select.class).value()); + assertTrue(relabelSql.contains("ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%')")); + } + @Test void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception { for (Method method : List.of( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index f40235374..bb869f860 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -190,6 +190,22 @@ class DispatchServiceTest { verify(orders, never()).insert(any(DispatchOrder.class)); } + @Test + void preArrangeRejectsPalletWithoutFbaWarehouse() { + DispatchPalletCandidate missingFba = new DispatchPalletCandidate(1L, 10L, 20L, 2L, + "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", "LIP1-P001", + null, null, null, null, 12, new BigDecimal("1.2000"), "RECEIVED"); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(missingFba)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("所选托盘尚未设置 FBA 仓,不能智能排车", exception.getReason()); + verify(orders, never()).insert(any(DispatchOrder.class)); + } + @Test void removingPalletFromOwnedDraftImmediatelyReleasesLock() { when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); @@ -307,6 +323,14 @@ class DispatchServiceTest { verify(candidates).available(null, null, null, null, "RECEIVED", "draft-1"); } + @Test + void relabelSourceDoesNotRequireOrQueryAnInboundPlan() { + when(candidates.relabelAvailable(null, "RL-1", null)).thenReturn(List.of()); + org.junit.jupiter.api.Assertions.assertTrue(service.platformCandidates("RL-1", null, "RELABEL", "draft-1").isEmpty()); + verify(candidates).relabelAvailable(null, "RL-1", null); + verify(candidates, never()).available(any(), any(), any(), any(), any(), any()); + } + @Test void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() { when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java index 0e3609ce5..d3e8d5b0b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -29,7 +29,8 @@ class InboundPlanSchemaContractTest { assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)")); assertTrue(schema.contains("expected_loading_at")); assertTrue(schema.contains("planned_arrival_date")); - assertTrue(schema.contains("creation_mode")); + assertTrue(schema.contains("creation_mode varchar(32)")); + assertTrue(schema.contains("modify column creation_mode varchar(32)")); assertTrue(schema.contains("pallet_type")); assertTrue(schema.contains("create table if not exists ll_order_command_record")); assertTrue(schema.contains("create table if not exists ll_order_hold_history")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java index 496ef1cc2..f074a326d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java @@ -25,7 +25,7 @@ class PdaPalletOperationControllerContractTest { .filter(PostMapping.class::isInstance).map(PostMapping.class::cast) .flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet()); assertEquals(Set.of("/pallets/resolve", "/relabel-pallet-label", "/assemble", "/disassemble", - "/relabel-disassemble", "/swap", "/unbind"), paths); + "/relabel-disassemble", "/relabel-sources", "/swap", "/unbind"), paths); } @Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index a803f1fbb..7743d3207 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -42,6 +42,17 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class PdaPalletOperationServiceTest { + @Test void relabelRebindingRejectsDispatchAndOutboundOccupation() { + RelabelApplication app = new RelabelApplication(); + for (String status : List.of("PRE_DISPATCH_CREATED", "OUTBOUND_CREATED", "OUTBOUNDED")) { + app.setFulfillmentStatus(status); + assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); + } + app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); + app.setOutboundOrderNo("OUT-1"); + assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); + } @Mock private PdaPalletOperationMapper mapper; @Mock private InventoryPositionMapper positions; @Mock private InventoryBalanceMapper balances; @@ -185,6 +196,13 @@ class PdaPalletOperationServiceTest { } @Test void relabelDisassembleMovesOnlySelectedHeldCartonWithoutRequiringLocations() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + ReflectionTestUtils.setField(service, "relabelWorkHistory", histories); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); + task.setId(900L); task.setStatus("COMPLETED"); + when(tasks.find("100", 600L)).thenReturn(task); var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null, "OCCUPIED", null, 1); var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, @@ -196,7 +214,7 @@ class PdaPalletOperationServiceTest { RelabelApplication application = new RelabelApplication(); application.setId(600L); application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND"); - application.setOverseasWarehouseId("88"); application.setOutboundOrderNo("OUT-1"); + application.setOverseasWarehouseId("88"); application.setFulfillmentStatus("WAIT_PRE_DISPATCH"); when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application); when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(application); RelabelItem selected = relabelItem(701L, "CARTON-A", 100L); @@ -266,6 +284,94 @@ class PdaPalletOperationServiceTest { relabelPalletMutations); } + @Test void actualSourceReconcilesOnlySelectedHoldAndKeepsOriginalInboundBalance() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + ReflectionTestUtils.setField(service, "relabelWorkHistory", histories); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L); task.setStatus("COMPLETED"); + when(tasks.find("100", 600L)).thenReturn(task); + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1"); + app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND"); + when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); + when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(app); + RelabelItem item = relabelItem(701L, "CARTON-A", 101L); + when(relabelItems.selectList(any())).thenReturn(List.of(item)); + when(relabelItems.selectById(701L)).thenReturn(item); + InventoryPosition anchor = position(101L, 30L, "ACCOUNTING", 2L, 3L); + InventoryPosition actual = position(100L, 20L, "SOURCE", 0L, 4L); actual.setAvailablePackages(5L); + AtomicReference target = new AtomicReference<>(); + when(positions.selectById(anyLong())).thenAnswer(invocation -> { + long id = invocation.getArgument(0); return id == 101L ? anchor : id == 100L ? actual : target.get(); + }); + doAnswer(invocation -> { target.set(invocation.getArgument(0)); return 1; }).when(positions).insert(any(InventoryPosition.class)); + when(mapper.lockPosition(eq("88"), anyLong())).thenAnswer(invocation -> { + InventoryPosition p = ((Long) invocation.getArgument(1)) == 101L ? anchor : actual; + PalletInventoryRow row = row(p.getId(), p.getPalletId(), p.getVersion(), p.getAvailablePackages()); + row.setHoldPackages(p.getHoldPackages()); return row; + }); + var src = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null, "OCCUPIED", null, 1); + var dst = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "IDLE", null, 2); + when(physical.resolve("SOURCE")).thenReturn(src); when(physical.resolve("TARGET")).thenReturn(dst); + when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any())) + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + when(mapper.content("88", 10L)).thenReturn(List.of()); + when(mapper.palletPackages("88", 20L)).thenAnswer(invocation -> actual.getAvailablePackages() + actual.getHoldPackages()); + when(mapper.palletPackages("88", 10L)).thenAnswer(invocation -> target.get() == null ? 0L : target.get().getHoldPackages()); + InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("88"); balance.setInboundCode("ORIGINAL-INBOUND"); + when(balances.selectById(200L)).thenReturn(balance); + AtomicReference op = new AtomicReference<>(); + doAnswer(invocation -> { op.set(invocation.getArgument(0)); return 1; }).when(mapper).insert(any(PdaPalletOperation.class)); + when(mapper.selectById(anyLong())).thenAnswer(invocation -> op.get()); + var result = service.relabelDisassemble(identity(), new PalletOperationRequests.RelabelDisassemble( + "qty-1", "RL-1", new PalletOperationRequests.PalletExpectation("SOURCE", 1), + new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("CARTON-A"), "quantity", + new PalletOperationRequests.MoveLine(100L, 1L, 4L))); + assertEquals("COMPLETED", result.status()); + assertEquals(1L, anchor.getHoldPackages()); // Other order's hold is not moved. + assertEquals(1L, anchor.getAvailablePackages()); + assertEquals(4L, actual.getAvailablePackages()); assertEquals(0L, actual.getHoldPackages()); + assertEquals(1L, target.get().getHoldPackages()); assertEquals(200L, target.get().getBalanceId()); + assertEquals(7L, anchor.getAvailablePackages() + anchor.getHoldPackages() + actual.getAvailablePackages() + + actual.getHoldPackages() + target.get().getHoldPackages()); + verify(relabelPalletMutations).moveSelectedItems(eq(app), argThat(values -> values.get(100L).equals(List.of(item))), anyMap()); + ArgumentCaptor entries = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers, times(4)).insert(entries.capture()); + assertTrue(entries.getAllValues().stream().allMatch(entry -> "ORIGINAL-INBOUND".equals(entry.getInboundCode()))); + verify(balances, never()).updateById(any(InventoryBalance.class)); + } + + @Test void boundMarksSurviveReloadButDoNotBlockANewReworkRound() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + ReflectionTestUtils.setField(service, "relabelWorkHistory", histories); + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L); + when(tasks.find("100", 600L)).thenReturn(task); + var history = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + history.setDetailJson("{\"round\":\"INITIAL\",\"cartonMarks\":[\"CARTON-A\"]}"); + when(histories.selectList(any())).thenReturn(List.of(history)); + assertEquals(java.util.Set.of("CARTON-A"), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app)); + task.setReviewedAt(LocalDateTime.of(2026, 9, 15, 12, 0)); + assertEquals(java.util.Set.of(), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app)); + } + + @Test void createsPalletBeforeCustomerConfirmationOnlyForClaimingOperator() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1"); + app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("PROCESSING"); + when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setStatus("PROCESSING"); task.setOperatorKey("7"); + when(tasks.find("100", 600L)).thenReturn(task); + when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm")).thenReturn(pallet(30L, "NEW", 88L, 0)); + assertEquals("NEW", service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "before-confirm")).palletCode()); + task.setOperatorKey("other"); + assertThrows(ResponseStatusException.class, () -> service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "different"))); + } + private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); } private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) { return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java index 5a0718253..b111d41d9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -61,6 +61,7 @@ class PdaRelabelServiceTest { void setUp() { service = new PdaRelabelService(); ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class)); ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "tasks", tasks); ReflectionTestUtils.setField(service, "unitFacts", unitFacts); @@ -103,7 +104,7 @@ class PdaRelabelServiceTest { PdaRelabelTask active = task("PROCESSING", 0); active.setApplicationId(99L); active.setRelabelNo("FBR-ACTIVE"); - when(tasks.lockUnfinishedByOperator("7")).thenReturn(active); + when(tasks.lockProcessingByOperator("7")).thenReturn(active); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-other"))); @@ -205,6 +206,36 @@ class PdaRelabelServiceTest { assertEquals(1, result.version()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"SKU_LABEL", "CARTON_AND_SKU"}) + void oldSkuOnlyScanPersistsEachRowAndCompletesBoxOnlyWhenRequiredStagesFinish(String type) { + application.setStatus("PROCESSING"); + item.setOperationType(type); item.setCartonMark("BOX-1"); item.setFbaCartonMark("FBA-1"); + item.setNotifiedPackageCount(1); + item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3},{\"skuCode\":\"OLD-B\",\"newSkuCode\":\"NEW-B\",\"quantity\":2}]"); + var task = task("PROCESSING", 0); + var saved = new ArrayList(); + when(histories.selectList(any())).thenAnswer(call -> List.copyOf(saved)); + when(histories.insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class))).thenAnswer(call -> { saved.add(call.getArgument(0)); return 1; }); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + lenient().when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + when(items.selectOne(any())).thenReturn(item); + var first = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 0, "sku-a")); + assertEquals(0, first.relabelledPackages()); assertEquals(1, first.version()); + assertTrue(saved.stream().anyMatch(entry -> "SKU_SCAN".equals(entry.getAction()) && entry.getDetailJson().contains("NEW-A"))); + assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 1, "duplicate"))); + assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "WRONG", 1, "wrong"))); + var second = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "OLD-B", 1, "sku-b")); + assertEquals(1, second.items().get(0).skuProcessedPackages()); + if ("CARTON_AND_SKU".equals(type)) { + assertEquals(0, second.relabelledPackages()); + var last = service.scanCarton("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.CartonScan("BOX-1", 2, "carton-last")); + assertEquals(1, last.relabelledPackages()); + } else assertEquals(1, second.relabelledPackages()); + assertEquals(1, persistedFacts.size()); + } + @Test void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() { application.setStatus("PROCESSING"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 7e0db4689..3c2cdf225 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -117,6 +117,7 @@ class RelabelApplicationServiceTest { lenient().when(feeSources.publishApproved(any(), any(), any())).thenReturn(new OverseasFeeSource()); } + @Test void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { RelabelRequests.Create request = request("key-1", 10, 6); @@ -144,7 +145,7 @@ class RelabelApplicationServiceTest { verify(orderHistories).insert(tailHistory.capture()); assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType()); - assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus()); + assertEquals("WAIT_RELABEL", tailOrder.getValue().getCurrentStatus()); assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}")); @@ -155,10 +156,36 @@ class RelabelApplicationServiceTest { assertEquals(6, application.getValue().getNotifiedPackageCount()); assertEquals("SKU-A", relabelItem.getValue().getSkuCode()); assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus()); - assertEquals("PENDING_ASSIGNMENT", tailHistory.getValue().getToStatus()); + assertEquals("WAIT_RELABEL", tailHistory.getValue().getToStatus()); assertEquals("FBR-1", result.application().relabelNo()); } + @Test + void warehouseTransferRelabelReusesExistingTailOrder() { + RelabelRequests.Create request = request("key-transfer", 10, 6); + RelabelApplication persisted = application(11L, "FBR-TRANSFER", "10001", "key-transfer", null, + RelabelApplicationService.PENDING_PROCESSING); + persisted.setLastlegOrderId(21L); + LastlegOrder transferOrder = order(21L, "10001", "WT-WH-20260916-560449", "INBOUND_COMPLETED"); + transferOrder.setOrderType("WAREHOUSE_TRANSFER"); + when(applications.findNo(eq("10001"), any())).thenReturn(persisted); + when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(transferOrder); + + service.createForExistingTailOrder("10001", "operator-1", 21L, request); + + ArgumentCaptor application = ArgumentCaptor.forClass(RelabelApplication.class); + verify(applications).insert(application.capture()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(orderHistories, never()).insert(any(LastlegOrderStatusHistory.class)); + assertEquals(21L, application.getValue().getLastlegOrderId()); + assertEquals(application.getValue().getRelabelNo(), transferOrder.getRelabelOrderNo()); + assertEquals(RelabelApplicationService.PENDING_PROCESSING, transferOrder.getRelabelStatus()); + assertEquals("WAREHOUSE_TRANSFER", transferOrder.getOrderType()); + assertEquals("INBOUND_COMPLETED", transferOrder.getCurrentStatus()); + } + @Test void createWithoutChargeSkipsWalletFreeze() { RelabelRequests.Create charged = request("key-free", 10, 6); @@ -242,27 +269,19 @@ class RelabelApplicationServiceTest { } @Test - void createKeepsOneOperationWarehouseWhenItemsHaveDifferentFbaDestinations() { + void createRejectsDifferentFbaDestinationsInOneApplication() { RelabelRequests.Item west = carton("SKU-A", "BOX-1", "FBA-1", "ONT8"); RelabelRequests.Item east = carton("SKU-B", "BOX-2", "FBA-2", "ABE8"); RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-multi", List.of(west, east)); - RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-multi", null, - RelabelApplicationService.PENDING_PROCESSING); - persisted.setLastlegOrderId(21L); - when(applications.findNo(eq("10001"), any())).thenReturn(persisted); - when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 1, 1))); - when(histories.selectList(any())).thenReturn(List.of()); - when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1", - RelabelApplicationService.PENDING_PROCESSING)); + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.create("10001", "operator-1", request)); - service.create("10001", "operator-1", request); - - ArgumentCaptor created = ArgumentCaptor.forClass(LastlegOrder.class); - verify(orders).insert(created.capture()); - assertEquals("WH-1", created.getValue().getOverseasWarehouseId()); - assertEquals(2, created.getValue().getPackageCount()); + assertEquals(400, exception.getStatusCode().value()); + assertEquals("同一张贴标申请只能选择一个 FBA 目的仓", exception.getReason()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(applications, never()).insert(any(RelabelApplication.class)); } @Test @@ -363,6 +382,9 @@ class RelabelApplicationServiceTest { application.setLastlegOrderId(21L); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); + LastlegOrder relabelOrder = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT"); + relabelOrder.setOrderType("RELABEL_APPLICATION"); + when(orders.selectById(21L)).thenReturn(relabelOrder); RelabelResponses.Detail result = service.changeStatus("10001", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("processing", "started")); @@ -372,12 +394,33 @@ class RelabelApplicationServiceTest { verify(applications).updateById(application); ArgumentCaptor updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class); verify(orders).updateById(updatedOrder.capture()); - assertEquals("PENDING_ASSIGNMENT", updatedOrder.getValue().getCurrentStatus()); + assertEquals("WAIT_RELABEL", updatedOrder.getValue().getCurrentStatus()); assertEquals("RELABEL:PROCESSING", updatedOrder.getValue().getSubOrderStatus()); verify(histories).insert(any(RelabelStatusHistory.class)); verify(orderHistories).insert(any(LastlegOrderStatusHistory.class)); } + @Test + void reviewStageIsReportedAsCompletedOnLinkedCustomerAndPlatformOrder() { + RelabelApplication application = application(11L, "FBR-REVIEW", "10001", "key-review", "hash", + "PROCESSING"); + application.setLastlegOrderId(21L); + LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT"); + when(applications.lockNo("10001", "FBR-REVIEW")).thenReturn(application); + when(applications.findNo("10001", "FBR-REVIEW")).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 1, 1))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(order); + + service.changeStatus("10001", "FBR-REVIEW", "operator-2", + new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted")); + + assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, application.getStatus()); + assertEquals("WAIT_CUSTOMER_CONFIRMATION", application.getFulfillmentStatus()); + assertEquals("COMPLETED", order.getRelabelStatus()); + assertEquals("RELABEL:CUSTOMER_CONFIRM_PENDING", order.getSubOrderStatus()); + } + @Test void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() { RelabelApplication completed = application(11L, "FBR-1", "10001", "key-1", "hash", "COMPLETED"); @@ -466,7 +509,7 @@ class RelabelApplicationServiceTest { var review = ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask.class); verify(pdaTasks).updateWithVersion(review.capture(), eq(1)); assertEquals("COMPLETED", review.getValue().getStatus()); - assertEquals("CUSTOMER_CONFIRMED", review.getValue().getReviewDecision()); + assertEquals("CONFIRMED", review.getValue().getReviewDecision()); assertNull(application.getOutboundOrderNo()); verify(outboundOrders, never()).insert(any(OutboundOrder.class)); @@ -475,12 +518,36 @@ class RelabelApplicationServiceTest { verify(applications, times(1)).updateById(application); } + @Test + void skuOnlyCustomerConfirmationCompletesWithoutPalletOrOutboundFulfillment() { + RelabelApplication application = confirmationApplication(null, false); + RelabelItem skuOnly = item(31L, application.getId(), "SKU-A", 1, 1); + skuOnly.setOperationType("SKU_LABEL"); + LastlegOrder linked = order(21L, "10001", "LMO-RELABEL-1", "WAIT_RELABEL"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + when(applications.findNo("10001", "FBR-1")).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(skuOnly)); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(linked); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-sku", "accepted")); + + assertEquals("COMPLETED", application.getStatus()); + assertEquals("NOT_REQUIRED", application.getFulfillmentStatus()); + assertEquals("COMPLETED", linked.getCurrentStatus()); + assertNull(application.getOutboundOrderNo()); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + verify(outboundItems, never()).insert(any(OutboundPalletItem.class)); + verify(outboundTasks, never()).insert(any(OutboundPdaTask.class)); + } + @Test void transferRelabelConfirmationReleasesTransferForDispatch() { RelabelApplication application = confirmationApplication("TRUCK", false); WarehouseTransferOrder transfer = new WarehouseTransferOrder(); transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); - transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); + transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); transfer.setFbaWarehouseCode("ONT8"); LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL"); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); @@ -498,6 +565,55 @@ class RelabelApplicationServiceTest { verify(orders).updateById(transferOrder); } + @Test + void transferRelabelConfirmationWithoutDestinationWaitsForFbaInsteadOfDispatch() { + RelabelApplication application = confirmationApplication("TRUCK", false); + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); + transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); + LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + stubDetail(application); + when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer); + when(orders.selectById(88L)).thenReturn(transferOrder); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-transfer-no-fba", "accepted")); + + assertEquals("WAIT_FBA_DESTINATION", transfer.getStatus()); + assertEquals("WAIT_FBA_DESTINATION", transferOrder.getCurrentStatus()); + assertEquals("WAREHOUSE_TRANSFER:WAIT_FBA_DESTINATION;RELABEL:COMPLETED", + transferOrder.getSubOrderStatus()); + assertEquals("WAIT_FBA_DESTINATION", application.getFulfillmentStatus()); + } + + @Test + void transferRelabelConfirmationDerivesUniqueDestinationFromRelabelItems() { + RelabelApplication application = confirmationApplication("TRUCK", false); + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); + transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); + LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL"); + RelabelItem relabelItem = item(31L, application.getId(), "SKU-A", 10, 6); + relabelItem.setDestinationFbaWarehouseCode("ONT8"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + when(applications.findNo("10001", "FBR-1")).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(relabelItem)); + when(histories.selectList(any())).thenReturn(List.of()); + when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer); + when(orders.selectById(88L)).thenReturn(transferOrder); + when(orders.selectById(application.getLastlegOrderId())).thenReturn( + order(application.getLastlegOrderId(), "10001", "LMO-RELABEL-1", application.getStatus())); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-transfer-derived-fba", "accepted")); + + assertEquals("ONT8", transfer.getFbaWarehouseCode()); + assertEquals("READY_FOR_DISPATCH", transfer.getStatus()); + assertEquals("ONT8", transferOrder.getFbaWarehouseCode()); + assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus()); + } + @Test void expressCustomerConfirmationRequiresShippingLabelWhenWarehousePrintingIsSelected() { RelabelApplication application = confirmationApplication("EXPRESS", true); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java new file mode 100644 index 000000000..806625f7b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.relabel.entity.*; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; + +class RelabelBindingGuardTest { + @Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() { + var guard = new RelabelBindingGuard(); + var items = mock(RelabelItemMapper.class); + var history = mock(PdaRelabelHistoryMapper.class); + var positions = mock(InventoryPositionMapper.class); + ReflectionTestUtils.setField(guard, "items", items); + ReflectionTestUtils.setField(guard, "histories", history); + ReflectionTestUtils.setField(guard, "positions", positions); + ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper()); + var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); + var item = new RelabelItem(); item.setCartonMark("A"); item.setOperationType("CARTON_LABEL"); + item.setSourceInventoryPositionId(3L); item.setNotifiedPackageCount(1); + when(items.selectList(any())).thenReturn(List.of(item)); + var position = new InventoryPosition(); position.setPalletNo("OLD"); position.setHoldPackages(1L); + when(positions.findTenantPosition("2", 3L)).thenReturn(position); + when(history.selectList(any())).thenReturn(List.of()); + assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + var binding = new PdaRelabelHistory(); binding.setAction("PALLET_BIND"); + binding.setDetailJson("{\"cartonMarks\":[\"A\"],\"targetPallets\":[\"NEW\"]}"); + when(history.selectList(any())).thenReturn(List.of(binding)); + assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + position.setPalletNo("NEW"); + assertDoesNotThrow(() -> guard.requireBound(app)); + var rework = new PdaRelabelHistory(); rework.setAction("REWORK_ARCHIVE"); + when(history.selectList(any())).thenReturn(List.of(binding, rework)); + assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + item.setOperationType("SKU_LABEL"); + assertDoesNotThrow(() -> guard.requireBound(app)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java new file mode 100644 index 000000000..36980b2da --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RelabelReturnCartonCandidateTest { + @Test + void candidatesIncludeAvailableMergedReturnCartonByWarehouseMark() { + RelabelApplicationService service = new RelabelApplicationService(); + InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class); + InventoryPositionMapper positions = mock(InventoryPositionMapper.class); + ReturnInventoryCartonMapper cartons = mock(ReturnInventoryCartonMapper.class); + ReturnInventoryCartonItemMapper cartonItems = mock(ReturnInventoryCartonItemMapper.class); + ReflectionTestUtils.setField(service, "inventoryBalances", balances); + ReflectionTestUtils.setField(service, "inventoryPositions", positions); + ReflectionTestUtils.setField(service, "returnCartons", cartons); + ReflectionTestUtils.setField(service, "returnCartonItems", cartonItems); + + ReturnInventoryCarton carton = new ReturnInventoryCarton(); + carton.setId(9001L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("WH-1"); + carton.setCartonMark("RC-WH-0001"); carton.setSourceReturnNo("RT-1,RT-2"); + carton.setStatus("AVAILABLE"); carton.setTotalQuantity(12L); + ReturnInventoryCartonItem cartonItem = new ReturnInventoryCartonItem(); + cartonItem.setCartonId(9001L); cartonItem.setSkuCode("SKU-RETURN"); + cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L); + when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton)); + when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem)); + + List result = service.candidates("10001", "WH-1", "RC-WH"); + + assertEquals(1, result.size()); + assertEquals("RETURN_CARTON", result.get(0).sourceType()); + assertEquals(9001L, result.get(0).returnCartonId()); + assertEquals("RC-WH-0001", result.get(0).cartonMark()); + assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode()); + assertEquals(12, result.get(0).skuItems().get(0).quantity()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java index 7dcc994a7..984cc730d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java @@ -11,6 +11,8 @@ class WarehouseTransferSchemaContractTest { void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception { String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + String deployment = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8).toLowerCase(); for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order", "ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation", "ll_warehouse_transfer_receipt"}) { @@ -21,5 +23,25 @@ class WarehouseTransferSchemaContractTest { assertTrue(migration.contains("uk_ll_wh_transfer_source")); assertTrue(migration.contains("uk_ll_wh_transfer_active_position")); assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source")); + assertTrue(baseline.contains("pickup_contact_name varchar(128) null")); + assertTrue(baseline.contains("pickup_contact_phone varchar(64) null")); + assertTrue(migration.contains("column_name='pickup_contact_name'")); + assertTrue(migration.contains("column_name='pickup_contact_phone'")); + assertTrue(baseline.contains("create table if not exists ll_warehouse_transfer_measurement_photo")); + assertTrue(migration.contains("create table if not exists ll_warehouse_transfer_measurement_photo")); + assertTrue(baseline.contains("received_package_count int null comment 'pda实际入库箱数'")); + assertTrue(migration.contains("column_name='received_package_count'")); + assertTrue(migration.contains("o.current_status='inbound_completed'")); + for (String field : new String[]{"pickup_contact_name", "review_decision", "destination_type", + "destination_binding_idempotency_key", "private_destination_address", + "private_destination_postal_code", "draft_idempotency_key", "bind_idempotency_key"}) { + assertTrue(deployment.contains(field), () -> "deployment SQL must include " + field); + } + for (String table : new String[]{"ll_warehouse_transfer_measurement_photo", "ll_inbound_plan_line", + "ll_inbound_plan_pallet", "ll_dispatch_item", "ll_delivery_order"}) { + assertTrue(deployment.contains(table), () -> "deployment SQL must include " + table); + } + assertTrue(deployment.contains("assert_relabel_transfer_latest_schema_ready")); + assertTrue(deployment.contains("贴标申请与仓储转运最新表结构已完整部署")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java index b7945c828..ef731e388 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java @@ -31,6 +31,25 @@ class MerchantWarehouseTransferControllerTest { verify(service).detail("100", "TR001"); } + @Test + void merchantSubmitUsesOnlyAuthenticatedTenant() { + controller.submit(user(100L, "MERCHANT"), "TR001"); + verify(service).submit("100", "TR001", "1"); + } + + @Test + void merchantUpdateUsesOnlyAuthenticatedTenant() { + WarehouseTransferRequests.Create request = mock(WarehouseTransferRequests.Create.class); + controller.updateDraft(user(100L, "MERCHANT"), "TR001", request); + verify(service).updateDraft("100", "TR001", "1", request); + } + + @Test + void merchantPrintsSubmittedCartonLabelsWithinAuthenticatedTenant() { + controller.cartonLabel(user(100L, "MERCHANT"), "TR001"); + verify(service).cartonLabel("100", "TR001"); + } + @Test void merchantEndpointRejectsPlatformIdentity() { assertThrows(ResponseStatusException.class, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java new file mode 100644 index 000000000..0f859dd2a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.transfer.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.multipart.MultipartFile; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaWarehouseTransferControllerContractTest { + @Test + void detailUsesOnlyAuthenticatedPdaWarehouseScopeAndTransferNumber() throws Exception { + var method = PdaWarehouseTransferController.class + .getMethod("detail", AuthenticatedUser.class, String.class); + + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{transferNo}"); + assertThat(method.getParameterCount()).isEqualTo(2); + } + + @Test + void measurementPhotoUploadUsesThePublicPdaRouteAndFileMultipartPart() throws Exception { + var method = PdaWarehouseTransferController.class.getMethod("uploadMeasurementPhoto", + AuthenticatedUser.class, String.class, MultipartFile.class); + + var mapping = method.getAnnotation(PostMapping.class); + assertThat(mapping.value()).containsExactly("/{transferNo}/measurement-photos/files"); + assertThat(mapping.consumes()).containsExactly("multipart/form-data"); + assertThat(method.getParameters()[2].getAnnotation(RequestParam.class).value()).isEqualTo("file"); + } + + @Test + void palletLabelUsesThePdaScopedTransferAndGeneratedWarehousePallet() throws Exception { + var method = PdaWarehouseTransferController.class.getMethod("palletLabel", + AuthenticatedUser.class, String.class, long.class, String.class); + + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/{transferNo}/pallets/{warehousePalletId}/label"); + assertThat(method.getParameters()[3].getAnnotation(RequestHeader.class).value()).isEqualTo("X-Request-Id"); + } + + @Test + void palletLifecycleExposesDraftGenerateBindAndDraftDeleteRoutes() throws Exception { + var draft = PdaWarehouseTransferController.class.getMethod("savePalletDraft", + AuthenticatedUser.class, String.class, WarehouseTransferRequests.PalletDraft.class); + var generate = PdaWarehouseTransferController.class.getMethod("generatePallet", + AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletAction.class); + var bind = PdaWarehouseTransferController.class.getMethod("bindPallet", + AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletBind.class); + var delete = PdaWarehouseTransferController.class.getMethod("deletePalletDraft", + AuthenticatedUser.class, String.class, long.class); + + assertThat(draft.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/drafts"); + assertThat(generate.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/generate"); + assertThat(bind.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/bind"); + assertThat(delete.getAnnotation(DeleteMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}"); + } +} 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 new file mode 100644 index 000000000..3f680f3cb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.transfer.dto; + +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehouseTransferCargoValidationTest { + @Test + void transferCargoAllowsSkuAndProductNameToBeOmitted() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var violations = factory.getValidator().validate( + new WarehouseTransferRequests.Item(null, null, 1, null)); + + assertTrue(violations.isEmpty()); + } + } + + @Test + void cartonRelabelAllowsOriginalSkuToBeOmitted() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var violations = factory.getValidator().validate(new WarehouseTransferRequests.RelabelItem( + "CARTON_LABEL", List.of("CUSTOMER-CARTON-1"), null, null, 1, + "FBA-CARTON-1", "ONT8", null)); + + assertTrue(violations.isEmpty()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java index 92807e87a..f714d3e35 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java @@ -11,12 +11,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class WarehouseTransferPalletValidationTest { @Test - void palletAcceptsDirectTotalVolumeAndWeightWithoutCartonDimensions() { + void palletAcceptsMeasuredDimensionsVolumeAndWeight() { try (var factory = Validation.buildDefaultValidatorFactory()) { var validator = factory.getValidator(); - var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5, - new BigDecimal("1.250000"), new BigDecimal("420.50"), - List.of(new WarehouseTransferRequests.PalletItem(101L, 5))); + var pallet = new WarehouseTransferRequests.Pallet(5, + new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"), + new BigDecimal("1.250000"), new BigDecimal("420.50")); assertTrue(validator.validate(pallet).isEmpty()); assertEquals(new BigDecimal("1.250000"), pallet.volumeCbm()); @@ -28,11 +28,23 @@ class WarehouseTransferPalletValidationTest { void palletRejectsNonPositiveTotalVolume() { try (var factory = Validation.buildDefaultValidatorFactory()) { var validator = factory.getValidator(); - var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5, - BigDecimal.ZERO, new BigDecimal("420.50"), - List.of(new WarehouseTransferRequests.PalletItem(101L, 5))); + var pallet = new WarehouseTransferRequests.Pallet(5, + new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"), + BigDecimal.ZERO, new BigDecimal("420.50")); assertEquals(1, validator.validate(pallet).size()); } } + + @Test + void palletRejectsMissingOrNonPositiveDimensions() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + var pallet = new WarehouseTransferRequests.Pallet(5, + BigDecimal.ZERO, null, new BigDecimal("125"), + new BigDecimal("1.250000"), new BigDecimal("420.50")); + + assertEquals(2, validator.validate(pallet).size()); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java new file mode 100644 index 000000000..b42f74175 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.gateway; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpTransferMeasurementPhotoFileGatewayContractTest { + @Test + void defaultsToTheFileServicesActualLocalPort() throws Exception { + var field = HttpTransferMeasurementPhotoFileGateway.class.getDeclaredField("baseUrl"); + + assertThat(field.getAnnotation(Value.class).value()) + .isEqualTo("${baiyue.clients.file.url:http://127.0.0.1:9206}"); + } +} 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 new file mode 100644 index 000000000..1177c8a77 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WarehouseTransferPalletAllocationTest { + @Test + void allocatesMultipleDeclaredSkusAcrossPalletsInStableDeclaredOrder() { + WarehouseTransferItem first = item(11L, 3); + WarehouseTransferItem second = item(12L, 4); + var allocations = WarehouseTransferService.derivePalletAllocations( + List.of(first, second), List.of(pallet(2), pallet(5))); + + assertEquals(List.of( + new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 2), + new WarehouseTransferService.DerivedPalletAllocation(1, 11L, 1), + new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 4)), allocations); + } + + @Test + void underReceiptConsumesDeclaredSkusInStableOrder() { + var allocations = WarehouseTransferService.derivePalletAllocations( + List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4))); + + assertEquals(List.of( + new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3), + new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1)), allocations); + assertEquals(4, allocations.stream().mapToInt(value -> value.packageCount()).sum()); + } + + @Test + void overReceiptAssignsExcessToFinalDeclaredSkuAcrossPallets() { + var allocations = WarehouseTransferService.derivePalletAllocations( + List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4), pallet(3))); + + assertEquals(List.of( + new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3), + new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1), + new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 3)), allocations); + assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum()); + } + + @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()); + } + + @Test + void requestsFreshPalletNumbersAndCarriesOnlyTheKnownFbaDestination() { + var known = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-1", "ONT8", 2); + var unknown = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-2", null, 1); + + assertEquals(List.of("WTR-1:1", "WTR-1:2"), known.stream().map(value -> value.businessKey()).toList()); + assertEquals(List.of("ONT8", "ONT8"), known.stream().map(value -> value.fbaWarehouseCode()).toList()); + assertEquals(true, known.stream().allMatch(value -> value.palletCode() == null)); + assertEquals(null, unknown.get(0).fbaWarehouseCode()); + assertEquals(null, unknown.get(0).palletCode()); + } + + private static WarehouseTransferItem item(long id, int packages) { + WarehouseTransferItem item = new WarehouseTransferItem(); + item.setId(id); item.setPackageCount(packages); + return item; + } + + private static WarehouseTransferRequests.Pallet pallet(int cartons) { + return new WarehouseTransferRequests.Pallet(cartons, BigDecimal.ONE, BigDecimal.ONE, + BigDecimal.ONE, new BigDecimal("0.000001"), BigDecimal.ONE); + } + + private static com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet persistedPallet(int cartons) { + var pallet = new com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet(); + pallet.setCartonCount(cartons); + return pallet; + } +} 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 a1dba7df4..0f23c0ae3 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 @@ -1,6 +1,7 @@ package com.baiyue.lastleg.transfer.service; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegInboundOrder; import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; @@ -19,11 +20,27 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton; import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper; +import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; @@ -62,14 +79,36 @@ import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class WarehouseTransferServiceTest { + @Test + void customerMainStatusFiltersExpandToInternalWorkflowStates() { + assertEquals(List.of(WarehouseTransferService.APPROVED, WarehouseTransferService.WAIT_PDA_RECEIVING), + WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_MEASURING)); + assertTrue(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_COMPLETED) + .containsAll(List.of(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION, + WarehouseTransferService.WAIT_FBA_DESTINATION, WarehouseTransferService.WAIT_RELABEL, + WarehouseTransferService.READY_FOR_DISPATCH, WarehouseTransferService.OUTBOUNDED))); + assertNull(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.DRAFT)); + } + @Mock WarehouseTransferOrderMapper transfers; @Mock WarehouseTransferItemMapper transferItems; @Mock WarehouseTransferCartonMapper transferCartons; @Mock WarehouseTransferSourceOrderMapper sourceOrders; @Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations; @Mock WarehouseTransferReceiptMapper transferReceipts; + @Mock WarehouseTransferMeasurementPhotoMapper measurementPhotos; + @Mock WarehouseTransferPalletMapper transferPallets; + @Mock WarehouseTransferPalletItemMapper transferPalletItems; + @Mock InboundStockReceiptMapper stockReceipts; + @Mock WarehouseTransferRelabelBindingMapper relabelBindings; + @Mock WarehouseResourceGateway warehouseResources; + @Mock TransferMeasurementPhotoFileGateway measurementPhotoFiles; @Mock InboundSnapshotMapper inboundSnapshots; @Mock LastlegInboundOrderMapper inboundOrders; + @Mock InboundPlanMapper inboundPlans; + @Mock InboundPlanLineMapper inboundPlanLines; + @Mock InboundPlanPalletMapper inboundPlanPallets; + @Mock InboundPlanPalletAllocationMapper inboundPlanAllocations; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper orderDetails; @Mock LastlegOrderStatusHistoryMapper orderHistories; @@ -88,8 +127,19 @@ class WarehouseTransferServiceTest { ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders); ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations); ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts); + ReflectionTestUtils.setField(service, "measurementPhotos", measurementPhotos); + ReflectionTestUtils.setField(service, "transferPallets", transferPallets); + ReflectionTestUtils.setField(service, "transferPalletItems", transferPalletItems); + ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); + ReflectionTestUtils.setField(service, "relabelBindings", relabelBindings); + ReflectionTestUtils.setField(service, "warehouseResources", warehouseResources); + ReflectionTestUtils.setField(service, "measurementPhotoFiles", measurementPhotoFiles); ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots); ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders); + ReflectionTestUtils.setField(service, "inboundPlans", inboundPlans); + ReflectionTestUtils.setField(service, "inboundPlanLines", inboundPlanLines); + ReflectionTestUtils.setField(service, "inboundPlanPallets", inboundPlanPallets); + ReflectionTestUtils.setField(service, "inboundPlanAllocations", inboundPlanAllocations); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); @@ -103,6 +153,116 @@ class WarehouseTransferServiceTest { lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of()); lenient().when(transferReceipts.selectList(any())).thenReturn(List.of()); lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(measurementPhotos.listOrder(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(measurementPhotos.listSubmitted(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(transferPalletItems.listOrder(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(relabelBindings.listOrder(anyString(), anyLong())).thenReturn(List.of()); + } + + @Test + void generatedPalletRetryWithSameKeyDoesNotGenerateAnotherPhysicalPallet() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setOperatingWarehouseId("77"); + WarehouseTransferPallet pallet = measuredPallet("GENERATED"); + pallet.setGenerateIdempotencyKey("GEN-1"); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet)); + + service.generatePallet(77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletAction("GEN-1")); + + verify(warehouseResources, never()).ensurePalletBatch(any()); + verify(warehouseResources, never()).confirmPalletBatch(any()); + } + + @Test + void scanBindingRejectsCodeOtherThanThisGeneratedPallet() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + WarehouseTransferPallet pallet = measuredPallet("GENERATED"); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.bindPallet( + 77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletBind("OTHER-PALLET", "BIND-1"))); + + assertEquals(409, exception.getStatusCode().value()); + verify(warehouseResources, never()).resolvePallet(anyString()); + } + + @Test + void palletizeRejectsWhenAnyPersistedPalletIsNotBound() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setOperatingWarehouseId("77"); + WarehouseTransferPallet pallet = measuredPallet("GENERATED"); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5))); + WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto(); + photo.setFileId(9001L); + when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo); + when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.palletize( + "OP-1", 77L, "WTR-1", new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L)))); + + assertEquals(409, exception.getStatusCode().value()); + verify(warehouseResources, never()).ensurePalletBatch(any()); + } + + @Test + void palletizeCompletesAfterEveryPalletIsBoundAndDoesNotGenerateAgain() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setOperatingWarehouseId("77"); + transfer.setInboundCode("WTR-1"); + WarehouseTransferPallet pallet = measuredPallet("BOUND"); + WarehouseTransferItem item = transferItem(51L, "SKU-1", 5); + LastlegOrder tail = tailOrder(); + LastlegInboundOrder inbound = new LastlegInboundOrder(); + inbound.setId(transfer.getInboundOrderId()); inbound.setCustomerId("TENANT-A"); + InboundPlan inboundPlan = new InboundPlan(); inboundPlan.setId(201L); inboundPlan.setStatus("PDA_PENDING"); + InboundPlanLine inboundLine = new InboundPlanLine(); inboundLine.setId(202L); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transferItems.selectList(any())).thenReturn(List.of(item)); + when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet)); + WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto(); + photo.setFileId(9001L); + when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo); + when(measurementPhotos.markSubmitted(eq("TENANT-A"), eq(transfer.getId()), any(), any())).thenReturn(1); + when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(tail); + when(inboundOrders.selectById(transfer.getInboundOrderId())).thenReturn(inbound); + when(inboundPlans.findBySourceOrder("TENANT-A", transfer.getLastlegOrderId())).thenReturn(inboundPlan); + when(inboundPlanLines.selectOne(any())).thenReturn(inboundLine); + when(warehouseResources.requireActiveSharedWarehouse(77L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference(77L, "WH-77", "Warehouse 77", "ACTIVE")); + when(inventory.stockIn(eq("TENANT-A"), eq("OP-1"), any())).thenReturn( + new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false)); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.palletize("OP-1", 77L, "WTR-1", + new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L))); + + assertEquals(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION, result.status()); + assertEquals("COMPLETED", pallet.getStatus()); + assertEquals(WarehouseTransferService.INBOUND_COMPLETED, tail.getCurrentStatus()); + assertEquals(5, tail.getReceivedPackageCount()); + assertEquals("RECEIVED", inbound.getStatus()); + assertEquals(5, inbound.getPackageCount()); + assertEquals("COMPLETED", inboundPlan.getStatus()); + verify(transferPalletItems).insert(any(com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem.class)); + verify(inventory).stockIn(eq("TENANT-A"), eq("OP-1"), any()); + verify(stockReceipts).insert(any(com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt.class)); + verify(inboundOrders).updateById(inbound); + verify(inboundPlans).updateById(inboundPlan); + verify(inboundPlanPallets).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet.class)); + ArgumentCaptor allocation = + ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation.class); + verify(inboundPlanAllocations).insert(allocation.capture()); + assertEquals(5, allocation.getValue().getPickedPackageCount()); + verify(warehouseResources, never()).ensurePalletBatch(any()); + verify(warehouseResources, never()).confirmPalletBatch(any()); } @Test @@ -111,7 +271,7 @@ class WarehouseTransferServiceTest { LastlegOrder historical = historicalOrder(); when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); when(orders.selectList(any())).thenReturn(List.of(historical)); - when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -120,23 +280,23 @@ class WarehouseTransferServiceTest { ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); verify(orders).insert(tailOrder.capture()); assertEquals(WarehouseTransferService.ORDER_TYPE, tailOrder.getValue().getOrderType()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus()); - assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus()); + assertEquals(WarehouseTransferService.DRAFT, tailOrder.getValue().getCurrentStatus()); + assertEquals("WAREHOUSE_TRANSFER:DRAFT;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus()); assertTrue(tailOrder.getValue().getOrderNo().matches("WT-WH-\\d{8}-\\d{6}")); assertNull(tailOrder.getValue().getTransferWarehouseId()); verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class)); verify(relabelApplications, never()).insert(any(RelabelApplication.class)); assertNull(tailOrder.getValue().getRelabelOrderNo()); assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus()); + assertEquals(WarehouseTransferService.DRAFT, result.status()); + assertEquals(WarehouseTransferService.DRAFT, result.orderStatus()); } @Test void transferWithoutRelabelDoesNotCreateRelabelDocument() { when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); - when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -146,6 +306,27 @@ class WarehouseTransferServiceTest { assertNull(result.relabelNo()); } + @Test + void transferRelabelRejectsMultipleDestinationWarehouses() { + WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( + null, null, "WH-1", null, "NONE", null, null, null, + null, null, null, null, "123 Pickup Road", null, null, 2, + new BigDecimal("0.1000"), "CUSTOMER_DELIVERY", LocalDateTime.of(2026, 9, 18, 10, 0), + null, null, null, "BOX-1,BOX-2", null, true, + LocalDateTime.of(2026, 9, 19, 10, 0), "Contact", "13800000000", null, "TRUCK", + "single-destination-check", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 2, null)), + List.of( + new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-1"), "SKU-1", null, 1, "FBA-1", "ONT8", null), + new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-2"), "SKU-1", null, 1, "FBA-2", "ACY2", null))); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.create("TENANT-A", "USER-1", request)); + + assertEquals(400, exception.getStatusCode().value()); + assertEquals("Warehouse transfer relabel only supports one destination warehouse per order", exception.getReason()); + verifyNoInteractions(transfers); + } + @Test void transferCanEnterTheTransitWarehouseBeforeFbaDestinationIsKnown() { WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( @@ -155,7 +336,7 @@ class WarehouseTransferServiceTest { List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); - when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -179,7 +360,7 @@ class WarehouseTransferServiceTest { null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, "key-incoming", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null), new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null))); - when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -199,7 +380,7 @@ class WarehouseTransferServiceTest { assertEquals(transfer.getValue().getTransferNo(), transfer.getValue().getInboundCode()); assertEquals(transfer.getValue().getTransferNo(), inbound.getValue().getInboundCode()); assertEquals("OVERSEAS", inbound.getValue().getDestinationType()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getValue().getStatus()); + assertEquals(WarehouseTransferService.DRAFT, inbound.getValue().getStatus()); assertNull(transfer.getValue().getTrackingNo()); assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus()); assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream() @@ -209,7 +390,7 @@ class WarehouseTransferServiceTest { } @Test - void approvedTransferWithoutRelabelEntersPendingAssignment() { + void approvedTransferWithoutRelabelEntersInboundMeasuring() { WarehouseTransferOrder transfer = persistedTransfer(); transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setNeedRelabel(false); @@ -225,14 +406,134 @@ class WarehouseTransferServiceTest { new WarehouseTransferRequests.Review("APPROVE", null)); assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus()); - assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus()); + assertEquals(WarehouseTransferService.INBOUND_MEASURING, tailOrder.getCurrentStatus()); assertEquals(WarehouseTransferService.APPROVED, result.status()); - assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus()); + assertEquals(WarehouseTransferService.INBOUND_MEASURING, result.orderStatus()); verify(transfers).updateById(transfer); verify(orders).updateById(tailOrder); verifyNoInteractions(inventory); } + @Test + void approvedIncomingTransferCreatesPendingInboundPlan() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setInboundCode("WTR-1"); transfer.setOperatingWarehouseId("77"); + transfer.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 17, 10, 0)); + LastlegOrder tailOrder = tailOrder(); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW); + LastlegInboundOrder inbound = new LastlegInboundOrder(); + inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.PENDING_REVIEW); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(inboundOrders.selectById(101L)).thenReturn(inbound); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + service.review("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Review("APPROVE", null)); + + ArgumentCaptor plan = ArgumentCaptor.forClass(InboundPlan.class); + ArgumentCaptor line = ArgumentCaptor.forClass(InboundPlanLine.class); + verify(inboundPlans).insert(plan.capture()); + verify(inboundPlanLines).insert(line.capture()); + assertEquals("PDA_PENDING", plan.getValue().getStatus()); + assertEquals("WT-WTR-1", plan.getValue().getPlanNo()); + assertEquals("WTR-1", line.getValue().getInboundCode()); + assertEquals(5, line.getValue().getRequiredPackageCount()); + assertEquals("PENDING_INBOUND", inbound.getStatus()); + assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus()); + } + + @Test + void merchantSubmitsDraftForReviewBeforePrintingLabels() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.DRAFT); + transfer.setInboundCode("WTR-1"); + LastlegOrder tailOrder = historicalOrder(); + tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setRelabelStatus("NOT_REQUIRED"); + LastlegInboundOrder inbound = new LastlegInboundOrder(); + inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.DRAFT); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(inboundOrders.selectById(101L)).thenReturn(inbound); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.submit("TENANT-A", "WTR-1", "USER-1"); + + assertEquals(WarehouseTransferService.PENDING_REVIEW, transfer.getStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getCurrentStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status()); + verify(transfers).updateById(transfer); + verify(orders).updateById(tailOrder); + verify(inboundOrders).updateById(inbound); + } + + @Test + void merchantUpdatesDraftAndRebuildsEditableSnapshots() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.DRAFT); + LastlegOrder tailOrder = historicalOrder(); + tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus(WarehouseTransferService.DRAFT); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false)); + + assertEquals(WarehouseTransferService.DRAFT, result.status()); + assertEquals("WH-1", transfer.getOperatingWarehouseId()); + assertEquals(new BigDecimal("0.2500"), tailOrder.getVolumeCbm()); + verify(transfers).updateById(transfer); + verify(orders).updateById(tailOrder); + verify(transferCartons).delete(any()); + verify(transferItems).delete(any()); + verify(relabelBindings).delete(any()); + verify(orderDetails).delete(any()); + verify(transferItems).insert(any(WarehouseTransferItem.class)); + verify(transferCartons, times(5)).insert(any(WarehouseTransferCarton.class)); + verify(orderDetails).insert(any(LastlegOrderDetail.class)); + } + + @Test + void merchantCannotUpdateSubmittedTransfer() { + WarehouseTransferOrder transfer = persistedTransfer(); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false))); + + assertEquals(409, error.getStatusCode().value()); + verify(transfers, never()).updateById(any(WarehouseTransferOrder.class)); + } + + @Test + void merchantDraftUpdateIsTenantScoped() { + when(transfers.lockNo("TENANT-B", "WTR-1")).thenReturn(null); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.updateDraft("TENANT-B", "WTR-1", "USER-2", request(false))); + + assertEquals(404, error.getStatusCode().value()); + verify(transfers, never()).updateById(any(WarehouseTransferOrder.class)); + } + + @Test + void draftCannotPrintCartonLabelsBeforeSubmission() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.DRAFT); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.cartonLabel("TENANT-A", "WTR-1")); + + assertEquals(409, error.getStatusCode().value()); + verify(transferCartons, never()).listOrder(anyString(), anyLong()); + } + @Test void rejectionRequiresNonBlankReasonBeforeAnyStateOrInventoryChange() { for (String decision : List.of("REJECT", "REJECTED")) { @@ -378,6 +679,67 @@ class WarehouseTransferServiceTest { assertEquals("RESERVED", allocation.getStatus()); } + @Test + void outboundRejectsTransferWithoutFbaDestinationBeforeInventoryMutation() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); + transfer.setFbaWarehouseCode(null); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.outbound("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Outbound("OUT-NO-FBA", null))); + + assertEquals(409, exception.getStatusCode().value()); + verifyNoInteractions(inventory); + verify(inventoryAllocations, never()).lockTransfer(anyString(), anyLong()); + verify(transfers, never()).updateById(any(WarehouseTransferOrder.class)); + } + + @Test + void merchantMeasurementPhotosExposeOnlySubmittedSelection() { + WarehouseTransferOrder transfer = persistedTransfer(); + WarehouseTransferMeasurementPhoto selected = new WarehouseTransferMeasurementPhoto(); + selected.setFileId(101L); selected.setFileName("selected.jpg"); selected.setContentType("image/jpeg"); + selected.setSizeBytes(12L); selected.setUploadedAt(LocalDateTime.of(2026, 9, 16, 10, 0)); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(measurementPhotos.listSubmitted("TENANT-A", 21L)).thenReturn(List.of(selected)); + + var result = service.measurementPhotos("TENANT-A", "WTR-1"); + + assertEquals(List.of(101L), result.stream().map(value -> value.fileId()).toList()); + verify(measurementPhotos, never()).listOrder("TENANT-A", 21L); + } + + @Test + void merchantCannotDownloadUploadedButUnsubmittedMeasurementPhoto() { + WarehouseTransferOrder transfer = persistedTransfer(); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(measurementPhotos.findSubmittedFile("TENANT-A", 21L, 102L)).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.downloadMeasurementPhoto("TENANT-A", "WTR-1", 102L)); + + assertEquals(404, exception.getStatusCode().value()); + verifyNoInteractions(measurementPhotoFiles); + } + + @Test + void pdaCanDownloadOwnWarehouseUploadedPhotoBeforeSubmission() { + WarehouseTransferOrder transfer = persistedTransfer(); + WarehouseTransferMeasurementPhoto draft = new WarehouseTransferMeasurementPhoto(); + draft.setFileId(102L); + var downloaded = new TransferMeasurementPhotoFileGateway.DownloadedFile( + new byte[]{1}, "draft.jpg", "image/jpeg"); + when(transfers.findNoByWarehouse("WTR-1", "92")).thenReturn(transfer); + when(measurementPhotos.findFile("TENANT-A", 21L, 102L)).thenReturn(draft); + when(measurementPhotoFiles.download("TENANT-A", 21L, 102L)).thenReturn(downloaded); + + var result = service.downloadPdaMeasurementPhoto(92L, "WTR-1", 102L); + + assertEquals("draft.jpg", result.fileName()); + verify(measurementPhotos, never()).findSubmittedFile(anyString(), anyLong(), anyLong()); + } + @Test void allocationRejectsPositionOutsideSelectedInboundOrder() { WarehouseTransferOrder transfer = transfer("APPROVED"); @@ -427,6 +789,7 @@ class WarehouseTransferServiceTest { transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L); transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setPackageCount(5); + transfer.setFbaWarehouseCode("ONT8"); return transfer; } @@ -436,6 +799,24 @@ class WarehouseTransferServiceTest { return transfer; } + private static WarehouseTransferPallet measuredPallet(String status) { + WarehouseTransferPallet pallet = new WarehouseTransferPallet(); + pallet.setId(61L); pallet.setTransferOrderId(21L); pallet.setCustomerId("TENANT-A"); + pallet.setSequenceNo(1); pallet.setWarehousePalletId(701L); pallet.setPalletNo("NEW-PALLET-1"); + pallet.setCartonCount(5); pallet.setLengthCm(new BigDecimal("100.00")); + pallet.setWidthCm(new BigDecimal("100.00")); pallet.setHeightCm(new BigDecimal("100.00")); + pallet.setVolumeCbm(new BigDecimal("1.000000")); pallet.setGrossWeightKg(new BigDecimal("50.000")); + pallet.setStatus(status); + return pallet; + } + + private static WarehouseTransferItem transferItem(long id, String sku, int packages) { + WarehouseTransferItem item = new WarehouseTransferItem(); + item.setId(id); item.setTransferOrderId(21L); item.setCustomerId("TENANT-A"); + item.setSkuCode(sku); item.setProductNameZh("Product"); item.setPackageCount(packages); + return item; + } + private static LastlegOrder tailOrder() { LastlegOrder order = historicalOrder(); order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT"); diff --git a/scripts/README.md b/scripts/README.md index 8a5c942b3..817f4a5fa 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,9 @@ # 后端脚本管理与全量 HTTP 验收索引 +`db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;只更新未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单为待换标/待排车/待出库,不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。 + +`db/warehouse-transfer-outbound-incremental.sql`:固定指向 `baiyue_lastleg`,一次补齐仓储转运审核、提货、PDA 测量打托、现场照片、换标、FBA/私人仓目的地、入库计划、智能排车和派送所需表、租户字段及索引,并将换标新 SKU 和转运托盘生命周期字段修正为本地要求的可空结构。无内置凭据;表使用 `CREATE TABLE IF NOT EXISTS`,字段和索引通过 `information_schema` 独立判断,可重复执行,不删除或改写业务单据。完整成功时返回 `WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY`;若历史数据存在同客户重复托盘号,则返回 `WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER`,且 `inbound_pallet_unique_index` 为 `BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。 + `mixed-container-outbound-test-env.mjs` 是经明确授权的测试环境留存混柜出库续跑入口:参数为 `source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report`,凭据由显式 `node --env-file` 注入。FBA 分支实时校验既有收货数据,创建排车、出库、派送及临时 PDA 账号,执行拣货、发车、测试签收文件保存和国内状态回查;一件代发分支只走专用候选、两托四箱出库、PDA 幂等拣货和完成回查,并明确记录派送未覆盖,不返回全流程成功退出码。finally 停用临时账号,不删除业务数据。输出报告作为检查点,失败后须先核对其中业务单号和实时状态,再用同一输出文件续跑;不得删除报告后盲目重试。它不代表普通海外仓目的地转换或所有分支签收验收通过。 全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。 @@ -71,6 +75,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 | | 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 | | 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` | +| 本地真实 API | `relabel-closure-local-acceptance.mjs` | 仅调用本机海外 Gateway/Print/File/Lastleg,创建合成换标单并验证重复下单、三类 PDF、PDA 换标、拆托/合托、返工和换标出库;会写入本地业务数据,不代表现场照片或财务验收 | | 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建草稿,`pending-review` 批量提交后停在待审核且不使用托盘/库位,`wait-split` 只供少量完整测量验收。参数、限制及重跑说明见 `real-order-xlsx-import.md` | | 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 | | 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 | @@ -276,6 +281,10 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行 ## 海外换标定向验收补充 -- `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。只允许本机开发环境且须有当前轮真实写入授权。 +- `db/seed-local-relabel-ready-orders.sql`:仅供明确授权的本机测试库存准备。调用者在同一 MySQL 会话提供客户、共享海外仓、有效虚拟库位、FBA目的仓、唯一批次及ID基数;凭据由本机进程环境提供。以事务插入三单共54箱、六托及原入仓快照、计划、明细、余额、仓位和明确标记 LOCAL_TEST_FIXTURE 的库存流水;不伪造PDA照片、签收操作或HTTP验收通过,不修改原数据。重复批次失败并回滚,不用upsert重置已消耗库存;成功数据保留,结果表输出到调用者日志,不自动清理。 + +- `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。履约阶段中断后可显式设置 `OVERSEAS_RESUME_FULFILLMENT=true`,复用目录内已通过的 `relabel.json` 且只续跑出库派送。只允许本机开发环境且须有当前轮真实写入授权。 - `scripts/overseas-relabel-real-acceptance.mjs`:定向真实 HTTP 换标作业,会创建换标、库存及排车记录;从显式环境文件读取平台/PDA账号、网关及客户参数,输出脚本配置的报告,不自动清理。失败保留证据与已创建资源,重跑前核对报告,必须另获指定环境授权。 +- 该脚本现按“扫码→组托→完工→客户确认”执行,`OVERSEAS_RELABEL_CHARGE_AMOUNT=0` 表示省略费用字段、只验仓库链路(不代表财务通过);默认 0.01 验钱包冻结。照片为有效的合成测试 PNG,不是真实现场照片。 +- `scripts/relabel-pdf-local-acceptance.mjs`:仅本机 Print→File 真实 HTTP 验收,显式提供 `INTERNAL_SERVICE_TOKEN`、`OVERSEAS_TEST_CUSTOMER_ID`,可配置 `PRINT_SERVICE_URL`、`FILE_SERVICE_URL`。创建三类测试 PDF 文件元数据,验证下载哈希及跨客户拒绝,报告 `reports/relabel-closure-20260916/pdf.json`;保留文件,不清理,失败非零退出,可重跑生成新 businessId。不代表换标下单/PDA全流程通过。 - `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。 diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 8b139fde4..271985394 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse'] test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 208], [overseas, overseasSchemas, domesticSchemas, 136]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 208], [overseas, overseasSchemas, domesticSchemas, 138]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (208|136) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (208|138) AS initialized_tables;$/) } }) diff --git a/scripts/current-session-overseas-relabel-acceptance.mjs b/scripts/current-session-overseas-relabel-acceptance.mjs index df617b1d5..2e43cc6de 100644 --- a/scripts/current-session-overseas-relabel-acceptance.mjs +++ b/scripts/current-session-overseas-relabel-acceptance.mjs @@ -113,6 +113,43 @@ async function createPda(adminToken, warehouseId, onCreated) { throw new Error('Temporary PDA security projection did not converge') } +async function merchantSessionForTenant(adminToken, tenantId) { + const ticket = await api(domestic, adminToken, 'POST', + `/api/platform/v1/iam/tenants/${tenantId}/impersonation`, { reason: `${marker} 海外换标真实验收` }) + return api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) +} + +async function findRelabelScope(adminToken, preferredMerchant = null) { + const sessions = [] + if (preferredMerchant?.accessToken && preferredMerchant?.overseasAccessToken) sessions.push(preferredMerchant) + if (!sessions.length) { + const tenants = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') + for (const tenant of tenants.records || []) { + try { sessions.push(await merchantSessionForTenant(adminToken, id(tenant.id))) } catch {} + } + } + for (const session of sessions) { + const customerId = jwtId(session.overseasAccessToken, 'tenant_id', 'tenantId') + if (!customerId) continue + let warehouses + try { warehouses = await api(domestic, session.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200') } + catch { continue } + for (const warehouse of warehouses.records || []) { + if (warehouse.status !== 'ACTIVE') continue + let candidates + try { candidates = await api(overseas, session.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`) } + catch { continue } + if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-') + && Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode + && item.positions?.some((position) => position.palletNo && position.palletNo !== 'UNBOUND' + && Number(position.availablePackages) >= 5))) { + return { merchant: session, customerId, warehouse } + } + } + } + throw new Error('No active customer has a destination warehouse and five cartons on a physical source pallet') +} + await mkdir(outputDir, { recursive: true }) let admin let pda @@ -124,29 +161,22 @@ try { } const storedMerchantToken = storedJwt('cc-client-overseas-access-token') const storedCustomerId = jwtId(storedMerchantToken, 'tenant_id', 'tenantId') - let merchant + let preferredMerchant = null try { - merchant = await refreshStoredSession('cc-client-refresh-token', 'merchant') + preferredMerchant = await refreshStoredSession('cc-client-refresh-token', 'merchant') } catch { - assert(storedCustomerId, 'Current Codex merchant session does not contain a customer id') - const ticket = await api(domestic, admin.accessToken, 'POST', - `/api/platform/v1/iam/tenants/${storedCustomerId}/impersonation`, { reason: `${marker} 当前客户海外API验收` }) - merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) + if (storedCustomerId) preferredMerchant = await merchantSessionForTenant(admin.accessToken, storedCustomerId) } - assert(admin.accessToken && admin.overseasAccessToken && merchant.accessToken && merchant.overseasAccessToken, 'Refresh response did not contain both regional tokens') - const customerId = jwtId(merchant.overseasAccessToken, 'tenant_id', 'tenantId') - assert(customerId, 'Current merchant customer id is missing from the overseas token') - const warehouses = await api(domestic, merchant.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200') - let selected = null - for (const warehouse of warehouses.records || []) { - if (warehouse.status !== 'ACTIVE') continue - const candidates = await api(overseas, merchant.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`) - if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-') && Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5))) { - selected = warehouse; break - } - } - assert(selected, 'Current merchant has no active overseas warehouse with a five-carton DS inventory source') - await disableStalePdas(admin.accessToken) + assert(admin.accessToken && admin.overseasAccessToken, 'Platform refresh response did not contain both regional tokens') + const resume = process.env.OVERSEAS_RESUME_FULFILLMENT === 'true' + if (resume && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || !process.env.OVERSEAS_TEST_WAREHOUSE_ID)) throw new Error('Resume requires explicit customer and warehouse from the saved run') + const scope = resume ? { + merchant: await merchantSessionForTenant(admin.accessToken, process.env.OVERSEAS_TEST_CUSTOMER_ID), + customerId: process.env.OVERSEAS_TEST_CUSTOMER_ID, + warehouse: { id: process.env.OVERSEAS_TEST_WAREHOUSE_ID, warehouseCode: process.env.OVERSEAS_TEST_WAREHOUSE_CODE || null }, + } : await findRelabelScope(admin.accessToken, preferredMerchant) + const { merchant, customerId, warehouse: selected } = scope + // Do not disable accounts from another acceptance run; cleanup owns only this run's account. pda = await createPda(admin.accessToken, id(selected.id), (created) => { pda = created }) const relabelReport = join(outputDir, 'relabel.json') @@ -156,7 +186,10 @@ try { PDA_OVERSEAS_ACCESS_TOKEN: pda.token, OVERSEAS_TEST_CUSTOMER_ID: customerId, OVERSEAS_TEST_WAREHOUSE_ID: id(selected.id), OVERSEAS_ACCEPTANCE_OUTPUT: relabelReport, OVERSEAS_ACCEPTANCE_SOURCE: relabelReport, OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport } - for (const script of ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs']) { + const scripts = process.env.OVERSEAS_CLOSURE_EXTRA === 'true' ? ['relabel-closure-local-acceptance.mjs'] : process.env.OVERSEAS_RESUME_FULFILLMENT === 'true' + ? ['overseas-relabel-fulfillment-acceptance.mjs'] + : ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs'] + for (const script of scripts) { const result = spawnSync(process.execPath, [resolve('scripts', script)], { cwd: resolve('.'), env: childEnv, stdio: 'inherit' }) if (result.error) throw result.error if (result.status !== 0) throw new Error(`${script} failed with exit code ${result.status}`) diff --git a/scripts/db/dev-lastleg-relabel-return-incremental.sql b/scripts/db/dev-lastleg-relabel-return-incremental.sql index fb1e62737..497d468c1 100644 --- a/scripts/db/dev-lastleg-relabel-return-incremental.sql +++ b/scripts/db/dev-lastleg-relabel-return-incremental.sql @@ -17,6 +17,33 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少基础表 ll_relabel_item,请先执行海外尾程基线'; END IF; + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order') THEN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name') THEN + ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人' AFTER delivery_address; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone') THEN + ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话' AFTER pickup_contact_name; + END IF; + END IF; + + CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo ( + id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID', + file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照', + size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at) + ) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用'; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at') THEN + ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间' AFTER uploaded_at; + END IF; + + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_outbound_order') + AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='request_hash') THEN + ALTER TABLE ll_outbound_order ADD COLUMN request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空' AFTER recipient_info; + END IF; + -- 贴标申请:FBA目的仓、换标模式、SKU组合明细及原库存快照。 IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code') THEN ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓编码(与海外操作仓分离)'; diff --git a/scripts/db/dev-warehouse-transfer-pallet-lifecycle-incremental.sql b/scripts/db/dev-warehouse-transfer-pallet-lifecycle-incremental.sql new file mode 100644 index 000000000..e094c5edc --- /dev/null +++ b/scripts/db/dev-warehouse-transfer-pallet-lifecycle-incremental.sql @@ -0,0 +1,25 @@ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_warehouse_transfer_pallet' + AND column_name = 'draft_idempotency_key' + ), + 'SELECT 1', + 'ALTER TABLE ll_warehouse_transfer_pallet + MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', + MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', + MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', + MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', + MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', + ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, + ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, + ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, + ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, + ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key' +)); +PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; +EXECUTE transfer_pallet_lifecycle_stmt; +DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index f705f966d..8945db60e 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -1173,7 +1173,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码', - delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', + delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人', pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号', tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', @@ -3963,6 +3963,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item ( USE baiyue_lastleg; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); PREPARE ll_outbound_lx_codes_stmt FROM @ddl; EXECUTE ll_outbound_lx_codes_stmt; DEALLOCATE PREPARE ll_outbound_lx_codes_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='request_hash'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN request_hash CHAR(64) NULL COMMENT ''一件代发创建请求摘要,其他出库类型为空'' AFTER recipient_info')); +PREPARE ll_outbound_request_hash_stmt FROM @ddl; EXECUTE ll_outbound_request_hash_stmt; DEALLOCATE PREPARE ll_outbound_request_hash_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='lx_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN lx_code VARCHAR(128) NULL COMMENT ''一件代发领星入库单代码''')); PREPARE ll_inbound_lx_code_stmt FROM @ddl; EXECUTE ll_inbound_lx_code_stmt; DEALLOCATE PREPARE ll_inbound_lx_code_stmt; @@ -4201,14 +4203,16 @@ PREPARE return_order_lastleg_no_stmt FROM @ddl; EXECUTE return_order_lastleg_no_ USE baiyue_lastleg; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', - warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', + sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', pallet_no VARCHAR(128) NULL COMMENT '生成后的真实托盘码', + warehouse_location_id BIGINT NULL COMMENT '生成后的真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NULL COMMENT '生成后的仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', - status VARCHAR(32) NOT NULL COMMENT '打托状态', completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + status VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', draft_idempotency_key VARCHAR(255) NULL COMMENT '草稿保存幂等键', draft_request_hash CHAR(64) NULL COMMENT '草稿请求摘要', generate_idempotency_key VARCHAR(255) NULL COMMENT '新托盘生成幂等键', bind_idempotency_key VARCHAR(255) NULL COMMENT '扫码绑定幂等键', bound_at DATETIME(3) NULL COMMENT '扫码确认绑定时间', completed_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_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) ) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_pallet' AND column_name='draft_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_pallet MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key')); +PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; EXECUTE transfer_pallet_lifecycle_stmt; DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数', @@ -4220,3 +4224,80 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id) ) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细'; + +-- 2026-09-16 / 039 仓储转运提货联系人独立于换标联系人。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address')); +PREPARE transfer_pickup_contact_name_stmt FROM @ddl; EXECUTE transfer_pickup_contact_name_stmt; DEALLOCATE PREPARE transfer_pickup_contact_name_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT ''提货联系电话'' AFTER pickup_contact_name')); +PREPARE transfer_pickup_contact_phone_stmt FROM @ddl; EXECUTE transfer_pickup_contact_phone_stmt; DEALLOCATE PREPARE transfer_pickup_contact_phone_stmt; + +-- 2026-09-16 / 040 仓储转运PDA测量现场照片引用。 +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo ( + id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID', + file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照', + size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at) +) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT ''随测量打托提交时间'' AFTER uploaded_at')); +PREPARE transfer_measurement_photo_submitted_stmt FROM @ddl; EXECUTE transfer_measurement_photo_submitted_stmt; DEALLOCATE PREPARE transfer_measurement_photo_submitted_stmt; + +-- 2026-09-16 / 041 仓储转运PDA实收箱数及历史完成状态回填。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='received_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN received_package_count INT NULL COMMENT ''PDA实际入库箱数'' AFTER package_count')); +PREPARE transfer_received_package_count_stmt FROM @ddl; EXECUTE transfer_received_package_count_stmt; DEALLOCATE PREPARE transfer_received_package_count_stmt; +UPDATE ll_order o +JOIN ll_warehouse_transfer_order t + ON t.customer_id=o.customer_id AND t.lastleg_order_id=o.id AND t.transfer_no=o.source_order_no +JOIN ( + SELECT customer_id,transfer_order_id,SUM(carton_count) AS received_package_count + FROM ll_warehouse_transfer_pallet + WHERE status='COMPLETED' + GROUP BY customer_id,transfer_order_id +) p ON p.customer_id=t.customer_id AND p.transfer_order_id=t.id +SET o.received_package_count=p.received_package_count, + o.current_status='INBOUND_COMPLETED', + o.updated_at=CURRENT_TIMESTAMP(3) +WHERE o.order_type='WAREHOUSE_TRANSFER' + AND t.status IN ('WAIT_CUSTOMER_CONFIRMATION','WAIT_FBA_DESTINATION','WAIT_RELABEL','READY_FOR_DISPATCH','ALLOCATED','OUTBOUNDED','PARTIALLY_RECEIVED','RECEIVED'); +UPDATE ll_inbound_order i +JOIN ll_warehouse_transfer_order t + ON t.customer_id=i.customer_id AND t.inbound_order_id=i.id AND t.inbound_code=t.transfer_no +JOIN ( + SELECT customer_id,transfer_order_id,COUNT(*) AS pallet_count,SUM(carton_count) AS package_count, + SUM(gross_weight_kg) AS gross_weight_kg,SUM(volume_cbm) AS volume_cbm + FROM ll_warehouse_transfer_pallet + WHERE status='COMPLETED' + GROUP BY customer_id,transfer_order_id +) p ON p.customer_id=t.customer_id AND p.transfer_order_id=t.id +SET i.status='RECEIVED',i.package_count=p.package_count,i.pallet_count=p.pallet_count, + i.gross_weight_kg=p.gross_weight_kg,i.volume_cbm=p.volume_cbm,i.updated_at=CURRENT_TIMESTAMP(3) +WHERE t.status IN ('WAIT_CUSTOMER_CONFIRMATION','WAIT_FBA_DESTINATION','WAIT_RELABEL','READY_FOR_DISPATCH','ALLOCATED','OUTBOUNDED','PARTIALLY_RECEIVED','RECEIVED'); + +-- 2026-09-16 / 042 仓储转运直接出库的FBA/私人仓目的地快照。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'' AFTER destination_warehouse_name, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓派送地址'' AFTER destination_type, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER delivery_address, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER contact_name')); +PREPARE transfer_outbound_destination_stmt FROM @ddl; EXECUTE transfer_outbound_destination_stmt; DEALLOCATE PREPARE transfer_outbound_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空''')); +PREPARE transfer_outbound_nullable_stmt FROM @ddl; EXECUTE transfer_outbound_nullable_stmt; DEALLOCATE PREPARE transfer_outbound_nullable_stmt; + +-- 2026-09-17 / 043 仓储转运目的地绑定及私人仓邮编快照。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照'' AFTER destination_type, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN private_destination_address VARCHAR(1000) NULL COMMENT ''私人仓完整地址'' AFTER destination_warehouse_name, ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address, ADD COLUMN private_destination_contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER private_destination_postal_code, ADD COLUMN private_destination_contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER private_destination_contact_name, ADD COLUMN destination_binding_idempotency_key VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'', ADD COLUMN destination_binding_request_hash CHAR(64) NULL COMMENT ''目的地绑定请求哈希''')); +PREPARE transfer_destination_stmt FROM @ddl; EXECUTE transfer_destination_stmt; DEALLOCATE PREPARE transfer_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='private_destination_postal_code'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address')); +PREPARE transfer_postal_stmt FROM @ddl; EXECUTE transfer_postal_stmt; DEALLOCATE PREPARE transfer_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='delivery_address'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_line_destination_stmt FROM @ddl; EXECUTE transfer_line_destination_stmt; DEALLOCATE PREPARE transfer_line_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_line_postal_stmt FROM @ddl; EXECUTE transfer_line_postal_stmt; DEALLOCATE PREPARE transfer_line_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'', ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_pallet_destination_stmt FROM @ddl; EXECUTE transfer_pallet_destination_stmt; DEALLOCATE PREPARE transfer_pallet_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_pallet_postal_stmt FROM @ddl; EXECUTE transfer_pallet_postal_stmt; DEALLOCATE PREPARE transfer_pallet_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); +PREPARE dispatch_destination_stmt FROM @ddl; EXECUTE dispatch_destination_stmt; DEALLOCATE PREPARE dispatch_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE dispatch_postal_stmt FROM @ddl; EXECUTE dispatch_postal_stmt; DEALLOCATE PREPARE dispatch_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address')); +PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt; diff --git a/scripts/db/relabel-file-scope-incremental.sql b/scripts/db/relabel-file-scope-incremental.sql index e458716eb..cad82579b 100644 --- a/scripts/db/relabel-file-scope-incremental.sql +++ b/scripts/db/relabel-file-scope-incremental.sql @@ -1,5 +1,8 @@ USE baiyue_lastleg; +-- 当前文件服务数据库;不删除或重建文件元数据。 +ALTER TABLE baiyue_file.file_object MODIFY COLUMN business_type VARCHAR(64) NULL COMMENT '关联业务类型'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_id VARCHAR(128) NULL COMMENT ''箱唛总文件ID'' AFTER total_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_name VARCHAR(255) NULL COMMENT ''箱唛总文件名'' AFTER carton_total_file_id')); diff --git a/scripts/db/relabel-wait-status-incremental.sql b/scripts/db/relabel-wait-status-incremental.sql new file mode 100644 index 000000000..d14947c6e --- /dev/null +++ b/scripts/db/relabel-wait-status-incremental.sql @@ -0,0 +1,16 @@ +-- 存量换标尾程主单状态修正:不创建入库单,不增减库存;可重复执行。 +USE baiyue_lastleg; +START TRANSACTION; +UPDATE ll_order o +JOIN ll_relabel_application a ON a.relabel_no=o.relabel_order_no AND a.customer_id=o.customer_id +SET o.current_status=CASE + WHEN a.status IN ('PENDING_PROCESSING','PROCESSING','CUSTOMER_CONFIRM_PENDING') THEN 'WAIT_RELABEL' + WHEN a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' THEN 'WAIT_OUTBOUND' + WHEN a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status IN ('WAIT_OUTBOUND_CREATION','OUTBOUND_CREATED') THEN 'WAIT_OUTBOUND' + ELSE o.current_status END, + o.updated_at=CURRENT_TIMESTAMP(3) +WHERE o.order_type='RELABEL_APPLICATION' AND o.current_status='PENDING_ASSIGNMENT' + AND o.inbound_order_no IS NULL + AND (a.status IN ('PENDING_PROCESSING','PROCESSING','CUSTOMER_CONFIRM_PENDING') + OR (a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status IN ('WAIT_PRE_DISPATCH','WAIT_OUTBOUND_CREATION','OUTBOUND_CREATED'))); +COMMIT; diff --git a/scripts/db/seed-local-relabel-ready-orders.sql b/scripts/db/seed-local-relabel-ready-orders.sql new file mode 100644 index 000000000..095f1d924 --- /dev/null +++ b/scripts/db/seed-local-relabel-ready-orders.sql @@ -0,0 +1,114 @@ +-- LOCAL ONLY: explicit test fixtures, NOT evidence of real PDA receiving. +-- Caller must set @fixture_customer_id, @fixture_warehouse_id, @fixture_location_id, +-- @fixture_destination_id, @fixture_base and @fixture_batch in the same connection. +-- Insert-only: duplicate IDs/numbers fail; never restore or overwrite consumed stock. +SET NAMES utf8mb4; +USE baiyue_lastleg; +CREATE TEMPORARY TABLE fixture_guard (ok INT NOT NULL CHECK (ok=1)); +INSERT INTO fixture_guard SELECT IF(@fixture_customer_id>0 AND @fixture_base>0 + AND @fixture_batch REGEXP '^[A-Z0-9-]{1,16}$',1,0); +SET @fixture_customer_name=(SELECT tenant_name FROM baiyue_iam.iam_tenant + WHERE id=@fixture_customer_id AND deleted=0 AND status='ACTIVE'); +SET @fixture_warehouse_code=(SELECT warehouse_code FROM baiyue_warehouse.wh_warehouse + WHERE id=@fixture_warehouse_id AND warehouse_type='OVERSEAS' AND status='ACTIVE' AND deleted=0); +SET @fixture_warehouse_name=(SELECT warehouse_name FROM baiyue_warehouse.wh_warehouse WHERE id=@fixture_warehouse_id); +SET @fixture_location_code=(SELECT location_code FROM baiyue_lastlag_warehouse.wh_location + WHERE id=@fixture_location_id AND warehouse_id=@fixture_warehouse_id AND location_type='VIRTUAL' AND status='ACTIVE'); +SET @fixture_destination_code=(SELECT warehouse_code FROM baiyue_warehouse.wh_warehouse + WHERE id=@fixture_destination_id AND warehouse_type='FBA' AND status='ACTIVE' AND deleted=0); +INSERT INTO fixture_guard SELECT IF(@fixture_customer_name IS NOT NULL AND @fixture_warehouse_code IS NOT NULL + AND @fixture_location_code IS NOT NULL AND @fixture_destination_code IS NOT NULL,1,0); +CREATE TEMPORARY TABLE fixture_orders (seq INT PRIMARY KEY, boxes INT, order_id BIGINT, plan_id BIGINT, + balance_id BIGINT, inbound_id BIGINT, order_no VARCHAR(32), plan_no VARCHAR(32), inbound_code VARCHAR(128), sku VARCHAR(128)); +INSERT INTO fixture_orders +SELECT n,6+n*6,@fixture_base+n,@fixture_base+100+n,@fixture_base+200+n,@fixture_base+300+n, + CONCAT('TL-',@fixture_batch,'-',n),CONCAT('IP-',@fixture_batch,'-',n), + CONCAT('IN-',@fixture_batch,'-',n),CONCAT('SKU-',@fixture_batch,'-',n) +FROM (SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3) numbers; +CREATE TEMPORARY TABLE fixture_pallets (seq INT, part INT, pallet_id BIGINT, position_id BIGINT, + pallet_code VARCHAR(64), boxes INT, PRIMARY KEY(seq,part)); +INSERT INTO fixture_pallets SELECT f.seq,p.n,@fixture_base+400+f.seq*10+p.n, + @fixture_base+500+f.seq*10+p.n,CONCAT('PAL-',@fixture_batch,'-',f.seq,'-',p.n),f.boxes/2 +FROM fixture_orders f CROSS JOIN (SELECT 1 n UNION ALL SELECT 2) p; +CREATE TEMPORARY TABLE fixture_numbers (n INT PRIMARY KEY); +INSERT INTO fixture_numbers SELECT a.n+b.n*10+1 FROM + (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a + CROSS JOIN (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2) b; +START TRANSACTION; +INSERT INTO baiyue_lastlag_warehouse.wh_pallet + (id,customer_id,warehouse_id,location_id,pallet_code,pallet_type,capacity_cbm,status,remark,fba_warehouse_code,version) +SELECT pallet_id,0,@fixture_warehouse_id,@fixture_location_id,pallet_code,'STANDARD',2,'OCCUPIED', + 'LOCAL_TEST_FIXTURE: relabel source inventory; not real PDA acceptance',@fixture_destination_code,0 FROM fixture_pallets; +INSERT INTO ll_inbound_order + (id,customer_id,source_region,source_inbound_id,source_order_id,source_order_no,inbound_code, + destination_warehouse_id,destination_warehouse_code,destination_type,package_count,product_quantity,pallet_count, + source_event_id,source_loading_plan_id,source_loading_plan_version,handover_version,payload_hash,status) +SELECT inbound_id,@fixture_customer_id,'FIRST_LEG',CONCAT('FIXTURE-',inbound_code),CAST(order_id AS CHAR),order_no,inbound_code, + @fixture_destination_id,@fixture_destination_code,'FBA',boxes,boxes*10,2, + CONCAT('FIXTURE-',order_no),CONCAT('FIXTURE-LOAD-',order_no),1,1,SHA2(order_no,256),'RECEIVED' FROM fixture_orders; +INSERT INTO ll_order + (id,order_no,customer_id,order_type,lx_code,destination_type,inbound_code,source_region,source_inbound_id, + source_order_id,source_order_no,customer_name,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name, + fba_warehouse_code,container_no,container_status,pallet_count,package_count,product_quantity, + inbound_order_no,current_status,source_event_id,source_loading_plan_id,source_payload_hash) +SELECT order_id,order_no,@fixture_customer_id,'FIRST_LEG',CONCAT('LX-',order_no),'FBA',inbound_code,'FIRST_LEG', + CONCAT('FIXTURE-',inbound_code),CAST(order_id AS CHAR),order_no,@fixture_customer_name, + @fixture_warehouse_id,@fixture_warehouse_code,@fixture_warehouse_name,@fixture_destination_code, + CONCAT('FIXTURE-',@fixture_batch),'LOCAL_TEST_FIXTURE',2,boxes,boxes*10,plan_no,'INBOUND_COMPLETED', + CONCAT('FIXTURE-',order_no),CONCAT('FIXTURE-LOAD-',order_no),SHA2(order_no,256) FROM fixture_orders; +INSERT INTO ll_order_detail + (id,customer_id,order_id,source_inbound_detail_id,sku_code,product_name_zh,package_count,product_quantity,pallet_count, + destination_warehouse_id,destination_warehouse_code,shipping_mark) +SELECT @fixture_base+10000+f.seq*100+n.n,@fixture_customer_id,f.order_id, + CONCAT('FIXTURE-',f.inbound_code,'-',n.n),f.sku,CONCAT('贴标测试商品-',f.seq),1,10,0, + @fixture_destination_id,@fixture_destination_code,CONCAT('CM-',@fixture_batch,'-',f.seq,'-',LPAD(n.n,3,'0')) +FROM fixture_orders f JOIN fixture_numbers n ON n.n<=f.boxes; +INSERT INTO ll_inbound_plan + (id,plan_no,customer_id,source_order_id,container_no,overseas_warehouse_id,planned_arrival_date, + pallet_type,assigned_flag,creation_mode,plan_remark,status,line_count,pallet_count,total_volume_cbm,version) +SELECT plan_id,plan_no,@fixture_customer_id,order_id,CONCAT('FIXTURE-',@fixture_batch),@fixture_warehouse_id, + CURRENT_DATE,'STANDARD',1,'MANUAL','LOCAL_TEST_FIXTURE: 已入库测试准备,非真实PDA签收验收','COMPLETED',1,2,2,0 FROM fixture_orders; +INSERT INTO ll_inbound_plan_line (id,customer_id,plan_id,order_id,inbound_code,required_package_count, + planned_pallet_count,destination_type,destination_warehouse_code,fba_warehouse_code) +SELECT @fixture_base+700+seq,@fixture_customer_id,plan_id,order_id,inbound_code,boxes,2,'FBA', + @fixture_destination_code,@fixture_destination_code FROM fixture_orders; +INSERT INTO ll_inbound_plan_pallet + (id,customer_id,plan_id,pallet_no,pallet_type,destination_warehouse_code,capacity_cbm,allocated_volume_cbm, + sequence_no,warehouse_pallet_id,assignment_status,actual_volume_cbm,actual_package_count) +SELECT p.pallet_id+1000,@fixture_customer_id,f.plan_id,p.pallet_code,'STANDARD',@fixture_destination_code, + 2,1,p.part,p.pallet_id,'ASSIGNED',1,p.boxes FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO ll_inventory_balance + (id,customer_id,customer_name,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name, + destination_warehouse_code,inbound_order_id,inbound_order_no,inbound_code,sku_code,product_name_zh, + available_packages,available_quantity,version) +SELECT balance_id,@fixture_customer_id,@fixture_customer_name,@fixture_warehouse_id,@fixture_warehouse_code, + @fixture_warehouse_name,@fixture_destination_code,order_id,plan_no,inbound_code,sku,CONCAT('贴标测试商品-',seq),boxes,boxes*10,0 FROM fixture_orders; +INSERT INTO ll_inventory_position + (id,customer_id,balance_id,overseas_warehouse_id,destination_warehouse_code,sku_code,pallet_id,pallet_no,batch_no, + warehouse_location_id,warehouse_location_code,available_packages,product_quantity,status,version) +SELECT p.position_id,@fixture_customer_id,f.balance_id,@fixture_warehouse_id,@fixture_destination_code,f.sku,p.pallet_id, + p.pallet_code,CONCAT('FIXTURE-',@fixture_batch),@fixture_location_id,@fixture_location_code,p.boxes,p.boxes*10,'OCCUPIED',0 +FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO ll_inbound_plan_pallet_allocation + (id,customer_id,plan_id,line_id,pallet_id,allocated_volume_cbm,planned_package_count, + counted_package_count,picked_package_count,actual_volume_cbm,status,version) +SELECT p.pallet_id+3000,@fixture_customer_id,f.plan_id,@fixture_base+700+f.seq,p.pallet_id+1000, + 1,p.boxes,p.boxes,p.boxes,1,'PICKED',0 +FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO ll_inventory_ledger + (id,customer_id,customer_name,balance_id,position_id,overseas_warehouse_id,destination_warehouse_code, + inbound_order_id,sku_code,pallet_id,pallet_no,batch_no,warehouse_location_code,business_type,business_order_no, + inbound_code,inbound_order_no,idempotency_key,status_from,status_to,before_packages,delta_packages,after_packages,remark,occurred_at) +SELECT p.position_id+2000,@fixture_customer_id,@fixture_customer_name,f.balance_id,p.position_id,@fixture_warehouse_id, + @fixture_destination_code,f.order_id,f.sku,p.pallet_id,p.pallet_code,CONCAT('FIXTURE-',@fixture_batch),@fixture_location_code, + 'LOCAL_TEST_FIXTURE',f.order_no,f.inbound_code,f.plan_no,CONCAT('LOCAL_TEST_FIXTURE:',p.pallet_code),'NONE','AVAILABLE', + 0,p.boxes,p.boxes,'Explicit local test fixture; not a real PDA stock-in event',NOW(3) +FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO fixture_guard SELECT IF(COUNT(*)=6 AND SUM(p.available_packages)=54,1,0) +FROM ll_inventory_position p JOIN fixture_pallets f ON f.position_id=p.id; +COMMIT; +SELECT f.order_no,f.plan_no,f.inbound_code,f.sku,p.pallet_code,p.boxes, + CONCAT('CM-',@fixture_batch,'-',f.seq,'-001') first_carton_mark, + CONCAT('CM-',@fixture_batch,'-',f.seq,'-',LPAD(f.boxes,3,'0')) last_carton_mark +FROM fixture_orders f JOIN fixture_pallets p ON p.seq=f.seq ORDER BY f.seq,p.part; diff --git a/scripts/db/warehouse-transfer-outbound-incremental.sql b/scripts/db/warehouse-transfer-outbound-incremental.sql new file mode 100644 index 000000000..aff2665d3 --- /dev/null +++ b/scripts/db/warehouse-transfer-outbound-incremental.sql @@ -0,0 +1,326 @@ +-- 仓储转运审核、PDA测量打托、换标、目的地绑定、智能排车与派送字段。 +-- 目标数据库:baiyue_lastleg +-- 可重复执行:字段和索引独立检查,补充缺失结构并修正必要的可空性;不更新业务数据。 + +USE `baiyue_lastleg`; + +DROP PROCEDURE IF EXISTS `ensure_transfer_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_nullable_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_index`; +DROP PROCEDURE IF EXISTS `ensure_transfer_pallet_unique_index`; + +DELIMITER $$ + +CREATE PROCEDURE `ensure_transfer_column`( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), + '` ', p_column_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ + +CREATE PROCEDURE `ensure_transfer_nullable_column`( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + AND is_nullable = 'NO' + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` MODIFY COLUMN `', REPLACE(p_column_name, '`', '``'), + '` ', p_column_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ + +CREATE PROCEDURE `ensure_transfer_index`( + IN p_table_name VARCHAR(64), + IN p_index_name VARCHAR(64), + IN p_index_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND index_name = p_index_name + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD ', p_index_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ + +CREATE PROCEDURE `ensure_transfer_pallet_unique_index`() +BEGIN + DECLARE v_duplicate_groups BIGINT DEFAULT 0; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_inbound_plan_pallet' + AND index_name = 'uk_ll_inbound_pallet_no' + ) THEN + SELECT COUNT(*) + INTO v_duplicate_groups + FROM ( + SELECT 1 + FROM `ll_inbound_plan_pallet` + GROUP BY `customer_id`, `pallet_no` + HAVING COUNT(*) > 1 + ) duplicate_groups; + + IF v_duplicate_groups = 0 THEN + ALTER TABLE `ll_inbound_plan_pallet` + ADD UNIQUE KEY `uk_ll_inbound_pallet_no` (`customer_id`,`pallet_no`); + END IF; + END IF; +END$$ + +DELIMITER ; + +-- 相关业务表统一补齐 SaaS 租户字段。默认值只用于兼容历史数据和单租户海外环境。 +CALL ensure_transfer_column('ll_delivery_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_dispatch_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_relabel_application', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_relabel_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_relabel_status_history', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_inventory_allocation', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_receipt', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_source_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); + +-- 退货换标允许只更换箱唛,不强制生成新 SKU。 +CALL ensure_transfer_nullable_column('ll_relabel_item', 'new_sku_code', 'VARCHAR(128) NULL COMMENT ''换标后SKU编码'''); + +-- 主单审核、提货、发货、换标和客户确认事实。 +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'pickup_contact_name', 'VARCHAR(128) NULL COMMENT ''提货联系人'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'pickup_contact_phone', 'VARCHAR(64) NULL COMMENT ''提货联系电话'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'estimated_ship_at', 'DATETIME(3) NULL COMMENT ''预计发货时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'review_decision', 'VARCHAR(32) NULL COMMENT ''审核决定'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'review_remark', 'VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'reviewed_by', 'VARCHAR(128) NULL COMMENT ''审核人ID'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'reviewed_at', 'DATETIME(3) NULL COMMENT ''审核时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'label_deadline', 'DATETIME(3) NULL COMMENT ''换标截止时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_contact_name', 'VARCHAR(128) NULL COMMENT ''换标联系人'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_contact_phone', 'VARCHAR(64) NULL COMMENT ''换标联系电话'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_charge_amount', 'DECIMAL(20,2) NULL COMMENT ''换标费用CNY'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_delivery_method', 'VARCHAR(32) NULL COMMENT ''换标后派送方式'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_completed_at', 'DATETIME(3) NULL COMMENT ''关联贴标完成时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'palletize_idempotency_key', 'VARCHAR(255) NULL COMMENT ''PDA打托幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'palletize_request_hash', 'CHAR(64) NULL COMMENT ''PDA打托请求哈希'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'confirmation_idempotency_key', 'VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'confirmation_request_hash', 'CHAR(64) NULL COMMENT ''客户测量确认请求哈希'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'customer_confirmed_at', 'DATETIME(3) NULL COMMENT ''客户确认测量时间'''); + +-- 仓储转运主单目的地。 +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_type', 'VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_id', 'VARCHAR(128) NULL COMMENT ''目的仓ID快照'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_code', 'VARCHAR(128) NULL COMMENT ''目的仓编码快照'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_name', 'VARCHAR(255) NULL COMMENT ''目的仓名称快照'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_address', 'VARCHAR(1000) NULL COMMENT ''私人仓完整地址'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_binding_idempotency_key', 'VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_binding_request_hash', 'CHAR(64) NULL COMMENT ''目的地绑定请求哈希'''); + +-- 转仓尾程主单记录PDA实收箱数。 +CALL ensure_transfer_column('ll_order', 'received_package_count', 'INT NULL COMMENT ''PDA实际入库箱数'''); + +-- PDA逐托草稿、生成、扫码绑定和整单完成事实。 +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_pallet` ( + `id` BIGINT NOT NULL COMMENT '打托事实ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `sequence_no` INT NOT NULL COMMENT '托序号', + `warehouse_pallet_id` BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', + `pallet_no` VARCHAR(128) NULL COMMENT '生成后的真实托盘码', + `warehouse_location_id` BIGINT NULL COMMENT '生成后的真实库位ID', + `warehouse_location_code` VARCHAR(128) NULL COMMENT '真实库位码', + `warehouse_resource_lock_id` BIGINT NULL COMMENT '生成后的仓储资源锁ID', + `carton_count` INT NOT NULL COMMENT '本托实收箱数', + `length_cm` DECIMAL(12,2) NOT NULL COMMENT '实测长cm', + `width_cm` DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', + `height_cm` DECIMAL(12,2) NOT NULL COMMENT '实测高cm', + `gross_weight_kg` DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', + `volume_cbm` DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', + `status` VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', + `draft_idempotency_key` VARCHAR(255) NULL COMMENT '草稿保存幂等键', + `draft_request_hash` CHAR(64) NULL COMMENT '草稿请求摘要', + `generate_idempotency_key` VARCHAR(255) NULL COMMENT '新托盘生成幂等键', + `bind_idempotency_key` VARCHAR(255) NULL COMMENT '扫码绑定幂等键', + `bound_at` DATETIME(3) NULL COMMENT '扫码确认绑定时间', + `completed_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_transfer_pallet_seq` (`transfer_order_id`,`sequence_no`), + UNIQUE KEY `uk_ll_transfer_pallet_physical` (`customer_id`,`warehouse_pallet_id`), + KEY `idx_ll_transfer_pallet_order` (`customer_id`,`transfer_order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA实测打托事实'; + +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_pallet_item` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID', + `transfer_pallet_id` BIGINT NOT NULL COMMENT '转运托盘事实ID', + `transfer_item_id` BIGINT NOT NULL COMMENT '转运申报明细ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `package_count` INT NOT NULL COMMENT '本托该SKU实收箱数', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_pallet_item` (`transfer_pallet_id`,`transfer_item_id`), + KEY `idx_ll_transfer_pallet_item_order` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运每托SKU箱数回填'; + +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_relabel_binding` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `operation_type` VARCHAR(32) NOT NULL COMMENT '换标类型', + `customer_carton_mark` VARCHAR(255) NOT NULL COMMENT '客户原箱唛', + `original_sku` VARCHAR(128) NOT NULL COMMENT '旧SKU', + `new_sku` VARCHAR(128) NULL COMMENT '新SKU', + `quantity` INT NOT NULL COMMENT '换标件数', + `fba_carton_mark` VARCHAR(255) NULL COMMENT '新FBA箱唛', + `destination_warehouse_code` VARCHAR(128) NULL COMMENT 'FBA目的仓', + `remark` VARCHAR(1000) NULL COMMENT '备注', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_relabel_mark` (`transfer_order_id`,`customer_carton_mark`), + KEY `idx_ll_transfer_relabel_order` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运换标预绑定明细'; + +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_pallet_id', 'BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'pallet_no', 'VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_location_id', 'BIGINT NULL COMMENT ''生成后的真实库位ID'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_resource_lock_id', 'BIGINT NULL COMMENT ''生成后的仓储资源锁ID'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'completed_at', 'DATETIME(3) NULL COMMENT ''整单打托完成时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'draft_idempotency_key', 'VARCHAR(255) NULL COMMENT ''草稿保存幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'draft_request_hash', 'CHAR(64) NULL COMMENT ''草稿请求摘要'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'generate_idempotency_key', 'VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'bind_idempotency_key', 'VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'bound_at', 'DATETIME(3) NULL COMMENT ''扫码确认绑定时间'''); + +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_measurement_photo` ( + `id` BIGINT NOT NULL COMMENT '测量照片引用ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '仓储转运单ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `file_id` BIGINT NOT NULL COMMENT '文件服务文件ID', + `file_name` VARCHAR(255) NULL COMMENT '文件名快照', + `content_type` VARCHAR(128) NULL COMMENT '文件媒体类型快照', + `size_bytes` BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', + `uploaded_by` VARCHAR(128) NULL COMMENT 'PDA上传操作人', + `uploaded_at` DATETIME(3) NOT NULL COMMENT '上传时间', + `submitted_at` DATETIME(3) NULL COMMENT '随测量打托提交时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_measurement_photo_file` (`customer_id`,`transfer_order_id`,`file_id`), + KEY `idx_ll_transfer_measurement_photo_order` (`customer_id`,`transfer_order_id`,`uploaded_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA测量现场照片引用'; +CALL ensure_transfer_column('ll_warehouse_transfer_measurement_photo', 'submitted_at', 'DATETIME(3) NULL COMMENT ''随测量打托提交时间'''); +CALL ensure_transfer_index('ll_warehouse_transfer_measurement_photo', 'uk_ll_transfer_measurement_photo_file', + 'UNIQUE KEY `uk_ll_transfer_measurement_photo_file` (`customer_id`,`transfer_order_id`,`file_id`)'); +CALL ensure_transfer_index('ll_warehouse_transfer_measurement_photo', 'idx_ll_transfer_measurement_photo_order', + 'KEY `idx_ll_transfer_measurement_photo_order` (`customer_id`,`transfer_order_id`,`uploaded_at`)'); + +-- 入库计划行和托盘保存私人仓目的地快照。 +CALL ensure_transfer_column('ll_inbound_plan_line', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''); + +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'destination_type', 'VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''); +CALL ensure_transfer_index('ll_inbound_plan_line', 'idx_ll_inbound_line_tenant_plan', + 'KEY `idx_ll_inbound_line_tenant_plan` (`customer_id`,`plan_id`)'); +CALL ensure_transfer_index('ll_inbound_plan_pallet', 'idx_ll_inbound_pallet_tenant_plan', + 'KEY `idx_ll_inbound_pallet_tenant_plan` (`customer_id`,`plan_id`)'); +CALL ensure_transfer_pallet_unique_index(); + +-- 智能排车候选和排车明细快照。 +CALL ensure_transfer_column('ll_dispatch_item', 'destination_type', 'VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_dispatch_item', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'''); +CALL ensure_transfer_column('ll_dispatch_item', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照'''); +CALL ensure_transfer_column('ll_dispatch_item', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照'''); +CALL ensure_transfer_column('ll_dispatch_item', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''); + +-- 派送单允许私人仓目的地,并保留地址、邮编及联系人快照。 +CALL ensure_transfer_column('ll_delivery_order', 'destination_type', 'VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_delivery_order', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓派送地址'''); +CALL ensure_transfer_column('ll_delivery_order', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编'''); +CALL ensure_transfer_column('ll_delivery_order', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人'''); +CALL ensure_transfer_column('ll_delivery_order', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话'''); + +-- 仓储转运直接出库历史兼容:这三列允许为空。 +CALL ensure_transfer_nullable_column('ll_delivery_order', 'dispatch_order_id', 'BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'''); +CALL ensure_transfer_nullable_column('ll_delivery_order', 'dispatch_no', 'VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'''); +CALL ensure_transfer_nullable_column('ll_delivery_order', 'destination_warehouse_code', 'VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空'''); + +DROP PROCEDURE IF EXISTS `ensure_transfer_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_nullable_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_index`; +DROP PROCEDURE IF EXISTS `ensure_transfer_pallet_unique_index`; + +SELECT DATABASE() AS target_database, + IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_inbound_plan_pallet' + AND index_name = 'uk_ll_inbound_pallet_no' + ), + 'WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY', + 'WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER' + ) AS migration_result, + IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_inbound_plan_pallet' + AND index_name = 'uk_ll_inbound_pallet_no' + ), + 'READY', + 'BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO' + ) AS inbound_pallet_unique_index; diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs index d2117ed5b..aa0912c22 100644 --- a/scripts/overseas-relabel-fulfillment-acceptance.mjs +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -22,8 +22,8 @@ function assert(value, message) { if (!value) throw new Error(message) } async function api(base, token, method, path, body) { const response = await fetch(`${base}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, - body: body === undefined ? undefined : JSON.stringify(body) }) + ...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, + body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body) }) const text = await response.text(); let value try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } if (!response.ok || String(value?.code) !== '200') { @@ -48,12 +48,32 @@ const platformToken = directPlatformToken || platformLogin.overseasAccessToken const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken const pdaToken = directPdaToken || pdaLogin.overseasAccessToken -await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { - vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', - carrierName: '尾程验收车队', route: `${marker}-ROUTE`, - estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(), -}) -const transfer = await api(overseas, merchantToken, 'POST', +let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) +let alreadyDeparted = task.status === 'DEPARTED' + || (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车')) +if (!alreadyDeparted && task.status === 'PENDING') { + await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { + vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', + carrierName: '尾程验收车队', route: `${marker}-ROUTE`, + estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(), + }) +} +const existingPage = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders?customerId=${customerId}&page=1&size=20&dispatchNo=${encodeURIComponent(dispatchNo)}`) +const existingDeliveries = (existingPage.records || []).filter((value) => value.dispatchNo === dispatchNo) +const existingOutboundNos = new Set(existingDeliveries.map((value) => value.outboundNo).filter(Boolean)) +assert(existingOutboundNos.size <= 1, 'Dispatch is linked to multiple outbound orders') +const transfer = existingDeliveries.length > 0 ? { + outboundNo: [...existingOutboundNos][0], + deliveries: existingDeliveries.map((value) => ({ + deliveryNo: value.deliveryNo, deliveryStatus: value.status, + destinationWarehouseId: value.destinationWarehouseId, + destinationWarehouseCode: value.destinationWarehouseCode, + destinationWarehouseName: value.destinationWarehouseName, + palletCount: value.palletCount, packageCount: value.packageCount, + })), + replayed: true, +} : await api(overseas, merchantToken, 'POST', `/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, { idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队', @@ -62,29 +82,53 @@ const transfer = await api(overseas, merchantToken, 'POST', appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }], }) -assert(transfer.outboundNo && transfer.deliveryNo, 'Transfer did not create outbound and delivery') +const transferDeliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] +assert(transfer.outboundNo && transferDeliveries.length > 0, + 'Transfer did not create outbound and delivery') +assert(transferDeliveries.length === 1, + `Expected one destination delivery, got ${transferDeliveries.length}`) +const deliveryNo = transferDeliveries[0].deliveryNo -let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) assert(Array.isArray(task.items) && task.items.length === 2, 'Expected two PDA pallet items') -for (const [itemIndex, item] of task.items.entries()) { - let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) - for (const [positionIndex, position] of item.positions.entries()) { - if (remaining <= 0) break - const packages = Math.min(remaining, Number(position.availablePackages)) - if (packages <= 0) continue - await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, { - positionId: id(position.id), packages, productQuantity: packages, - idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker, - }) - remaining -= packages +if (!alreadyDeparted) { + if (task.items.every(item => Number(item.pickedPackages) === 0)) { + await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { + vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', + carrierName: '尾程验收车队', fbaAppointmentNo: `UAT-${marker.slice(-8)}`, + fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'CONFIRMED', + estimatedArrivalAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19), + }) } - assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`) + for (const [itemIndex, item] of task.items.entries()) { + let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) + for (const [positionIndex, position] of item.positions.entries()) { + if (remaining <= 0) break + const packages = Math.min(remaining, Number(position.availablePackages)) + if (packages <= 0) continue + await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, { + positionId: id(position.id), packages, productQuantity: packages, + idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker, + }) + remaining -= packages + } + assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`) + } + task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) + alreadyDeparted = task.status === 'DEPARTED' + || (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车')) } -const completed = await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') }) -assert(completed.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART') -const departed = await api(overseas, pdaToken, 'POST', +if (!alreadyDeparted && task.status !== 'COMPLETED') { + const pickingCompleted = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') }) + assert(pickingCompleted.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART') + task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) +} +assert(alreadyDeparted || task.status === 'COMPLETED', 'Dispatch did not finish picking') +const departed = alreadyDeparted ? { + status: 'DEPARTED', + shippedPackages: task.items.reduce((total, item) => total + Number(item.pickedPackages), 0), +} : await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, { idempotencyKey: key('DEPART'), remark: marker }) assert(departed.status === 'DEPARTED', 'Dispatch did not reach DEPARTED') @@ -92,24 +136,47 @@ const outbound = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`) assert(outbound.outbound.status === 'OUTBOUNDED', 'Outbound did not reach OUTBOUNDED') let delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`) -const delivering = await api(overseas, platformToken, 'PUT', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/status?customerId=${customerId}`, { - status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker, + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) +if (delivery.order.status === 'WAIT_DELIVERY') { + const delivering = await api(overseas, platformToken, 'PUT', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, { + status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker, + }) + assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING') + delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) +} +if (delivery.order.status !== 'DELIVERED') { + assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`) + const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}` + const documents = await api(overseas, platformToken, 'GET', documentsPath) + const form = new FormData() + form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }), + `${marker}-${deliveryNo}-receipt.txt`) + const receipt = await api(overseas, platformToken, 'POST', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form) + const existingBindings = (documents.categories || []).map((category) => ({ + category: category.category, + fileIds: (category.files || []).map((file) => id(file.fileId)), + })).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0) + await api(overseas, platformToken, 'PUT', documentsPath, { + expectedVersion: Number(documents.version), idempotencyKey: key('SAVE-RECEIPT'), + bolNo: documents.bolNo || null, + documents: [...existingBindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }], }) -assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING') -delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`) -const delivered = await api(overseas, platformToken, 'POST', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/complete?customerId=${customerId}`, { - status: 'DELIVERED', expectedVersion: Number(delivery.order.version), remark: marker, - }) -assert(delivered.deliveryStatus === 'DELIVERED', 'Delivery did not reach DELIVERED') + delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) +} +assert(delivery.order.status === 'DELIVERED', 'Receipt did not automatically complete delivery') +const visits = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/customer-visits?page=1&size=200&customerId=${customerId}&keyword=${encodeURIComponent(deliveryNo)}`) +const visit = (visits.records || []).find((value) => value.deliveryNo === deliveryNo) +assert(visit?.status === 'PENDING', 'Delivery completion did not create a pending customer visit') const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo, dispatchNo, dispatchStatus: departed.status, outboundNo: transfer.outboundNo, - outboundStatus: outbound.outbound.status, deliveryNo: transfer.deliveryNo, - deliveryStatus: delivered.deliveryStatus, visitId: id(delivered.visitId), shippedPackages: departed.shippedPackages, + outboundStatus: outbound.outbound.status, deliveryNo, + deliveryStatus: delivery.order.status, visitId: id(visit.id), shippedPackages: departed.shippedPackages, passed: true, finishedAt: new Date().toISOString() } await writeFile(output, JSON.stringify(report, null, 2), 'utf8') console.log(JSON.stringify(report, null, 2)) diff --git a/scripts/overseas-relabel-real-acceptance.mjs b/scripts/overseas-relabel-real-acceptance.mjs index f6b5f1879..329754dc4 100644 --- a/scripts/overseas-relabel-real-acceptance.mjs +++ b/scripts/overseas-relabel-real-acceptance.mjs @@ -13,6 +13,8 @@ const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json' const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` +const chargeAmount = Number(process.env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01') +if (!Number.isFinite(chargeAmount) || chargeAmount < 0) throw new Error('Invalid charge amount') for (const [name, value] of Object.entries({ customerId, warehouseId })) { if (!value) throw new Error(`Missing environment variable: ${name}`) @@ -21,7 +23,7 @@ if (!directPlatformToken && (!platformUser || !platformPassword)) throw new Erro if (!directMerchantToken && (!platformUser || !platformPassword)) throw new Error('Missing merchant token or platform credentials') if (!directPdaToken && (!pdaUser || !pdaPassword)) throw new Error('Missing PDA credentials or token') -const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', scenarios: [], passed: false } +const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], passed: false } function key(value) { return `${marker}-${value}`.slice(0, 200) } function id(value) { return value == null ? '' : String(value) } async function api(base, token, method, path, body, headers = {}) { @@ -70,7 +72,7 @@ async function resolvePallet(palletCode) { return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) } -async function createAndApprove(name, cartonCount) { +async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) { const suffix = `${name}-${Date.now()}` const items = Array.from({ length: cartonCount }, (_, index) => ({ sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId), @@ -78,49 +80,68 @@ async function createAndApprove(name, cartonCount) { productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`, - fbaCartonMark: `FBA-${suffix}-${index + 1}`, destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, - operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: source.skuCode, + fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null, + destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, + operationType, skuItems: [{ skuCode: source.skuCode, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }], })) const created = await api(overseas, platformToken, 'POST', `/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, { customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId, labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), - contactName: '尾程验收', contactPhone: '13800000006', chargeAmount: 0.01, - remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: 'TRUCK', + contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}), + remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), + deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK', printShippingLabel: false, items, }) const relabelNo = created.application.relabelNo - await poll(() => api(overseas, platformToken, 'GET', + if (chargeAmount > 0) await poll(() => api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/relabel-applications/${relabelNo}`), (detail) => detail.application.walletStatus === 'FROZEN') let detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) }) for (const [index, item] of detail.items.entries()) { + if (operationType === 'CARTON_AND_SKU') { + detail = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, { + cartonMark: item.cartonMark, expectedVersion: Number(detail.version), + idempotencyKey: key(`${name}-CARTON-${index}`), + }) + const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId)) + assert(Number(stageItem?.cartonProcessedPackages) === 1 + && Number(stageItem?.skuProcessedPackages) === 0, + `${relabelNo} carton stage incorrectly locked or completed SKU stage`) + } detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, { - itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark, + itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null, + skuVerifications: (item.skuItems || []).map((sku) => ({ + skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity), + })), expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`), }) } + const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) + const scenario = { name, operationType, relabelNo, items: detail.items, sourceBefore } + if (prepare) await prepare(scenario) const form = new FormData() - form.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), `${marker}.png`) + // Valid synthetic PNG for HTTP validation; not a photograph of real warehouse work. + form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), `${marker}.png`) const photo = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form) detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, { photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-COMPLETE`), remark: marker, }) - detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/review`, { - decision: 'APPROVE', expectedVersion: Number(detail.version), - idempotencyKey: key(`${name}-APPROVE`), remark: marker, - }) assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`) const confirmed = await api(overseas, merchantToken, 'POST', `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, { idempotencyKey: key(`${name}-CONFIRM`), remark: marker, }) - assert(confirmed.application.status === 'READY_FOR_OUTBOUND', `${relabelNo} did not reach READY_FOR_OUTBOUND`) - return { name, relabelNo, items: confirmed.items, sourceBefore: Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) } + const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND' + assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`) + assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode), + `${relabelNo} did not preserve the destination warehouse`) + return { ...scenario, items: confirmed.items } } async function printPallet(relabelNo, suffix) { @@ -141,26 +162,38 @@ async function moveCarton(scenario, item, target, index) { return result } -const fresh = await createAndApprove('NEW-PALLET', 1) -const sharedTarget = await printPallet(fresh.relabelNo, 'SHARED') -const printReplay = await printPallet(fresh.relabelNo, 'SHARED') -assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') -await moveCarton(fresh, fresh.items[0], sharedTarget, 0) -fresh.targets = [sharedTarget] +const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL') +skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) +assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets') +skuOnly.targetPallets = [] +skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode +delete skuOnly.items + +let sharedTarget +const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => { + sharedTarget = await printPallet(scenario.relabelNo, 'SHARED') + const printReplay = await printPallet(scenario.relabelNo, 'SHARED') + assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') + await moveCarton(scenario, scenario.items[0], sharedTarget, 0) + scenario.targets = [sharedTarget] +}) -const merged = await createAndApprove('MERGE-EXISTING', 1) const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) -await moveCarton(merged, merged.items[0], sharedTarget, 0) +const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => { + await moveCarton(scenario, scenario.items[0], sharedTarget, 0) + scenario.targets = [sharedTarget] +}) const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase') merged.targets = [sharedTarget] -const split = await createAndApprove('SPLIT-TWO-TARGETS', 2) -const splitA = await printPallet(split.relabelNo, 'SPLIT-A') -const splitB = await printPallet(split.relabelNo, 'SPLIT-B') -await moveCarton(split, split.items[0], splitA, 0) -await moveCarton(split, split.items[1], splitB, 1) -split.targets = [splitA, splitB] +const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => { + const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A') + const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B') + await moveCarton(scenario, scenario.items[0], splitA, 0) + await moveCarton(scenario, scenario.items[1], splitB, 1) + scenario.targets = [splitA, splitB] +}) for (const scenario of [fresh, merged, split]) { const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) @@ -191,7 +224,7 @@ const ledgerRows = ledger.records.filter((row) => String(row.remark || '').inclu assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger') assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger') assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason') -report.scenarios = [fresh, merged, split] +report.scenarios = [skuOnly, fresh, merged, split] report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) } report.passed = true report.finishedAt = new Date().toISOString() diff --git a/scripts/relabel-closure-local-acceptance.mjs b/scripts/relabel-closure-local-acceptance.mjs new file mode 100644 index 000000000..b20454678 --- /dev/null +++ b/scripts/relabel-closure-local-acceptance.mjs @@ -0,0 +1,116 @@ +import { mkdir, writeFile, readFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +const base = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +if (!['localhost', '127.0.0.1'].includes(new URL(base).hostname)) throw new Error('Local acceptance only') +const merchant = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN, pda = process.env.PDA_OVERSEAS_ACCESS_TOKEN, admin = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN +const warehouse = process.env.OVERSEAS_TEST_WAREHOUSE_ID +if (!merchant || !pda || !admin || !warehouse) throw new Error('Explicit session and warehouse required') +const marker = `CLOSE-${Date.now()}` +const report = { marker, passed: false, scope: 'REAL_HTTP_SYNTHETIC_WAREHOUSE_EVIDENCE_NO_FINANCE', scenarios: [] } +const key = () => `${marker}-${crypto.randomUUID()}` +const root = '/api/merchant/v1/last-leg/relabel-applications' +const work = '/api/pda/v1/last-leg/relabel-tasks' +const pallets = '/api/pda/v1/last-leg/pallet-operations' +const assert = (condition, message) => { if (!condition) throw new Error(message) } +async function raw(token, method, path, body) { + const response = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) }, body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined }) + const result = await response.json() + return { ...result, http: response.status } +} +async function api(token, method, path, body) { + const result = await raw(token, method, path, body) + assert(result.http === 200 && String(result.code) === '200', `${path}: ${result.code} ${result.message}`) + return result.data +} +async function reject(token, path, body, message) { + const result = await raw(token, 'POST', path, body) + assert(String(result.code) !== '200' && message.test(result.message || ''), `Expected rejection at ${path}, got ${result.code} ${result.message}`) +} +const resolve = code => api(pda, 'POST', `${pallets}/pallets/resolve`, { code }) +async function move(no, mark, from, to) { + const source = await resolve(from), target = await resolve(to) + const request = { relabelNo: no, source: { palletCode: from, expectedVersion: source.palletVersion }, target: { palletCode: to, expectedVersion: target.palletVersion }, cartonMarks: [mark], reason: marker, idempotencyKey: key() } + const result = await api(pda, 'POST', `${pallets}/relabel-disassemble`, request) + const replay = await api(pda, 'POST', `${pallets}/relabel-disassemble`, request) + assert(replay.replayed === true, 'Move replay was not idempotent') + const after = await resolve(to) + assert(Number(after.totalPackages) === Number(target.totalPackages) + 1 && after.status === 'OCCUPIED', 'Target stock/status did not update exactly once') + assert(Number((await resolve(from)).totalPackages) === Number(source.totalPackages) - 1, 'Source stock was not reduced once') + return result +} +try { + for (const deliveryMethod of ['TRUCK_PICKUP', 'EXPRESS', 'EXPRESS_PICKUP']) { + const candidates = await api(merchant, 'GET', `${root}/inventory-candidates?operationWarehouseId=${warehouse}`) + const candidate = candidates.find(value => value.destinationFbaWarehouseCode && value.positions?.some(pos => Number(pos.availablePackages) > 0 && pos.palletNo !== 'UNBOUND')) + assert(candidate, 'No available inventory for another scenario') + const position = candidate.positions.find(pos => Number(pos.availablePackages) > 0 && pos.palletNo !== 'UNBOUND') + const mark = `${marker}-${deliveryMethod}` + const request = { customerOrderNo: mark, operationWarehouseId: warehouse, labelDeadline: new Date(Date.now() + 86400000).toISOString().slice(0, 19), contactName: '流程验收', contactPhone: '13800000000', deliveryMethod, printShippingLabel: false, idempotencyKey: key(), items: [{ sourceInventoryBalanceId: candidate.inventoryBalanceId, sourceInventoryPositionId: position.positionId, sourceInboundOrderId: candidate.inboundOrderId, skuCode: candidate.skuCode || 'TEST', productNameZh: '验收测试货物', totalPackageCount: 1, notifiedPackageCount: 1, cartonMark: mark, fbaCartonMark: `FBA-${mark}`, destinationFbaWarehouseCode: candidate.destinationFbaWarehouseCode, operationType: 'CARTON_LABEL', skuItems: [] }] } + request.items[0].shipmentNo = `SHIP-${mark}` + const created = await api(merchant, 'POST', root, request) + const no = created.application.relabelNo + const scenario = { deliveryMethod, relabelNo: no, originalPallet: position.palletNo, checks: [] } + report.scenarios.push(scenario) + await reject(merchant, root, { ...request, idempotencyKey: key() }, /已用于换标单/) + scenario.checks.push('DUPLICATE_APPLICATION_REJECTED') + // Read an actual Print-generated test document from File, then upload it through the merchant API. + const pdfReport = JSON.parse(await readFile('reports/relabel-closure-20260916/pdf.json', 'utf8')) + const doc = pdfReport.files[0] + const pdfResponse = await fetch(`http://127.0.0.1:9206/internal/file/v1/business-files/${process.env.OVERSEAS_TEST_CUSTOMER_ID}/${doc.category}/${pdfReport.businessId}/${doc.fileId}/content`, { headers: { 'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN } }) + const pdf = Buffer.from(await pdfResponse.arrayBuffer()) + assert(pdfResponse.ok && pdf.subarray(0, 5).toString() === '%PDF-', 'Test PDF not available') + for (const scope of ['CARTON_TOTAL', 'SKU_TOTAL', 'ITEM']) { + const form = new FormData(); form.append('file', new Blob([pdf], { type: 'application/pdf' }), `${marker}.pdf`) + await api(merchant, 'POST', `${root}/${no}/files?scope=${scope}${scope === 'ITEM' ? `&itemId=${created.items[0].id}` : ''}`, form) + } + let task = await api(pda, 'POST', `${work}/${no}/claim`, { idempotencyKey: key() }) + const documents = await api(pda, 'GET', `${work}/${no}/documents`) + assert(documents.length >= 3, 'PDA is missing uploaded label documents') + for (const file of documents) { + const response = await fetch(`${base}${work}/${no}/documents/${file.fileId}/download?scope=${file.scope}${file.itemId ? `&itemId=${file.itemId}` : ''}`, { headers: { Authorization: `Bearer ${pda}` } }) + const downloaded = Buffer.from(await response.arrayBuffer()) + assert(response.ok && createHash('sha256').update(downloaded).digest('hex') === createHash('sha256').update(pdf).digest('hex'), 'PDA document mismatch') + } + scenario.checks.push('MERCHANT_UPLOAD_PDA_DOWNLOAD_THREE_SCOPES') + task = await api(pda, 'POST', `${work}/${no}/cartons/scan`, { cartonMark: mark, expectedVersion: task.version, idempotencyKey: key() }) + const photoForm = new FormData(); photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic.png') + const photo = await api(pda, 'POST', `${work}/${no}/photos/files`, photoForm) + const complete = () => ({ photoFileIds: [photo.fileId], expectedVersion: task.version, remark: marker, idempotencyKey: key() }) + await reject(pda, `${work}/${no}/complete`, complete(), /托盘绑定/) + scenario.checks.push('UNBOUND_COMPLETION_REJECTED') + const first = await api(pda, 'POST', `${pallets}/relabel-pallet-label`, { relabelNo: no, idempotencyKey: key() }) + await move(no, mark, position.palletNo, first.palletCode) + const second = await api(pda, 'POST', `${pallets}/relabel-pallet-label`, { relabelNo: no, idempotencyKey: key() }) + await move(no, mark, first.palletCode, second.palletCode) + scenario.targetPallet = second.palletCode + scenario.checks.push('REBIND_STOCK_CONSERVED_AND_REPLAY_SAFE') + task = await api(pda, 'POST', `${work}/${no}/complete`, complete()) + if (deliveryMethod === 'TRUCK_PICKUP') { + task = await api(admin, 'POST', `/api/platform/v1/last-leg/relabel-applications/${no}/rework`, { decision: 'REWORK', expectedVersion: task.version, idempotencyKey: key(), remark: '验收退回:请核对并重新贴标' }) + assert(task.reviewRemark?.includes('验收退回'), 'Rework remark missing on PDA') + task = await api(pda, 'POST', `${work}/${no}/cartons/scan`, { cartonMark: mark, expectedVersion: task.version, idempotencyKey: key() }) + await reject(pda, `${work}/${no}/complete`, complete(), /托盘绑定/) + await move(no, mark, second.palletCode, first.palletCode) + scenario.targetPallet = first.palletCode + task = await api(pda, 'POST', `${work}/${no}/complete`, complete()) + scenario.checks.push('REWORK_RESCAN_REBIND_AND_RESUBMIT') + } + const confirmed = await api(merchant, 'POST', `${root}/${no}/confirm-completion`, { idempotencyKey: key(), remark: marker }) + scenario.outboundNo = confirmed.application.outboundOrderNo + assert(scenario.outboundNo, 'Customer confirmation did not create direct outbound') + await reject(pda, `${pallets}/relabel-disassemble`, { relabelNo: no, source: { palletCode: scenario.targetPallet, expectedVersion: 0 }, target: { palletCode: position.palletNo, expectedVersion: 0 }, cartonMarks: [mark], idempotencyKey: key() }, /排车或出库/) + const resolved = await api(pda, 'GET', `/api/pda/v1/last-leg/outbound-tasks/resolve/${scenario.outboundNo}`) + const picked = await api(pda, 'POST', `/api/pda/v1/last-leg/outbound-tasks/${resolved.taskNo}/pallets/${scenario.targetPallet}/pick`, { idempotencyKey: key() }) + await api(pda, 'POST', `/api/pda/v1/last-leg/outbound-tasks/${resolved.taskNo}/complete`, { idempotencyKey: key() }) + const outbound = await api(admin, 'GET', `/api/platform/v1/last-leg/outbound-orders/${scenario.outboundNo}?page=1&size=20`) + assert(outbound.outbound.status === 'OUTBOUNDED', 'Direct outbound not completed') + scenario.outboundStatus = outbound.outbound.status + scenario.checks.push('DIRECT_OUTBOUND_COMPLETED', 'OUTBOUND_REBIND_REJECTED') + } + report.passed = true +} catch (error) { report.error = error.message; process.exitCode = 1 } +finally { + await mkdir('reports/relabel-closure-20260916', { recursive: true }) + await writeFile('reports/relabel-closure-20260916/extra.json', JSON.stringify(report, null, 2)) + console.log(JSON.stringify(report, null, 2)) +} diff --git a/scripts/relabel-pdf-local-acceptance.mjs b/scripts/relabel-pdf-local-acceptance.mjs new file mode 100644 index 000000000..37031a4ab --- /dev/null +++ b/scripts/relabel-pdf-local-acceptance.mjs @@ -0,0 +1,42 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' + +const token = process.env.INTERNAL_SERVICE_TOKEN +if (!token) throw new Error('INTERNAL_SERVICE_TOKEN is required') +const printBase = process.env.PRINT_SERVICE_URL || 'http://127.0.0.1:9207' +const fileBase = process.env.FILE_SERVICE_URL || 'http://127.0.0.1:9206' +for (const url of [printBase, fileBase]) if (!['localhost', '127.0.0.1'].includes(new URL(url).hostname)) throw new Error('Local services only') +const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID +if (!customerId) throw new Error('OVERSEAS_TEST_CUSTOMER_ID is required') +const businessId = String(Date.now()) +const headers = { 'X-Internal-Service-Token': token, 'X-Operator-Id': '1' } +const report = { scope: 'LOCAL_PRINT_AND_FILE_HTTP_ONLY', businessId, passed: false, files: [] } +const ensure = (ok, message) => { if (!ok) throw new Error(message) } +try { + const rendered = await fetch(`${printBase}/internal/print/v1/pdfs/inbound-marks`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ templateCode: 'INBOUND_MARK_V1', pageSpec: { widthMm: 100, heightMm: 100 }, downloadFileName: 'relabel-acceptance.pdf', labels: [{ topCode: 'RELABEL-ACCEPTANCE', warehouseText: 'ONT8', cartonText: 'CTNS:1-1', routeText: '验收测试标签', qrContent: `ACCEPTANCE-${businessId}` }] }) }) + const pdf = Buffer.from(await rendered.arrayBuffer()) + ensure(rendered.ok && pdf.subarray(0, 5).toString() === '%PDF-', `Print failed: HTTP ${rendered.status}`) + report.pdfBytes = pdf.length + const hash = value => createHash('sha256').update(value).digest('hex') + for (const category of ['OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT', 'OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT', 'OVERSEAS_RELABEL_ITEM_DOCUMENT']) { + const form = new FormData(); form.append('file', new Blob([pdf], { type: 'application/pdf' }), 'relabel-acceptance.pdf') + const path = `/internal/file/v1/business-files/${customerId}/${category}/${businessId}` + const upload = await fetch(fileBase + path, { method: 'POST', headers, body: form }) + const body = await upload.json() + ensure(upload.ok && String(body.code) === '200', `${category}: ${body.code} ${body.message}`) + const fileId = body.data.fileId + const downloaded = await fetch(`${fileBase}${path}/${fileId}/content`, { headers }) + const bytes = Buffer.from(await downloaded.arrayBuffer()) + ensure(downloaded.ok && hash(bytes) === hash(pdf), 'Downloaded PDF does not match uploaded bytes') + const wrongOwner = await fetch(`${fileBase}/internal/file/v1/business-files/1/${category}/${businessId}/${fileId}`, { headers }) + const denied = await wrongOwner.json() + ensure(!wrongOwner.ok || String(denied.code) !== '200', 'File was visible across customers') + report.files.push({ category, fileId, sha256: hash(bytes), ownershipCheck: 'PASS' }) + } + report.passed = true +} catch (error) { report.error = error.message; process.exitCode = 1 } +finally { + await mkdir('reports/relabel-closure-20260916', { recursive: true }) + await writeFile('reports/relabel-closure-20260916/pdf.json', JSON.stringify(report, null, 2)) + console.log(JSON.stringify(report, null, 2)) +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java index 66c6033af..736dce6a7 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -7,14 +7,17 @@ import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; import com.baiyue.warehouse.security.InternalServiceTokenGuard; import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.service.PalletLabelPrintService; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/internal/v1/warehouse-pallets") public class InternalWarehousePalletController { @Resource private WarehouseResourceService service; + @Resource private PalletLabelPrintService palletLabels; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping("/ensure-dropship") @@ -49,4 +52,14 @@ public class InternalWarehousePalletController { return ApiResponse.success(service.resolvePublicPallet(palletId)); } + @GetMapping("/{warehouseId}/{palletId}/label") + public ResponseEntity label( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @PathVariable long warehouseId, @PathVariable long palletId, + @RequestParam String transferNo) { + tokenGuard.verify(token); + return palletLabels.printTransfer(warehouseId, palletId, transferNo, requestId); + } + } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java index 2da38bfbe..08bf14e64 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java @@ -25,6 +25,16 @@ public class PalletLabelPrintService { private String internalToken; public ResponseEntity print(long warehouseId, long palletId, String requestId) { + return print(warehouseId, palletId, requestId, "PALLET"); + } + + public ResponseEntity printTransfer(long warehouseId, long palletId, String transferNo, + String requestId) { + String reference = transferNo == null || transferNo.isBlank() ? "转运单" : "转运单 " + transferNo.trim(); + return print(warehouseId, palletId, requestId, reference); + } + + private ResponseEntity print(long warehouseId, long palletId, String requestId, String reference) { var pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, palletId); String code = pallet.getPalletCode(); String fbaCode = pallet.getFbaWarehouseCode(); @@ -37,8 +47,9 @@ public class PalletLabelPrintService { } var warehouse = warehouses.required(warehouseId); String fileName = code.replaceAll("[\\\\/:*?\"<>|]", "-") + "-托盘码.pdf"; - var label = new PrintPdfClient.Label(code, fbaCode == null || fbaCode.isBlank() ? "—" : fbaCode, - "PALLET", warehouse.warehouseName(), code); + var label = new PrintPdfClient.Label(code, + fbaCode == null || fbaCode.isBlank() ? "目的仓待定" : fbaCode, + reference, warehouse.warehouseName(), code); var request = new PrintPdfClient.Request("INBOUND_MARK_V1", new PrintPdfClient.PageSpec(new BigDecimal("100"), new BigDecimal("100")), fileName, List.of(label)); try { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index 407991006..d9bcd4a6d 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -386,9 +386,11 @@ public class WarehouseResourceService { } private static String generatedPalletCode(String warehouseCode, long palletId) { - String boundedWarehouseCode = warehouseCode.length() <= 30 ? warehouseCode : warehouseCode.substring(0, 30); - return "PLT-" + boundedWarehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) - + "-" + Long.toUnsignedString(palletId); + String compactWarehouseCode = warehouseCode.replaceAll("[^A-Za-z0-9]", "").toUpperCase(java.util.Locale.ROOT); + if (compactWarehouseCode.length() > 9) compactWarehouseCode = compactWarehouseCode.substring(0, 9); + return "PLT-" + compactWarehouseCode + "-" + + LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd")) + "-" + + Long.toUnsignedString(palletId, 36).toUpperCase(java.util.Locale.ROOT); } private static String defaultLocationCode(String warehouseCode) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/InternalWarehousePalletLabelContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/InternalWarehousePalletLabelContractTest.java new file mode 100644 index 000000000..97fa17267 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/InternalWarehousePalletLabelContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.warehouse.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +import static org.assertj.core.api.Assertions.assertThat; + +class InternalWarehousePalletLabelContractTest { + @Test + void labelEndpointIsInternalAndRequiresTheServiceToken() throws Exception { + var method = InternalWarehousePalletController.class.getMethod("label", + String.class, String.class, long.class, long.class, String.class); + + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/{warehouseId}/{palletId}/label"); + assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value()) + .isEqualTo("X-Internal-Service-Token"); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java index 5035541d1..ee805f0ec 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java @@ -59,6 +59,26 @@ class PalletLabelPrintServiceTest { verify(print, never()).generateInboundMarks(any(), any(), any()); } + @Test + void transferLabelIncludesTransferNumberAndMakesAnUnknownDestinationExplicit() { + pallet.setPalletCode("ABCD1234567-ABE3-003"); + pallet.setFbaWarehouseCode(null); + byte[] pdf = "%PDF-transfer-label".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + when(print.generateInboundMarks(anyString(), anyString(), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(pdf)); + + service.printTransfer(200L, 9L, "WTR-20260916-1", "request-transfer"); + + var request = ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateInboundMarks(eq("test-token"), eq("request-transfer"), request.capture()); + assertThat(request.getValue().labels()).singleElement().satisfies(label -> { + assertThat(label.topCode()).isEqualTo("ABCD1234567-ABE3-003"); + assertThat(label.cartonText()).isEqualTo("转运单 WTR-20260916-1"); + assertThat(label.warehouseText()).isEqualTo("目的仓待定"); + assertThat(label.routeText()).isEqualTo("洛杉矶仓"); + }); + } + @Test void refusesWrongWarehouseBeforeCallingPrint() { when(resources.pallet(0L, 201L, 9L)).thenThrow(new BizException(ResultCode.NOT_FOUND, "托盘不存在")); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index 888a44884..e94c4fe67 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -262,7 +262,8 @@ class WarehouseResourceServiceTest { ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); verify(repository).insertPallet(captor.capture()); - assertThat(captor.getValue().getPalletCode()).matches("PLT-US-LAX-01-[0-9]{8}-[0-9]+"); + assertThat(captor.getValue().getPalletCode()).matches("PLT-USLAX01-[0-9]{6}-[0-9A-Z]+"); + assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(34); assertThat(captor.getValue().getLocationId()).isEqualTo(30003L); assertThat(captor.getValue().getId()).isNotNull(); } @@ -283,7 +284,7 @@ class WarehouseResourceServiceTest { ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); verify(repository).insertPallet(captor.capture()); - assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(64); + assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(34); } @Test