This commit is contained in:
wujinliang
2026-09-18 09:16:10 +08:00
181 changed files with 5959 additions and 1939 deletions
Vendored
-11
View File
@@ -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 路由静态门禁') { stage('Gateway 路由静态门禁') {
when { when {
expression { env.NO_SERVICE_CHANGE != 'true' } expression { env.NO_SERVICE_CHANGE != 'true' }
+2 -2
View File
@@ -7,7 +7,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 | | 区域 | 完整入口 | 服务 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_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` 拼写。 - 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。 - 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。
@@ -24,7 +24,7 @@
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 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 不算成功。
## 平台管理员密码 ## 平台管理员密码
File diff suppressed because one or more lines are too long
+2
View File
@@ -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。 - 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 客户单文件资质入口只接受 `LEGAL_PERSON_ID/FREIGHT_FORWARDER_CERTIFICATE/BANK_ACCOUNT_LICENSE`,客户范围只取登录 Token;营业执照不得绕过 `required-package`。同类当前资质已存在时必须显式 `replace=true`,成功后旧元数据标记为已替代;上传不会自动提交审核。 - 客户单文件资质入口只接受 `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。 - 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。国内 PDA 禁止直连 File 平台接口,必须经 Firstleg 校验任务、仓库和操作者后,由 Firstleg 使用内部服务令牌访问 File。
@@ -24,7 +24,10 @@ enum BusinessFileCategory {
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_UNLOAD_PHOTO(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_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_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_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, 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), LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
+1 -1
View File
@@ -6,7 +6,7 @@ USE baiyue_file;
CREATE TABLE IF NOT EXISTS file_object ( CREATE TABLE IF NOT EXISTS file_object (
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户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', business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId',
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', object_key VARCHAR(512) NOT NULL COMMENT '对象存储键',
@@ -4,7 +4,7 @@ info:
version: 1.0.0 version: 1.0.0
description: >- description: >-
File 只保存文件并强制客户、业务类型、业务ID与文件ID复合归属。 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。 OVERSEAS_RELABEL_ITEM_DOCUMENT 的 businessId 为 item.id。
Lastleg 必须先校验申请与明细存在、明细所属申请及其客户归属、业务状态和权限。 Lastleg 必须先校验申请与明细存在、明细所属申请及其客户归属、业务状态和权限。
客户归属未知时失败关闭,不得使用平台归属或替代业务ID。 客户归属未知时失败关闭,不得使用平台归属或替代业务ID。
@@ -88,7 +88,7 @@ components:
fileId: {type: string, pattern: '^[1-9][0-9]*$'} fileId: {type: string, pattern: '^[1-9][0-9]*$'}
ownerScope: {type: string, enum: [CUSTOMER]} ownerScope: {type: string, enum: [CUSTOMER]}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'} 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]*$'} businessId: {type: string, pattern: '^[1-9][0-9]*$'}
fileName: {type: string} fileName: {type: string}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'} sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
@@ -112,7 +112,7 @@ components:
required: true required: true
schema: schema:
type: string 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: BusinessId:
name: businessId name: businessId
in: path in: path
@@ -30,8 +30,8 @@ class OverseasExceptionEvidenceContractTest {
try (var stream = getClass().getResourceAsStream("/db/file.sql")) { try (var stream = getClass().getResourceAsStream("/db/file.sql")) {
assertThat(stream).isNotNull(); assertThat(stream).isNotNull();
String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertThat(sql).contains("business_type VARCHAR(32)", "OVERSEAS_EXCEPTION_EVIDENCE"); assertThat(sql).contains("business_type VARCHAR(64)", "OVERSEAS_EXCEPTION_EVIDENCE");
assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(32); assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(64);
} }
} }
} }
@@ -25,7 +25,7 @@ class RelabelDocumentFileTest {
@TempDir Path root; @TempDir Path root;
@ParameterizedTest @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) { void pdfPolicyChecksContentTypeSignatureAndSize(BusinessFileCategory category) {
BusinessFileStorage storage = storage(); BusinessFileStorage storage = storage();
byte[] exactLimit = new byte[10 * 1024 * 1024]; byte[] exactLimit = new byte[10 * 1024 * 1024];
@@ -52,7 +52,7 @@ class RelabelDocumentFileTest {
} }
@ParameterizedTest @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 { void uploadAndReadRequireExactCustomerBusinessTypeBusinessAndFile(BusinessFileCategory category) throws Exception {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "relabel-test"), FileObjectEntity.class); TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "relabel-test"), FileObjectEntity.class);
FileObjectMapper files = mock(FileObjectMapper.class); FileObjectMapper files = mock(FileObjectMapper.class);
@@ -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");
}
}
@@ -43,12 +43,11 @@ class FinanceContractTest {
assertFalse(operations.contains("bl.source_id=f.source_fee_id")); assertFalse(operations.contains("bl.source_id=f.source_fee_id"));
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance")); assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance"));
assertTrue(unified.contains("assert_finance_permissions"));
assertTrue(unified.contains("merchant:finance:statement:view")); assertTrue(unified.contains("merchant:finance:statement:view"));
int walletEventSchema = unified.indexOf("CREATE TABLE IF NOT EXISTS fin_wallet_event_outbox"); int walletEventSchema = unified.indexOf("CREATE TABLE fin_wallet_event_outbox");
int finalPass = unified.lastIndexOf("SELECT 'PASS' AS migration_status"); int finalPass = unified.lastIndexOf("SELECT 'PASS' AS initialization_status");
assertTrue(walletEventSchema >= 0); assertTrue(walletEventSchema >= 0);
assertTrue(finalPass > walletEventSchema); assertTrue(finalPass > walletEventSchema);
assertTrue(unified.contains("CREATE PROCEDURE assert_wallet_event_schema()")); assertFalse(unified.contains("CREATE PROCEDURE assert_wallet_event_schema()"));
} }
} }
@@ -22,12 +22,13 @@ class FinanceSettlementSchemaContractTest {
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)") .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("total_amount DECIMAL(20,6)", "paid_amount DECIMAL(20,6)", "outstanding_amount DECIMAL(20,6)")
.contains("updated_at DATETIME(3)"); .contains("updated_at DATETIME(3)");
assertThat(unified) String normalized = unified.toLowerCase().replaceAll("\\s+", "");
.contains("CREATE TABLE IF NOT EXISTS fin_settlement (") assertThat(normalized)
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (") .contains("createtablefin_settlement(")
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (") .contains("createtablefin_settlement_line(")
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)") .contains("createtablefin_settlement_payment(")
.contains("'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16"); .contains("idx_fin_settlement_customer_direction_status(tenant_id,direction,status)");
assertThat(unified).doesNotContain("ALTER TABLE fin_settlement", "assert_finance");
} }
private static Path resolveUnifiedSql() { private static Path resolveUnifiedSql() {
@@ -62,16 +62,14 @@ class FinanceSqlCommentContractTest {
} }
@Test @Test
void unifiedMigrationAddsBillLineAdjustmentReasonForNewAndExistingSchemas() throws Exception { void unifiedInitializationDefinesBillLineAdjustmentReasonDirectly() throws Exception {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path unified = Files.exists(direct) ? 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); String sql = Files.readString(unified, StandardCharsets.UTF_8);
assertThat(sql) assertThat(sql)
.contains("adjustment_reason VARCHAR(500) NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录'") .contains("adjustment_reason varchar(500) DEFAULT NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录'")
.contains("table_schema='baiyue_finance' AND table_name='fin_bill_line' AND column_name='adjustment_reason'") .doesNotContain("ALTER TABLE baiyue_finance.fin_bill_line", "queue_column_comment");
.contains("ALTER TABLE baiyue_finance.fin_bill_line ADD COLUMN adjustment_reason")
.contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'");
} }
@Test @Test
@@ -97,12 +95,17 @@ class FinanceSqlCommentContractTest {
assertThat(baseline) assertThat(baseline)
.contains("updated_by_name VARCHAR(128) NULL COMMENT '最后修改操作人名称快照'") .contains("updated_by_name VARCHAR(128) NULL COMMENT '最后修改操作人名称快照'")
.contains("created_by_name VARCHAR(128) NULL COMMENT '登记操作人名称快照'"); .contains("created_by_name VARCHAR(128) NULL COMMENT '登记操作人名称快照'");
assertThat(unified) assertThat(tableBlock(unified, "fin_bill")).contains("created_by_name", "updated_by_name");
.contains("CALL finance_add_operator_snapshot_column('fin_bill', 'created_by_name', 'created_by')") assertThat(tableBlock(unified, "fin_receipt")).contains("created_by_name");
.contains("CALL finance_add_operator_snapshot_column('fin_bill', 'updated_by_name', 'updated_by')") assertThat(tableBlock(unified, "fin_cost")).contains("created_by_name");
.contains("CALL finance_add_operator_snapshot_column('fin_receipt', 'created_by_name', 'created_by')") assertThat(tableBlock(unified, "fin_settlement_payment")).contains("created_by_name");
.contains("CALL finance_add_operator_snapshot_column('fin_cost', 'created_by_name', 'created_by')") assertThat(tableBlock(unified, "fin_bill_revision")).contains("created_by_name");
.contains("CALL finance_add_operator_snapshot_column('fin_settlement_payment', 'created_by_name', 'created_by')") assertThat(unified).doesNotContain("finance_add_operator_snapshot_column");
.contains("CALL finance_add_operator_snapshot_column('fin_bill_revision', 'created_by_name', 'created_by')"); }
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);
} }
} }
@@ -14,12 +14,14 @@ class FinanceSubjectSeedContractTest {
String module = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); 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); String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
for (String sql : new String[] {module, unified}) { assertThat(module)
assertThat(sql) .contains("INSERT INTO fin_subject")
.contains("INSERT INTO fin_subject") .contains("('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)")
.contains("('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)") .contains("ON DUPLICATE KEY UPDATE")
.contains("ON DUPLICATE KEY UPDATE") .contains("status = 'ACTIVE'");
.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");
} }
} }
@@ -10,18 +10,16 @@ import static org.assertj.core.api.Assertions.assertThat;
class FinanceWalletContractTest { class FinanceWalletContractTest {
@Test @Test
void unifiedMigrationDropsTheLegacyBalanceConstraintBeforeRewritingBalances() throws Exception { void unifiedInitializationDefinesTheFinalWalletBalanceConstraintWithoutRewritingData() throws Exception {
Path migration = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); Path migration = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(migration)) migration = Path.of("..").resolve(migration); if (!Files.exists(migration)) migration = Path.of("..").resolve(migration);
String sql = Files.readString(migration, StandardCharsets.UTF_8); String sql = Files.readString(migration, StandardCharsets.UTF_8);
int procedure = sql.indexOf("CREATE PROCEDURE migrate_fin_wallet_credit()"); String account = tableBlock(sql, "fin_wallet_account");
int dropConstraint = sql.indexOf("ALTER TABLE fin_wallet_account DROP CHECK chk_fin_wallet_account_balance", procedure); assertThat(account).contains(
int rewriteBalance = sql.indexOf("SET actual_balance=actual_balance-credit_used", procedure); "credit_expires_on date DEFAULT NULL",
"CONSTRAINT chk_fin_wallet_account_balance CHECK",
assertThat(procedure).isGreaterThanOrEqualTo(0); "frozen_balance >= credit_frozen");
assertThat(sql.substring(procedure, rewriteBalance)) assertThat(sql).doesNotContain("migrate_fin_wallet_credit", "SET actual_balance=actual_balance-credit_used");
.contains("CHECK_CLAUSE LIKE '%actual_balance%'", "v_legacy_wallet_constraint OR v_credit_expiry_missing");
assertThat(dropConstraint).isBetween(procedure, rewriteBalance - 1);
} }
@Test @Test
@@ -68,4 +66,10 @@ class FinanceWalletContractTest {
.contains("/holds/{businessType}/{businessNo}/release") .contains("/holds/{businessType}/{businessNo}/release")
.contains("Idempotency-Key"); .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);
}
} }
@@ -65,7 +65,7 @@ class PdaConvergenceOpenApiContractTest {
.contains("loadingPlanId:", "containerBookingId:") .contains("loadingPlanId:", "containerBookingId:")
.doesNotContain("containerNo:"); .doesNotContain("containerNo:");
assertThat(batchItemRequest) 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:"); .doesNotContain("releaseMode:", "reservationIds:", "locationIds:");
String pickItem = yaml.substring(yaml.indexOf(" PickItem:"), yaml.indexOf(" PendingTask:")); String pickItem = yaml.substring(yaml.indexOf(" PickItem:"), yaml.indexOf(" PendingTask:"));
assertThat(pickItem).contains("unitType:", "palletId:", "palletNo:", "indivisible:", "SEALED_PALLET"); assertThat(pickItem).contains("unitType:", "palletId:", "palletNo:", "indivisible:", "SEALED_PALLET");
@@ -25,9 +25,8 @@ class TruckAppointmentControllerContractTest {
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql"); Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
Path unifiedPath=Files.exists(direct)?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); String unifiedSql=Files.readString(unifiedPath,StandardCharsets.UTF_8);
for(String sql:new String[]{moduleSql,unifiedSql}) { assertThat(moduleSql).contains("'bill_of_lading_no'","'actual_arrived_warehouse_at'","'actual_loading_at'","'actual_departed_at'");
assertThat(sql).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 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");} @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");}
@@ -17,17 +17,22 @@ class ColoadGroupSqlContractTest {
String baseline = Files.readString(Path.of("src/main/resources/db/phase3-coload-group-schema.sql")); 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")); String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for (String source : new String[]{baseline, unified}) { 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) assertThat(source)
.contains("CREATE TABLE IF NOT EXISTS fl_coload_group (") .contains("ACTIVE", "LOCKED", "CONSUMED", "CANCELLED")
.contains("CREATE TABLE IF NOT EXISTS fl_coload_group_member (") .contains("chk_coload_group_status")
.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("uk_coload_active_inbound") .contains("uk_coload_active_inbound")
.contains("full_package_count") .contains("full_package_count")
.contains("uk_coload_command") .contains("uk_coload_command")
.contains("locked_plan_id"); .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("chk_coload_group_destination")
.doesNotContain("destination_type") .doesNotContain("destination_type")
.doesNotContain("destination_warehouse_id") .doesNotContain("destination_warehouse_id")
@@ -30,11 +30,12 @@ class LoadingEvidenceSqlContractTest {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path unified = Files.exists(direct) ? 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); 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_active")
.contains("uk_cabinet_evidence_operation") .contains("uk_cabinet_evidence_operation")
.contains("normalize_loading_cabinet_evidence_collation") .contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id)")
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_evidence") .doesNotContain("CREATE TABLE fl_loading_evidence")
.doesNotContain("normalize_loading_cabinet_evidence_collation")
.doesNotContain("phase3_loading_evidence_add_column"); .doesNotContain("phase3_loading_evidence_add_column");
} }
} }
@@ -38,8 +38,13 @@ class PdaConvergenceSqlContractTest {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8); StandardCharsets.UTF_8);
assertThat(createBlock(unified, "fl_loading_pick_item_assignment")) assertThat(unified)
.isEqualTo(createBlock(sql, "fl_loading_pick_item_assignment")); .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 @Test
@@ -108,49 +108,28 @@ class CarrierScheduleQuerySqlContractTest {
@Test @Test
void unifiedDeploymentEntryContainsTheSameContract() throws Exception { void unifiedDeploymentEntryContainsTheSameContract() throws Exception {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8); StandardCharsets.UTF_8).toLowerCase();
assertThat(unified) assertThat(unified)
.contains("fl_carrier_schedule_query") .contains("create table fl_carrier_schedule_query")
.contains("uk_carrier_schedule_query_scope") .contains("uk_carrier_schedule_query_scope")
.contains("id IN (5102184,5102186,5102188)") .contains("trigger_source varchar(32) not null default 'manual'")
.contains("assert_carrier_schedule_query_schema") .contains("latest_standard_node varchar(32) default null")
.contains("CREATE PROCEDURE migrate_carrier_schedule_retry_columns()") .contains("latest_node_observed_at datetime(3) default null")
.contains("ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL'") .contains("stopped_at datetime(3) default null")
.contains("ADD COLUMN latest_standard_node VARCHAR(32) NULL") .contains("stop_reason varchar(500) default null")
.contains("ADD COLUMN latest_node_observed_at DATETIME(3) NULL") .contains("consecutive_failures int unsigned not null default '0'")
.contains("ADD COLUMN stopped_at DATETIME(3) NULL") .contains("window_started_at datetime(3) not null")
.contains("ADD COLUMN stop_reason VARCHAR(500) NULL") .contains("attempt_count int unsigned not null default '0'")
.contains("ADD COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0") .contains("next_allowed_at datetime(3) default null")
.contains("ADD COLUMN window_started_at DATETIME(3) NULL") .contains("key idx_carrier_schedule_query_due (status,next_allowed_at,id)")
.contains("ADD COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0") .contains("unique key uk_carrier_schedule_query_scope")
.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("(customer_id,ocean_booking_id,container_scope_id)") .contains("(customer_id,ocean_booking_id,container_scope_id)")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") .contains("chk_carrier_schedule_query_attempt check ((attempt_count between 0 and 3))")
.contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'") .contains("arrived_destination_port")
.contains("'OFFICIAL_API','MANUAL'") .contains("empty_returned")
.contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')") .contains("platform:firstleg:port-schedule-query:manual")
.contains("ARRIVED_DESTINATION_PORT") .doesNotContain("migrate_carrier_schedule_retry_columns")
.contains("EMPTY_RETURNED") .doesNotContain("drop check chk_carrier_schedule_query_attempt");
.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','执行船司查询'");
} }
@Test @Test
@@ -158,13 +137,13 @@ class CarrierScheduleQuerySqlContractTest {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8); StandardCharsets.UTF_8);
assertThat(unified) assertThat(unified)
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key'") .contains("event_key varchar(160) DEFAULT NULL")
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version'") .contains("event_version int unsigned NOT NULL DEFAULT '1'")
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence'") .contains("aggregate_sequence int unsigned DEFAULT NULL")
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash'") .contains("payload_hash varchar(71) DEFAULT NULL")
.contains("uk_inbound_outbox_event_key") .contains("uk_inbound_outbox_event_key")
.contains("MODIFY COLUMN payload_hash VARCHAR(71)") .contains("payload_json longtext NOT NULL")
.contains("MODIFY COLUMN payload_json LONGTEXT NOT NULL"); .doesNotContain("firstleg_finance_add_column");
} }
private static String moduleSql() throws Exception { private static String moduleSql() throws Exception {
@@ -108,16 +108,16 @@ class ContainerBookingMapperContractTest {
@Test @Test
@DisplayName("初始化与统一增量锁定真实柜号权威、唯一和ISO结构约束") @DisplayName("初始化与统一增量锁定真实柜号权威、唯一和ISO结构约束")
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception { void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
for (String source : new String[] { String module = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql")), String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")) assertThat(module.toLowerCase())
}) { .contains("国内唯一权威真实集装箱号")
assertThat(source) .contains("提柜后可原子补录");
.contains("国内唯一权威真实集装箱号") for (String source : new String[] {module, unified}) {
.contains("提柜后可原子补录") assertThat(source.toLowerCase())
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)") .contains("unique key uk_container_booking_container_no (container_no)")
.contains("CONSTRAINT chk_container_booking_iso6346") .contains("constraint chk_container_booking_iso6346")
.contains("container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'"); .contains("^[a-z]{3}[ujz][0-9]{7}$");
} }
} }
} }
@@ -28,21 +28,23 @@ class CustomerDimensionConfigSqlContractTest {
); );
for (String sql : sqlEntrypoints) { for (String sql : sqlEntrypoints) {
assertThat(sql) String normalized = sql.toLowerCase().replace(", ", ",");
assertThat(normalized)
.contains("fl_customer_dimension_config") .contains("fl_customer_dimension_config")
.contains("box_type VARCHAR(24) NOT NULL") .contains("box_type varchar(24) not null")
.contains("inner_length_cm DECIMAL(9,3) NOT NULL") .contains("inner_length_cm decimal(9,3) not null")
.contains("inner_width_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("inner_height_cm decimal(9,3) not null")
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000") .contains("edge_addition_cm decimal(6,3) not null default")
.contains("default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ACTUAL'") .contains("default_calculation_mode varchar(16)")
.contains("initialization_key VARCHAR(128) NOT NULL") .contains("not null default 'actual'")
.contains("created_by BIGINT UNSIGNED NOT NULL") .contains("initialization_key varchar(128) not null")
.contains("updated_by BIGINT UNSIGNED NOT NULL") .contains("created_by bigint unsigned not null")
.contains("version INT NOT NULL DEFAULT 0") .contains("updated_by bigint unsigned not null")
.contains("UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type)") .contains("version int not null default")
.contains("CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE'))") .contains("unique key uk_customer_dimension_config_tenant_box (tenant_id,box_type)")
.doesNotContain("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)"); .contains("'small'", "'medium'", "'large'", "'extra_large'")
.doesNotContain("unique key uk_customer_dimension_config_tenant (tenant_id)");
} }
} }
@@ -104,21 +106,19 @@ class CustomerDimensionConfigSqlContractTest {
} }
@Test @Test
void unifiedMigrationKeepsPermissionGuardsAndAssertsFinalFourRowSchema() throws Exception { void unifiedInitializationKeepsPermissionsAndFinalFourRowSchema() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8); StandardCharsets.UTF_8);
assertThat(sql) assertThat(sql)
.contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()") .contains("CREATE TABLE fl_customer_dimension_config")
.contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010") .contains("UNIQUE KEY uk_customer_dimension_config_tenant_box")
.contains("r.role_code = 'PLATFORM_ADMIN'") .contains("5102111,0,'PLATFORM','BUTTON',5100010")
.contains("r.role_code = 'ORDER_OPERATOR'") .contains("firstleg:dimension-config:view")
.contains("index_name = 'uk_customer_dimension_config_tenant_box'") .contains("firstleg:dimension-config:edit")
.contains("客户四箱规尺寸配置字段不完整") .contains("VALUES (4000001,5102111,0,CURRENT_TIMESTAMP(3))")
.contains("客户默认计费规则初始化幂等字段未创建") .contains("VALUES (4000003,5102111,0,CURRENT_TIMESTAMP(3))")
.contains("index_name = 'idx_charge_rule_initialization'") .doesNotContain("unified_guard_dimension_config_permissions");
.contains("客户默认计费规则初始化索引未完整创建")
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确");
} }
@Test @Test
@@ -129,43 +129,21 @@ class CustomerDimensionConfigSqlContractTest {
StandardCharsets.UTF_8); StandardCharsets.UTF_8);
assertThat(schema).contains("calculation_rule_version VARCHAR(64) NOT NULL"); assertThat(schema).contains("calculation_rule_version VARCHAR(64) NOT NULL");
assertThat(unified) assertThat(unified).contains("calculation_rule_version varchar(64) NOT NULL")
.contains("calculation_rule_version VARCHAR(64) NOT NULL") .doesNotContain("MODIFY COLUMN calculation_rule_version");
.contains("MODIFY COLUMN calculation_rule_version VARCHAR(64) NOT NULL");
} }
@Test @Test
void unifiedMigrationUpgradesOnlyRecognizedLegacyTableAndBackfillsIdempotently() throws Exception { void unifiedInitializationCreatesOnlyTheFinalDimensionTableWithoutBackfill() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8); 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) assertThat(sql)
.contains("AND column_name = 'box_type') = 0") .contains("CREATE TABLE fl_customer_dimension_config")
.contains("AND column_name IN ('id', 'tenant_id', 'edge_addition_cm', 'remark', 'initialization_key'") .contains("COMMENT='客户固定四箱规尺寸配置'")
.contains("AND table_name = 'fl_customer_dimension_config') <> 11") .doesNotContain("unified_upgrade_legacy_customer_dimension_config")
.contains("AND index_name = 'uk_customer_dimension_config_tenant'") .doesNotContain("DROP TABLE baiyue_firstleg.fl_customer_dimension_config")
.contains("DROP TABLE baiyue_firstleg.fl_customer_dimension_config") .doesNotContain("ON DUPLICATE KEY UPDATE");
.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");
} }
private static String resource(String path) throws Exception { private static String resource(String path) throws Exception {
@@ -49,34 +49,31 @@ class DropshipPalletizationSqlContractTest {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
for (String sql : new String[]{inbound, unified}) { for (String sql : new String[]{inbound, unified}) {
assertThat(sql) assertThat(sql.toLowerCase())
.contains("counted_cartons INT UNSIGNED NOT NULL DEFAULT 0") .contains("counted_cartons int unsigned not null default")
.contains("discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0") .contains("discrepancy_cartons int unsigned not null default")
.contains("pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托'") .contains("pallet_status varchar(16) not null comment '托盘状态:open组托中、sealed已封托'")
.contains("expected_sku_quantity INT UNSIGNED NOT NULL") .contains("expected_sku_quantity int unsigned not null")
.contains("actual_sku_quantity INT UNSIGNED NOT NULL") .contains("actual_sku_quantity int unsigned not null")
.contains("count_operation_id VARCHAR(128) NOT NULL") .contains("count_operation_id varchar(128) not null")
.contains("bind_operation_id VARCHAR(128) NULL"); .contains("bind_operation_id varchar(128)");
} }
for (String sql : new String[]{loading, unified}) { for (String sql : new String[]{loading, unified}) {
assertThat(sql) String normalized = sql.toLowerCase().replace(", ", ",");
.contains("unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE'") assertThat(normalized)
.contains("pallet_id BIGINT UNSIGNED NULL") .contains("unit_type varchar(24) not null default 'package'")
.contains("indivisible TINYINT(1) NOT NULL DEFAULT 0") .contains("pallet_id bigint unsigned")
.contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS") .contains("indivisible tinyint(1) not null default")
.contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)") .contains("active_assignment_key varchar(96) generated always as")
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (") .contains("unique key uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key)")
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ("); .doesNotContain("create table fl_loading_work_pallet (")
.doesNotContain("create table fl_loading_work_pallet_carton (");
} }
assertThat(unified) assertThat(unified)
.contains("CREATE PROCEDURE converge_loading_assignment_schema()") .contains("CONSTRAINT chk_dropship_pallet_status CHECK")
.contains("CREATE PROCEDURE converge_dropship_palletization_schema()") .contains("UNIQUE KEY uk_loading_item_plan_assignment")
.contains("ALTER TABLE fl_dropship_pallet DROP CHECK chk_dropship_pallet_status") .doesNotContain("converge_loading_assignment_schema")
.contains("ALTER TABLE fl_dropship_pallet DROP CHECK chk_dropship_pallet_status;\n END IF;\n UPDATE fl_dropship_pallet") .doesNotContain("converge_dropship_palletization_schema");
.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)");
} }
@Test @Test
@@ -11,9 +11,13 @@ import static org.assertj.core.api.Assertions.assertThat;
class FeeItemMigrationSqlContractTest { class FeeItemMigrationSqlContractTest {
@Test @Test
void temporarySeedTableIsNotReopenedWithinOneMysqlStatement() throws Exception { void temporarySeedTableIsNotReopenedWithinOneMysqlStatement() throws Exception {
assertMysqlCompatible(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertMysqlCompatible(resolve( assertMysqlCompatible(resolve(
"firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql")); "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 { private static void assertMysqlCompatible(Path path) throws Exception {
@@ -35,9 +39,12 @@ class FeeItemMigrationSqlContractTest {
@Test @Test
void inboundFeeSourceIndexUsesTheFinalVersionedDefinition() throws Exception { void inboundFeeSourceIndexUsesTheFinalVersionedDefinition() throws Exception {
assertFinalSourceIndex(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertFinalSourceIndex(resolve( assertFinalSourceIndex(resolve(
"firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql")); "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 { private static void assertFinalSourceIndex(Path path) throws Exception {
@@ -17,11 +17,11 @@ class FulfillmentTypeSqlContractTest {
assertFulfillmentMigration(order, "baiyue_order.ord_order"); assertFulfillmentMigration(order, "baiyue_order.ord_order");
assertFulfillmentMigration(inbound, "fl_inbound_order"); assertFulfillmentMigration(inbound, "fl_inbound_order");
assertFulfillmentMigration(unified, "baiyue_order.ord_order"); assertThat(unified.toLowerCase())
assertFulfillmentMigration(unified, "fl_inbound_order"); .contains("create table ord_order")
assertThat(unified) .contains("create table fl_inbound_order")
.contains("unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type')") .contains("fulfillment_type varchar(32) not null default 'standard'")
.contains("unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type')"); .doesNotContain("unified_assert_column");
} }
private static void assertFulfillmentMigration(String sql, String table) { private static void assertFulfillmentMigration(String sql, String table) {
@@ -29,20 +29,17 @@ class InboundCartonLabelSqlContractTest {
assertThat(versioningMigration).contains("phase2_carton_label_add_column('label_version'") assertThat(versioningMigration).contains("phase2_carton_label_add_column('label_version'")
.contains("phase2_carton_label_add_column('batch_status'") .contains("phase2_carton_label_add_column('batch_status'")
.contains("idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version)"); .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") assertThat(unified).contains("CREATE TABLE fl_inbound_carton_label")
.contains("units_per_carton INT UNSIGNED NULL") .contains("units_per_carton int unsigned DEFAULT NULL")
.contains("unified_carton_label_add_column('label_version'") .contains("label_version int unsigned NOT NULL DEFAULT '1'")
.contains("unified_carton_label_add_column('batch_status'") .contains("batch_status varchar(16) NOT NULL DEFAULT 'ACTIVE'")
.contains("unified_carton_label_add_column('cargo_fingerprint'") .contains("cargo_fingerprint char(64) DEFAULT NULL")
.contains("unified_carton_label_add_column('voided_at'") .contains("voided_at datetime(3) DEFAULT NULL")
.contains("ALTER TABLE fl_inbound_carton_label DROP INDEX uk_carton_label_index") .contains("UNIQUE KEY uk_carton_label_index (tenant_id,inbound_id,label_version,carton_index)")
.contains("ADD 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("ADD KEY idx_carton_label_active (tenant_id,inbound_id,batch_status,label_version)")
.contains("uk_carton_label_box_code") .contains("uk_carton_label_box_code")
.contains("uk_carton_label_index") .contains("uk_carton_label_index")
.contains("稳定箱号映射表未创建") .doesNotContain("unified_carton_label_add_column");
.contains("稳定箱码映射兼容字段未完整创建")
.contains("箱唛有效批次索引未完整创建");
} }
private String resource(String path) throws Exception { private String resource(String path) throws Exception {
@@ -23,7 +23,7 @@ class InboundStatusUnificationSqlContractTest {
.doesNotContain("UPDATE fl_inbound_order") .doesNotContain("UPDATE fl_inbound_order")
.doesNotContain("UPDATE fl_inbound_status_history"); .doesNotContain("UPDATE fl_inbound_status_history");
assertThat(unified) assertThat(unified)
.contains("入库完成后的唯一状态为 WAIT_SPLIT") .contains("WAIT_SPLIT待分柜")
.doesNotContain("历史 WAIT_SPLIT") .doesNotContain("历史 WAIT_SPLIT")
.doesNotContain("SET process_status = 'WAIT_SPLIT'") .doesNotContain("SET process_status = 'WAIT_SPLIT'")
.doesNotContain("SET from_status = 'WAIT_SPLIT'") .doesNotContain("SET from_status = 'WAIT_SPLIT'")
@@ -77,9 +77,12 @@ class InboundWorkflowSqlContractTest {
.contains("uk_measure_box_code") .contains("uk_measure_box_code")
.contains("information_schema.STATISTICS"); .contains("information_schema.STATISTICS");
assertThat(unified) 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("uk_measure_box_code")
.contains("单箱当前测量字段未完整创建"); .doesNotContain("phase2_measure_add_column");
} }
@Test @Test
@@ -97,10 +100,13 @@ class InboundWorkflowSqlContractTest {
.doesNotContain("final_weight_kg") .doesNotContain("final_weight_kg")
.doesNotContain("uk_pallet_allocation_pallet"); .doesNotContain("uk_pallet_allocation_pallet");
assertThat(unified) assertThat(unified)
.contains("CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_id'") .contains("CREATE TABLE fl_inbound_weight_result")
.contains("ALTER TABLE fl_inbound_weight_result DROP COLUMN final_weight_kg") .contains("pallet_id bigint unsigned NOT NULL")
.contains("ALTER TABLE fl_inbound_pallet_allocation DROP INDEX uk_pallet_allocation_pallet") .contains("pallet_tare_weight_kg decimal(18,3) NOT NULL")
.contains("称重托盘快照或毛重净重字段未正确收敛") .contains("gross_weight_kg decimal(18,3) DEFAULT NULL")
.contains("同票托盘唯一约束仍阻止托盘主数据复用"); .contains("net_weight_kg decimal(18,3) DEFAULT NULL")
.contains("KEY idx_weight_inbound_pallet")
.doesNotContain("final_weight_kg")
.doesNotContain("uk_pallet_allocation_pallet");
} }
} }
@@ -19,18 +19,20 @@ class MojiaMeasurementReceiptSqlContractTest {
StandardCharsets.UTF_8); StandardCharsets.UTF_8);
for (String sql : new String[]{schema, unified}) { for (String sql : new String[]{schema, unified}) {
assertThat(sql) String normalized = sql.toLowerCase()
.contains("CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt") .replace("create table if not exists ", "create table ")
.contains("box_code VARCHAR(128) NOT NULL") .replace("create table ", "create table if not exists ");
.contains("scanned_shipment_id VARCHAR(64) NULL") assertThat(normalized)
.contains("image_file_id BIGINT UNSIGNED NOT NULL") .contains("create table if not exists fl_mojia_measurement_receipt")
.contains("status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED'") .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") .contains("idx_mojia_receipt_status_time")
.doesNotContain("picture_base64"); .doesNotContain("picture_base64");
} }
assertThat(unified) assertThat(unified)
.contains("墨家未匹配测量收件箱字段未完整创建") .contains("COMMENT='墨家模拟或未匹配入库单的原始测量收件箱'")
.contains("箱级测量实扫Shipment ID字段未创建") .contains("calculation_rule_version varchar(64) NOT NULL");
.contains("测量计算规则版本字段长度不是64");
} }
} }
@@ -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_by_name'")
.contains("phase3_split_add_column('fl_preload_plan', 'confirmed_at'"); .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_at'");
assertThat(unifiedSql) assertThat(unifiedSql)
.contains("CREATE PROCEDURE migrate_preload_confirmation_columns()") .contains("CREATE TABLE fl_preload_plan")
.contains("ADD COLUMN confirmed_by BIGINT UNSIGNED NULL") .contains("confirmed_by bigint unsigned DEFAULT NULL")
.contains("ADD COLUMN confirmed_by_name VARCHAR(64) NULL") .contains("confirmed_by_name varchar(64) DEFAULT NULL")
.contains("ADD COLUMN confirmed_at DATETIME(3) NULL"); .contains("confirmed_at datetime(3) DEFAULT NULL")
.doesNotContain("migrate_preload_confirmation_columns");
} }
@Test @Test
@@ -188,9 +189,8 @@ class PdaSplitSettlementSqlContractTest {
.contains("UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)") .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)"); .doesNotContain("UNIQUE KEY uk_pallet_allocation_slot (tenant_id, block_id, slot_no)");
assertThat(unifiedSql) assertThat(unifiedSql)
.contains("'tenant_id,inbound_id,block_id,slot_no'") .contains("UNIQUE KEY uk_pallet_allocation_slot (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("UNIQUE KEY uk_pallet_allocation_weight (tenant_id,inbound_id,weight_result_id)")
.contains("'tenant_id,inbound_id,weight_result_id'") .doesNotContain("ADD UNIQUE KEY uk_pallet_allocation_slot");
.contains("ADD UNIQUE KEY uk_pallet_allocation_weight (tenant_id,inbound_id,weight_result_id)");
} }
} }
@@ -58,36 +58,26 @@ class Phase3BookingTruckPermissionSqlContractTest {
} }
@Test @Test
void unifiedSqlLocksCanonicalIdsScopeParentsAndRoleGrants() throws Exception { void unifiedSqlCreatesCanonicalIdsScopeParentsAndRoleGrants() throws Exception {
String sql = unifiedSql(); String sql = unifiedSql();
assertThat(sql) String normalized = sql.replace(", ", ",");
.contains("UNIQUE KEY uk_permission_scope_code (tenant_id, app_code, permission_code)") assertThat(normalized)
.contains("CREATE PROCEDURE assert_ocean_booking_permission_ids_available()") .contains("UNIQUE KEY uk_permission_scope_code (tenant_id,app_code,permission_code)")
.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 或权限码被其他语义占用")
.contains("(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list'") .contains("(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list'")
.contains("(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate'") .contains("(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate'")
.contains("(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'") .contains("(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'")
.contains("(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate'") .contains("(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate'")
.contains("permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 6") .contains("VALUES (4000001,5102140,0,CURRENT_TIMESTAMP(3))")
.contains("p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 12") .contains("VALUES (4000003,5102140,0,CURRENT_TIMESTAMP(3))")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("VALUES (4000001,5102150,0,CURRENT_TIMESTAMP(3))")
.doesNotContain("assert_phase3_booking_truck_permission_ids_available") .contains("VALUES (4000003,5102150,0,CURRENT_TIMESTAMP(3))")
.doesNotContain("assert_ocean_booking_permission_ids_available")
.doesNotContain("assert_truck_appointment_permission_ids_available")
.doesNotContain("/trucking"); .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); .isEqualTo(1);
assertThat(count(sql, "(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'")) assertThat(count(normalized, "(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()"))
.isEqualTo(1); .isEqualTo(1);
} }
@@ -17,10 +17,20 @@ class Phase3ChannelContainerMigrationContractTest {
} }
@Test @Test
void unifiedMigrationRestoresGuardsBeforeSurfacingFailures() throws Exception { void unifiedInitializationCreatesFinalGuardsAndColumnsWithoutMigrationWindow() throws Exception {
String sql = unifiedMigration(); String sql = unifiedMigration();
assertSafeWindow(sql, true); assertThat(sql)
assertNoDuplicateInboundColumns(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) { private static void assertNoDuplicateInboundColumns(String sql) {
@@ -5,370 +5,89 @@ import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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; import static org.assertj.core.api.Assertions.assertThat;
class Phase3FinalUnifiedSqlContractTest { class Phase3FinalUnifiedSqlContractTest {
private static final Path UNIFIED = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); 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 @Test
void unifiedEntryOwnsPhase3OutboundCustomsPortAndEvidenceHardening() throws Exception { void unifiedEntryCreatesTheFinalPhase3SchemaDirectly() throws Exception {
String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8); 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) 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_active (container_booking_id,evidence_type,active_flag)")
.contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)") .contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)")
.contains("normalize_loading_cabinet_evidence_collation") .contains("UNIQUE KEY uk_inbound_one_active_remainder")
.contains("table_collation<>'utf8mb4_0900_ai_ci'") .contains("UNIQUE KEY uk_inbound_split_source_once")
.contains("column_name IN ('container_booking_id','source_task_id','active_flag','operation_key','request_hash')") .contains("CREATE TRIGGER trg_inbound_split_detail_before_insert")
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_evidence", "phase3_loading_evidence_add_column") .contains("CREATE TRIGGER trg_inbound_split_detail_before_update")
.contains("CALL assert_phase3_final_schema()") .contains("CREATE TRIGGER trg_inbound_split_detail_before_delete")
.contains("trg_port_schedule_history_before_update") .contains("CREATE TRIGGER trg_port_schedule_history_before_update")
.contains("trg_port_schedule_history_before_delete") .contains("CREATE TRIGGER trg_port_schedule_history_before_delete")
.contains("trg_loading_pick_operation_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 @Test
void unifiedOceanBookingBlockIncludesExistingSchemaReconciliation() throws Exception { void unifiedEntrySeedsCanonicalPermissionsAndBothPlatformRoles() throws Exception {
String sql = normalise(Files.readString(UNIFIED)); String sql = sql();
assertThat(sql) for (int id : new int[] {5102140, 5102149, 5102150, 5102154, 5102160, 5102164,
.contains("DROP CHECK chk_ocean_booking_status") 5102170, 5102172, 5102180, 5102183, 5102190, 5102193}) {
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK") assertThat(sql).contains(Integer.toString(id));
.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<String> 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));
} }
}
@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) assertThat(sql)
.contains("CREATE PROCEDURE queue_table_comment") .contains("platform:firstleg:ocean-booking:list")
.contains("CREATE PROCEDURE queue_column_comment") .contains("platform:firstleg:truck-appointment:list")
.contains("CREATE PROCEDURE apply_one_table_comments") .contains("platform:firstleg:outbound-dispatch:list")
.contains("CREATE PROCEDURE apply_queued_comments") .contains("platform:firstleg:customs:list")
.contains("NOT (BINARY v_current_comment=BINARY p_column_comment)") .contains("platform:firstleg:port-schedule:list")
.contains("SET v_signature_before=SHA2") .contains("platform:firstleg:pda:view")
.contains("REPLACE(v_generation_expression,CONCAT(CHAR(92),CHAR(39)),CHAR(39))") .contains("VALUES (4000001,5102190,0,CURRENT_TIMESTAMP(3))")
.contains("BINARY target.signature_before") .contains("VALUES (4000003,5102190,0,CURRENT_TIMESTAMP(3))");
.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"));
} }
@Test @Test
void unifiedCreateDefinitionsHaveChineseTableAndColumnComments() throws Exception { void initializationPassIsUniqueAndFinal() throws Exception {
String sql = normalise(Files.readString(resolveUnified())); String sql = sql();
List<String> issues = new ArrayList<>(); String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 208 AS initialized_tables;";
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";
assertThat(count(sql, pass)).isEqualTo(1); assertThat(count(sql, pass)).isEqualTo(1);
int passIndex = sql.lastIndexOf(pass); assertThat(sql.trim()).endsWith(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) assertThat(sql)
.doesNotContain("\n ADD COLUMN IF NOT EXISTS") .doesNotContain("SELECT 'PASS' AS migration_status")
.doesNotContain("\n DROP CHECK IF EXISTS") .doesNotContain("ON DUPLICATE KEY UPDATE");
.doesNotContain("\n+--")
.doesNotContain("\n---")
.doesNotContain("\n>>>")
.doesNotContain("\n<<<")
.contains("CREATE PROCEDURE phase3_pda_add_column")
.contains("CREATE PROCEDURE phase3_pda_replace_operation_check");
} }
@Test private static String sql() throws Exception {
void unifiedPdaBlockDropsLegacyPerItemReleaseColumns() throws Exception { return Files.readString(UNIFIED, StandardCharsets.UTF_8)
String sql = normalise(Files.readString(resolveUnified())); .replace("\r\n", "\n")
.replace("\uFEFF", "");
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 int count(String value, String token) { private static int count(String value, String token) {
return value.split(java.util.regex.Pattern.quote(token), -1).length - 1; 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<String> splitDefinitions(String body) {
List<String> 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;
}
} }
@@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class ServiceCenterUnifiedSqlContractTest { class ServiceCenterUnifiedSqlContractTest {
@Test @Test
void unifiedMigrationContainsServiceCenterSchemaAndGrants() throws Exception { void unifiedInitializationContainsServiceCenterSchemaAndGrants() throws Exception {
Path working = Path.of(System.getProperty("user.dir")); Path working = Path.of(System.getProperty("user.dir"));
Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(path)) { if (!Files.exists(path)) {
@@ -19,24 +19,24 @@ class ServiceCenterUnifiedSqlContractTest {
String sql = Files.readString(path, StandardCharsets.UTF_8); String sql = Files.readString(path, StandardCharsets.UTF_8);
assertThat(sql) assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking") .contains("CREATE TABLE fl_ocean_booking")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_party") .contains("CREATE TABLE fl_ocean_booking_party")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement") .contains("CREATE TABLE fl_ocean_booking_container_requirement")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation") .contains("CREATE TABLE fl_ocean_booking_container_allocation")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history") .contains("CREATE TABLE fl_ocean_booking_status_history")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation") .contains("CREATE TABLE fl_ocean_booking_operation")
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment") .contains("CREATE TABLE fl_truck_appointment")
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_history") .contains("CREATE TABLE fl_truck_appointment_history")
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment") .contains("CREATE TABLE fl_truck_appointment_attachment")
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation") .contains("CREATE TABLE fl_truck_appointment_operation")
.contains("5102140") .contains("5102140")
.contains("5102149") .contains("5102149")
.contains("5102150") .contains("5102150")
.contains("5102154") .contains("5102154")
.contains("PLATFORM_ADMIN") .contains("VALUES (4000001,5102140,0,CURRENT_TIMESTAMP(3))")
.contains("ORDER_OPERATOR") .contains("VALUES (4000003,5102140,0,CURRENT_TIMESTAMP(3))")
.contains("统一迁移失败:订舱或拖车预约角色授权未完整写入") .contains("VALUES (4000001,5102150,0,CURRENT_TIMESTAMP(3))")
.contains("assert_phase3_service_center_schema()") .contains("VALUES (4000003,5102150,0,CURRENT_TIMESTAMP(3))")
.contains("CALL assert_phase3_service_center_schema();"); .doesNotContain("assert_phase3_service_center_schema");
} }
} }
@@ -7,406 +7,102 @@ import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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; import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("智能装柜SQL契约") @DisplayName("智能装柜SQL契约")
class SmartLoadingSqlContractTest { class SmartLoadingSqlContractTest {
private static final Pattern PERMISSION_PATTERN = Pattern.compile("'((?:platform:firstleg:)[^']+)'");
private static final Set<String> 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 @Test
@DisplayName("平台聚合与客户明细边界及原型快照字段完整") void moduleMigrationStillProtectsExistingDatabaseUpgrades() throws Exception {
void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception { String sql = moduleMigration();
assertThat(migration()) assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_document_sequence")
.contains("CREATE TABLE IF NOT EXISTS fl_container_booking") .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")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item") .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_pick_task")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_history") .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item")
.contains("account_id BIGINT UNSIGNED NOT NULL") .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history")
.contains("customer_name VARCHAR(128) NOT NULL") .contains("migrate_legacy_loading_pick_task")
.contains("warehouse_id BIGINT UNSIGNED NOT NULL") .contains("assert_phase3_smart_loading_schema")
.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("智能装柜迁移失败:平台角色授权未完整写入"); .contains("智能装柜迁移失败:平台角色授权未完整写入");
} }
@Test @Test
@DisplayName("模块脚本和统一脚本均校验11项权限与两个角色22项授权") void unifiedInitializationCreatesSeparateLoadingAndInboundTaskShapes() throws Exception {
void shouldAssertCompletePermissionGrants() throws Exception { String sql = unified();
assertPermissionContract(migration()); assertThat(sql)
assertPermissionContract(unifiedMigration()); .contains("CREATE TABLE fl_loading_pick_task")
assertThat(unifiedMigration()) .contains("loading_plan_id bigint unsigned NOT NULL")
.contains("CALL migrate_legacy_loading_pick_task()") .contains("container_booking_id bigint unsigned NOT NULL")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task") .contains("task_status varchar(24) NOT NULL DEFAULT 'LOADING'")
.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)'")
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)") .contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
.contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_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 fl_inbound_loading_pick_task")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task (") .contains("tenant_id bigint unsigned NOT NULL")
.contains("tenant_id BIGINT UNSIGNED NOT NULL") .contains("inbound_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("expected_count INT UNSIGNED NULL") .contains("KEY idx_loading_pick_status (tenant_id,status,updated_at)")
.contains("picked_count INT UNSIGNED NULL") .doesNotContain("migrate_legacy_loading_pick_task");
.contains("UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id, inbound_id)")
.contains("KEY idx_loading_pick_status (tenant_id, status, updated_at)");
} }
@Test @Test
@DisplayName("迁移拒绝缺列错类型和混合拣货表型") void unifiedInitializationCreatesFinalConcurrencyAndCandidateIndexes() throws Exception {
void shouldRejectMalformedLegacyPickTaskShapes() throws Exception { String sql = unified();
assertMalformedShapeGuards(migration()); assertThat(sql)
assertMalformedShapeGuards(unifiedMigration()); .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") return new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
} }
private static String unifiedMigration() throws Exception { private static String unified() throws Exception {
Path working = Path.of("").toAbsolutePath(); Path direct = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); Path path = Files.exists(direct) ? direct : Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(file)) { return Files.readString(path, StandardCharsets.UTF_8);
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<String> 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();
} }
} }
@@ -22,12 +22,12 @@ class TerminalDrayageSqlContractTest {
assertThat(selected).isEqualTo(fields); assertThat(selected).isEqualTo(fields);
if (name.equals("pendingMails")) assertThat(sql).contains("earlier.container_booking_id=m.container_booking_id", "earlier.id<m.id", "earlier.status<>'SENT'"); if (name.equals("pendingMails")) assertThat(sql).contains("earlier.container_booking_id=m.container_booking_id", "earlier.id<m.id", "earlier.status<>'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"))) { String baseline = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"));
assertThat(Files.readString(path)).contains( String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (", assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (");
"CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))", for (String sql : java.util.List.of(baseline, unified)) assertThat(sql).contains(
"('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')"); "PENDING","SENDING","SENT","FAILED",
} "SELECT_FLEET","SEND_PICKUP_EMAIL","SCHEDULE_PICKUP","RECORD_EMPTY_RETURN");
} }
@Test @Test
@@ -35,10 +35,10 @@ class TerminalDrayageSqlContractTest {
String baseline = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql")); 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"); assertThat(baseline).doesNotContain("actual_pickup_at", "chk_terminal_drayage_times");
String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertThat(release).contains("ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at", assertThat(release)
"DROP CHECK chk_terminal_drayage_history_action", .contains("CREATE TABLE fl_terminal_drayage")
"ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN"); .contains("CONSTRAINT chk_terminal_drayage_history_action CHECK")
assertThat(release).doesNotContain("chk_terminal_drayage_times"); .doesNotContain("actual_pickup_at", "chk_terminal_drayage_times", "ALTER TABLE fl_terminal_drayage");
} }
@Test @Test
@@ -62,12 +62,13 @@ class TerminalDrayageSqlContractTest {
} }
@Test @Test
void unifiedMigrationRepairsColumnsAfterAPartialDdlRun() throws Exception { void unifiedInitializationCreatesFinalColumnsWithoutRepairDdl() throws Exception {
String sql = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); String sql = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertThat(sql).contains( assertThat(sql).contains(
"ALTER TABLE fl_terminal_drayage", "CREATE TABLE fl_terminal_drayage",
"ALTER TABLE fl_terminal_drayage_history", "CREATE TABLE fl_terminal_drayage_history",
"ALTER TABLE fl_terminal_drayage_operation", "CREATE TABLE fl_terminal_drayage_operation",
"MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间'"); "estimated_empty_return_at datetime(3) DEFAULT NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间'");
assertThat(sql).doesNotContain("ALTER TABLE fl_terminal_drayage");
} }
} }
@@ -81,7 +81,8 @@ VALUES
(5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement','海外采购','/cross-border/procurement',NULL,70,'ACTIVE',1,0), (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), (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), (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), ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
permission_name=VALUES(permission_name), route_path=VALUES(route_path), 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; 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:procurement',
'merchant:cross-border:domestic-trucking', 'merchant:cross-border:domestic-trucking',
'merchant:cross-border:pallet-shipping', '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' 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; 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:procurement',
'merchant:cross-border:domestic-trucking', 'merchant:cross-border:domestic-trucking',
'merchant:cross-border:pallet-shipping', '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='海外客户权限初始化失败:权限资源不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row IF (SELECT COUNT(*) FROM iam_role_permission grant_row
@@ -256,7 +259,8 @@ BEGIN
'merchant:cross-border:procurement', 'merchant:cross-border:procurement',
'merchant:cross-border:domestic-trucking', 'merchant:cross-border:domestic-trucking',
'merchant:cross-border:pallet-shipping', '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='海外客户权限初始化失败:客户默认管理员授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整';
END IF; END IF;
END$$ END$$
@@ -7,23 +7,18 @@ import static org.assertj.core.api.Assertions.assertThat;
class NotificationSqlContractTest { 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 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 direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
Path migration=Files.exists(direct)?direct:Path.of("..","deploy","sql","头程数据库统一增量-MySQL8.sql").normalize(); Path migration=Files.exists(direct)?direct:Path.of("..","deploy","sql","头程数据库统一增量-MySQL8.sql").normalize();
String sql=Files.readString(migration,StandardCharsets.UTF_8); 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", "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_task_no","uk_notification_task_idempotency","uk_notification_source_event",
"uk_notification_recipient","uk_notification_inbox_recipient","uk_notification_event", "uk_notification_recipient","uk_notification_inbox_recipient","uk_notification_event",
"assert_notification_center_schema","permission_code='merchant:notification:view'", "'merchant:notification:view'", "'platform:notification:template:list'",
"permission_code LIKE 'platform:notification%'"); "'platform:notification:task:list'");
assertThat(sql).contains("COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_iam'");
assertThat(sql) assertThat(sql)
.contains(")) <> 7 THEN") .contains("(5106014,0,'MERCHANT','BUTTON'")
.contains("permission_code='merchant:notification:view')) <> 13 THEN") .doesNotContain("assert_notification_center_schema", "information_schema.statistics");
.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();"));
} }
} }
@@ -60,15 +60,9 @@ class AdminPrototypeMenuPermissionSqlContractTest {
} }
assertThat(unified) assertThat(unified)
.contains("platform:finance:receivable-statistics:export") .contains("platform:finance:receivable-statistics:export")
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34") .contains("(4000001,5105050,0,CURRENT_TIMESTAMP(3))")
.contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 35") .contains("(4000100,5305010,0,CURRENT_TIMESTAMP(3))")
.contains("Finance 导出和下载中心授权不完整") .doesNotContain("CREATE PROCEDURE assert_finance_permissions", "CALL migrate_wallet_permissions");
.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();")));
} }
@Test @Test
@@ -80,8 +74,10 @@ class AdminPrototypeMenuPermissionSqlContractTest {
.contains("'platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete'"); .contains("'platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete'");
String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
assertThat(unified).contains("<> 4") assertThat(unified)
.contains("platform:overseas-storage-fee:list', 'platform:overseas-storage-fee:create'"); .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 @Test
@@ -90,27 +90,16 @@ class ContainerWorkspacePermissionSqlContractTest {
} }
@Test @Test
void unifiedIncrementContainsTheCompleteModuleSql() throws Exception { void cleanInitializationContainsTheCompletePermissionCatalog() throws Exception {
Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
if (!Files.exists(unifiedPath)) { if (!Files.exists(unifiedPath)) {
unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
} }
String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
String startMarker = "-- 货柜双工作台权限开始(与 iam/db/container-workspace-permissions.sql 保持一致)"; assertThat(codes(unified)).containsAll(REQUIRED);
String endMarker = "-- 货柜双工作台权限结束,恢复头程数据库上下文"; assertThat(unified)
int start = unified.indexOf(startMarker); .contains("(5102090,0,'PLATFORM','MENU'", "(5102205,0,'PLATFORM','BUTTON'")
int bodyStart = unified.indexOf('\n', start) + 1; .doesNotContain("migrate_container_workspace_permissions", "ON DUPLICATE KEY UPDATE");
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");
} }
private static String sql() throws Exception { private static String sql() throws Exception {
@@ -20,15 +20,24 @@ class CustomerCodeSchemaContractTest {
} }
String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
assertSequenceDefinition(baseline); assertMigrationSequenceDefinition(baseline);
assertSequenceDefinition(unified); assertInitializationSequenceDefinition(unified);
} }
private static void assertSequenceDefinition(String sql) { private static void assertMigrationSequenceDefinition(String sql) {
assertThat(sql) assertThat(sql)
.contains("tenant_code VARCHAR(8) NOT NULL") .contains("tenant_code VARCHAR(8) NOT NULL")
.contains("CREATE TABLE IF NOT EXISTS iam_number_sequence") .contains("CREATE TABLE IF NOT EXISTS iam_number_sequence")
.contains("PRIMARY KEY (sequence_key)") .contains("PRIMARY KEY (sequence_key)")
.contains("INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1)"); .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");
}
} }
@@ -28,7 +28,9 @@ class CustomerDimensionInitializationSqlContractTest {
assertContract(standalone); assertContract(standalone);
assertContract(unified); assertContract(unified);
assertThat(standalone).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); 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 @Test
@@ -49,7 +49,7 @@ class CustomerProfileMigrationContractTest {
} }
@Test @Test
void shouldHandleCase03() throws IOException { void cleanInitializationContainsTheFinalCustomerProfileContract() throws IOException {
Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", "..")); Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", ".."));
Path migration = repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); Path migration = repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
Path readme = repositoryRoot.resolve("deploy/sql/README.md"); Path readme = repositoryRoot.resolve("deploy/sql/README.md");
@@ -60,43 +60,36 @@ class CustomerProfileMigrationContractTest {
String instructions = Files.readString(readme, StandardCharsets.UTF_8); String instructions = Files.readString(readme, StandardCharsets.UTF_8);
assertThat(sql) assertThat(sql)
.contains("customer_nickname VARCHAR(32)") .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("DEFAULT 'PROFILE_REQUIRED'") .contains("DEFAULT 'PROFILE_REQUIRED'")
.contains("CALL phase1_iam_add_column('iam_tenant', 'approved_at'") .contains("approved_at datetime(3)")
.contains("CALL phase1_iam_add_column('iam_tenant', 'approved_by'") .contains("approved_by bigint")
.contains("CALL phase1_iam_add_column('iam_tenant', 'reject_reason'") .contains("reject_reason varchar(500)")
.contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment'") .contains("review_comment varchar(500)")
.contains("platform:customer:view") .contains("platform:customer:view")
.contains("platform:customer:export") .contains("platform:customer:export")
.contains("platform:download:list") .contains("platform:download:list")
.contains("'MERCHANT_OWNER'") .contains("'MERCHANT_OWNER'")
.contains("owner_role.id, permission.id, 0") .contains("SELECT 'PASS' AS initialization_status")
.contains("统一增量全部执行完成"); .doesNotContain("ALTER TABLE iam_tenant", "phase1_iam_add_column");
assertThat(instructions) assertThat(instructions)
.contains("phase1-platform-master-data-and-permission-management.sql") .contains("当前开发阶段只维护纯初始化")
.contains("phase1-customer-profile.sql") .contains("模块 `src/main/resources/db/` 用作结构来源和开发追溯")
.contains("phase1-onboarding.sql") .contains("不再向历史迁移文件追加发布步骤");
.contains("`PROFILE_REQUIRED` 默认状态、审核结果字段、资质审核备注及成员审计字段")
.contains("全局 `MERCHANT_OWNER` 角色模板")
.contains("测试环境结构发布只执行全量固定入口");
} }
@Test @Test
void unifiedMigrationGrantsCustomerOperationsToPlatformAdmin() throws IOException { void cleanInitializationGrantsCustomerOperationsToPlatformAdmin() throws IOException {
Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", "..")); Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", ".."));
String sql = Files.readString( String sql = Files.readString(
repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8); StandardCharsets.UTF_8);
int grantStart = sql.indexOf("-- 新增平台操作权限默认只授予系统管理员。"); assertThat(sql)
int grantEnd = sql.indexOf("-- 已有客户运营角色继续具备", grantStart);
assertThat(grantStart).isGreaterThanOrEqualTo(0);
assertThat(grantEnd).isGreaterThan(grantStart);
assertThat(sql.substring(grantStart, grantEnd))
.contains("'platform:customer:reset-password'") .contains("'platform:customer:reset-password'")
.contains("'platform:customer:delete'") .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))");
} }
} }
@@ -46,28 +46,16 @@ class FinanceG1G2PermissionSqlContractTest {
@Test @Test
void unifiedSqlMirrorsCatalogAndGrantsTheFinanceCatalogToPlatformAdmin() throws Exception { void unifiedSqlMirrorsCatalogAndGrantsTheFinanceCatalogToPlatformAdmin() throws Exception {
String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8) String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
.replace("\r\n", "\n");
String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"),
sql.indexOf("-- Admin 原型菜单与财务五页权限补充"));
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) 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-ar:list")
.contains("platform:finance:settlement-ap:list") .contains("platform:finance:settlement-ap:list")
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34") .doesNotContain("migrate_finance_g1_g4_permissions", "status='INACTIVE',deleted=1");
.contains("rp.role_id=4000001")
.contains("LIKE 'platform:finance:%')) <> 35")
.contains("平台管理员 Finance 授权不完整");
} }
private static Path resolveUnifiedSql() { private static Path resolveUnifiedSql() {
@@ -39,21 +39,13 @@ class FinanceG3G4PermissionSqlContractTest {
@Test @Test
void unifiedSqlContainsCatalogAndGrantsItToPlatformAdmin() throws Exception { void unifiedSqlContainsCatalogAndGrantsItToPlatformAdmin() throws Exception {
String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"), for (String code : CODES) assertThat(sql).contains(code);
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");
assertThat(sql) 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-ar:list")
.contains("platform:finance:settlement-ap:list") .contains("platform:finance:settlement-ap:list")
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34") .doesNotContain("migrate_finance_g1_g4_permissions", "finance G1-G4 permission id/code conflict");
.contains("rp.role_id=4000001")
.contains("LIKE 'platform:finance:%')) <> 35");
} }
private static Path resolveUnifiedSql() { private static Path resolveUnifiedSql() {
@@ -63,10 +63,11 @@ class InboundHoldPermissionSqlContractTest {
} }
@Test @Test
void unifiedIncrementContainsTheSameMigrationBody() throws Exception { void cleanInitializationContainsThePermissionAndDefaultGrant() throws Exception {
String marker = "DROP PROCEDURE IF EXISTS migrate_inbound_hold_permission;"; assertThat(unifiedSql())
.contains("(5102032,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:hold'")
assertThat(migrationBody(unifiedSql(), marker)).isEqualTo(migrationBody(sql(), marker)); .contains("(4000001,5102032,0,CURRENT_TIMESTAMP(3))")
.doesNotContain("migrate_inbound_hold_permission", "ON DUPLICATE KEY UPDATE");
} }
private static String sql() throws Exception { private static String sql() throws Exception {
@@ -80,11 +81,4 @@ class InboundHoldPermissionSqlContractTest {
return Files.readString(path.normalize(), StandardCharsets.UTF_8); 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();
}
} }
@@ -18,9 +18,17 @@ class InitialIdentitySeedSqlContractTest {
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
String unified = Files.readString(unifiedSql(), StandardCharsets.UTF_8); String unified = Files.readString(unifiedSql(), StandardCharsets.UTF_8);
assertPlatformIdentitySeed(identitySeed); assertPlatformIdentitySeed(identitySeed);
assertPlatformIdentitySeed(unified); assertCleanPlatformIdentitySeed(unified);
assertGlobalMerchantTemplate(templateSeed); 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) { private static void assertPlatformIdentitySeed(String sql) {
@@ -47,6 +55,14 @@ class InitialIdentitySeedSqlContractTest {
.doesNotContain("WHERE tenant_id = 10001 AND app_code = 'MERCHANT'"); .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() { private static Path unifiedSql() {
Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
@@ -13,7 +13,16 @@ class LocationPermissionSqlContractTest {
@Test @Test
void acceptsKnownPollutionAndRestoresCanonicalPermissionIds() throws Exception { void acceptsKnownPollutionAndRestoresCanonicalPermissionIds() throws Exception {
assertRepairContract(moduleSql()); 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) { private static void assertRepairContract(String sql) {
@@ -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);
}
}
@@ -18,7 +18,8 @@ class OverseasCustomerPermissionSqlContractTest {
"merchant:cross-border:procurement", "merchant:cross-border:procurement",
"merchant:cross-border:domestic-trucking", "merchant:cross-border:domestic-trucking",
"merchant:cross-border:pallet-shipping", "merchant:cross-border:pallet-shipping",
"merchant:warehouse:inbound:list"); "merchant:warehouse:inbound:list",
"merchant:overseas-warehouse:return-inventory:list");
@Test @Test
void declaresTheFrozenCustomerOverseasPermissionCatalog() throws Exception { void declaresTheFrozenCustomerOverseasPermissionCatalog() throws Exception {
@@ -32,11 +33,12 @@ class OverseasCustomerPermissionSqlContractTest {
"'merchant:cross-border:procurement','海外采购','/cross-border/procurement'", "'merchant:cross-border:procurement','海外采购','/cross-border/procurement'",
"'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking'", "'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking'",
"'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping'", "'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 @Test
void grantsAllFourPermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception { void grantsAllFivePermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception {
String sql = sql(); String sql = sql();
int comment = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口"); int comment = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口");
int grantStart = sql.indexOf("INSERT IGNORE INTO iam_role_permission", comment); int grantStart = sql.indexOf("INSERT IGNORE INTO iam_role_permission", comment);
@@ -64,7 +66,7 @@ class OverseasCustomerPermissionSqlContractTest {
"海外客户权限初始化失败:客户默认管理员授权不完整", "海外客户权限初始化失败:客户默认管理员授权不完整",
"permission.app_code='MERCHANT'", "permission.app_code='MERCHANT'",
"role.role_code='MERCHANT_OWNER'", "role.role_code='MERCHANT_OWNER'",
")) <> 4 THEN"); ")) <> 5 THEN");
} }
@Test @Test
@@ -64,10 +64,10 @@ class OverseasPhase4PermissionSqlContractTest {
Set<String> moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") Set<String> moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8)); .getContentAsString(StandardCharsets.UTF_8));
assertThat(codes(sql)).containsAll(moduleCatalog); assertThat(codes(sql)).containsAll(moduleCatalog);
assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'"); assertThat(sql)
assertPdaPickerGrant(sql); .contains("CREATE TABLE iam_permission", "CREATE TABLE iam_role_permission", "'lastleg:pda:operate'")
assertCustomAdminGrant(sql); .contains("(4000005,5107015,0,CURRENT_TIMESTAMP(3))")
assertCustomAdminNavigationGrant(sql); .doesNotContain("assert_overseas_pda_picker_permission", "DELETE role_permission FROM");
} }
@Test @Test
@@ -78,25 +78,33 @@ class OverseasPhase4PermissionSqlContractTest {
if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql");
String unifiedSql = Files.readString(path, StandardCharsets.UTF_8); String unifiedSql = Files.readString(path, StandardCharsets.UTF_8);
for (String sql : List.of(moduleSql, unifiedSql)) { String matrix = matrixBlock(moduleSql);
String matrix = matrixBlock(sql); assertThat(matrix).contains("WAREHOUSE_OPERATOR", "WAREHOUSE_TALLY");
assertThat(matrix).contains("WAREHOUSE_OPERATOR", "WAREHOUSE_TALLY"); assertThat(codes(matrix)).containsExactlyInAnyOrder(
assertThat(codes(matrix)).containsExactlyInAnyOrder( "platform:overseas-warehouse:last-mile-order:list",
"platform:overseas-warehouse:last-mile-order:list", "platform:overseas-warehouse:inbound-order:list",
"platform:overseas-warehouse:inbound-order:list", "platform:overseas-warehouse:relabel-order:list",
"platform:overseas-warehouse:relabel-order:list", "platform:overseas-warehouse:inventory:list",
"platform:overseas-warehouse:inventory:list", "platform:overseas-warehouse:warehouse:list",
"platform:overseas-warehouse:warehouse:list", "platform:overseas-warehouse:location:list",
"platform:overseas-warehouse:location:list", "platform:overseas-warehouse:pallet:list",
"platform:overseas-warehouse:pallet:list", "platform:overseas-business:purchase:list",
"platform:overseas-business:purchase:list", "platform:overseas-business:truck:list",
"platform:overseas-business:truck:list", "platform:overseas-business:looseboard:list");
"platform:overseas-business:looseboard:list"); assertThat(matrix).doesNotContain("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
assertThat(matrix).doesNotContain("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'");
assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'"); assertThat(moduleSql).contains("assert_phase4_warehouse_role_minimum", "<> 20",
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')");
"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 @Test
@@ -143,35 +143,28 @@ class Phase5DeliveryPermissionSqlContractTest {
} }
@Test @Test
void domesticUnifiedEntryInlinesTheCompleteModuleSqlAfterPhase4Permissions() throws Exception { void domesticCleanInitializationContainsTheCompletePermissionCatalog() throws Exception {
String moduleSql = sql().trim();
String unifiedSql = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql"); 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(codes(unifiedSql)).containsAll(REQUIRED);
assertThat(outboundDelivery).isGreaterThan(overseasCatalog); assertThat(unifiedSql)
assertThat(bodyStart).isGreaterThan(outboundDelivery); .contains("(4000004,5107064,0,CURRENT_TIMESTAMP(3))")
assertThat(nextSection).isGreaterThan(bodyStart); .contains("(4000004,5107094,0,CURRENT_TIMESTAMP(3))")
assertThat(unifiedSql.substring(bodyStart, nextSection).trim()).isEqualTo(moduleSql); .contains("(4000100,5306000,0,CURRENT_TIMESTAMP(3))")
assertThat(codes(unifiedSql.substring(outboundDelivery, nextSection))) .contains("(4000100,5306001,0,CURRENT_TIMESTAMP(3))")
.containsExactlyInAnyOrderElementsOf(REQUIRED); .doesNotContain("guard_phase5_delivery_permission_ids", "tmp_phase5_delivery_role_permission");
} }
@Test @Test
void deploymentGuideUsesOnlyTheDomesticUnifiedEntry() throws Exception { void deploymentGuideDefinesCleanRegionalInitializationOnly() throws Exception {
String readme = repositoryFile("deploy/sql/README.md"); String readme = repositoryFile("deploy/sql/README.md");
assertThat(readme).contains( assertThat(readme).contains(
"海外排车、出库与派送权限", "当前开发阶段只维护纯初始化",
"测试和部署只执行固定入口,不再单独执行模块脚本", "两个 SQL 文件沿用原文件名",
"排车列表/编辑/取消/转派送", "国内 IAM 是全部平台与客户权限的唯一权威",
"异常订单列表/创建/处理和客户回访列表/编辑十六项权限", "模块 `src/main/resources/db/` 用作结构来源和开发追溯",
"merchant:warehouse:delivery:list", "不再向历史迁移文件追加发布步骤");
"merchant:warehouse:delivery:create",
"PDA 岗位不得获得上述后台权限");
} }
private static String sql() throws Exception { private static String sql() throws Exception {
@@ -22,10 +22,7 @@ class QualificationSchemaContractTest {
assertThat(baseline).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'"); assertThat(baseline).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'");
assertThat(unified) assertThat(unified)
.contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'") .contains("original_file_name varchar(255) NOT NULL COMMENT '上传时原始文件名'")
.contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'original_file_name',") .doesNotContain("phase1_iam_add_column", "information_schema.columns");
.contains("table_name='iam_tenant_qualification'")
.contains("column_name='original_file_name'")
.contains("统一迁移失败:IAM客户资质缺少原始文件名列");
} }
} }
@@ -26,18 +26,17 @@ class SecurityEventSqlContractTest {
} }
@Test @Test
void unifiedMigrationIncludesSecurityEventSchemaAndAssertion() throws Exception { void cleanInitializationIncludesSecurityEventSchema() throws Exception {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path migration = Files.exists(direct) Path migration = Files.exists(direct)
? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql").normalize(); ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql").normalize();
String sql = Files.readString(migration, StandardCharsets.UTF_8); String sql = Files.readString(migration, StandardCharsets.UTF_8);
assertThat(sql) assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS iam_security_subject_version") .contains("CREATE TABLE iam_security_subject_version")
.contains("CREATE TABLE IF NOT EXISTS iam_security_tenant_version") .contains("CREATE TABLE iam_security_tenant_version")
.contains("CREATE TABLE IF NOT EXISTS iam_security_event_outbox") .contains("CREATE TABLE iam_security_event_outbox")
.contains("idx_security_outbox_publish", "idx_security_outbox_aggregate") .contains("idx_security_outbox_publish", "idx_security_outbox_aggregate")
.contains("CALL assert_iam_security_event_schema();"); .contains("SELECT 'PASS' AS initialization_status")
assertThat(sql.indexOf("CALL assert_iam_security_event_schema();")) .doesNotContain("assert_iam_security_event_schema", "CREATE TABLE IF NOT EXISTS iam_security_event");
.isLessThan(sql.lastIndexOf("SELECT 'PASS' AS migration_status"));
} }
} }
@@ -18,13 +18,19 @@ class WarehouseRoleSqlContractTest {
@Test @Test
void unifiedDeploymentContainsTheSameWarehouseRoleContract() throws Exception { void unifiedDeploymentContainsTheSameWarehouseRoleContract() throws Exception {
String sql = unifiedSql(); String sql = unifiedSql();
assertWarehouseRoleContract(sql); assertThat(sql)
assertThat(roleMigrationBlock(sql)) .contains("(4000004,0,'PLATFORM','WAREHOUSE_OPERATOR','国内仓后台操作员','SYSTEM'")
.doesNotContain("\n ADD COLUMN IF NOT EXISTS") .contains("(4000005,0,'PLATFORM','OVERSEAS_WAREHOUSE_PDA_OPERATOR','海外仓 PDA 作业员','SYSTEM'")
.doesNotContain("DROP CHECK IF EXISTS"); .contains("(4000006,0,'PLATFORM','WAREHOUSE_TALLY','国内仓理货复核员','SYSTEM'")
assertThat(sql.lastIndexOf("SELECT 'PASS' AS migration_status")) .contains("(4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM'")
.isGreaterThan(sql.indexOf("CALL assert_phase3_warehouse_roles_ready()")); .contains("(6000004,4000004,0,'TENANT',NULL")
assertThat(sql.trim()).endsWith("AS message;"); .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) { private static void assertWarehouseRoleContract(String sql) {
@@ -100,15 +106,6 @@ class WarehouseRoleSqlContractTest {
return sql.substring(start, end > start ? end : sql.length()); 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 { private static String moduleSql() throws Exception {
return new ClassPathResource("db/phase3-warehouse-roles.sql") return new ClassPathResource("db/phase3-warehouse-roles.sql")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
+13 -1
View File
@@ -49,7 +49,7 @@
| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 | | 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 入仓后 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 | | 海外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 仓内通用扫码与操作历史 | `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 收货候选 | | 船节点接收与海外 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 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” | | 平台排车列表/详情与智能预排 | `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`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。 - 混合换标 `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 不再自行批准复核。 - 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。
@@ -70,6 +70,27 @@ public class DeliveryTransferController {
return ApiResponse.success(service.createReturnOutbound(requiredCustomer(user, customerId), operator(user), request)); 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<java.util.List<DeliveryResponses.WarehouseTransferOutboundCandidate>> 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<DeliveryResponses.OutboundDetail> 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") @GetMapping("/outbound-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage( public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
@@ -75,6 +75,20 @@ public final class DeliveryRequests {
@NotNull @Size(min=1, max=200) List<@NotBlank @Size(max=128) String> cartonMarks, @NotNull @Size(min=1, max=200) List<@NotBlank @Size(max=128) String> cartonMarks,
@Size(max=1000) String remark, @Size(max=1000) String remark,
@NotBlank @Size(max=255) String idempotencyKey) {} @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, public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages,
@NotBlank @Size(max=128) String sku, @NotBlank @Size(max=128) String sku,
@Size(max=200) List<@NotBlank @Size(max=128) String> cartonMarks) { @Size(max=200) List<@NotBlank @Size(max=128) String> cartonMarks) {
@@ -31,7 +31,8 @@ public final class DeliveryResponses {
String remark, LocalDateTime createdAt, long version, List<String> allowedActions, String remark, LocalDateTime createdAt, long version, List<String> allowedActions,
String bolNo, DeliveryDocumentDtos.Summary documentSummary, String bolNo, DeliveryDocumentDtos.Summary documentSummary,
String destinationWarehouseId, String destinationWarehouseCode, 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, public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo, String receivingAddress, String shipmentNo, String fbaTrackingNo,
@@ -59,6 +60,16 @@ public final class DeliveryResponses {
String cartonMark, String fbaCartonMark, String cartonMark, String fbaCartonMark,
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
long totalQuantity, String status) {} 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 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 PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {}
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark, public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
@@ -26,6 +26,11 @@ public class DeliveryOrder {
private String destinationWarehouseId; private String destinationWarehouseId;
private String destinationWarehouseCode; private String destinationWarehouseCode;
private String destinationWarehouseName; private String destinationWarehouseName;
private String destinationType;
private String deliveryAddress;
private String postalCode;
private String contactName;
private String contactPhone;
private String deliveryGroupKey; private String deliveryGroupKey;
private String status; private String status;
private String waybillNo; private String waybillNo;
@@ -39,6 +39,7 @@ public class DeliveryCompletedOutboxService {
Objects.requireNonNull(completedAt, "completedAt"); Objects.requireNonNull(completedAt, "completedAt");
Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>(); Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>();
for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) { for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) {
if ("RELABEL_APPLICATION".equals(order.getOrderType())) continue;
if (!StringUtils.hasText(order.getSourceInboundId())) continue; if (!StringUtils.hasText(order.getSourceInboundId())) continue;
requireText(order.getSourceOrderId(), "sourceOrderId"); requireText(order.getSourceOrderId(), "sourceOrderId");
requireText(order.getInboundCode(), "inboundCode"); requireText(order.getInboundCode(), "inboundCode");
@@ -60,7 +60,9 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService; import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; 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.WarehouseTransferPalletMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -97,6 +99,7 @@ public class DeliveryTransferService {
@Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryCommandService inventoryCommands; @Resource private InventoryCommandService inventoryCommands;
@Resource private WarehouseTransferPalletMapper warehouseTransferPallets; @Resource private WarehouseTransferPalletMapper warehouseTransferPallets;
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
@Resource private DropshipOrderProfileMapper dropshipProfiles; @Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private DropshipCartonMapper dropshipCartons; @Resource private DropshipCartonMapper dropshipCartons;
@Resource private DropshipCartonSkuMapper dropshipCartonSkus; @Resource private DropshipCartonSkuMapper dropshipCartonSkus;
@@ -217,6 +220,11 @@ public class DeliveryTransferService {
firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()), firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()),
destination.getDestinationWarehouseId())); destination.getDestinationWarehouseId()));
delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName())); 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()); delivery.setDeliveryGroupKey(entry.getKey());
LastlegOrder firstOrder = firstTailOrder(customerId, groupItems); LastlegOrder firstOrder = firstTailOrder(customerId, groupItems);
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); 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); return taskResult(outbound, task);
} }
@Transactional(readOnly = true)
public List<DeliveryResponses.WarehouseTransferOutboundCandidate> warehouseTransferOutboundCandidates(
String customerId, String warehouseId) {
List<WarehouseTransferOrder> transfers = warehouseTransfers.selectList(
new LambdaQueryWrapper<WarehouseTransferOrder>()
.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<DeliveryResponses.WarehouseTransferOutboundCandidate> result = new ArrayList<>();
for (WarehouseTransferOrder transfer : transfers) {
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
List<WarehouseTransferPallet> 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<Long> palletIds = request.palletIds().stream().distinct().toList();
if (palletIds.size() != request.palletIds().size()) throw conflict("出库托盘不可重复");
List<WarehouseTransferPallet> 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<InventoryPosition> 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<DeliveryRequests.OneClickItem> items) { private String validateOneClickItems(String customerId, List<DeliveryRequests.OneClickItem> items) {
String warehouseId = null; String warehouseId = null;
Map<String, Long> requestedPackages = new LinkedHashMap<>(); Map<String, Long> requestedPackages = new LinkedHashMap<>();
@@ -541,6 +698,40 @@ public class DeliveryTransferService {
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
return taskResult(outbound, 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()); 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 (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())) { if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) {
@@ -612,8 +803,8 @@ public class DeliveryTransferService {
if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) { if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
throw notFound("Outbound order not found in the bound warehouse"); throw notFound("Outbound order not found in the bound warehouse");
} }
if (!"ONE_CLICK_DROPSHIP".equals(outbound.getOutboundType())) { if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND").contains(outbound.getOutboundType())) {
throw conflict("整托扫码仅适用于一件代发出库"); throw conflict("整托扫码仅适用于一件代发或换标出库");
} }
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) { if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
throw conflict("PDA task is not available for picking"); throw conflict("PDA task is not available for picking");
@@ -695,6 +886,23 @@ public class DeliveryTransferService {
new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成")); new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成"));
} }
} }
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
List<String> 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); return taskResult(outbound, task);
} }
@@ -724,6 +932,23 @@ public class DeliveryTransferService {
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); }
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); } 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()) { if (!existingDeliveries.isEmpty()) {
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) { for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().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); 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(), candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
value(candidate.getAvailablePackages())); 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.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.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 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 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<DispatchItem> 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 LastlegOrder firstTailOrder(String customerId, List<DispatchItem> 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) { 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()); String code = firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode());
if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT); if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT);
if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim(); 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<DispatchItem> items) { private String resolveLxCodes(String customerId, List<DispatchItem> items) {
List<String> inboundCodes = items.stream().map(DispatchItem::getInboundCode) List<String> inboundCodes = items.stream().map(DispatchItem::getInboundCode)
@@ -1029,6 +1257,14 @@ public class DeliveryTransferService {
return measuredWeight.add(orderWeight); return measuredWeight.add(orderWeight);
} }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } 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) { private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; } try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
catch (NumberFormatException ignored) { return null; } catch (NumberFormatException ignored) { return null; }
@@ -46,7 +46,7 @@ public class SmartDispatchController {
@RequestParam(required = false) String sourceStage, @RequestParam(required = false) String sourceStage,
@RequestParam(required = false) String draftToken) { @RequestParam(required = false) String draftToken) {
requirePlatform(user); 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.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken));
} }
return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo, return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo,
@@ -30,6 +30,11 @@ public class DispatchItem {
private String destinationWarehouseCode; private String destinationWarehouseCode;
private String destinationWarehouseName; private String destinationWarehouseName;
private String fbaWarehouseCode; private String fbaWarehouseCode;
private String destinationType;
private String deliveryAddress;
private String postalCode;
private String contactName;
private String contactPhone;
private Integer packageCount; private Integer packageCount;
private BigDecimal volumeCbm; private BigDecimal volumeCbm;
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */ /** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
@@ -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 " + "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 " + "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 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 " + "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_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_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_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)) " +
"<if test='customerId != null and customerId != \"\"'> AND ip.customer_id=#{customerId}</if> " + "<if test='customerId != null and customerId != \"\"'> AND ip.customer_id=#{customerId}</if> " +
"<if test='planIds != null and planIds.size() > 0'> AND p.plan_id IN " + "<if test='planIds != null and planIds.size() > 0'> AND p.plan_id IN " +
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></if> " + "<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></if> " +
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " + "<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " + "<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " + "<if test='sourceStage != null and sourceStage != \"\"'> AND " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " + "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
@@ -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 " + "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 " + "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 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 " + "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_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_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " + "LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} AND p.id IN " + "WHERE ip.customer_id=#{customerId} AND p.id IN " +
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " + "<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"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 " + "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_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 " + "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," + "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," + "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) " + "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 " + "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 " + "JOIN ll_inventory_position pos ON pos.id=ri.source_inventory_position_id AND pos.customer_id=a.customer_id " +
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " + "LEFT JOIN ll_warehouse_transfer_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' " + "WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" + "<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" + "<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') </if>" + "<if test='orderNo != null and 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},'%')) </if>" +
"AND pos.hold_packages &gt; 0 " + "AND pos.hold_packages &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " + "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 " + "ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " +
@@ -30,7 +30,12 @@ public record DispatchPalletCandidate(
BigDecimal volumeCbm, BigDecimal volumeCbm,
String sourceStage, String sourceStage,
String cartonMarks, 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, public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
String overseasWarehouseId, String overseasWarehouseName, String orderNo, 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, this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId,
overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId, overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm, 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, public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
@@ -51,7 +56,7 @@ public record DispatchPalletCandidate(
BigDecimal volumeCbm, String sourceStage) { BigDecimal volumeCbm, String sourceStage) {
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode, this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null); fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null, null, null, null, null, null);
} }
public String sourcePalletKey() { public String sourcePalletKey() {
// Once received, the physical pallet is the shared resource even when it contains // Once received, the physical pallet is the shared resource even when it contains
@@ -66,6 +66,7 @@ public class DispatchService {
@Resource private LastlegOrderMapper tailOrders; @Resource private LastlegOrderMapper tailOrders;
@Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundOrderMapper outboundOrders;
@Resource private RelabelApplicationMapper relabelApplications; @Resource private RelabelApplicationMapper relabelApplications;
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
public String resolveCustomerScopeForPlans(List<Long> planIds) { public String resolveCustomerScopeForPlans(List<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
@@ -107,12 +108,14 @@ public class DispatchService {
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode, public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode,
String sourceStage, String draftToken) { String sourceStage, String draftToken) {
validateSourceStage(sourceStage); validateSourceStage(sourceStage);
List<DispatchPalletCandidate> result = candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), List<DispatchPalletCandidate> result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
trimToNull(sourceStage), trimToNull(draftToken)); trimToNull(sourceStage), trimToNull(draftToken));
if (!StringUtils.hasText(sourceStage) || "RECEIVED".equals(sourceStage)) { if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) {
List<DispatchPalletCandidate> relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null); List<DispatchPalletCandidate> relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null);
if (relabel != null && !relabel.isEmpty()) { 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; return result;
@@ -270,6 +273,11 @@ public class DispatchService {
if (selected.size() != palletIds.size()) { if (selected.size() != palletIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); 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); assertNoDetainedOrders(customerId, selected);
boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
.anyMatch(value -> value == null || value.isBlank()); .anyMatch(value -> value == null || value.isBlank());
@@ -396,6 +404,11 @@ public class DispatchService {
item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode());
item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setDestinationWarehouseName(candidate.destinationWarehouseName());
item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); 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.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
item.setSourceStage(candidate.sourceStage()); item.setSourceStage(candidate.sourceStage());
@@ -433,6 +446,7 @@ public class DispatchService {
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
} }
relabelBindingGuard.requireBound(application);
List<DispatchPalletCandidate> selected = candidates.relabelAvailable(customerId, null, request.relabelNo()); List<DispatchPalletCandidate> selected = candidates.relabelAvailable(customerId, null, request.relabelNo());
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet()); Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList(); selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
@@ -653,10 +667,23 @@ public class DispatchService {
} }
private static String candidateFbaCode(DispatchPalletCandidate candidate) { 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()) return StringUtils.hasText(candidate.fbaWarehouseCode())
? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode()); ? 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) { private static String itemFbaCode(DispatchItem item) {
return StringUtils.hasText(item.getFbaWarehouseCode()) return StringUtils.hasText(item.getFbaWarehouseCode())
? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode()); ? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode());
@@ -716,8 +743,8 @@ public class DispatchService {
} }
private static void validateSourceStage(String sourceStage) { private static void validateSourceStage(String sourceStage) {
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) { if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED", "RELABEL").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN, RECEIVED or RELABEL");
} }
} }
@@ -41,7 +41,12 @@ public record LastlegOrderSummary(
String nodeSource, String nodeSource,
int version, int version,
Integer palletCount, Integer palletCount,
@Schema(description = "客户预报箱数")
Integer packageCount, Integer packageCount,
@Schema(description = "PDA 实收箱数;尚未完成收货时为空")
Integer receivedPackageCount,
@Schema(description = "实收减预报箱数;尚未完成收货时为空")
Integer packageDifference,
BigDecimal volumeCbm, BigDecimal volumeCbm,
Integer productQuantity, Integer productQuantity,
BigDecimal volumeWeightKg, BigDecimal volumeWeightKg,
@@ -44,6 +44,7 @@ public class LastlegOrder {
private Integer version; private Integer version;
private Integer palletCount; private Integer palletCount;
private Integer packageCount; private Integer packageCount;
private Integer receivedPackageCount;
private Integer pickedPackageCount; private Integer pickedPackageCount;
private Integer shippedPackageCount; private Integer shippedPackageCount;
private BigDecimal volumeCbm; private BigDecimal volumeCbm;
@@ -21,6 +21,10 @@ public class InboundPlanLine {
private String destinationWarehouseId; private String destinationWarehouseId;
private String destinationWarehouseCode; private String destinationWarehouseCode;
private String destinationWarehouseName; private String destinationWarehouseName;
private String deliveryAddress;
private String postalCode;
private String contactName;
private String contactPhone;
private BigDecimal volumeCbm; private BigDecimal volumeCbm;
private Integer requiredPackageCount; private Integer requiredPackageCount;
private Integer plannedPalletCount; private Integer plannedPalletCount;
@@ -22,6 +22,11 @@ public class InboundPlanPallet {
private String destinationWarehouseId; private String destinationWarehouseId;
private String destinationWarehouseCode; private String destinationWarehouseCode;
private String destinationWarehouseName; private String destinationWarehouseName;
private String destinationType;
private String deliveryAddress;
private String postalCode;
private String contactName;
private String contactPhone;
private BigDecimal capacityCbm; private BigDecimal capacityCbm;
private BigDecimal allocatedVolumeCbm; private BigDecimal allocatedVolumeCbm;
private Integer sequenceNo; private Integer sequenceNo;
@@ -6,6 +6,8 @@ import com.fasterxml.jackson.annotation.JsonAlias;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatusCode;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -293,6 +295,27 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
return palletBatch("/internal/v1/warehouse-pallet-batches/compensate", request); return palletBatch("/internal/v1/warehouse-pallet-batches/compensate", request);
} }
@Override
public PrintableFile printPalletLabel(long warehouseId, long palletId, String transferNo, String requestId) {
ResponseEntity<byte[]> 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) { private PalletBatchResponse palletBatch(String path, Object body) {
PalletBatchEnvelope response = client.post().uri(path) PalletBatchEnvelope response = client.post().uri(path)
.header("X-Internal-Service-Token", internalToken).body(body).retrieve() .header("X-Internal-Service-Token", internalToken).body(body).retrieve()
@@ -39,6 +39,9 @@ public interface WarehouseResourceGateway {
default PalletBatchResponse compensatePalletBatch(PalletBatchCompensation request) { default PalletBatchResponse compensatePalletBatch(PalletBatchCompensation request) {
throw new UnsupportedOperationException("Warehouse pallet batch compensation is not configured"); 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, record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) { 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, record PalletBatchItem(long customerId, String businessKey, String inboundPlanNo, int sequenceNo,
long palletId, String palletCode, long locationId, long resourceLockId, long palletId, String palletCode, long locationId, long resourceLockId,
String status) { } String status) { }
record PrintableFile(byte[] content, String fileName, String contentType) { }
} }
@@ -511,9 +511,6 @@ public class InventoryCommandService {
String skuCode = sku(requestedSkuCode); String skuCode = sku(requestedSkuCode);
String destinationId = trim(destinationWarehouseId); String destinationId = trim(destinationWarehouseId);
String destinationCode = trim(destinationWarehouseCode); String destinationCode = trim(destinationWarehouseCode);
if (destinationId == null && destinationCode == null) {
throw badRequest("Destination warehouse ID or code is required");
}
if (destinationCode == null) destinationCode = ""; if (destinationCode == null) destinationCode = "";
InventoryBalance balance = balances.lockDimension(customerId, frozenWarehouseId, InventoryBalance balance = balances.lockDimension(customerId, frozenWarehouseId,
destinationCode, inboundOrderId, inboundCode); destinationCode, inboundOrderId, inboundCode);
@@ -2,9 +2,22 @@ package com.baiyue.lastleg.model;
/** Standard main-process statuses for a last-leg order. */ /** Standard main-process statuses for a last-leg order. */
public enum LastlegOrderStatus { public enum LastlegOrderStatus {
PENDING_REVIEW("待审核"),
INBOUND_MEASURING("入库测量中"),
PENDING_ASSIGNMENT("待分配"), PENDING_ASSIGNMENT("待分配"),
PENDING_INBOUND("待入库"), PENDING_INBOUND("待入库"),
INBOUND_COMPLETED("入库完成"), INBOUND_COMPLETED("入库完成"),
WAIT_RELABEL("待换标"),
WAIT_FBA_DESTINATION("待确认目的仓"),
READY_FOR_DISPATCH("待创建出库单"),
WAIT_OUTBOUND("待出库"),
ALLOCATED("已分配库存"),
OUTBOUNDED("已出库"),
PARTIALLY_RECEIVED("部分收货"),
RECEIVED("已收货"),
IN_TRANSIT("运输中"),
COMPLETED("已完成"),
CANCELLED("已取消"),
PENDING_DELIVERY("待派送"), PENDING_DELIVERY("待派送"),
DELIVERING("派送中"), DELIVERING("派送中"),
DELIVERED("派送完成"); DELIVERED("派送完成");
@@ -28,6 +28,13 @@ public class PdaPalletOperationController {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request)); return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request));
} }
@PostMapping("/relabel-sources")
public ApiResponse<java.util.List<PalletOperationResponses.RelabelSource>> relabelSources(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.RelabelSources request) {
return ApiResponse.success(service.relabelSources(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/relabel-pallet-label") @PostMapping("/relabel-pallet-label")
public ApiResponse<PalletOperationResponses.PrintablePallet> createRelabelPallet( public ApiResponse<PalletOperationResponses.PrintablePallet> createRelabelPallet(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@@ -13,6 +13,7 @@ public final class PalletOperationRequests {
private PalletOperationRequests() { } private PalletOperationRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { } 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, public record NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo,
@NotBlank @Size(max = 128) String idempotencyKey) { } @NotBlank @Size(max = 128) String idempotencyKey) { }
public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode, 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, @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { } @Size(max = 500) String reason) { }
/** 换标场景按箱唛拆托并绑定到一个目标托盘。箱唛位置由服务端从换标单快照解析。 */ /** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */
public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey, public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 64) String relabelNo, @NotBlank @Size(max = 64) String relabelNo,
@Valid PalletExpectation source, @Valid PalletExpectation source,
@Valid PalletExpectation target, @Valid PalletExpectation target,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks, @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<String> cartonMarks, String reason) {
this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null);
}
}
public record Swap(@NotBlank @Size(max = 128) String idempotencyKey, public record Swap(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source, @Valid PalletExpectation source,
@@ -5,6 +5,9 @@ import java.util.List;
public final class PalletOperationResponses { public final class PalletOperationResponses {
private PalletOperationResponses() { } private PalletOperationResponses() { }
public record RelabelSource(long positionId, long positionVersion, String palletCode,
String locationCode, String inboundCode, long stockPackages,
long movablePackages, List<String> cartonMarks) { }
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId, public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
String inboundCode, String skuCode, String productName, String inboundCode, String skuCode, String productName,
@@ -23,6 +23,12 @@ public interface PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation>
PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey, PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey,
@Param("idempotencyKey") String idempotencyKey); @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<PalletInventoryRow> 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} " + @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 " + "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") "ORDER BY b.customer_id,b.inbound_code,p.id")
@@ -78,13 +78,42 @@ public class PdaPalletOperationService {
return content(identity.overseasWarehouseId(), pallet); return content(identity.overseasWarehouseId(), pallet);
} }
public List<PalletOperationResponses.RelabelSource> relabelSources(PdaWarehouseIdentity identity,
PalletOperationRequests.RelabelSources request) {
RelabelApplication app = relabelApplications.findAnyByRelabelNo(request.relabelNo());
requireRelabelWork(identity, app);
Set<String> boundMarks = boundRelabelMarks(app);
List<RelabelItem> items = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()));
Map<Long, List<RelabelItem>> 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<PalletOperationResponses.RelabelSource> 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, public PalletOperationResponses.PrintablePallet createRelabelPallet(PdaWarehouseIdentity identity,
PalletOperationRequests.NewRelabelPallet request) { PalletOperationRequests.NewRelabelPallet request) {
String relabelNo = required(request.relabelNo(), "换标单号不能为空"); String relabelNo = required(request.relabelNo(), "换标单号不能为空");
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { requireRelabelWork(identity, application);
throw conflict("换标单尚未完成客户确认,不能生成新托盘");
}
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("换标单不属于 Token 冻结的海外仓"); throw conflict("换标单不属于 Token 冻结的海外仓");
} }
@@ -153,9 +182,7 @@ public class PdaPalletOperationService {
if (replay != null) return replay; if (replay != null) return replay;
String relabelNo = required(request.relabelNo(), "换标单号不能为空"); String relabelNo = required(request.relabelNo(), "换标单号不能为空");
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { requireRelabelWork(identity, application);
throw conflict("换标单尚未完成客户确认,不能执行拆托");
}
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("换标单不属于 Token 冻结的海外仓"); throw conflict("换标单不属于 Token 冻结的海外仓");
} }
@@ -177,29 +204,61 @@ public class PdaPalletOperationService {
} }
Map<Long, Long> packagesByPosition = new LinkedHashMap<>(); Map<Long, Long> packagesByPosition = new LinkedHashMap<>();
Map<Long, List<RelabelItem>> itemsByPosition = new LinkedHashMap<>(); Map<Long, List<RelabelItem>> itemsByPosition = new LinkedHashMap<>();
Map<Long, InventoryPosition> 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<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact>()
.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) { for (RelabelItem item : selected) {
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
|| item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照"); || item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照");
InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId()); InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId());
if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId()) 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()))) { || !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致"); throw conflict("来源托盘必须属于本次拆托的原入仓单和库存批次");
} }
packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum); anchors.put(position.getId(), position);
itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item); 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<PlannedMove> moves = packagesByPosition.entrySet().stream().map(entry -> { List<PlannedMove> moves = packagesByPosition.entrySet().stream().map(entry -> {
InventoryPosition position = positions.selectById(entry.getKey()); 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(), return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(),
source.palletCode(), target.palletId(), target.palletCode(), target.locationId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD); locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD);
}).toList(); }).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 base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false);
Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(), 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() String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank()
? "" : ":" + request.reason().trim()); ? "" : ":" + request.reason().trim());
return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request, return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request,
@@ -288,6 +347,10 @@ public class PdaPalletOperationService {
throw conflict("库存所在托盘已变化,请重新扫码"); throw conflict("库存所在托盘已变化,请重新扫码");
} }
if (value(locked.getVersion()) != move.expectedVersion()) 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()); if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages());
else requireMovable(locked, move.packages()); else requireMovable(locked, move.packages());
customers.add(locked.getCustomerId()); customers.add(locked.getCustomerId());
@@ -296,6 +359,8 @@ public class PdaPalletOperationService {
} }
if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems( if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems(
plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets); plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets);
if (plan.relabelMutation() != null)
recordRelabelBinding(identity, plan.relabelMutation(), relabelTargets);
for (PalletChange change : plan.changes()) { for (PalletChange change : plan.changes()) {
long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId()); long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId());
boolean shouldBeEmpty = "IDLE".equals(change.targetStatus()); boolean shouldBeEmpty = "IDLE".equals(change.targetStatus());
@@ -320,7 +385,8 @@ public class PdaPalletOperationService {
private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) { private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) {
RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo()); 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.getCustomerId(), mutation.application().getCustomerId())
|| !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId)) || !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId))
|| !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) { || !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<RelabelItem> selected = mutation.itemsByPosition().get(move.positionId());
Set<String> alreadyBound = boundRelabelMarks(mutation.application());
if (selected.stream().anyMatch(item -> alreadyBound.contains(item.getCartonMark())))
throw conflict("换标箱已在本轮组托,请刷新后重试");
requireRelabelDestination(identity.overseasWarehouseId(), selected, physical.resolve(move.targetPalletCode()));
Map<Long, Long> 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, private InventoryPosition moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) { PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
InventoryPosition source = positions.selectById(sourceRow.getPositionId()); InventoryPosition source = positions.selectById(sourceRow.getPositionId());
@@ -458,6 +570,89 @@ public class PdaPalletOperationService {
if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘"); 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<String> 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<String> marks = new HashSet<>();
var records = relabelWorkHistory.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory>()
.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<Long, InventoryPosition> 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<RelabelItem> selected,
PalletPhysicalGateway.PalletReference target) {
Set<String> 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<RelabelItem>()
.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<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId, private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
PalletOperationRequests.PalletExpectation first, List<PalletOperationRequests.PalletExpectation> rest) { PalletOperationRequests.PalletExpectation first, List<PalletOperationRequests.PalletExpectation> rest) {
List<PalletOperationRequests.PalletExpectation> values = new ArrayList<>(); values.add(first); values.addAll(rest); List<PalletOperationRequests.PalletExpectation> values = new ArrayList<>(); values.add(first); values.addAll(rest);
@@ -658,6 +853,7 @@ public class PdaPalletOperationService {
List<PlannedMove> moves, List<PalletChange> changes, List<PlannedMove> moves, List<PalletChange> changes,
RelabelMutation relabelMutation) { } RelabelMutation relabelMutation) { }
private record RelabelMutation(RelabelApplication application, private record RelabelMutation(RelabelApplication application,
Map<Long, List<RelabelItem>> itemsByPosition) { } Map<Long, List<RelabelItem>> itemsByPosition,
Map<Long, InventoryPosition> anchors) { }
private enum InventoryBucket { AVAILABLE, HOLD } private enum InventoryBucket { AVAILABLE, HOLD }
} }
@@ -36,6 +36,16 @@ public class RelabelApplicationController {
return ApiResponse.success(pdaService.returnForRework(operator(user), user.username(), relabelNo, request)); return ApiResponse.success(pdaService.returnForRework(operator(user), user.username(), relabelNo, request));
} }
@PostMapping("/{relabelNo}/confirm-completion")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
public ApiResponse<RelabelResponses.Detail> 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") @GetMapping("/{relabelNo}/review-task")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
public ApiResponse<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> reviewTask( public ApiResponse<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> reviewTask(
@@ -39,9 +39,9 @@ public final class RelabelRequests {
} }
public record Item( public record Item(
@NotNull Long sourceInventoryBalanceId, Long sourceInventoryBalanceId,
@NotNull Long sourceInventoryPositionId, Long sourceInventoryPositionId,
@NotNull Long sourceInboundOrderId, Long sourceInboundOrderId,
@Size(max = 128) String skuCode, @Size(max = 128) String skuCode,
@Size(max = 128) String productImageFileId, @Size(max = 128) String productImageFileId,
@Size(max = 255) String productImageFileName, @Size(max = 255) String productImageFileName,
@@ -61,8 +61,22 @@ public final class RelabelRequests {
@Size(max = 255) String fbaCartonMark, @Size(max = 255) String fbaCartonMark,
@Size(max = 128) String destinationFbaWarehouseCode, @Size(max = 128) String destinationFbaWarehouseCode,
@Size(max = 32) String operationType, @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<SkuItem> 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, public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId,
String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String skuCode, String productImageFileId, String productImageFileName, String productImageUrl,
@@ -74,7 +88,7 @@ public final class RelabelRequests {
productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount,
notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark,
relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark,
destinationFbaWarehouseCode, "CARTON_LABEL", List.of()); destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null);
} }
public Item(String skuCode, String productImageFileId, String productImageFileName, public Item(String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount, String productImageUrl, String productNameZh, int totalPackageCount,
@@ -85,7 +99,7 @@ public final class RelabelRequests {
this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl,
productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode,
newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl, 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, public Item(String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount, String productImageUrl, String productNameZh, int totalPackageCount,
@@ -94,7 +108,7 @@ public final class RelabelRequests {
this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh, this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh,
totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode,
labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, "ONT8", labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, "ONT8",
"CARTON_LABEL", List.of()); "CARTON_LABEL", List.of(), null);
} }
} }
@@ -36,7 +36,9 @@ public final class RelabelResponses {
String inboundCode, String overseasWarehouseId, String overseasWarehouseCode, String inboundCode, String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String destinationFbaWarehouseCode, String overseasWarehouseName, String destinationFbaWarehouseCode,
String destinationFbaWarehouseName, String skuCode, String productNameZh, String destinationFbaWarehouseName, String skuCode, String productNameZh,
long availablePackages, long version, List<InventoryPosition> positions) {} long availablePackages, long version, List<InventoryPosition> positions,
String sourceType, Long returnCartonId, String cartonMark,
List<SkuItem> skuItems) {}
public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo,
String relabelOrderNo, String orderType, String status, Integer packageCount, String relabelOrderNo, String orderType, String status, Integer packageCount,
LocalDateTime createdAt) {} LocalDateTime createdAt) {}
@@ -20,7 +20,7 @@ public class HttpRelabelDocumentFileGateway implements RelabelDocumentFileGatewa
@Resource private RestClient.Builder restClientBuilder; @Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory; @Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties; @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; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client; private RestClient client;
@@ -6,4 +6,14 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper @Mapper
public interface RelabelItemMapper extends BaseMapper<RelabelItem> { public interface RelabelItemMapper extends BaseMapper<RelabelItem> {
@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);
} }
@@ -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));
}
}
@@ -70,6 +70,14 @@ public class PdaRelabelController {
identity.overseasWarehouseId(), relabelNo, request)); identity.overseasWarehouseId(), relabelNo, request));
} }
@PostMapping("/{relabelNo}/skus/scan")
public ApiResponse<PdaRelabelResponses.Detail> 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") @GetMapping("/{relabelNo}/documents")
public ApiResponse<List<PdaRelabelService.Document>> documents(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<List<PdaRelabelService.Document>> documents(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) { @PathVariable String relabelNo) {
@@ -14,6 +14,10 @@ public final class PdaRelabelRequests {
private PdaRelabelRequests() { } private PdaRelabelRequests() { }
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { } 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, public record CartonScan(@NotBlank @Size(max = 255) String cartonMark,
@NotNull @Min(0) Integer expectedVersion, @NotNull @Min(0) Integer expectedVersion,
@@ -26,7 +26,8 @@ public final class PdaRelabelResponses {
String cartonMark, String fbaCartonMark, String operationType, String cartonMark, String fbaCartonMark, String operationType,
List<SkuItem> skuItems, List<SkuItem> skuItems,
int expectedPackages, int relabelledPackages, int skippedPackages, 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 SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { }
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
@@ -15,8 +15,18 @@ public interface PdaRelabelTaskMapper extends BaseMapper<PdaRelabelTask> {
@Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE") @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); 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") @Select("""
PdaRelabelTask lockUnfinishedByOperator(@Param("operatorKey") String operatorKey); 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("""
UPDATE ll_relabel_pda_task UPDATE ll_relabel_pda_task
@@ -51,6 +51,7 @@ import java.util.stream.Collectors;
@Service @Service
public class PdaRelabelService { public class PdaRelabelService {
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard;
public static final String PROCESSING = "PROCESSING"; public static final String PROCESSING = "PROCESSING";
public static final String WAIT_REVIEW = "WAIT_REVIEW"; public static final String WAIT_REVIEW = "WAIT_REVIEW";
public static final String COMPLETED = "COMPLETED"; public static final String COMPLETED = "COMPLETED";
@@ -109,7 +110,7 @@ public class PdaRelabelService {
throw conflict("Relabel application is not executable"); throw conflict("Relabel application is not executable");
} }
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); 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())) { if (activeTask != null && !Objects.equals(activeTask.getApplicationId(), application.getId())) {
throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单"); throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单");
} }
@@ -194,6 +195,49 @@ public class PdaRelabelService {
public record Document(String scope, String fileId, String fileName, Long itemId) { } 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<Integer> done = new java.util.HashSet<>();
for (var history : histories.selectList(new LambdaQueryWrapper<PdaRelabelHistory>()
.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) @Transactional(readOnly = true)
public List<Document> documents(long warehouseId, String relabelNo) { public List<Document> documents(long warehouseId, String relabelNo) {
RelabelApplication app = requireApplication(relabelNo, warehouseId, false); RelabelApplication app = requireApplication(relabelNo, warehouseId, false);
@@ -264,6 +308,7 @@ public class PdaRelabelService {
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, request.expectedVersion()); requireVersion(task, request.expectedVersion());
if (!request.manualCompletion()) bindingGuard.requireBound(application);
List<RelabelItem> detailItems = itemList(application); List<RelabelItem> detailItems = itemList(application);
List<PdaRelabelUnitFact> facts = factList(task); List<PdaRelabelUnitFact> facts = factList(task);
boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream() boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream()
@@ -558,7 +603,7 @@ public class PdaRelabelService {
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task, private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
String operatorKey) { String operatorKey) {
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(), 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()), operationType(application), value(application.getItemCount()),
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), 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.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
@@ -580,7 +625,8 @@ public class PdaRelabelService {
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"), value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
count(itemFacts, "EXCEPTION"), count(itemFacts, "EXCEPTION"),
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0), 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(); }).toList();
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact -> List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), 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(); history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList();
return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(), return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(),
application.getLabelDeadline(), application.getContactName(), application.getContactPhone(), 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 ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
@@ -605,6 +651,13 @@ public class PdaRelabelService {
itemResponses, factResponses, historyResponses); 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, private void insertHistory(PdaRelabelTask task, String action, String from, String to,
String operatorKey, String operatorName, Object detail) { String operatorKey, String operatorName, Object detail) {
PdaRelabelHistory history = new PdaRelabelHistory(); PdaRelabelHistory history = new PdaRelabelHistory();
@@ -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.entity.PdaRelabelTask;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; 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.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
@@ -84,6 +88,8 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private InventoryCommandService inventoryCommands; @Resource private InventoryCommandService inventoryCommands;
@Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryPositionMapper inventoryPositions; @Resource private InventoryPositionMapper inventoryPositions;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private RelabelDocumentFileGateway documentFiles; @Resource private RelabelDocumentFileGateway documentFiles;
@Resource private PdaRelabelTaskMapper pdaTasks; @Resource private PdaRelabelTaskMapper pdaTasks;
@Resource private RelabelPhotoFileGateway photoFiles; @Resource private RelabelPhotoFileGateway photoFiles;
@@ -238,7 +244,7 @@ public class RelabelApplicationService implements WalletResultHandler {
.gt(InventoryBalance::getAvailablePackages, 0) .gt(InventoryBalance::getAvailablePackages, 0)
.like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode)) .like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode))
.orderByDesc(InventoryBalance::getUpdatedAt)); .orderByDesc(InventoryBalance::getUpdatedAt));
return balances.stream().map(balance -> { List<RelabelResponses.InventoryCandidate> result = new java.util.ArrayList<>(balances.stream().map(balance -> {
List<RelabelResponses.InventoryPosition> positions = inventoryPositions.listBalance(customerId, balance.getId()) List<RelabelResponses.InventoryPosition> positions = inventoryPositions.listBalance(customerId, balance.getId())
.stream().filter(position -> value(position.getAvailablePackages()) > 0) .stream().filter(position -> value(position.getAvailablePackages()) > 0)
.map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(), .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.getInboundOrderNo(), balance.getInboundCode(), balance.getOverseasWarehouseId(),
balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(), balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(),
balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(), balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(),
balance.getProductNameZh(), available, value(balance.getVersion()), positions); balance.getProductNameZh(), available, value(balance.getVersion()), positions,
}).filter(candidate -> candidate.availablePackages() > 0).toList(); "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<RelabelResponses.SkuItem> 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) @Transactional(readOnly = true)
@@ -280,6 +305,19 @@ public class RelabelApplicationService implements WalletResultHandler {
} }
@Transactional @Transactional
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { 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"); if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
String deliveryMethod = trim(request.deliveryMethod()); 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"); if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data");
return detail(customerId, replay.getRelabelNo()); return detail(customerId, replay.getRelabelNo());
} }
Set<String> 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 total = 0;
int notified = 0; int notified = 0;
List<SourceInventory> sources = new java.util.ArrayList<>(); List<SourceInventory> sources = new java.util.ArrayList<>();
@@ -312,6 +357,25 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) { if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) {
throw badRequest("The same carton mark cannot create more than one relabel task"); 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()); InventoryPosition position = inventoryPositions.findTenantPosition(customerId, item.sourceInventoryPositionId());
if (position == null || !Objects.equals(position.getBalanceId(), item.sourceInventoryBalanceId())) { if (position == null || !Objects.equals(position.getBalanceId(), item.sourceInventoryBalanceId())) {
throw conflict("Selected inventory position is no longer available"); throw conflict("Selected inventory position is no longer available");
@@ -325,7 +389,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) { if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) {
throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo()); 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(); LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId(); long applicationId = IdWorker.getId();
@@ -335,13 +399,15 @@ public class RelabelApplicationService implements WalletResultHandler {
boolean requiresWallet = chargeAmount.signum() > 0; boolean requiresWallet = chargeAmount.signum() > 0;
int holdIndex = 0; int holdIndex = 0;
for (SourceInventory source : sources) { for (SourceInventory source : sources) {
if (source.returnCarton() != null) continue;
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo, source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo,
null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++, null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++,
"贴标申请冻结原入仓单库存")); "贴标申请冻结原入仓单库存"));
} }
LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo, LastlegOrder tailOrder = existingTailOrderId == null
operationWarehouseId, total, now); ? insertTailOrder(customerId, request, requestHash, relabelNo, operationWarehouseId, total, now)
: linkExistingTailOrder(customerId, existingTailOrderId, relabelNo, total, now);
List<LastlegOrder> tailOrders = List.of(tailOrder); List<LastlegOrder> tailOrders = List.of(tailOrder);
RelabelApplication application = new RelabelApplication(); RelabelApplication application = new RelabelApplication();
@@ -372,6 +438,14 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setUpdatedAt(now); application.setUpdatedAt(now);
applications.insert(application); 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) { if (requiresWallet) {
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
relabelNo, chargeAmount, "换标订单费用冻结", 1L); relabelNo, chargeAmount, "换标订单费用冻结", 1L);
@@ -383,8 +457,10 @@ public class RelabelApplicationService implements WalletResultHandler {
insertItem(customerId, applicationId, source, now); insertItem(customerId, applicationId, source, now);
} }
insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null, if (existingTailOrderId == null) {
order.getCurrentStatus(), "Relabel application submitted", now)); tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null,
order.getCurrentStatus(), "Relabel application submitted", now));
}
return detail(customerId, relabelNo); return detail(customerId, relabelNo);
} }
@@ -501,7 +577,8 @@ public class RelabelApplicationService implements WalletResultHandler {
} }
for (LastlegOrder order : linkedOrders) { for (LastlegOrder order : linkedOrders) {
String orderBefore = order.getCurrentStatus(); 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) { String orderStatus = switch (next) {
case CUSTOMER_CONFIRM_PENDING -> mainStatus; case CUSTOMER_CONFIRM_PENDING -> mainStatus;
case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType()) case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType())
@@ -606,23 +683,27 @@ public class RelabelApplicationService implements WalletResultHandler {
PdaRelabelTask reviewTask = pdaTasks.lock(customerId, application.getId()); PdaRelabelTask reviewTask = pdaTasks.lock(customerId, application.getId());
if (reviewTask == null || !Set.of("WAIT_REVIEW", "COMPLETED").contains(reviewTask.getStatus())) if (reviewTask == null || !Set.of("WAIT_REVIEW", "COMPLETED").contains(reviewTask.getStatus()))
throw conflict("仓库尚未提交换标完工复核"); 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()) if (Boolean.TRUE.equals(application.getPrintShippingLabel())
&& !StringUtils.hasText(application.getShippingLabelFileId())) { && !StringUtils.hasText(application.getShippingLabelFileId())) {
throw conflict("Upload the shipping label before confirming relabel completion"); throw conflict("Upload the shipping label before confirming relabel completion");
} }
List<RelabelItem> 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(); LocalDateTime now = LocalDateTime.now();
String before = application.getStatus(); String before = application.getStatus();
application.setStatus(READY_FOR_OUTBOUND); application.setStatus(requiresOutbound ? READY_FOR_OUTBOUND : "COMPLETED");
application.setCustomerConfirmationKey(key); application.setCustomerConfirmationKey(key);
application.setCustomerConfirmedAt(now); application.setCustomerConfirmedAt(now);
application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod()) application.setFulfillmentStatus(requiresOutbound
? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION"); ? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION")
if (!"TRUCK".equals(application.getDeliveryMethod())) { : "NOT_REQUIRED");
if (requiresOutbound && !"TRUCK".equals(application.getDeliveryMethod())) {
createDirectOutbound(application, operatorId, now); createDirectOutbound(application, operatorId, now);
application.setFulfillmentStatus("OUTBOUND_CREATED"); application.setFulfillmentStatus("OUTBOUND_CREATED");
} }
@@ -630,7 +711,7 @@ public class RelabelApplicationService implements WalletResultHandler {
applications.updateById(application); applications.updateById(application);
for (LastlegOrder order : tailOrders(application)) { for (LastlegOrder order : tailOrders(application)) {
String orderBefore = order.getCurrentStatus(); String orderBefore = order.getCurrentStatus();
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setCurrentStatus(requiresOutbound ? "PENDING_ASSIGNMENT" : "COMPLETED");
order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus()); order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus());
order.setRelabelStatus("COMPLETED"); order.setRelabelStatus("COMPLETED");
order.setUpdatedAt(now); order.setUpdatedAt(now);
@@ -640,24 +721,34 @@ public class RelabelApplicationService implements WalletResultHandler {
request.remark(), now); request.remark(), now);
} }
} }
insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId, insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId,
request.remark(), now); request.remark(), now);
// 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。 // 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。
if (warehouseTransfers != null) { if (warehouseTransfers != null) {
WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo); WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo);
if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) { if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) {
transfer.setStatus("READY_FOR_DISPATCH"); Set<String> 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.setRelabelCompletedAt(now);
transfer.setUpdatedAt(now); transfer.setUpdatedAt(now);
warehouseTransfers.updateById(transfer); warehouseTransfers.updateById(transfer);
application.setFulfillmentStatus(transferStatus);
applications.updateById(application);
// 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。
LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId()); LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId());
if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) { if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) {
String transferOrderBefore = transferOrder.getCurrentStatus(); String transferOrderBefore = transferOrder.getCurrentStatus();
transferOrder.setCurrentStatus("READY_FOR_DISPATCH"); transferOrder.setCurrentStatus(transferStatus);
transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode());
transferOrder.setRelabelStatus("COMPLETED"); transferOrder.setRelabelStatus("COMPLETED");
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:" transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED");
+ "COMPLETED");
transferOrder.setUpdatedAt(now); transferOrder.setUpdatedAt(now);
orders.updateById(transferOrder); orders.updateById(transferOrder);
if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) { 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.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND");
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum()); 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.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
Map<Long, List<RelabelItem>> byPosition = relabelItems.stream().collect(Collectors.groupingBy( Map<Long, List<RelabelItem>> byPosition = relabelItems.stream().collect(Collectors.groupingBy(
RelabelItem::getSourceInventoryPositionId, LinkedHashMap::new, Collectors.toList())); this::outboundSourceKey, LinkedHashMap::new, Collectors.toList()));
for (Map.Entry<Long, List<RelabelItem>> entry : byPosition.entrySet()) { for (Map.Entry<Long, List<RelabelItem>> entry : byPosition.entrySet()) {
InventoryPosition position = inventoryPositions.selectById(entry.getKey()); InventoryPosition position = entry.getKey() > 0 ? inventoryPositions.selectById(entry.getKey()) : null;
if (position == null || !application.getCustomerId().equals(position.getCustomerId())) { if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId()))) {
throw conflict("Relabel source inventory position no longer exists"); throw conflict("Relabel source inventory position no longer exists");
} }
List<RelabelItem> group = entry.getValue(); List<RelabelItem> group = entry.getValue();
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId());
item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.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.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum());
item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode) item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode)
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); .filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).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); item.setCreatedAt(now); outboundItems.insert(item);
} }
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId()); 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<RelabelItem> values) { private String writeOutboundRefs(String relabelNo, Long positionId, List<RelabelItem> values) {
try { try {
return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo, Map<String, Object> refs = new LinkedHashMap<>();
"sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of( 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(), ""), "itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
"destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""), "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); } } 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.setDestinationFbaWarehouseCode(trim(source.destinationFbaWarehouseCode()));
item.setOperationType(operationType(source)); item.setOperationType(operationType(source));
item.setSkuRelabelItemsJson(writeSkuItems(source.skuItems())); item.setSkuRelabelItemsJson(writeSkuItems(source.skuItems()));
item.setSourceInventoryBalanceId(balance.getId()); if (balance != null) {
item.setSourceInventoryPositionId(source.sourceInventoryPositionId()); item.setSourceInventoryBalanceId(balance.getId());
item.setSourceInboundOrderId(balance.getInboundOrderId()); item.setSourceInventoryPositionId(source.sourceInventoryPositionId());
item.setSourceInboundOrderNo(balance.getInboundOrderNo()); item.setSourceInboundOrderId(balance.getInboundOrderId());
item.setSourceInboundCode(balance.getInboundCode()); item.setSourceInboundOrderNo(balance.getInboundOrderNo());
item.setSourceWarehouseId(balance.getOverseasWarehouseId()); item.setSourceInboundCode(balance.getInboundCode());
item.setSourceWarehouseCode(balance.getOverseasWarehouseCode()); item.setSourceWarehouseId(balance.getOverseasWarehouseId());
item.setSourceWarehouseName(balance.getOverseasWarehouseName()); 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); item.setCreatedAt(now); items.insert(item);
} }
@@ -831,7 +933,7 @@ public class RelabelApplicationService implements WalletResultHandler {
order.setSourceInboundId(relabelNo + ":" + warehouseId); order.setSourceInboundId(relabelNo + ":" + warehouseId);
order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId); order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId);
order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0); 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.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo);
order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark())); order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark()));
order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId); order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId);
@@ -840,6 +942,17 @@ public class RelabelApplicationService implements WalletResultHandler {
return order; 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<LastlegOrder> tailOrders(RelabelApplication application) { private List<LastlegOrder> tailOrders(RelabelApplication application) {
List<LastlegOrder> linked = orders.selectList(new LambdaQueryWrapper<LastlegOrder>() List<LastlegOrder> linked = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, application.getCustomerId()) .eq(LastlegOrder::getCustomerId, application.getCustomerId())
@@ -850,12 +963,27 @@ public class RelabelApplicationService implements WalletResultHandler {
return legacy == null ? List.of() : List.of(legacy); 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 long value(Long value) { return value == null ? 0L : value; }
private static int intValue(Integer value) { return value == null ? 0 : 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) { private void releaseInventory(String customerId, String operatorId, RelabelApplication application) {
for (RelabelItem item : itemList(application.getId())) { 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; if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null) continue;
inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move(
item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null, item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null,
@@ -938,9 +1066,15 @@ public class RelabelApplicationService implements WalletResultHandler {
private static String businessRelabelStatus(String workflowStatus) { private static String businessRelabelStatus(String workflowStatus) {
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING"; 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; ? "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) { private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } .digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
@@ -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<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()))
.stream().filter(item -> !"SKU_LABEL".equals(item.getOperationType())).toList();
if (cartons.isEmpty()) return;
Map<String, Set<String>> bindings = new HashMap<>();
for (PdaRelabelHistory history : histories.selectList(new QueryWrapper<PdaRelabelHistory>()
.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<String> 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); }
}
@@ -6,6 +6,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests; import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -20,7 +21,7 @@ import org.springframework.web.server.ResponseStatusException;
@RestController @RestController
@RequestMapping("/api/merchant/v1/last-leg/return-inventory") @RequestMapping("/api/merchant/v1/last-leg/return-inventory")
public class MerchantReturnInventoryController { 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; @Resource private ReturnInventoryService service;
@GetMapping @GetMapping
@@ -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_LIST = "merchant:warehouse:delivery:list";
public static final String MERCHANT_DELIVERY_CREATE = "merchant:warehouse:delivery:create"; 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_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_PROCUREMENT = "merchant:cross-border:procurement";
public static final String MERCHANT_TRUCKING = "merchant:cross-border:domestic-trucking"; public static final String MERCHANT_TRUCKING = "merchant:cross-border:domestic-trucking";
public static final String MERCHANT_LOOSEBOARD = "merchant:cross-border:pallet-shipping"; public static final String MERCHANT_LOOSEBOARD = "merchant:cross-border:pallet-shipping";
@@ -569,7 +569,7 @@ public class LastlegOrderService {
public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output) public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output)
throws IOException { 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; final int batchSize = 1000;
long page = 1; long page = 1;
while (true) { while (true) {
@@ -585,7 +585,8 @@ public class LastlegOrderService {
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId()) csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()), ? 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.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(relabelBusinessStatus(order.getRelabelStatus())), 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()), 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.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode,
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(), o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER", 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.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()),
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), 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()); 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) { static String displayOrderNo(LastlegOrder order) {
return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo()) return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo())
? order.getSourceOrderNo() : order.getOrderNo(); ? order.getSourceOrderNo() : order.getOrderNo();
@@ -12,6 +12,14 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; 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 @RestController
@RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers") @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)); return ApiResponse.success(service.create(customer(user), operator(user), request));
} }
@PutMapping("/{transferNo}")
public ApiResponse<WarehouseTransferResponses.Detail> 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<WarehouseTransferResponses.Detail> 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<WarehouseTransferResponses.Detail> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo) {
return ApiResponse.success(service.submit(customer(user), transferNo, operator(user)));
}
@GetMapping @GetMapping
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid WarehouseTransferRequests.Query query) { @Valid WarehouseTransferRequests.Query query) {
@@ -36,6 +68,29 @@ public class MerchantWarehouseTransferController {
return ApiResponse.success(service.detail(customer(user), transferNo)); return ApiResponse.success(service.detail(customer(user), transferNo));
} }
@GetMapping("/{transferNo}/carton-label")
public ApiResponse<WarehouseTransferResponses.Label> cartonLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo) {
return ApiResponse.success(service.cartonLabel(customer(user), transferNo));
}
@GetMapping("/{transferNo}/measurement-photos/files")
public ApiResponse<List<WarehouseTransferResponses.MeasurementPhoto>> measurementPhotos(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
return ApiResponse.success(service.measurementPhotos(customer(user), transferNo));
}
@GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download")
public ResponseEntity<byte[]> 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") @PostMapping("/{transferNo}/confirm-measurement")
public ApiResponse<WarehouseTransferResponses.Detail> confirmMeasurement( public ApiResponse<WarehouseTransferResponses.Detail> confirmMeasurement(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@@ -16,7 +16,16 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; 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; import java.util.List;
@RestController @RestController
@@ -32,6 +41,42 @@ public class PdaWarehouseTransferController {
return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId())); return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId()));
} }
@GetMapping("/{transferNo}")
public ApiResponse<WarehouseTransferResponses.Detail> 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<WarehouseTransferResponses.MeasurementPhoto> 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<List<WarehouseTransferResponses.MeasurementPhoto>> 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<byte[]> 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") @PostMapping("/{transferNo}/palletize")
public ApiResponse<WarehouseTransferResponses.Detail> palletize( public ApiResponse<WarehouseTransferResponses.Detail> palletize(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@@ -40,4 +85,49 @@ public class PdaWarehouseTransferController {
return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(), return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(),
transferNo, request)); transferNo, request));
} }
@PostMapping("/{transferNo}/pallets/drafts")
public ApiResponse<WarehouseTransferResponses.Detail> 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<WarehouseTransferResponses.Detail> 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<WarehouseTransferResponses.Detail> 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<WarehouseTransferResponses.Detail> 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<byte[]> 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());
}
} }
@@ -24,14 +24,14 @@ public class WarehouseTransferController {
@PostMapping @PostMapping
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody WarehouseTransferRequests.Create request) { @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 @GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) { @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}") @GetMapping("/{transferNo}")
@@ -56,6 +56,17 @@ public class WarehouseTransferController {
return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request)); return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request));
} }
@PostMapping("/bind-destination")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
public ApiResponse<java.util.List<WarehouseTransferResponses.Detail>> 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") @PostMapping("/{transferNo}/allocations")
public ApiResponse<WarehouseTransferResponses.Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<WarehouseTransferResponses.Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo, @RequestParam(required = false) String customerId, @PathVariable String transferNo,

Some files were not shown because too many files have changed in this diff Show More