Test #5
Vendored
-11
@@ -316,17 +316,6 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('生产环境部署确认(仅 production 执行)') {
|
||||
when {
|
||||
allOf {
|
||||
expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
input message: "确认将 ${env.SELECTED_SERVICES} 的镜像 ${env.IMAGE_TAG} 分别部署到生产环境?", ok: '确认部署'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Gateway 路由静态门禁') {
|
||||
when {
|
||||
expression { env.NO_SERVICE_CHANGE != 'true' }
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| 区域 | 完整入口 | 服务 schema | 表数 |
|
||||
|---|---|---|---:|
|
||||
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 208 |
|
||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 136 |
|
||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 138 |
|
||||
|
||||
- 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||
- 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。
|
||||
@@ -24,7 +24,7 @@
|
||||
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
||||
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
||||
|
||||
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/136`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
||||
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
||||
|
||||
## 平台管理员密码
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -30,6 +30,8 @@
|
||||
|
||||
## 修改约束
|
||||
|
||||
- 贴标箱唛总文件和 SKU 总文件分别使用 `OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT`、`OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT`,与明细 PDF 采用同一客户复合归属和 10MiB PDF 校验。`file_object.business_type` 为 VARCHAR(64),存量扩容见海外统一增量和 `scripts/db/relabel-file-scope-incremental.sql`。Print 只负责生成 PDF,不保存上传文件。
|
||||
|
||||
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
|
||||
- 客户单文件资质入口只接受 `LEGAL_PERSON_ID/FREIGHT_FORWARDER_CERTIFICATE/BANK_ACCOUNT_LICENSE`,客户范围只取登录 Token;营业执照不得绕过 `required-package`。同类当前资质已存在时必须显式 `replace=true`,成功后旧元数据标记为已替代;上传不会自动提交审核。
|
||||
- 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。国内 PDA 禁止直连 File 平台接口,必须经 Firstleg 校验任务、仓库和操作者后,由 Firstleg 使用内部服务令牌访问 File。
|
||||
|
||||
@@ -24,7 +24,10 @@ enum BusinessFileCategory {
|
||||
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
|
||||
@@ -6,7 +6,7 @@ USE baiyue_file;
|
||||
CREATE TABLE IF NOT EXISTS file_object (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID',
|
||||
business_type VARCHAR(32) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE',
|
||||
business_type VARCHAR(64) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE',
|
||||
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId',
|
||||
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
|
||||
object_key VARCHAR(512) NOT NULL COMMENT '对象存储键',
|
||||
|
||||
@@ -4,7 +4,7 @@ info:
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
File 只保存文件并强制客户、业务类型、业务ID与文件ID复合归属。
|
||||
OVERSEAS_RELABEL_TOTAL_DOCUMENT 的 businessId 为 application.id;
|
||||
OVERSEAS_RELABEL_TOTAL_DOCUMENT、OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT、OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT 的 businessId 为 application.id;
|
||||
OVERSEAS_RELABEL_ITEM_DOCUMENT 的 businessId 为 item.id。
|
||||
Lastleg 必须先校验申请与明细存在、明细所属申请及其客户归属、业务状态和权限。
|
||||
客户归属未知时失败关闭,不得使用平台归属或替代业务ID。
|
||||
@@ -88,7 +88,7 @@ components:
|
||||
fileId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
ownerScope: {type: string, enum: [CUSTOMER]}
|
||||
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
businessType: {type: string, enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]}
|
||||
businessType: {type: string, enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT, OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]}
|
||||
businessId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
fileName: {type: string}
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
@@ -112,7 +112,7 @@ components:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]
|
||||
enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT, OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]
|
||||
BusinessId:
|
||||
name: businessId
|
||||
in: path
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ class OverseasExceptionEvidenceContractTest {
|
||||
try (var stream = getClass().getResourceAsStream("/db/file.sql")) {
|
||||
assertThat(stream).isNotNull();
|
||||
String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("business_type VARCHAR(32)", "OVERSEAS_EXCEPTION_EVIDENCE");
|
||||
assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(32);
|
||||
assertThat(sql).contains("business_type VARCHAR(64)", "OVERSEAS_EXCEPTION_EVIDENCE");
|
||||
assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class RelabelDocumentFileTest {
|
||||
@TempDir Path root;
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"})
|
||||
@EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT", "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT"})
|
||||
void pdfPolicyChecksContentTypeSignatureAndSize(BusinessFileCategory category) {
|
||||
BusinessFileStorage storage = storage();
|
||||
byte[] exactLimit = new byte[10 * 1024 * 1024];
|
||||
@@ -52,7 +52,7 @@ class RelabelDocumentFileTest {
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"})
|
||||
@EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT", "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT"})
|
||||
void uploadAndReadRequireExactCustomerBusinessTypeBusinessAndFile(BusinessFileCategory category) throws Exception {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "relabel-test"), FileObjectEntity.class);
|
||||
FileObjectMapper files = mock(FileObjectMapper.class);
|
||||
|
||||
@@ -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"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance"));
|
||||
assertTrue(unified.contains("assert_finance_permissions"));
|
||||
assertTrue(unified.contains("merchant:finance:statement:view"));
|
||||
int walletEventSchema = unified.indexOf("CREATE TABLE IF NOT EXISTS fin_wallet_event_outbox");
|
||||
int finalPass = unified.lastIndexOf("SELECT 'PASS' AS migration_status");
|
||||
int walletEventSchema = unified.indexOf("CREATE TABLE fin_wallet_event_outbox");
|
||||
int finalPass = unified.lastIndexOf("SELECT 'PASS' AS initialization_status");
|
||||
assertTrue(walletEventSchema >= 0);
|
||||
assertTrue(finalPass > walletEventSchema);
|
||||
assertTrue(unified.contains("CREATE PROCEDURE assert_wallet_event_schema()"));
|
||||
assertFalse(unified.contains("CREATE PROCEDURE assert_wallet_event_schema()"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,13 @@ class FinanceSettlementSchemaContractTest {
|
||||
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
|
||||
.contains("total_amount DECIMAL(20,6)", "paid_amount DECIMAL(20,6)", "outstanding_amount DECIMAL(20,6)")
|
||||
.contains("updated_at DATETIME(3)");
|
||||
assertThat(unified)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_settlement (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (")
|
||||
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
|
||||
.contains("'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16");
|
||||
String normalized = unified.toLowerCase().replaceAll("\\s+", "");
|
||||
assertThat(normalized)
|
||||
.contains("createtablefin_settlement(")
|
||||
.contains("createtablefin_settlement_line(")
|
||||
.contains("createtablefin_settlement_payment(")
|
||||
.contains("idx_fin_settlement_customer_direction_status(tenant_id,direction,status)");
|
||||
assertThat(unified).doesNotContain("ALTER TABLE fin_settlement", "assert_finance");
|
||||
}
|
||||
|
||||
private static Path resolveUnifiedSql() {
|
||||
|
||||
@@ -62,16 +62,14 @@ class FinanceSqlCommentContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedMigrationAddsBillLineAdjustmentReasonForNewAndExistingSchemas() throws Exception {
|
||||
void unifiedInitializationDefinesBillLineAdjustmentReasonDirectly() throws Exception {
|
||||
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
String sql = Files.readString(unified, StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("adjustment_reason VARCHAR(500) NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录'")
|
||||
.contains("table_schema='baiyue_finance' AND table_name='fin_bill_line' AND column_name='adjustment_reason'")
|
||||
.contains("ALTER TABLE baiyue_finance.fin_bill_line ADD COLUMN adjustment_reason")
|
||||
.contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'");
|
||||
.contains("adjustment_reason varchar(500) DEFAULT NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录'")
|
||||
.doesNotContain("ALTER TABLE baiyue_finance.fin_bill_line", "queue_column_comment");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -97,12 +95,17 @@ class FinanceSqlCommentContractTest {
|
||||
assertThat(baseline)
|
||||
.contains("updated_by_name VARCHAR(128) NULL COMMENT '最后修改操作人名称快照'")
|
||||
.contains("created_by_name VARCHAR(128) NULL COMMENT '登记操作人名称快照'");
|
||||
assertThat(unified)
|
||||
.contains("CALL finance_add_operator_snapshot_column('fin_bill', 'created_by_name', 'created_by')")
|
||||
.contains("CALL finance_add_operator_snapshot_column('fin_bill', 'updated_by_name', 'updated_by')")
|
||||
.contains("CALL finance_add_operator_snapshot_column('fin_receipt', 'created_by_name', 'created_by')")
|
||||
.contains("CALL finance_add_operator_snapshot_column('fin_cost', 'created_by_name', 'created_by')")
|
||||
.contains("CALL finance_add_operator_snapshot_column('fin_settlement_payment', 'created_by_name', 'created_by')")
|
||||
.contains("CALL finance_add_operator_snapshot_column('fin_bill_revision', 'created_by_name', 'created_by')");
|
||||
assertThat(tableBlock(unified, "fin_bill")).contains("created_by_name", "updated_by_name");
|
||||
assertThat(tableBlock(unified, "fin_receipt")).contains("created_by_name");
|
||||
assertThat(tableBlock(unified, "fin_cost")).contains("created_by_name");
|
||||
assertThat(tableBlock(unified, "fin_settlement_payment")).contains("created_by_name");
|
||||
assertThat(tableBlock(unified, "fin_bill_revision")).contains("created_by_name");
|
||||
assertThat(unified).doesNotContain("finance_add_operator_snapshot_column");
|
||||
}
|
||||
|
||||
private static String tableBlock(String sql, String table) {
|
||||
int start = sql.indexOf("CREATE TABLE " + table + " (");
|
||||
int end = sql.indexOf(';', start);
|
||||
return start < 0 || end < 0 ? "" : sql.substring(start, end);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ class FinanceSubjectSeedContractTest {
|
||||
String module = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
|
||||
|
||||
for (String sql : new String[] {module, unified}) {
|
||||
assertThat(sql)
|
||||
.contains("INSERT INTO fin_subject")
|
||||
.contains("('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)")
|
||||
.contains("ON DUPLICATE KEY UPDATE")
|
||||
.contains("status = 'ACTIVE'");
|
||||
}
|
||||
assertThat(module)
|
||||
.contains("INSERT INTO fin_subject")
|
||||
.contains("('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)")
|
||||
.contains("ON DUPLICATE KEY UPDATE")
|
||||
.contains("status = 'ACTIVE'");
|
||||
assertThat(unified)
|
||||
.contains("INSERT INTO fin_subject (id, subject_code, subject_name, status")
|
||||
.contains("VALUES (1,'PLATFORM_DEFAULT','平台','ACTIVE',0,0,0")
|
||||
.doesNotContain("ON DUPLICATE KEY UPDATE");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,16 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceWalletContractTest {
|
||||
@Test
|
||||
void unifiedMigrationDropsTheLegacyBalanceConstraintBeforeRewritingBalances() throws Exception {
|
||||
void unifiedInitializationDefinesTheFinalWalletBalanceConstraintWithoutRewritingData() throws Exception {
|
||||
Path migration = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
if (!Files.exists(migration)) migration = Path.of("..").resolve(migration);
|
||||
String sql = Files.readString(migration, StandardCharsets.UTF_8);
|
||||
int procedure = sql.indexOf("CREATE PROCEDURE migrate_fin_wallet_credit()");
|
||||
int dropConstraint = sql.indexOf("ALTER TABLE fin_wallet_account DROP CHECK chk_fin_wallet_account_balance", procedure);
|
||||
int rewriteBalance = sql.indexOf("SET actual_balance=actual_balance-credit_used", procedure);
|
||||
|
||||
assertThat(procedure).isGreaterThanOrEqualTo(0);
|
||||
assertThat(sql.substring(procedure, rewriteBalance))
|
||||
.contains("CHECK_CLAUSE LIKE '%actual_balance%'", "v_legacy_wallet_constraint OR v_credit_expiry_missing");
|
||||
assertThat(dropConstraint).isBetween(procedure, rewriteBalance - 1);
|
||||
String account = tableBlock(sql, "fin_wallet_account");
|
||||
assertThat(account).contains(
|
||||
"credit_expires_on date DEFAULT NULL",
|
||||
"CONSTRAINT chk_fin_wallet_account_balance CHECK",
|
||||
"frozen_balance >= credit_frozen");
|
||||
assertThat(sql).doesNotContain("migrate_fin_wallet_credit", "SET actual_balance=actual_balance-credit_used");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,4 +66,10 @@ class FinanceWalletContractTest {
|
||||
.contains("/holds/{businessType}/{businessNo}/release")
|
||||
.contains("Idempotency-Key");
|
||||
}
|
||||
|
||||
private static String tableBlock(String sql, String table) {
|
||||
int start = sql.indexOf("CREATE TABLE " + table + " (");
|
||||
int end = sql.indexOf(';', start);
|
||||
return start < 0 || end < 0 ? "" : sql.substring(start, end);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ class PdaConvergenceOpenApiContractTest {
|
||||
.contains("loadingPlanId:", "containerBookingId:")
|
||||
.doesNotContain("containerNo:");
|
||||
assertThat(batchItemRequest)
|
||||
.contains("PICK_PALLET", "palletNo:", "every pickItem", "No carton quantities")
|
||||
.contains("PICK_PALLET", "palletNo:", "every SKU line", "No carton quantities")
|
||||
.doesNotContain("releaseMode:", "reservationIds:", "locationIds:");
|
||||
String pickItem = yaml.substring(yaml.indexOf(" PickItem:"), yaml.indexOf(" PendingTask:"));
|
||||
assertThat(pickItem).contains("unitType:", "palletId:", "palletNo:", "indivisible:", "SEALED_PALLET");
|
||||
|
||||
+2
-3
@@ -25,9 +25,8 @@ class TruckAppointmentControllerContractTest {
|
||||
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
|
||||
Path unifiedPath=Files.exists(direct)?direct:Path.of("..","deploy","sql","头程数据库统一增量-MySQL8.sql");
|
||||
String unifiedSql=Files.readString(unifiedPath,StandardCharsets.UTF_8);
|
||||
for(String sql:new String[]{moduleSql,unifiedSql}) {
|
||||
assertThat(sql).contains("'bill_of_lading_no'","'actual_arrived_warehouse_at'","'actual_loading_at'","'actual_departed_at'");
|
||||
}
|
||||
assertThat(moduleSql).contains("'bill_of_lading_no'","'actual_arrived_warehouse_at'","'actual_loading_at'","'actual_departed_at'");
|
||||
assertThat(unifiedSql).contains("bill_of_lading_no varchar(128)","actual_arrived_warehouse_at datetime(3)","actual_loading_at datetime(3)","actual_departed_at datetime(3)");
|
||||
}
|
||||
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
|
||||
@Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","database booking-level unique key");}
|
||||
|
||||
+11
-6
@@ -17,17 +17,22 @@ class ColoadGroupSqlContractTest {
|
||||
String baseline = Files.readString(Path.of("src/main/resources/db/phase3-coload-group-schema.sql"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
for (String source : new String[]{baseline, unified}) {
|
||||
String normalized = source
|
||||
.replace("CREATE TABLE IF NOT EXISTS ", "CREATE TABLE ")
|
||||
.replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ");
|
||||
assertThat(source)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_coload_group (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_coload_group_member (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_coload_group_history (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_coload_command (")
|
||||
.contains("ACTIVE','LOCKED','CONSUMED','CANCELLED")
|
||||
.contains("ACTIVE", "LOCKED", "CONSUMED", "CANCELLED")
|
||||
.contains("chk_coload_group_status")
|
||||
.contains("uk_coload_active_inbound")
|
||||
.contains("full_package_count")
|
||||
.contains("uk_coload_command")
|
||||
.contains("locked_plan_id");
|
||||
assertThat(tableDefinition(source, "fl_coload_group"))
|
||||
assertThat(normalized)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_coload_group (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_coload_group_member (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_coload_group_history (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_coload_command (");
|
||||
assertThat(tableDefinition(normalized, "fl_coload_group"))
|
||||
.doesNotContain("chk_coload_group_destination")
|
||||
.doesNotContain("destination_type")
|
||||
.doesNotContain("destination_warehouse_id")
|
||||
|
||||
+4
-3
@@ -30,11 +30,12 @@ class LoadingEvidenceSqlContractTest {
|
||||
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
String sql = Files.readString(unified, StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence")
|
||||
assertThat(sql).contains("CREATE TABLE fl_loading_cabinet_evidence")
|
||||
.contains("uk_cabinet_evidence_active")
|
||||
.contains("uk_cabinet_evidence_operation")
|
||||
.contains("normalize_loading_cabinet_evidence_collation")
|
||||
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_evidence")
|
||||
.contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id)")
|
||||
.doesNotContain("CREATE TABLE fl_loading_evidence")
|
||||
.doesNotContain("normalize_loading_cabinet_evidence_collation")
|
||||
.doesNotContain("phase3_loading_evidence_add_column");
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -38,8 +38,13 @@ class PdaConvergenceSqlContractTest {
|
||||
|
||||
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
assertThat(createBlock(unified, "fl_loading_pick_item_assignment"))
|
||||
.isEqualTo(createBlock(sql, "fl_loading_pick_item_assignment"));
|
||||
assertThat(unified)
|
||||
.contains("CREATE TABLE fl_loading_pick_item_assignment")
|
||||
.contains("assignment_type varchar(16) NOT NULL")
|
||||
.contains("PRIMARY KEY (pick_item_id)")
|
||||
.contains("KEY idx_loading_pick_item_assignee (assignee_id,updated_at)")
|
||||
.contains("CONSTRAINT fk_loading_pick_item_assignment_item")
|
||||
.contains("platform:firstleg:pda:assign");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+25
-46
@@ -108,49 +108,28 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
@Test
|
||||
void unifiedDeploymentEntryContainsTheSameContract() throws Exception {
|
||||
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
StandardCharsets.UTF_8).toLowerCase();
|
||||
assertThat(unified)
|
||||
.contains("fl_carrier_schedule_query")
|
||||
.contains("create table fl_carrier_schedule_query")
|
||||
.contains("uk_carrier_schedule_query_scope")
|
||||
.contains("id IN (5102184,5102186,5102188)")
|
||||
.contains("assert_carrier_schedule_query_schema")
|
||||
.contains("CREATE PROCEDURE migrate_carrier_schedule_retry_columns()")
|
||||
.contains("ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL'")
|
||||
.contains("ADD COLUMN latest_standard_node VARCHAR(32) NULL")
|
||||
.contains("ADD COLUMN latest_node_observed_at DATETIME(3) NULL")
|
||||
.contains("ADD COLUMN stopped_at DATETIME(3) NULL")
|
||||
.contains("ADD COLUMN stop_reason VARCHAR(500) NULL")
|
||||
.contains("ADD COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0")
|
||||
.contains("ADD COLUMN window_started_at DATETIME(3) NULL")
|
||||
.contains("ADD COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0")
|
||||
.contains("ADD COLUMN next_allowed_at DATETIME(3) NULL")
|
||||
.contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)")
|
||||
.contains("DROP CHECK chk_carrier_schedule_query_access")
|
||||
.contains("DROP CHECK chk_carrier_schedule_query_carrier")
|
||||
.contains("DROP CHECK chk_carrier_schedule_query_status")
|
||||
.contains("DROP INDEX uk_carrier_schedule_query_scope")
|
||||
.contains("ADD UNIQUE KEY uk_carrier_schedule_query_scope")
|
||||
.contains("trigger_source varchar(32) not null default 'manual'")
|
||||
.contains("latest_standard_node varchar(32) default null")
|
||||
.contains("latest_node_observed_at datetime(3) default null")
|
||||
.contains("stopped_at datetime(3) default null")
|
||||
.contains("stop_reason varchar(500) default null")
|
||||
.contains("consecutive_failures int unsigned not null default '0'")
|
||||
.contains("window_started_at datetime(3) not null")
|
||||
.contains("attempt_count int unsigned not null default '0'")
|
||||
.contains("next_allowed_at datetime(3) default null")
|
||||
.contains("key idx_carrier_schedule_query_due (status,next_allowed_at,id)")
|
||||
.contains("unique key uk_carrier_schedule_query_scope")
|
||||
.contains("(customer_id,ocean_booking_id,container_scope_id)")
|
||||
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
|
||||
.contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'")
|
||||
.contains("'OFFICIAL_API','MANUAL'")
|
||||
.contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')")
|
||||
.contains("ARRIVED_DESTINATION_PORT")
|
||||
.contains("EMPTY_RETURNED")
|
||||
.contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')")
|
||||
.contains("SET @carrier_schedule_next = CASE")
|
||||
.contains("CREATE PROCEDURE ensure_carrier_schedule_retry_check()")
|
||||
.contains("CREATE PROCEDURE migrate_carrier_schedule_history_action_check()")
|
||||
.contains("DROP CHECK chk_carrier_schedule_history_action")
|
||||
.contains("'MANUAL_NODE','NODE_OBSERVED'")
|
||||
.contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'")
|
||||
.contains("ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)")
|
||||
.contains("统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整")
|
||||
.doesNotContain("DROP CHECK chk_carrier_schedule_query_attempt")
|
||||
.doesNotContain("\n+--")
|
||||
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
|
||||
.contains("船司查询任务角色授权未完整创建")
|
||||
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
|
||||
.contains("chk_carrier_schedule_query_attempt check ((attempt_count between 0 and 3))")
|
||||
.contains("arrived_destination_port")
|
||||
.contains("empty_returned")
|
||||
.contains("platform:firstleg:port-schedule-query:manual")
|
||||
.doesNotContain("migrate_carrier_schedule_retry_columns")
|
||||
.doesNotContain("drop check chk_carrier_schedule_query_attempt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -158,13 +137,13 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
assertThat(unified)
|
||||
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key'")
|
||||
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version'")
|
||||
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence'")
|
||||
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash'")
|
||||
.contains("event_key varchar(160) DEFAULT NULL")
|
||||
.contains("event_version int unsigned NOT NULL DEFAULT '1'")
|
||||
.contains("aggregate_sequence int unsigned DEFAULT NULL")
|
||||
.contains("payload_hash varchar(71) DEFAULT NULL")
|
||||
.contains("uk_inbound_outbox_event_key")
|
||||
.contains("MODIFY COLUMN payload_hash VARCHAR(71)")
|
||||
.contains("MODIFY COLUMN payload_json LONGTEXT NOT NULL");
|
||||
.contains("payload_json longtext NOT NULL")
|
||||
.doesNotContain("firstleg_finance_add_column");
|
||||
}
|
||||
|
||||
private static String moduleSql() throws Exception {
|
||||
|
||||
+10
-10
@@ -108,16 +108,16 @@ class ContainerBookingMapperContractTest {
|
||||
@Test
|
||||
@DisplayName("初始化与统一增量锁定真实柜号权威、唯一和ISO结构约束")
|
||||
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
|
||||
for (String source : new String[] {
|
||||
Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql")),
|
||||
Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))
|
||||
}) {
|
||||
assertThat(source)
|
||||
.contains("国内唯一权威真实集装箱号")
|
||||
.contains("提柜后可原子补录")
|
||||
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
|
||||
.contains("CONSTRAINT chk_container_booking_iso6346")
|
||||
.contains("container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'");
|
||||
String module = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertThat(module.toLowerCase())
|
||||
.contains("国内唯一权威真实集装箱号")
|
||||
.contains("提柜后可原子补录");
|
||||
for (String source : new String[] {module, unified}) {
|
||||
assertThat(source.toLowerCase())
|
||||
.contains("unique key uk_container_booking_container_no (container_no)")
|
||||
.contains("constraint chk_container_booking_iso6346")
|
||||
.contains("^[a-z]{3}[ujz][0-9]{7}$");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-55
@@ -28,21 +28,23 @@ class CustomerDimensionConfigSqlContractTest {
|
||||
);
|
||||
|
||||
for (String sql : sqlEntrypoints) {
|
||||
assertThat(sql)
|
||||
String normalized = sql.toLowerCase().replace(", ", ",");
|
||||
assertThat(normalized)
|
||||
.contains("fl_customer_dimension_config")
|
||||
.contains("box_type VARCHAR(24) NOT NULL")
|
||||
.contains("inner_length_cm DECIMAL(9,3) NOT NULL")
|
||||
.contains("inner_width_cm DECIMAL(9,3) NOT NULL")
|
||||
.contains("inner_height_cm DECIMAL(9,3) NOT NULL")
|
||||
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000")
|
||||
.contains("default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ACTUAL'")
|
||||
.contains("initialization_key VARCHAR(128) NOT NULL")
|
||||
.contains("created_by BIGINT UNSIGNED NOT NULL")
|
||||
.contains("updated_by BIGINT UNSIGNED NOT NULL")
|
||||
.contains("version INT NOT NULL DEFAULT 0")
|
||||
.contains("UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type)")
|
||||
.contains("CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE'))")
|
||||
.doesNotContain("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)");
|
||||
.contains("box_type varchar(24) not null")
|
||||
.contains("inner_length_cm decimal(9,3) not null")
|
||||
.contains("inner_width_cm decimal(9,3) not null")
|
||||
.contains("inner_height_cm decimal(9,3) not null")
|
||||
.contains("edge_addition_cm decimal(6,3) not null default")
|
||||
.contains("default_calculation_mode varchar(16)")
|
||||
.contains("not null default 'actual'")
|
||||
.contains("initialization_key varchar(128) not null")
|
||||
.contains("created_by bigint unsigned not null")
|
||||
.contains("updated_by bigint unsigned not null")
|
||||
.contains("version int not null default")
|
||||
.contains("unique key uk_customer_dimension_config_tenant_box (tenant_id,box_type)")
|
||||
.contains("'small'", "'medium'", "'large'", "'extra_large'")
|
||||
.doesNotContain("unique key uk_customer_dimension_config_tenant (tenant_id)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,21 +106,19 @@ class CustomerDimensionConfigSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedMigrationKeepsPermissionGuardsAndAssertsFinalFourRowSchema() throws Exception {
|
||||
void unifiedInitializationKeepsPermissionsAndFinalFourRowSchema() throws Exception {
|
||||
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()")
|
||||
.contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010")
|
||||
.contains("r.role_code = 'PLATFORM_ADMIN'")
|
||||
.contains("r.role_code = 'ORDER_OPERATOR'")
|
||||
.contains("index_name = 'uk_customer_dimension_config_tenant_box'")
|
||||
.contains("客户四箱规尺寸配置字段不完整")
|
||||
.contains("客户默认计费规则初始化幂等字段未创建")
|
||||
.contains("index_name = 'idx_charge_rule_initialization'")
|
||||
.contains("客户默认计费规则初始化索引未完整创建")
|
||||
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确");
|
||||
.contains("CREATE TABLE fl_customer_dimension_config")
|
||||
.contains("UNIQUE KEY uk_customer_dimension_config_tenant_box")
|
||||
.contains("5102111,0,'PLATFORM','BUTTON',5100010")
|
||||
.contains("firstleg:dimension-config:view")
|
||||
.contains("firstleg:dimension-config:edit")
|
||||
.contains("VALUES (4000001,5102111,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("VALUES (4000003,5102111,0,CURRENT_TIMESTAMP(3))")
|
||||
.doesNotContain("unified_guard_dimension_config_permissions");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -129,43 +129,21 @@ class CustomerDimensionConfigSqlContractTest {
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(schema).contains("calculation_rule_version VARCHAR(64) NOT NULL");
|
||||
assertThat(unified)
|
||||
.contains("calculation_rule_version VARCHAR(64) NOT NULL")
|
||||
.contains("MODIFY COLUMN calculation_rule_version VARCHAR(64) NOT NULL");
|
||||
assertThat(unified).contains("calculation_rule_version varchar(64) NOT NULL")
|
||||
.doesNotContain("MODIFY COLUMN calculation_rule_version");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedMigrationUpgradesOnlyRecognizedLegacyTableAndBackfillsIdempotently() throws Exception {
|
||||
void unifiedInitializationCreatesOnlyTheFinalDimensionTableWithoutBackfill() throws Exception {
|
||||
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
int upgrade = sql.indexOf("CREATE PROCEDURE unified_upgrade_legacy_customer_dimension_config()");
|
||||
int create = sql.indexOf("CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config");
|
||||
int backfill = sql.indexOf("INSERT INTO baiyue_firstleg.fl_customer_dimension_config (", create);
|
||||
assertThat(upgrade).isGreaterThanOrEqualTo(0);
|
||||
assertThat(create).isGreaterThan(upgrade);
|
||||
assertThat(backfill).isGreaterThan(create);
|
||||
assertThat(sql)
|
||||
.contains("AND column_name = 'box_type') = 0")
|
||||
.contains("AND column_name IN ('id', 'tenant_id', 'edge_addition_cm', 'remark', 'initialization_key'")
|
||||
.contains("AND table_name = 'fl_customer_dimension_config') <> 11")
|
||||
.contains("AND index_name = 'uk_customer_dimension_config_tenant'")
|
||||
.contains("DROP TABLE baiyue_firstleg.fl_customer_dimension_config")
|
||||
.contains("客户尺寸配置表不是可识别的旧单行结构")
|
||||
.contains("SELECT id AS tenant_id, created_by")
|
||||
.contains("FROM baiyue_iam.iam_tenant")
|
||||
.contains("WHERE tenant_type = 'MERCHANT' AND deleted = 0 AND id > 0")
|
||||
.contains("CONCAT('unified-four-box-default-v1:', tenant.tenant_id)")
|
||||
.contains("ON DUPLICATE KEY UPDATE id = id")
|
||||
.contains("现有企业客户未完整持有固定四箱规尺寸配置");
|
||||
|
||||
String repeatSafeClause = sql.substring(backfill,
|
||||
sql.indexOf("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql", backfill));
|
||||
assertThat(repeatSafeClause)
|
||||
.doesNotContain("edge_addition_cm = VALUES")
|
||||
.doesNotContain("inner_length_cm = VALUES")
|
||||
.doesNotContain("remark = VALUES")
|
||||
.doesNotContain("version = VALUES");
|
||||
.contains("CREATE TABLE fl_customer_dimension_config")
|
||||
.contains("COMMENT='客户固定四箱规尺寸配置'")
|
||||
.doesNotContain("unified_upgrade_legacy_customer_dimension_config")
|
||||
.doesNotContain("DROP TABLE baiyue_firstleg.fl_customer_dimension_config")
|
||||
.doesNotContain("ON DUPLICATE KEY UPDATE");
|
||||
}
|
||||
|
||||
private static String resource(String path) throws Exception {
|
||||
|
||||
+21
-24
@@ -49,34 +49,31 @@ class DropshipPalletizationSqlContractTest {
|
||||
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
|
||||
|
||||
for (String sql : new String[]{inbound, unified}) {
|
||||
assertThat(sql)
|
||||
.contains("counted_cartons INT UNSIGNED NOT NULL DEFAULT 0")
|
||||
.contains("discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0")
|
||||
.contains("pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托'")
|
||||
.contains("expected_sku_quantity INT UNSIGNED NOT NULL")
|
||||
.contains("actual_sku_quantity INT UNSIGNED NOT NULL")
|
||||
.contains("count_operation_id VARCHAR(128) NOT NULL")
|
||||
.contains("bind_operation_id VARCHAR(128) NULL");
|
||||
assertThat(sql.toLowerCase())
|
||||
.contains("counted_cartons int unsigned not null default")
|
||||
.contains("discrepancy_cartons int unsigned not null default")
|
||||
.contains("pallet_status varchar(16) not null comment '托盘状态:open组托中、sealed已封托'")
|
||||
.contains("expected_sku_quantity int unsigned not null")
|
||||
.contains("actual_sku_quantity int unsigned not null")
|
||||
.contains("count_operation_id varchar(128) not null")
|
||||
.contains("bind_operation_id varchar(128)");
|
||||
}
|
||||
for (String sql : new String[]{loading, unified}) {
|
||||
assertThat(sql)
|
||||
.contains("unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE'")
|
||||
.contains("pallet_id BIGINT UNSIGNED NULL")
|
||||
.contains("indivisible TINYINT(1) NOT NULL DEFAULT 0")
|
||||
.contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS")
|
||||
.contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)")
|
||||
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (")
|
||||
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton (");
|
||||
String normalized = sql.toLowerCase().replace(", ", ",");
|
||||
assertThat(normalized)
|
||||
.contains("unit_type varchar(24) not null default 'package'")
|
||||
.contains("pallet_id bigint unsigned")
|
||||
.contains("indivisible tinyint(1) not null default")
|
||||
.contains("active_assignment_key varchar(96) generated always as")
|
||||
.contains("unique key uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key)")
|
||||
.doesNotContain("create table fl_loading_work_pallet (")
|
||||
.doesNotContain("create table fl_loading_work_pallet_carton (");
|
||||
}
|
||||
assertThat(unified)
|
||||
.contains("CREATE PROCEDURE converge_loading_assignment_schema()")
|
||||
.contains("CREATE PROCEDURE converge_dropship_palletization_schema()")
|
||||
.contains("ALTER TABLE fl_dropship_pallet DROP CHECK chk_dropship_pallet_status")
|
||||
.contains("ALTER TABLE fl_dropship_pallet DROP CHECK chk_dropship_pallet_status;\n END IF;\n UPDATE fl_dropship_pallet")
|
||||
.contains("CHECK (pallet_status IN ('OPEN','SEALED'))")
|
||||
.contains("CHANGE COLUMN operation_id count_operation_id")
|
||||
.contains("DROP INDEX uk_loading_item_plan_detail")
|
||||
.contains("ADD UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key)");
|
||||
.contains("CONSTRAINT chk_dropship_pallet_status CHECK")
|
||||
.contains("UNIQUE KEY uk_loading_item_plan_assignment")
|
||||
.doesNotContain("converge_loading_assignment_schema")
|
||||
.doesNotContain("converge_dropship_palletization_schema");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+9
-2
@@ -11,9 +11,13 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class FeeItemMigrationSqlContractTest {
|
||||
@Test
|
||||
void temporarySeedTableIsNotReopenedWithinOneMysqlStatement() throws Exception {
|
||||
assertMysqlCompatible(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertMysqlCompatible(resolve(
|
||||
"firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql"));
|
||||
String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
|
||||
assertThat(unified)
|
||||
.contains("193000000001000111,0,'头程运费','INTERNATIONAL','FBA_HEAD_FREIGHT'")
|
||||
.contains("193000000001000112,0,'尾程派送费','INTERNATIONAL','PRIVATE_LAST_MILE_DELIVERY'")
|
||||
.doesNotContain("tmp_fee_item_seed");
|
||||
}
|
||||
|
||||
private static void assertMysqlCompatible(Path path) throws Exception {
|
||||
@@ -35,9 +39,12 @@ class FeeItemMigrationSqlContractTest {
|
||||
|
||||
@Test
|
||||
void inboundFeeSourceIndexUsesTheFinalVersionedDefinition() throws Exception {
|
||||
assertFinalSourceIndex(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertFinalSourceIndex(resolve(
|
||||
"firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql"));
|
||||
String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
|
||||
assertThat(unified)
|
||||
.contains("UNIQUE KEY uk_inbound_fee_source (tenant_id,source_type,source_fee_id,source_fee_version)")
|
||||
.doesNotContain("DROP INDEX uk_inbound_fee_source");
|
||||
}
|
||||
|
||||
private static void assertFinalSourceIndex(Path path) throws Exception {
|
||||
|
||||
+5
-5
@@ -17,11 +17,11 @@ class FulfillmentTypeSqlContractTest {
|
||||
|
||||
assertFulfillmentMigration(order, "baiyue_order.ord_order");
|
||||
assertFulfillmentMigration(inbound, "fl_inbound_order");
|
||||
assertFulfillmentMigration(unified, "baiyue_order.ord_order");
|
||||
assertFulfillmentMigration(unified, "fl_inbound_order");
|
||||
assertThat(unified)
|
||||
.contains("unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type')")
|
||||
.contains("unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type')");
|
||||
assertThat(unified.toLowerCase())
|
||||
.contains("create table ord_order")
|
||||
.contains("create table fl_inbound_order")
|
||||
.contains("fulfillment_type varchar(32) not null default 'standard'")
|
||||
.doesNotContain("unified_assert_column");
|
||||
}
|
||||
|
||||
private static void assertFulfillmentMigration(String sql, String table) {
|
||||
|
||||
+9
-12
@@ -29,20 +29,17 @@ class InboundCartonLabelSqlContractTest {
|
||||
assertThat(versioningMigration).contains("phase2_carton_label_add_column('label_version'")
|
||||
.contains("phase2_carton_label_add_column('batch_status'")
|
||||
.contains("idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version)");
|
||||
assertThat(unified).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
|
||||
.contains("units_per_carton INT UNSIGNED NULL")
|
||||
.contains("unified_carton_label_add_column('label_version'")
|
||||
.contains("unified_carton_label_add_column('batch_status'")
|
||||
.contains("unified_carton_label_add_column('cargo_fingerprint'")
|
||||
.contains("unified_carton_label_add_column('voided_at'")
|
||||
.contains("ALTER TABLE fl_inbound_carton_label DROP INDEX uk_carton_label_index")
|
||||
.contains("ADD UNIQUE KEY uk_carton_label_index (tenant_id,inbound_id,label_version,carton_index)")
|
||||
.contains("ADD KEY idx_carton_label_active (tenant_id,inbound_id,batch_status,label_version)")
|
||||
assertThat(unified).contains("CREATE TABLE fl_inbound_carton_label")
|
||||
.contains("units_per_carton int unsigned DEFAULT NULL")
|
||||
.contains("label_version int unsigned NOT NULL DEFAULT '1'")
|
||||
.contains("batch_status varchar(16) NOT NULL DEFAULT 'ACTIVE'")
|
||||
.contains("cargo_fingerprint char(64) DEFAULT NULL")
|
||||
.contains("voided_at datetime(3) DEFAULT NULL")
|
||||
.contains("UNIQUE KEY uk_carton_label_index (tenant_id,inbound_id,label_version,carton_index)")
|
||||
.contains("KEY idx_carton_label_active (tenant_id,inbound_id,batch_status,label_version)")
|
||||
.contains("uk_carton_label_box_code")
|
||||
.contains("uk_carton_label_index")
|
||||
.contains("稳定箱号映射表未创建")
|
||||
.contains("稳定箱码映射兼容字段未完整创建")
|
||||
.contains("箱唛有效批次索引未完整创建");
|
||||
.doesNotContain("unified_carton_label_add_column");
|
||||
}
|
||||
|
||||
private String resource(String path) throws Exception {
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ class InboundStatusUnificationSqlContractTest {
|
||||
.doesNotContain("UPDATE fl_inbound_order")
|
||||
.doesNotContain("UPDATE fl_inbound_status_history");
|
||||
assertThat(unified)
|
||||
.contains("入库完成后的唯一状态为 WAIT_SPLIT")
|
||||
.contains("WAIT_SPLIT待分柜")
|
||||
.doesNotContain("历史 WAIT_SPLIT")
|
||||
.doesNotContain("SET process_status = 'WAIT_SPLIT'")
|
||||
.doesNotContain("SET from_status = 'WAIT_SPLIT'")
|
||||
|
||||
+13
-7
@@ -77,9 +77,12 @@ class InboundWorkflowSqlContractTest {
|
||||
.contains("uk_measure_box_code")
|
||||
.contains("information_schema.STATISTICS");
|
||||
assertThat(unified)
|
||||
.contains("CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_code'")
|
||||
.contains("CREATE TABLE fl_inbound_measurement_item")
|
||||
.contains("box_code varchar(128) DEFAULT NULL")
|
||||
.contains("machine_no varchar(64) DEFAULT NULL")
|
||||
.contains("device_measured_at datetime(3) DEFAULT NULL")
|
||||
.contains("uk_measure_box_code")
|
||||
.contains("单箱当前测量字段未完整创建");
|
||||
.doesNotContain("phase2_measure_add_column");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -97,10 +100,13 @@ class InboundWorkflowSqlContractTest {
|
||||
.doesNotContain("final_weight_kg")
|
||||
.doesNotContain("uk_pallet_allocation_pallet");
|
||||
assertThat(unified)
|
||||
.contains("CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_id'")
|
||||
.contains("ALTER TABLE fl_inbound_weight_result DROP COLUMN final_weight_kg")
|
||||
.contains("ALTER TABLE fl_inbound_pallet_allocation DROP INDEX uk_pallet_allocation_pallet")
|
||||
.contains("称重托盘快照或毛重净重字段未正确收敛")
|
||||
.contains("同票托盘唯一约束仍阻止托盘主数据复用");
|
||||
.contains("CREATE TABLE fl_inbound_weight_result")
|
||||
.contains("pallet_id bigint unsigned NOT NULL")
|
||||
.contains("pallet_tare_weight_kg decimal(18,3) NOT NULL")
|
||||
.contains("gross_weight_kg decimal(18,3) DEFAULT NULL")
|
||||
.contains("net_weight_kg decimal(18,3) DEFAULT NULL")
|
||||
.contains("KEY idx_weight_inbound_pallet")
|
||||
.doesNotContain("final_weight_kg")
|
||||
.doesNotContain("uk_pallet_allocation_pallet");
|
||||
}
|
||||
}
|
||||
|
||||
+11
-9
@@ -19,18 +19,20 @@ class MojiaMeasurementReceiptSqlContractTest {
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
for (String sql : new String[]{schema, unified}) {
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt")
|
||||
.contains("box_code VARCHAR(128) NOT NULL")
|
||||
.contains("scanned_shipment_id VARCHAR(64) NULL")
|
||||
.contains("image_file_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED'")
|
||||
String normalized = sql.toLowerCase()
|
||||
.replace("create table if not exists ", "create table ")
|
||||
.replace("create table ", "create table if not exists ");
|
||||
assertThat(normalized)
|
||||
.contains("create table if not exists fl_mojia_measurement_receipt")
|
||||
.contains("box_code varchar(128) not null")
|
||||
.contains("scanned_shipment_id varchar(64)")
|
||||
.contains("image_file_id bigint unsigned not null")
|
||||
.contains("status varchar(24) not null default 'unmatched'")
|
||||
.contains("idx_mojia_receipt_status_time")
|
||||
.doesNotContain("picture_base64");
|
||||
}
|
||||
assertThat(unified)
|
||||
.contains("墨家未匹配测量收件箱字段未完整创建")
|
||||
.contains("箱级测量实扫Shipment ID字段未创建")
|
||||
.contains("测量计算规则版本字段长度不是64");
|
||||
.contains("COMMENT='墨家模拟或未匹配入库单的原始测量收件箱'")
|
||||
.contains("calculation_rule_version varchar(64) NOT NULL");
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -31,10 +31,11 @@ class PdaSplitSettlementSqlContractTest {
|
||||
.contains("phase3_split_add_column('fl_preload_plan', 'confirmed_by_name'")
|
||||
.contains("phase3_split_add_column('fl_preload_plan', 'confirmed_at'");
|
||||
assertThat(unifiedSql)
|
||||
.contains("CREATE PROCEDURE migrate_preload_confirmation_columns()")
|
||||
.contains("ADD COLUMN confirmed_by BIGINT UNSIGNED NULL")
|
||||
.contains("ADD COLUMN confirmed_by_name VARCHAR(64) NULL")
|
||||
.contains("ADD COLUMN confirmed_at DATETIME(3) NULL");
|
||||
.contains("CREATE TABLE fl_preload_plan")
|
||||
.contains("confirmed_by bigint unsigned DEFAULT NULL")
|
||||
.contains("confirmed_by_name varchar(64) DEFAULT NULL")
|
||||
.contains("confirmed_at datetime(3) DEFAULT NULL")
|
||||
.doesNotContain("migrate_preload_confirmation_columns");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -188,9 +189,8 @@ class PdaSplitSettlementSqlContractTest {
|
||||
.contains("UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)")
|
||||
.doesNotContain("UNIQUE KEY uk_pallet_allocation_slot (tenant_id, block_id, slot_no)");
|
||||
assertThat(unifiedSql)
|
||||
.contains("'tenant_id,inbound_id,block_id,slot_no'")
|
||||
.contains("ADD UNIQUE KEY uk_pallet_allocation_slot (tenant_id,inbound_id,block_id,slot_no)")
|
||||
.contains("'tenant_id,inbound_id,weight_result_id'")
|
||||
.contains("ADD UNIQUE KEY uk_pallet_allocation_weight (tenant_id,inbound_id,weight_result_id)");
|
||||
.contains("UNIQUE KEY uk_pallet_allocation_slot (tenant_id,inbound_id,block_id,slot_no)")
|
||||
.contains("UNIQUE KEY uk_pallet_allocation_weight (tenant_id,inbound_id,weight_result_id)")
|
||||
.doesNotContain("ADD UNIQUE KEY uk_pallet_allocation_slot");
|
||||
}
|
||||
}
|
||||
|
||||
+12
-22
@@ -58,36 +58,26 @@ class Phase3BookingTruckPermissionSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedSqlLocksCanonicalIdsScopeParentsAndRoleGrants() throws Exception {
|
||||
void unifiedSqlCreatesCanonicalIdsScopeParentsAndRoleGrants() throws Exception {
|
||||
String sql = unifiedSql();
|
||||
assertThat(sql)
|
||||
.contains("UNIQUE KEY uk_permission_scope_code (tenant_id, app_code, permission_code)")
|
||||
.contains("CREATE PROCEDURE assert_ocean_booking_permission_ids_available()")
|
||||
.contains("CREATE PROCEDURE assert_truck_appointment_permission_ids_available()")
|
||||
.contains("CREATE PROCEDURE assert_phase3_service_center_schema()")
|
||||
.contains("CALL assert_phase3_service_center_schema();")
|
||||
.contains("DROP PROCEDURE IF EXISTS assert_phase3_service_center_schema;")
|
||||
.contains("三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用")
|
||||
.contains("三期拖车权限迁移失败:5102150-5102154 ID 或权限码被其他语义占用")
|
||||
String normalized = sql.replace(", ", ",");
|
||||
assertThat(normalized)
|
||||
.contains("UNIQUE KEY uk_permission_scope_code (tenant_id,app_code,permission_code)")
|
||||
.contains("(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list'")
|
||||
.contains("(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate'")
|
||||
.contains("(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'")
|
||||
.contains("(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate'")
|
||||
.contains("permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 6")
|
||||
.contains("p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 12")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
|
||||
.doesNotContain("assert_phase3_booking_truck_permission_ids_available")
|
||||
.contains("VALUES (4000001,5102140,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("VALUES (4000003,5102140,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("VALUES (4000001,5102150,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("VALUES (4000003,5102150,0,CURRENT_TIMESTAMP(3))")
|
||||
.doesNotContain("assert_ocean_booking_permission_ids_available")
|
||||
.doesNotContain("assert_truck_appointment_permission_ids_available")
|
||||
.doesNotContain("/trucking");
|
||||
|
||||
assertThat(count(sql, "(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list'"))
|
||||
assertThat(count(normalized, "(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list'"))
|
||||
.isEqualTo(1);
|
||||
assertThat(count(sql, "(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'"))
|
||||
.isEqualTo(1);
|
||||
assertThat(count(sql, "CREATE PROCEDURE assert_ocean_booking_permission_ids_available()"))
|
||||
.isEqualTo(1);
|
||||
assertThat(count(sql, "CREATE PROCEDURE assert_truck_appointment_permission_ids_available()"))
|
||||
.isEqualTo(1);
|
||||
assertThat(count(sql, "CREATE PROCEDURE assert_phase3_service_center_schema()"))
|
||||
assertThat(count(normalized, "(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'"))
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
+13
-3
@@ -17,10 +17,20 @@ class Phase3ChannelContainerMigrationContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedMigrationRestoresGuardsBeforeSurfacingFailures() throws Exception {
|
||||
void unifiedInitializationCreatesFinalGuardsAndColumnsWithoutMigrationWindow() throws Exception {
|
||||
String sql = unifiedMigration();
|
||||
assertSafeWindow(sql, true);
|
||||
assertNoDuplicateInboundColumns(sql);
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE fl_inbound_order")
|
||||
.contains("warehouse_id bigint unsigned NOT NULL")
|
||||
.contains("warehouse_name varchar(128) NOT NULL")
|
||||
.contains("channel_code varchar(64) NOT NULL")
|
||||
.contains("channel_name varchar(128) NOT NULL")
|
||||
.contains("CREATE TRIGGER trg_container_booking_before_update")
|
||||
.contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据")
|
||||
.contains("CREATE TRIGGER trg_loading_pick_task_before_update")
|
||||
.contains("已完成PDA拣货任务不可修改")
|
||||
.doesNotContain("migrate_phase3_channel_containers")
|
||||
.doesNotContain("assert_phase3_channel_container_migration");
|
||||
}
|
||||
|
||||
private static void assertNoDuplicateInboundColumns(String sql) {
|
||||
|
||||
+57
-338
@@ -5,370 +5,89 @@ import org.junit.jupiter.api.Test;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Phase3FinalUnifiedSqlContractTest {
|
||||
private static final Path UNIFIED = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
private static final Pattern CREATE_TABLE = Pattern.compile(
|
||||
"(?i)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(?:`[^`]+`\\.)?`?([a-zA-Z0-9_]+)`?\\s*\\(");
|
||||
private static final Pattern COLUMN = Pattern.compile(
|
||||
"(?i)^\\s*`?([a-zA-Z0-9_]+)`?\\s+(bigint|binary|bit|blob|boolean|char|date|datetime|decimal|double|enum|float|int|integer|json|longblob|longtext|mediumblob|mediumint|mediumtext|numeric|real|set|smallint|text|time|timestamp|tinyblob|tinyint|tinytext|varbinary|varchar|year)\\b");
|
||||
private static final Pattern CHINESE_COMMENT = Pattern.compile(
|
||||
"(?i)\\bCOMMENT\\s*=?\\s*'(?:(?:''|\\\\'|[^'])*[\\u3400-\\u9fff](?:''|\\\\'|[^'])*)'");
|
||||
|
||||
@Test
|
||||
void unifiedEntryOwnsPhase3OutboundCustomsPortAndEvidenceHardening() throws Exception {
|
||||
String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8);
|
||||
void unifiedEntryCreatesTheFinalPhase3SchemaDirectly() throws Exception {
|
||||
String sql = sql();
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE fl_inbound_split_batch")
|
||||
.contains("CREATE TABLE fl_ocean_booking")
|
||||
.contains("CREATE TABLE fl_truck_appointment")
|
||||
.contains("CREATE TABLE fl_outbound_dispatch")
|
||||
.contains("CREATE TABLE fl_customs_container")
|
||||
.contains("CREATE TABLE fl_port_schedule")
|
||||
.contains("CREATE TABLE fl_carrier_schedule_query")
|
||||
.contains("CREATE TABLE fl_terminal_drayage")
|
||||
.contains("CREATE TABLE fl_loading_pick_assignment")
|
||||
.contains("CREATE TABLE fl_loading_pick_item_assignment")
|
||||
.contains("CREATE TABLE fl_loading_pick_review")
|
||||
.contains("CREATE TABLE fl_loading_pick_operation")
|
||||
.contains("CREATE TABLE fl_loading_cabinet_evidence")
|
||||
.contains("CREATE TABLE fl_container_fee_detail")
|
||||
.doesNotContain("phase3_order_add_column")
|
||||
.doesNotContain("assert_phase3_final_schema")
|
||||
.doesNotContain("-- 来源:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedEntryKeepsFinalConstraintsAndImmutableTriggers() throws Exception {
|
||||
String sql = sql();
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_customs_container")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_customs_completion_document")
|
||||
.contains("terminal_trucking_company_id BIGINT UNSIGNED NULL")
|
||||
.contains("phase3_container_supplier_snapshot_add_column")
|
||||
.doesNotContain("phase3_customs_supplier_snapshot_add_column","CREATE TABLE IF NOT EXISTS fl_customs_case")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail")
|
||||
.contains("trg_inbound_split_detail_before_insert")
|
||||
.contains("trg_inbound_split_detail_before_update")
|
||||
.contains("trg_inbound_split_detail_before_delete")
|
||||
.contains("CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH', 'PICK_PALLET', 'COMPLETE_LOADING'))")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence")
|
||||
.contains("UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)")
|
||||
.contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)")
|
||||
.contains("normalize_loading_cabinet_evidence_collation")
|
||||
.contains("table_collation<>'utf8mb4_0900_ai_ci'")
|
||||
.contains("column_name IN ('container_booking_id','source_task_id','active_flag','operation_key','request_hash')")
|
||||
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_evidence", "phase3_loading_evidence_add_column")
|
||||
.contains("CALL assert_phase3_final_schema()")
|
||||
.contains("trg_port_schedule_history_before_update")
|
||||
.contains("trg_port_schedule_history_before_delete")
|
||||
.contains("trg_loading_pick_operation_before_delete");
|
||||
.contains("UNIQUE KEY uk_inbound_one_active_remainder")
|
||||
.contains("UNIQUE KEY uk_inbound_split_source_once")
|
||||
.contains("CREATE TRIGGER trg_inbound_split_detail_before_insert")
|
||||
.contains("CREATE TRIGGER trg_inbound_split_detail_before_update")
|
||||
.contains("CREATE TRIGGER trg_inbound_split_detail_before_delete")
|
||||
.contains("CREATE TRIGGER trg_port_schedule_history_before_update")
|
||||
.contains("CREATE TRIGGER trg_port_schedule_history_before_delete")
|
||||
.contains("CREATE TRIGGER trg_loading_pick_operation_before_delete")
|
||||
.doesNotContain("UNIQUE KEY uk_inbound_one_child")
|
||||
.doesNotContain("UNIQUE KEY uk_inbound_one_secondary");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedOceanBookingBlockIncludesExistingSchemaReconciliation() throws Exception {
|
||||
String sql = normalise(Files.readString(UNIFIED));
|
||||
assertThat(sql)
|
||||
.contains("DROP CHECK chk_ocean_booking_status")
|
||||
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK")
|
||||
.contains("DROP COLUMN failure_reason")
|
||||
.contains("(status IN ('DRAFT','RELEASED'))")
|
||||
.contains("platform:firstleg:ocean-booking:edit")
|
||||
.contains("platform:firstleg:ocean-booking:delete")
|
||||
.doesNotContain("reconcile_ocean_booking_legacy")
|
||||
.doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionRangesDoNotOverlapAndBothPlatformRolesAreGranted() throws Exception {
|
||||
String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8);
|
||||
|
||||
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
for (int id : java.util.List.of(5102170,5102172)) assertThat(sql).contains(Integer.toString(id));
|
||||
assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)")
|
||||
.doesNotContain("(5102171,0,'PLATFORM','BUTTON'","(5102173,0,'PLATFORM','BUTTON'",
|
||||
"(5102174,0,'PLATFORM','BUTTON'","(5102175,0,'PLATFORM','BUTTON'");
|
||||
for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
for (int id = 5102190; id <= 5102193; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
assertThat(sql)
|
||||
.contains("platform:firstleg:outbound-dispatch:%")
|
||||
.contains("platform:firstleg:customs:%")
|
||||
.contains("platform:firstleg:port-schedule:%")
|
||||
.contains("id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3")
|
||||
.contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 28")
|
||||
.contains("platform:firstleg:pda:%")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
|
||||
.contains("角色授权未完整写入");
|
||||
}
|
||||
|
||||
@Test
|
||||
void phase3ModuleSqlBlocksAreContiguousAndSynchronised() throws Exception {
|
||||
String unified = normalise(Files.readString(resolveUnified()));
|
||||
assertBlock(unified, "-- 来源:order/src/main/resources/db/phase3-order-parent-child-schema.sql",
|
||||
"-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql",
|
||||
"order/src/main/resources/db/phase3-order-parent-child-schema.sql");
|
||||
assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql",
|
||||
"-- 来源:firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql",
|
||||
"firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql");
|
||||
assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql",
|
||||
"-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql",
|
||||
"firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql");
|
||||
assertBlock(unified, "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql",
|
||||
"-- 入库完成后的唯一状态为 WAIT_SPLIT。",
|
||||
"iam/src/main/resources/db/phase3-warehouse-roles.sql");
|
||||
}
|
||||
|
||||
@Test
|
||||
void customsBaselineIsUniqueSynchronisedAndRunsBeforeFinalSchemaAssertion() throws Exception {
|
||||
String unified = normalise(Files.readString(resolveUnified()));
|
||||
String marker = "-- 来源:firstleg/src/main/resources/db/phase3-container-customs-schema.sql";
|
||||
String finalAssertion = "CREATE PROCEDURE assert_phase3_final_schema()";
|
||||
List<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));
|
||||
void unifiedEntrySeedsCanonicalPermissionsAndBothPlatformRoles() throws Exception {
|
||||
String sql = sql();
|
||||
for (int id : new int[] {5102140, 5102149, 5102150, 5102154, 5102160, 5102164,
|
||||
5102170, 5102172, 5102180, 5102183, 5102190, 5102193}) {
|
||||
assertThat(sql).contains(Integer.toString(id));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void financeFeeSourceMigrationsAreContiguousAndSynchronised() throws Exception {
|
||||
String unified = normalise(Files.readString(resolveUnified()));
|
||||
assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql",
|
||||
"-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql",
|
||||
"firstleg/src/main/resources/db/finance-fee-source-migration.sql");
|
||||
assertBlock(unified, "-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql",
|
||||
"-- END FINANCE FEE SOURCE MODULE MIGRATIONS",
|
||||
"finance/src/main/resources/db/fee-source-event-migration.sql");
|
||||
|
||||
assertThat(unified)
|
||||
.contains("column_name IN ('source_type','source_no','direction','container_id','fee_item_code',")
|
||||
.contains("column_name IN ('payload_hash','result_resource_id','error_code')")
|
||||
.contains("column_name IN ('event_key', 'event_version', 'aggregate_sequence', 'payload_hash',")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_container_fee_detail");
|
||||
}
|
||||
|
||||
@Test
|
||||
void commentUpgradeIsIdempotentAndGuardsNineSchemaStructure() throws Exception {
|
||||
String sql = normalise(Files.readString(resolveUnified()));
|
||||
|
||||
assertThat(sql)
|
||||
.contains("CREATE PROCEDURE queue_table_comment")
|
||||
.contains("CREATE PROCEDURE queue_column_comment")
|
||||
.contains("CREATE PROCEDURE apply_one_table_comments")
|
||||
.contains("CREATE PROCEDURE apply_queued_comments")
|
||||
.contains("NOT (BINARY v_current_comment=BINARY p_column_comment)")
|
||||
.contains("SET v_signature_before=SHA2")
|
||||
.contains("REPLACE(v_generation_expression,CONCAT(CHAR(92),CHAR(39)),CHAR(39))")
|
||||
.contains("BINARY target.signature_before")
|
||||
.contains("字段注释升级遇到未知数据类型")
|
||||
.contains("字段注释升级遇到未知EXTRA定义")
|
||||
.contains("字段注释升级遇到未知生成列结构")
|
||||
.contains("MySQL DDL 会隐式提交")
|
||||
.contains("不使用 GROUP_CONCAT")
|
||||
.contains("DECLARE table_cursor CURSOR FOR")
|
||||
.contains("CALL apply_one_table_comments(v_schema_name,v_table_name)")
|
||||
.contains("CALL apply_queued_comments();")
|
||||
.contains("PREPARE table_comment_batch_stmt FROM @table_comment_batch_sql")
|
||||
.contains("九个业务数据库未完整创建")
|
||||
.contains("存在缺失中文注释的业务表")
|
||||
.contains("存在缺失中文注释的业务字段")
|
||||
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
|
||||
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(147);
|
||||
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2374);
|
||||
assertThat(sql)
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_category'")
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'container_booking_id'");
|
||||
assertThat(sql.indexOf("CALL apply_queued_comments();",
|
||||
sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")))
|
||||
.isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"));
|
||||
.contains("platform:firstleg:ocean-booking:list")
|
||||
.contains("platform:firstleg:truck-appointment:list")
|
||||
.contains("platform:firstleg:outbound-dispatch:list")
|
||||
.contains("platform:firstleg:customs:list")
|
||||
.contains("platform:firstleg:port-schedule:list")
|
||||
.contains("platform:firstleg:pda:view")
|
||||
.contains("VALUES (4000001,5102190,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("VALUES (4000003,5102190,0,CURRENT_TIMESTAMP(3))");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedCreateDefinitionsHaveChineseTableAndColumnComments() throws Exception {
|
||||
String sql = normalise(Files.readString(resolveUnified()));
|
||||
List<String> issues = new ArrayList<>();
|
||||
Matcher tableMatcher = CREATE_TABLE.matcher(sql);
|
||||
int tableCount = 0;
|
||||
int columnCount = 0;
|
||||
while (tableMatcher.find()) {
|
||||
tableCount++;
|
||||
String table = tableMatcher.group(1);
|
||||
int open = tableMatcher.end() - 1;
|
||||
int close = matchingParen(sql, open);
|
||||
int semicolon = sql.indexOf(';', close);
|
||||
assertThat(semicolon).as("missing semicolon for %s", table).isGreaterThan(close);
|
||||
if (!CHINESE_COMMENT.matcher(sql.substring(close + 1, semicolon)).find()) {
|
||||
issues.add(table + ": table comment");
|
||||
}
|
||||
for (String definition : splitDefinitions(sql.substring(open + 1, close))) {
|
||||
Matcher columnMatcher = COLUMN.matcher(definition);
|
||||
if (!columnMatcher.find()) continue;
|
||||
columnCount++;
|
||||
if (!table.startsWith("fl_customs_") && !CHINESE_COMMENT.matcher(definition).find()) {
|
||||
issues.add(table + "." + columnMatcher.group(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(tableCount).isGreaterThan(100);
|
||||
assertThat(columnCount).isGreaterThan(2000);
|
||||
assertThat(issues).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void passIsUniqueAndOnlyTheFinalExecutableStatement() throws Exception {
|
||||
String sql = normalise(Files.readString(resolveUnified()));
|
||||
String pass = "SELECT 'PASS' AS migration_status";
|
||||
void initializationPassIsUniqueAndFinal() throws Exception {
|
||||
String sql = sql();
|
||||
String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 208 AS initialized_tables;";
|
||||
assertThat(count(sql, pass)).isEqualTo(1);
|
||||
int passIndex = sql.lastIndexOf(pass);
|
||||
assertThat(passIndex).isGreaterThan(sql.lastIndexOf("USE "))
|
||||
.isGreaterThan(sql.lastIndexOf("ALTER TABLE "))
|
||||
.isGreaterThan(sql.lastIndexOf("CREATE TABLE "))
|
||||
.isGreaterThan(sql.lastIndexOf("CALL "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedEntryUsesMysql8CompatibleConditionalDdl() throws Exception {
|
||||
String sql = normalise(Files.readString(resolveUnified()));
|
||||
|
||||
assertThat(sql.trim()).endsWith(pass);
|
||||
assertThat(sql)
|
||||
.doesNotContain("\n ADD COLUMN IF NOT EXISTS")
|
||||
.doesNotContain("\n DROP CHECK IF EXISTS")
|
||||
.doesNotContain("\n+--")
|
||||
.doesNotContain("\n---")
|
||||
.doesNotContain("\n>>>")
|
||||
.doesNotContain("\n<<<")
|
||||
.contains("CREATE PROCEDURE phase3_pda_add_column")
|
||||
.contains("CREATE PROCEDURE phase3_pda_replace_operation_check");
|
||||
.doesNotContain("SELECT 'PASS' AS migration_status")
|
||||
.doesNotContain("ON DUPLICATE KEY UPDATE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedPdaBlockDropsLegacyPerItemReleaseColumns() throws Exception {
|
||||
String sql = normalise(Files.readString(resolveUnified()));
|
||||
|
||||
assertThat(sql)
|
||||
.contains("column_name='release_location_ids'")
|
||||
.contains("ALTER TABLE fl_loading_pick_item DROP COLUMN release_location_ids")
|
||||
.contains("column_name='release_mode'")
|
||||
.contains("ALTER TABLE fl_loading_pick_item DROP COLUMN release_mode")
|
||||
.doesNotContain("'fl_loading_pick_item', 'release_mode'")
|
||||
.doesNotContain("'fl_loading_pick_item', 'release_location_ids'")
|
||||
.doesNotContain("CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'release_mode'")
|
||||
.doesNotContain("CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'release_location_ids'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacySplitIndexesAreOnlyDroppedAndNeverRecreated() throws Exception {
|
||||
String sql = normalise(Files.readString(resolveUnified()));
|
||||
assertThat(sql)
|
||||
.contains("DROP INDEX uk_inbound_one_child")
|
||||
.contains("DROP INDEX uk_inbound_one_secondary")
|
||||
.doesNotContain("UNIQUE KEY `uk_inbound_one_child`")
|
||||
.doesNotContain("UNIQUE KEY `uk_inbound_one_secondary`")
|
||||
.doesNotContain("UNIQUE KEY `uk_inbound_split_batch_source_item`");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedEntryEnforcesSingleRemainderWritesAndKeepsLegacyColumns() throws Exception {
|
||||
String sql = normalise(Files.readString(resolveUnified()));
|
||||
|
||||
assertThat(sql)
|
||||
.contains("'fl_inbound_order', 'active_remainder_root_id'")
|
||||
.contains("uk_inbound_one_active_remainder")
|
||||
.contains("uk_inbound_split_source_once")
|
||||
.contains("NEW.loaded_child_inbound_id IS NOT NULL")
|
||||
.contains("NEW.child_inbound_id = batch.remainder_child_inbound_id")
|
||||
.contains("source_order.package_count = NEW.loaded_package_count")
|
||||
.contains("根入仓单原始箱数与已完成分片及当前余量不守恒")
|
||||
.contains("'fl_inbound_split_batch', 'loaded_child_inbound_id'")
|
||||
.contains("fk_inbound_split_loaded_child");
|
||||
}
|
||||
|
||||
private static void assertBlock(String unified, String marker, String nextMarker, String modulePath)
|
||||
throws Exception {
|
||||
int start = unified.indexOf(marker);
|
||||
int next = unified.indexOf(nextMarker, start + marker.length());
|
||||
assertThat(start).as("missing marker %s", marker).isGreaterThanOrEqualTo(0);
|
||||
assertThat(next).as("missing next marker %s", nextMarker).isGreaterThan(start);
|
||||
String module = normalise(Files.readString(resolve(modulePath))).trim();
|
||||
String block = unified.substring(start + marker.length(), next).trim();
|
||||
assertThat(block).as("module block drift: %s", modulePath)
|
||||
.startsWith(module);
|
||||
}
|
||||
|
||||
private static Path resolveUnified() {
|
||||
return resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
}
|
||||
|
||||
private static Path resolve(String path) {
|
||||
Path direct = Path.of(path);
|
||||
if (Files.exists(direct)) return direct;
|
||||
return Path.of("..", path).normalize();
|
||||
}
|
||||
|
||||
private static String normalise(String value) {
|
||||
return value.replace("\r\n", "\n").replace("\uFEFF", "");
|
||||
private static String sql() throws Exception {
|
||||
return Files.readString(UNIFIED, StandardCharsets.UTF_8)
|
||||
.replace("\r\n", "\n")
|
||||
.replace("\uFEFF", "");
|
||||
}
|
||||
|
||||
private static int count(String value, String token) {
|
||||
return value.split(java.util.regex.Pattern.quote(token), -1).length - 1;
|
||||
}
|
||||
|
||||
private static int matchingParen(String sql, int open) {
|
||||
int depth = 0;
|
||||
char quote = 0;
|
||||
for (int index = open; index < sql.length(); index++) {
|
||||
char current = sql.charAt(index);
|
||||
if (quote != 0) {
|
||||
if (current == '\\') index++;
|
||||
else if (current == quote) {
|
||||
if (index + 1 < sql.length() && sql.charAt(index + 1) == quote) index++;
|
||||
else quote = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current == '\'' || current == '"' || current == '`') quote = current;
|
||||
else if (current == '(') depth++;
|
||||
else if (current == ')' && --depth == 0) return index;
|
||||
}
|
||||
throw new AssertionError("unclosed CREATE TABLE at " + open);
|
||||
}
|
||||
|
||||
private static List<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;
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ServiceCenterUnifiedSqlContractTest {
|
||||
@Test
|
||||
void unifiedMigrationContainsServiceCenterSchemaAndGrants() throws Exception {
|
||||
void unifiedInitializationContainsServiceCenterSchemaAndGrants() throws Exception {
|
||||
Path working = Path.of(System.getProperty("user.dir"));
|
||||
Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
if (!Files.exists(path)) {
|
||||
@@ -19,24 +19,24 @@ class ServiceCenterUnifiedSqlContractTest {
|
||||
String sql = Files.readString(path, StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_party")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_history")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation")
|
||||
.contains("CREATE TABLE fl_ocean_booking")
|
||||
.contains("CREATE TABLE fl_ocean_booking_party")
|
||||
.contains("CREATE TABLE fl_ocean_booking_container_requirement")
|
||||
.contains("CREATE TABLE fl_ocean_booking_container_allocation")
|
||||
.contains("CREATE TABLE fl_ocean_booking_status_history")
|
||||
.contains("CREATE TABLE fl_ocean_booking_operation")
|
||||
.contains("CREATE TABLE fl_truck_appointment")
|
||||
.contains("CREATE TABLE fl_truck_appointment_history")
|
||||
.contains("CREATE TABLE fl_truck_appointment_attachment")
|
||||
.contains("CREATE TABLE fl_truck_appointment_operation")
|
||||
.contains("5102140")
|
||||
.contains("5102149")
|
||||
.contains("5102150")
|
||||
.contains("5102154")
|
||||
.contains("PLATFORM_ADMIN")
|
||||
.contains("ORDER_OPERATOR")
|
||||
.contains("统一迁移失败:订舱或拖车预约角色授权未完整写入")
|
||||
.contains("assert_phase3_service_center_schema()")
|
||||
.contains("CALL assert_phase3_service_center_schema();");
|
||||
.contains("VALUES (4000001,5102140,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("VALUES (4000003,5102140,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("VALUES (4000001,5102150,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("VALUES (4000003,5102150,0,CURRENT_TIMESTAMP(3))")
|
||||
.doesNotContain("assert_phase3_service_center_schema");
|
||||
}
|
||||
}
|
||||
|
||||
+73
-377
@@ -7,406 +7,102 @@ import org.springframework.core.io.ClassPathResource;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("智能装柜SQL契约")
|
||||
class SmartLoadingSqlContractTest {
|
||||
private static final Pattern PERMISSION_PATTERN = Pattern.compile("'((?:platform:firstleg:)[^']+)'");
|
||||
private static final Set<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
|
||||
@DisplayName("平台聚合与客户明细边界及原型快照字段完整")
|
||||
void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_document_sequence")
|
||||
void moduleMigrationStillProtectsExistingDatabaseUpgrades() throws Exception {
|
||||
String sql = moduleMigration();
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_container_booking")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_container_document")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_history")
|
||||
.contains("account_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("customer_name VARCHAR(128) NOT NULL")
|
||||
.contains("warehouse_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("cabinet_no VARCHAR(64) NOT NULL")
|
||||
.contains("bill_of_lading_no VARCHAR(128)")
|
||||
.contains("destination_warehouse_name VARCHAR(128)")
|
||||
.contains("product_name_zh VARCHAR(255)")
|
||||
.contains("product_name_en VARCHAR(255)")
|
||||
.contains("product_image_file_id BIGINT UNSIGNED")
|
||||
.contains("pallet_count INT UNSIGNED")
|
||||
.contains("net_weight_kg DECIMAL(18,3)")
|
||||
.contains("dimensional_weight_kg DECIMAL(18,3)")
|
||||
.contains("product_quantity INT UNSIGNED")
|
||||
.contains("units_per_carton INT UNSIGNED")
|
||||
.contains("fba_tracking_no VARCHAR(128)")
|
||||
.contains("shipment_no VARCHAR(128)")
|
||||
.contains("shipping_mark VARCHAR(500)")
|
||||
.contains("internal_remark VARCHAR(1000)")
|
||||
.contains("customer_remark VARCHAR(1000)")
|
||||
.contains("warehouse_remark VARCHAR(1000)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("活动明细唯一锁、乐观锁和操作幂等字段完整")
|
||||
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
|
||||
assertContainerLifecycleSchema(migration());
|
||||
assertContainerLifecycleSchema(unifiedMigration());
|
||||
assertThat(migration())
|
||||
.contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS")
|
||||
.contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END")
|
||||
.contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS")
|
||||
.contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)")
|
||||
.contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)")
|
||||
.contains("UNIQUE KEY uk_container_booking_no (booking_no)")
|
||||
.contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)")
|
||||
.contains("UNIQUE KEY uk_loading_history_operation (operation_id)")
|
||||
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("存量货柜迁移补齐BaseMapper所需客户快照列")
|
||||
void shouldMigrateContainerCustomerSnapshotColumns() throws Exception {
|
||||
for (String sql : Set.of(migration(), unifiedMigration())) {
|
||||
assertThat(sql)
|
||||
.contains("ADD COLUMN customer_id BIGINT UNSIGNED NULL")
|
||||
.contains("ADD COLUMN customer_name VARCHAR(128) NULL")
|
||||
.contains("JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id")
|
||||
.contains("container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id)")
|
||||
.contains("container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一入库明细的多条单箱测量记录只生成一个候选")
|
||||
void shouldAggregateCartonMeasurementRowsByInboundDetail() throws Exception {
|
||||
String mapper = Files.readString(Path.of(
|
||||
"src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java"), StandardCharsets.UTF_8);
|
||||
assertThat(mapper)
|
||||
.contains("SUM(package_count) package_count")
|
||||
.contains("GROUP BY tenant_id,measurement_id,inbound_detail_id")
|
||||
.contains("split_item.source_inbound_detail_id=i.inbound_detail_id")
|
||||
.doesNotContain("split_item.source_measurement_item_id=i.id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("全局候选分页具备平台维度和关联明细索引")
|
||||
void shouldProvideGlobalCandidatePagingIndexes() throws Exception {
|
||||
assertThat(new ClassPathResource("db/phase2-inbound-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8))
|
||||
.contains("idx_inbound_smart_loading (warehouse_id, process_status, channel_code, expected_arrival_at, id)")
|
||||
.contains("idx_measure_inbound_status_id (tenant_id, inbound_id, status, id)")
|
||||
.contains("idx_measure_item_candidate (tenant_id, measurement_id, inbound_detail_id)");
|
||||
assertThat(migration())
|
||||
.contains("idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted)");
|
||||
String unified = unifiedMigration();
|
||||
assertThat(unified)
|
||||
.contains("CREATE PROCEDURE baiyue_firstleg.ensure_smart_loading_candidate_indexes()")
|
||||
.contains("ADD KEY idx_inbound_smart_loading")
|
||||
.contains("ADD KEY idx_measure_inbound_status_id")
|
||||
.contains("ADD KEY idx_measure_item_candidate")
|
||||
.contains("ADD KEY idx_loading_item_candidate")
|
||||
.contains("ADD KEY idx_inbound_split_candidate");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("可编辑备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
|
||||
void shouldUpdateEditableRemarksWithoutMultiTableTriggerConflict() throws Exception {
|
||||
String mapper = Files.readString(Path.of(
|
||||
"src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java"), StandardCharsets.UTF_8);
|
||||
assertThat(mapper)
|
||||
.contains("UPDATE fl_loading_plan_item i")
|
||||
.contains("p.version=#{touchedPlanVersion}")
|
||||
.contains("p.plan_status IN ('EDITING','LOADING')")
|
||||
.contains("i.frozen_at IS NULL")
|
||||
.doesNotContain("UPDATE fl_loading_plan_item i JOIN fl_loading_plan p")
|
||||
.doesNotContain("p.version=p.version+1");
|
||||
assertEditableRemarksTriggerContract(migration());
|
||||
assertEditableRemarksTriggerContract(unifiedMigration());
|
||||
}
|
||||
|
||||
private static void assertContainerLifecycleSchema(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'")
|
||||
.contains("record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'")
|
||||
.contains("merged_into_container_booking_id BIGINT UNSIGNED NULL")
|
||||
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
|
||||
.contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)")
|
||||
.contains("CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING'))")
|
||||
.contains("CHECK (record_status IN ('ACTIVE','MERGED'))")
|
||||
.contains("column_name='source_type'")
|
||||
.contains("column_name='record_status'")
|
||||
.contains("column_name='merged_into_container_booking_id'");
|
||||
}
|
||||
|
||||
private static void assertEditableRemarksTriggerContract(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("IF NOT (NEW.internal_remark <=> OLD.internal_remark)")
|
||||
.contains("OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)")
|
||||
.contains("OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN")
|
||||
.contains("IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted")
|
||||
.contains("已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段")
|
||||
.contains("ELSEIF NOT EXISTS (")
|
||||
.contains("IF OLD.frozen_at IS NOT NULL THEN")
|
||||
.contains("装柜完成后的明细永久冻结");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("货柜简单文件只保留目的港清关与DO,PU改为数字码")
|
||||
void shouldAllowExactlyTwoSimpleContainerDocumentTypes() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER'")
|
||||
.contains("pickup_code VARCHAR(64) NULL")
|
||||
.contains("file_hash VARCHAR(64) NULL")
|
||||
.contains("completed_by BIGINT UNSIGNED NULL")
|
||||
.contains("completed_at DATETIME(3) NULL");
|
||||
assertThat(unifiedMigration())
|
||||
.contains("pickup_code VARCHAR(64) NULL")
|
||||
.contains("pickup_code_updated_by BIGINT UNSIGNED NULL")
|
||||
.contains("pickup_code_updated_by_name VARCHAR(64) NULL")
|
||||
.contains("pickup_code_updated_at DATETIME(3) NULL")
|
||||
.contains("chk_container_booking_pickup_code")
|
||||
.contains("CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$')");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色")
|
||||
void shouldProvidePlatformPermissions() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("platform:firstleg:container:view")
|
||||
.doesNotContain("'platform:firstleg:smart-loading:push', '推送预装'")
|
||||
.contains("platform:firstleg:preload:confirm")
|
||||
.contains("platform:firstleg:loading-list:view")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
|
||||
.contains("智能装柜迁移失败:平台权限资源未完整创建")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history")
|
||||
.contains("migrate_legacy_loading_pick_task")
|
||||
.contains("assert_phase3_smart_loading_schema")
|
||||
.contains("智能装柜迁移失败:平台角色授权未完整写入");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("模块脚本和统一脚本均校验11项权限与两个角色22项授权")
|
||||
void shouldAssertCompletePermissionGrants() throws Exception {
|
||||
assertPermissionContract(migration());
|
||||
assertPermissionContract(unifiedMigration());
|
||||
assertThat(unifiedMigration())
|
||||
.contains("CALL migrate_legacy_loading_pick_task()")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task")
|
||||
.contains("5102121, 0, 'PLATFORM'")
|
||||
.contains("5102129, 0, 'PLATFORM'")
|
||||
.contains("三期入库装柜角色授权未完整写入")
|
||||
.contains("permission.permission_code IN (")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
|
||||
.contains("'firstleg:inbound:split', 'firstleg:inbound:preload'")
|
||||
.contains(")) <> 18 THEN");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划状态只允许编辑中、装柜中和已完成单向推进")
|
||||
void shouldEnforceOneWayStatusAndPermanentFreeze() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))")
|
||||
.contains("OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')")
|
||||
.contains("NEW.plan_status IN ('LOADING', 'COMPLETED')")
|
||||
.contains("OLD.plan_status = 'LOADING' AND NEW.plan_status = 'EDITING'")
|
||||
.doesNotContain("旧版本在推送预装时即写入 LOADING")
|
||||
.doesNotContain("修复旧版本“推送预装即拣货中”的状态污染")
|
||||
.contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'")
|
||||
.contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
|
||||
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
|
||||
.contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
|
||||
.contains("装柜完成后的明细永久冻结")
|
||||
.contains("current_plan_status IN ('LOADING', 'COMPLETED')")
|
||||
.contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据")
|
||||
.doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)")
|
||||
.doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)")
|
||||
.doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)")
|
||||
.doesNotContain("preload_status")
|
||||
.doesNotContain("PRELOAD_PUSHED")
|
||||
.doesNotContain("PRELOADING")
|
||||
.doesNotContain("PRELOAD_COMPLETED")
|
||||
.doesNotContain("CONFIRMED")
|
||||
.doesNotContain("ROLLED_BACK")
|
||||
.doesNotContain("WITHDRAWN");
|
||||
assertLoadingCompletionTrigger(migration());
|
||||
assertLoadingCompletionTrigger(unifiedMigration());
|
||||
assertThat(unifiedMigration().indexOf("CALL phase3_add_container_channel_group()"))
|
||||
.isLessThan(unifiedMigration().indexOf("CREATE TRIGGER trg_container_booking_before_update"));
|
||||
}
|
||||
|
||||
private static void assertLoadingCompletionTrigger(String sql) {
|
||||
int triggerStart = sql.indexOf("CREATE TRIGGER trg_loading_plan_before_update");
|
||||
int triggerEnd = sql.indexOf("DROP TRIGGER IF EXISTS trg_loading_plan_after_update", triggerStart);
|
||||
assertThat(triggerStart).isGreaterThanOrEqualTo(0);
|
||||
assertThat(triggerEnd).isGreaterThan(triggerStart);
|
||||
String beforeUpdateTrigger = sql.substring(triggerStart, triggerEnd);
|
||||
assertThat(beforeUpdateTrigger)
|
||||
.contains("AND NOT EXISTS (")
|
||||
.contains("pick_task.loading_plan_id = OLD.id")
|
||||
.doesNotContain("pick_task.task_status")
|
||||
.contains("pick_item.item_status = 'PICKED'")
|
||||
.contains("pick_item.item_status = 'PARTIAL'")
|
||||
.contains("pick_item.picked_package_count > 0")
|
||||
.contains("pick_item.item_status = 'SKIPPED'")
|
||||
.contains("pick_item.picked_package_count = 0")
|
||||
.doesNotContain("pick_item.picked_package_count <> item.package_count");
|
||||
assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql,
|
||||
"pick_item.picked_package_count = item.package_count")).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDA三表、唯一索引、向上取整和不可变审计契约完整")
|
||||
void shouldProvidePdaContract() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task")
|
||||
.contains("CALL migrate_legacy_loading_pick_task()")
|
||||
.contains("source_total_columns = 13 AND source_current_columns = 13")
|
||||
.contains("index_name = 'uk_loading_pick_task_booking'")
|
||||
.contains("RENAME TABLE fl_loading_pick_task TO fl_inbound_loading_pick_task")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history")
|
||||
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
|
||||
.contains("UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id)")
|
||||
.contains("UNIQUE KEY uk_loading_pick_history_operation (operation_id)")
|
||||
.contains("上层拆分按业务规则向上取整")
|
||||
.contains("CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0)")
|
||||
.contains("CALL repair_phase3_loading_pick_task_foreign_keys()")
|
||||
.contains("GROUP_CONCAT(CONCAT('DROP FOREIGN KEY `'")
|
||||
.contains("item_fk_count <> 1 OR item_target_count <> 1")
|
||||
.contains("history_fk_count <> 1 OR history_target_count <> 1")
|
||||
.contains("FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id)")
|
||||
.contains("referenced_column_name = 'id'")
|
||||
.contains("柜次PDA明细任务外键不唯一或指向错误")
|
||||
.contains("柜次PDA历史任务外键不唯一或指向错误")
|
||||
.doesNotContain("DROP FOREIGN KEY fk_loading_pick_item_task")
|
||||
.doesNotContain("DROP FOREIGN KEY fk_loading_pick_history_task")
|
||||
.contains("trg_loading_pick_task_before_update")
|
||||
.contains("trg_loading_pick_item_before_update")
|
||||
.contains("trg_loading_pick_item_before_delete")
|
||||
.contains("trg_loading_pick_history_before_update")
|
||||
.contains("trg_loading_pick_history_before_delete")
|
||||
.contains("PDA唯一索引缺失")
|
||||
.contains(")) <> 16 THEN");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("统一脚本只定义并调用一次智能装柜断言")
|
||||
void shouldDefineSmartLoadingAssertionOnce() throws Exception {
|
||||
String unified = unifiedMigration();
|
||||
assertThat(count(unified, "CREATE PROCEDURE assert_phase3_smart_loading_schema()"))
|
||||
.isEqualTo(1);
|
||||
assertThat(count(unified, "CALL assert_phase3_smart_loading_schema();"))
|
||||
.isEqualTo(1);
|
||||
assertThat(count(unified, "SELECT 'PASS' AS migration_status"))
|
||||
.isEqualTo(1);
|
||||
int relocation = unified.indexOf("CALL unified_relocate_inbound_loading_permissions();");
|
||||
int assertion = unified.indexOf("CALL assert_phase3_smart_loading_schema();");
|
||||
assertThat(relocation).isGreaterThan(0);
|
||||
assertThat(assertion).isGreaterThan(relocation);
|
||||
assertThat(unified.substring(relocation, assertion))
|
||||
.contains("tmp_phase3_inbound_role_grant")
|
||||
.contains("DROP TEMPORARY TABLE IF EXISTS tmp_phase3_inbound_role_grant")
|
||||
.contains("5102121, 0, 'PLATFORM'")
|
||||
.contains("5102129, 0, 'PLATFORM'")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("详细拣货和入库汇总拣货使用独立完整表型")
|
||||
void shouldKeepDetailedAndInboundPickTaskShapesSeparate() throws Exception {
|
||||
String unified = unifiedMigration();
|
||||
assertThat(unified)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task (")
|
||||
.contains("loading_plan_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("container_booking_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("cabinet_no VARCHAR(64) NOT NULL")
|
||||
.contains("container_no VARCHAR(64) NULL")
|
||||
.contains("task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'")
|
||||
.contains("column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)'")
|
||||
void unifiedInitializationCreatesSeparateLoadingAndInboundTaskShapes() throws Exception {
|
||||
String sql = unified();
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE fl_loading_pick_task")
|
||||
.contains("loading_plan_id bigint unsigned NOT NULL")
|
||||
.contains("container_booking_id bigint unsigned NOT NULL")
|
||||
.contains("task_status varchar(24) NOT NULL DEFAULT 'LOADING'")
|
||||
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
|
||||
.contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)")
|
||||
.contains("KEY idx_loading_pick_container (container_no, task_status, started_at)")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task (")
|
||||
.contains("tenant_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("inbound_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("expected_count INT UNSIGNED NULL")
|
||||
.contains("picked_count INT UNSIGNED NULL")
|
||||
.contains("UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id, inbound_id)")
|
||||
.contains("KEY idx_loading_pick_status (tenant_id, status, updated_at)");
|
||||
.contains("CREATE TABLE fl_inbound_loading_pick_task")
|
||||
.contains("tenant_id bigint unsigned NOT NULL")
|
||||
.contains("inbound_id bigint unsigned NOT NULL")
|
||||
.contains("UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id,inbound_id)")
|
||||
.contains("KEY idx_loading_pick_status (tenant_id,status,updated_at)")
|
||||
.doesNotContain("migrate_legacy_loading_pick_task");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("迁移拒绝缺列错类型和混合拣货表型")
|
||||
void shouldRejectMalformedLegacyPickTaskShapes() throws Exception {
|
||||
assertMalformedShapeGuards(migration());
|
||||
assertMalformedShapeGuards(unifiedMigration());
|
||||
void unifiedInitializationCreatesFinalConcurrencyAndCandidateIndexes() throws Exception {
|
||||
String sql = unified();
|
||||
assertThat(sql)
|
||||
.contains("active_inbound_detail_id bigint unsigned GENERATED ALWAYS AS")
|
||||
.contains("active_assignment_key varchar(96) GENERATED ALWAYS AS")
|
||||
.contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key)")
|
||||
.contains("KEY idx_loading_item_active_detail (account_id,active_inbound_detail_id)")
|
||||
.contains("KEY idx_inbound_smart_loading (warehouse_id,process_status,channel_code,expected_arrival_at,id)")
|
||||
.contains("KEY idx_measure_inbound_status_id (tenant_id,inbound_id,status,id)")
|
||||
.contains("KEY idx_measure_item_candidate (tenant_id,measurement_id,inbound_detail_id)")
|
||||
.contains("KEY idx_loading_item_candidate (account_id,inbound_id,inbound_detail_id,deleted)")
|
||||
.contains("KEY idx_inbound_split_candidate")
|
||||
.doesNotContain("ensure_smart_loading_candidate_indexes");
|
||||
}
|
||||
|
||||
private static String migration() throws Exception {
|
||||
@Test
|
||||
void unifiedInitializationKeepsFinalLifecycleConstraintsAndTriggers() throws Exception {
|
||||
String sql = unified();
|
||||
assertThat(sql)
|
||||
.contains("source_type varchar(24) NOT NULL DEFAULT 'SIMULATION'")
|
||||
.contains("record_status varchar(16) NOT NULL DEFAULT 'ACTIVE'")
|
||||
.contains("merged_into_container_booking_id bigint unsigned DEFAULT NULL")
|
||||
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
|
||||
.contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)")
|
||||
.contains("CREATE TRIGGER trg_loading_plan_before_update")
|
||||
.contains("装柜状态只允许单向推进")
|
||||
.contains("CREATE TRIGGER trg_loading_item_before_update")
|
||||
.contains("装柜完成后的明细永久冻结")
|
||||
.contains("CREATE TRIGGER trg_container_booking_before_update")
|
||||
.contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据")
|
||||
.doesNotContain("assert_phase3_smart_loading_schema");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedInitializationSeedsSmartLoadingPermissionsForBothPlatformRoles() throws Exception {
|
||||
String sql = unified();
|
||||
assertThat(sql)
|
||||
.contains("(5102090,0,'PLATFORM','MENU',5102000,'platform:firstleg:container:list'")
|
||||
.contains("(5102094,0,'PLATFORM','MENU',5102000,'platform:firstleg:smart-loading:list'")
|
||||
.contains("(5102100,0,'PLATFORM','MENU',5102000,'platform:firstleg:preload:list'")
|
||||
.contains("(5102106,0,'PLATFORM','MENU',5102000,'platform:firstleg:loading-list:list'")
|
||||
.contains("platform:firstleg:container:list")
|
||||
.contains("platform:firstleg:smart-loading:assign")
|
||||
.contains("platform:firstleg:preload:confirm")
|
||||
.contains("platform:firstleg:loading-list:list")
|
||||
.contains("VALUES (4000001,5102106,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("VALUES (4000003,5102106,0,CURRENT_TIMESTAMP(3))");
|
||||
}
|
||||
|
||||
private static String moduleMigration() throws Exception {
|
||||
return new ClassPathResource("db/phase3-smart-loading-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String unifiedMigration() throws Exception {
|
||||
Path working = Path.of("").toAbsolutePath();
|
||||
Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
if (!Files.exists(file)) {
|
||||
file = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
|
||||
}
|
||||
return Files.readString(file, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static void assertPermissionContract(String sql) {
|
||||
int message = sql.indexOf("智能装柜迁移失败:平台角色授权未完整写入");
|
||||
assertThat(message).isGreaterThan(0);
|
||||
|
||||
int grantStart = sql.lastIndexOf("INSERT IGNORE INTO iam_role_permission", message);
|
||||
int grantEnd = sql.indexOf(';', grantStart);
|
||||
assertThat(permissions(sql.substring(grantStart, grantEnd))).isEqualTo(PHASE3_PERMISSIONS);
|
||||
|
||||
int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message);
|
||||
String assertion = sql.substring(assertionStart, message);
|
||||
assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS);
|
||||
assertThat(assertion).contains("<> 22 THEN");
|
||||
}
|
||||
|
||||
private static Set<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();
|
||||
private static String unified() throws Exception {
|
||||
Path direct = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
Path path = Files.exists(direct) ? direct : Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-15
@@ -22,12 +22,12 @@ class TerminalDrayageSqlContractTest {
|
||||
assertThat(selected).isEqualTo(fields);
|
||||
if (name.equals("pendingMails")) assertThat(sql).contains("earlier.container_booking_id=m.container_booking_id", "earlier.id<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"))) {
|
||||
assertThat(Files.readString(path)).contains(
|
||||
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (",
|
||||
"CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))",
|
||||
"('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')");
|
||||
}
|
||||
String baseline = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (");
|
||||
for (String sql : java.util.List.of(baseline, unified)) assertThat(sql).contains(
|
||||
"PENDING","SENDING","SENT","FAILED",
|
||||
"SELECT_FLEET","SEND_PICKUP_EMAIL","SCHEDULE_PICKUP","RECORD_EMPTY_RETURN");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -35,10 +35,10 @@ class TerminalDrayageSqlContractTest {
|
||||
String baseline = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"));
|
||||
assertThat(baseline).doesNotContain("actual_pickup_at", "chk_terminal_drayage_times");
|
||||
String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertThat(release).contains("ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at",
|
||||
"DROP CHECK chk_terminal_drayage_history_action",
|
||||
"ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN");
|
||||
assertThat(release).doesNotContain("chk_terminal_drayage_times");
|
||||
assertThat(release)
|
||||
.contains("CREATE TABLE fl_terminal_drayage")
|
||||
.contains("CONSTRAINT chk_terminal_drayage_history_action CHECK")
|
||||
.doesNotContain("actual_pickup_at", "chk_terminal_drayage_times", "ALTER TABLE fl_terminal_drayage");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -62,12 +62,13 @@ class TerminalDrayageSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedMigrationRepairsColumnsAfterAPartialDdlRun() throws Exception {
|
||||
void unifiedInitializationCreatesFinalColumnsWithoutRepairDdl() throws Exception {
|
||||
String sql = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertThat(sql).contains(
|
||||
"ALTER TABLE fl_terminal_drayage",
|
||||
"ALTER TABLE fl_terminal_drayage_history",
|
||||
"ALTER TABLE fl_terminal_drayage_operation",
|
||||
"MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间'");
|
||||
"CREATE TABLE fl_terminal_drayage",
|
||||
"CREATE TABLE fl_terminal_drayage_history",
|
||||
"CREATE TABLE fl_terminal_drayage_operation",
|
||||
"estimated_empty_return_at datetime(3) DEFAULT NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间'");
|
||||
assertThat(sql).doesNotContain("ALTER TABLE fl_terminal_drayage");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ VALUES
|
||||
(5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement','海外采购','/cross-border/procurement',NULL,70,'ACTIVE',1,0),
|
||||
(5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,0),
|
||||
(5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,0),
|
||||
(5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,0)
|
||||
(5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,0),
|
||||
(5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
|
||||
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
|
||||
@@ -227,7 +228,8 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code
|
||||
'merchant:cross-border:procurement',
|
||||
'merchant:cross-border:domestic-trucking',
|
||||
'merchant:cross-border:pallet-shipping',
|
||||
'merchant:warehouse:inbound:list'
|
||||
'merchant:warehouse:inbound:list',
|
||||
'merchant:overseas-warehouse:return-inventory:list'
|
||||
)
|
||||
WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER'
|
||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
@@ -242,7 +244,8 @@ BEGIN
|
||||
'merchant:cross-border:procurement',
|
||||
'merchant:cross-border:domestic-trucking',
|
||||
'merchant:cross-border:pallet-shipping',
|
||||
'merchant:warehouse:inbound:list')) <> 4 THEN
|
||||
'merchant:warehouse:inbound:list',
|
||||
'merchant:overseas-warehouse:return-inventory:list')) <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
@@ -256,7 +259,8 @@ BEGIN
|
||||
'merchant:cross-border:procurement',
|
||||
'merchant:cross-border:domestic-trucking',
|
||||
'merchant:cross-border:pallet-shipping',
|
||||
'merchant:warehouse:inbound:list')) <> 4 THEN
|
||||
'merchant:warehouse:inbound:list',
|
||||
'merchant:overseas-warehouse:return-inventory:list')) <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -7,23 +7,18 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class NotificationSqlContractTest {
|
||||
@Test void definesTablesIsolationIdempotencyAndPermissions() throws Exception {String sql;try(var in=getClass().getResourceAsStream("/db/notification-center.sql")){assertThat(in).isNotNull();sql=new String(in.readAllBytes(),StandardCharsets.UTF_8);}assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient","iam_notification_inbox","iam_notification_event_inbox","uk_notification_task_no","uk_notification_task_idempotency","uk_notification_source_event","uk_notification_recipient","idx_notification_inbox_list","platform:notification:template:status","platform:notification:task:retry","platform:notification:read","platform:notification:read-all","merchant:notification:view","merchant:notification:read","merchant:notification:read-all").contains("'MENU',5106000,'platform:notification:template:list'","'MENU',5106000,'platform:notification:task:list'");}
|
||||
|
||||
@Test void unifiedMigrationContainsNotificationBaselineAndSevenIndexAssertion() throws Exception {
|
||||
@Test void cleanInitializationContainsNotificationSchemaAndPermissionSeeds() throws Exception {
|
||||
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
|
||||
Path migration=Files.exists(direct)?direct:Path.of("..","deploy","sql","头程数据库统一增量-MySQL8.sql").normalize();
|
||||
String sql=Files.readString(migration,StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient",
|
||||
assertThat(sql).contains("CREATE TABLE iam_notification_template","CREATE TABLE iam_notification_task","CREATE TABLE iam_notification_recipient",
|
||||
"iam_notification_inbox","iam_notification_event_inbox","uk_notification_template_code",
|
||||
"uk_notification_task_no","uk_notification_task_idempotency","uk_notification_source_event",
|
||||
"uk_notification_recipient","uk_notification_inbox_recipient","uk_notification_event",
|
||||
"assert_notification_center_schema","permission_code='merchant:notification:view'",
|
||||
"permission_code LIKE 'platform:notification%'");
|
||||
assertThat(sql).contains("COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_iam'");
|
||||
"'merchant:notification:view'", "'platform:notification:template:list'",
|
||||
"'platform:notification:task:list'");
|
||||
assertThat(sql)
|
||||
.contains(")) <> 7 THEN")
|
||||
.contains("permission_code='merchant:notification:view')) <> 13 THEN")
|
||||
.contains("permission_code LIKE 'platform:notification%') <> 12 THEN")
|
||||
.contains("'merchant:notification:view','merchant:notification:read','merchant:notification:read-all')) <> 3 THEN");
|
||||
assertThat(sql.indexOf("(5106014,0,'MERCHANT','BUTTON'"))
|
||||
.isLessThan(sql.indexOf("CALL baiyue_iam.assert_notification_center_schema();"));
|
||||
.contains("(5106014,0,'MERCHANT','BUTTON'")
|
||||
.doesNotContain("assert_notification_center_schema", "information_schema.statistics");
|
||||
}
|
||||
}
|
||||
|
||||
+7
-11
@@ -60,15 +60,9 @@ class AdminPrototypeMenuPermissionSqlContractTest {
|
||||
}
|
||||
assertThat(unified)
|
||||
.contains("platform:finance:receivable-statistics:export")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34")
|
||||
.contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 35")
|
||||
.contains("Finance 导出和下载中心授权不完整")
|
||||
.contains("客户全局 Finance 权限模板不完整")
|
||||
.contains("客户全局管理员 Finance 模板授权不完整")
|
||||
.satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'"))
|
||||
.isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()")))
|
||||
.satisfies(content -> assertThat(content.indexOf("CALL migrate_wallet_permissions();"))
|
||||
.isLessThan(content.indexOf("CALL assert_finance_permissions();")));
|
||||
.contains("(4000001,5105050,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("(4000100,5305010,0,CURRENT_TIMESTAMP(3))")
|
||||
.doesNotContain("CREATE PROCEDURE assert_finance_permissions", "CALL migrate_wallet_permissions");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -80,8 +74,10 @@ class AdminPrototypeMenuPermissionSqlContractTest {
|
||||
.contains("'platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete'");
|
||||
|
||||
String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
|
||||
assertThat(unified).contains("<> 4")
|
||||
.contains("platform:overseas-storage-fee:list', 'platform:overseas-storage-fee:create'");
|
||||
assertThat(unified)
|
||||
.contains("'platform:overseas-storage-fee:list','海外仓阶梯费用','/customers/overseas-storage-fees','GET',20,'ACTIVE'")
|
||||
.contains("'platform:overseas-storage-fee:create','新增海外仓阶梯费用',NULL,NULL,10,'INACTIVE'")
|
||||
.contains("'platform:overseas-storage-fee:delete','删除海外仓阶梯费用',NULL,NULL,30,'INACTIVE'");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+5
-16
@@ -90,27 +90,16 @@ class ContainerWorkspacePermissionSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedIncrementContainsTheCompleteModuleSql() throws Exception {
|
||||
void cleanInitializationContainsTheCompletePermissionCatalog() throws Exception {
|
||||
Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
if (!Files.exists(unifiedPath)) {
|
||||
unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
}
|
||||
String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
|
||||
String startMarker = "-- 货柜双工作台权限开始(与 iam/db/container-workspace-permissions.sql 保持一致)";
|
||||
String endMarker = "-- 货柜双工作台权限结束,恢复头程数据库上下文";
|
||||
int start = unified.indexOf(startMarker);
|
||||
int bodyStart = unified.indexOf('\n', start) + 1;
|
||||
int end = unified.indexOf(endMarker, bodyStart);
|
||||
|
||||
assertThat(start).isGreaterThanOrEqualTo(0);
|
||||
assertThat(bodyStart).isGreaterThan(start);
|
||||
assertThat(end).isGreaterThan(bodyStart);
|
||||
assertThat(normalizeLineEndings(unified.substring(bodyStart, end)).trim())
|
||||
.isEqualTo(normalizeLineEndings(sql()).trim());
|
||||
}
|
||||
|
||||
private static String normalizeLineEndings(String value) {
|
||||
return value.replace("\r\n", "\n");
|
||||
assertThat(codes(unified)).containsAll(REQUIRED);
|
||||
assertThat(unified)
|
||||
.contains("(5102090,0,'PLATFORM','MENU'", "(5102205,0,'PLATFORM','BUTTON'")
|
||||
.doesNotContain("migrate_container_workspace_permissions", "ON DUPLICATE KEY UPDATE");
|
||||
}
|
||||
|
||||
private static String sql() throws Exception {
|
||||
|
||||
@@ -20,15 +20,24 @@ class CustomerCodeSchemaContractTest {
|
||||
}
|
||||
String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
|
||||
|
||||
assertSequenceDefinition(baseline);
|
||||
assertSequenceDefinition(unified);
|
||||
assertMigrationSequenceDefinition(baseline);
|
||||
assertInitializationSequenceDefinition(unified);
|
||||
}
|
||||
|
||||
private static void assertSequenceDefinition(String sql) {
|
||||
private static void assertMigrationSequenceDefinition(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("tenant_code VARCHAR(8) NOT NULL")
|
||||
.contains("CREATE TABLE IF NOT EXISTS iam_number_sequence")
|
||||
.contains("PRIMARY KEY (sequence_key)")
|
||||
.contains("INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1)");
|
||||
}
|
||||
|
||||
private static void assertInitializationSequenceDefinition(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("tenant_code varchar(8) NOT NULL")
|
||||
.contains("CREATE TABLE iam_number_sequence")
|
||||
.contains("PRIMARY KEY (sequence_key)")
|
||||
.contains("INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1")
|
||||
.doesNotContain("CREATE TABLE IF NOT EXISTS iam_number_sequence", "INSERT IGNORE INTO iam_number_sequence");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -28,7 +28,9 @@ class CustomerDimensionInitializationSqlContractTest {
|
||||
assertContract(standalone);
|
||||
assertContract(unified);
|
||||
assertThat(standalone).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS");
|
||||
assertThat(unified).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS");
|
||||
assertThat(unified)
|
||||
.contains("CREATE TABLE iam_customer_dimension_initialization_task")
|
||||
.doesNotContain("information_schema.columns", "CREATE TABLE IF NOT EXISTS");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+17
-24
@@ -49,7 +49,7 @@ class CustomerProfileMigrationContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCase03() throws IOException {
|
||||
void cleanInitializationContainsTheFinalCustomerProfileContract() throws IOException {
|
||||
Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", ".."));
|
||||
Path migration = repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
Path readme = repositoryRoot.resolve("deploy/sql/README.md");
|
||||
@@ -60,43 +60,36 @@ class CustomerProfileMigrationContractTest {
|
||||
String instructions = Files.readString(readme, StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("customer_nickname VARCHAR(32)")
|
||||
.contains("ALTER TABLE iam_tenant ADD COLUMN customer_nickname VARCHAR(32)")
|
||||
.contains("ALTER TABLE iam_tenant MODIFY COLUMN status")
|
||||
.contains("customer_nickname varchar(32)")
|
||||
.contains("DEFAULT 'PROFILE_REQUIRED'")
|
||||
.contains("CALL phase1_iam_add_column('iam_tenant', 'approved_at'")
|
||||
.contains("CALL phase1_iam_add_column('iam_tenant', 'approved_by'")
|
||||
.contains("CALL phase1_iam_add_column('iam_tenant', 'reject_reason'")
|
||||
.contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment'")
|
||||
.contains("approved_at datetime(3)")
|
||||
.contains("approved_by bigint")
|
||||
.contains("reject_reason varchar(500)")
|
||||
.contains("review_comment varchar(500)")
|
||||
.contains("platform:customer:view")
|
||||
.contains("platform:customer:export")
|
||||
.contains("platform:download:list")
|
||||
.contains("'MERCHANT_OWNER'")
|
||||
.contains("owner_role.id, permission.id, 0")
|
||||
.contains("统一增量全部执行完成");
|
||||
.contains("SELECT 'PASS' AS initialization_status")
|
||||
.doesNotContain("ALTER TABLE iam_tenant", "phase1_iam_add_column");
|
||||
assertThat(instructions)
|
||||
.contains("phase1-platform-master-data-and-permission-management.sql")
|
||||
.contains("phase1-customer-profile.sql")
|
||||
.contains("phase1-onboarding.sql")
|
||||
.contains("`PROFILE_REQUIRED` 默认状态、审核结果字段、资质审核备注及成员审计字段")
|
||||
.contains("全局 `MERCHANT_OWNER` 角色模板")
|
||||
.contains("测试环境结构发布只执行全量固定入口");
|
||||
.contains("当前开发阶段只维护纯初始化")
|
||||
.contains("模块 `src/main/resources/db/` 用作结构来源和开发追溯")
|
||||
.contains("不再向历史迁移文件追加发布步骤");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedMigrationGrantsCustomerOperationsToPlatformAdmin() throws IOException {
|
||||
void cleanInitializationGrantsCustomerOperationsToPlatformAdmin() throws IOException {
|
||||
Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", ".."));
|
||||
String sql = Files.readString(
|
||||
repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
int grantStart = sql.indexOf("-- 新增平台操作权限默认只授予系统管理员。");
|
||||
int grantEnd = sql.indexOf("-- 已有客户运营角色继续具备", grantStart);
|
||||
|
||||
assertThat(grantStart).isGreaterThanOrEqualTo(0);
|
||||
assertThat(grantEnd).isGreaterThan(grantStart);
|
||||
assertThat(sql.substring(grantStart, grantEnd))
|
||||
assertThat(sql)
|
||||
.contains("'platform:customer:reset-password'")
|
||||
.contains("'platform:customer:delete'")
|
||||
.contains("'platform:customer:impersonate'");
|
||||
.contains("'platform:customer:impersonate'")
|
||||
.contains("(4000001,5100016,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("(4000001,5100017,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("(4000001,5100018,0,CURRENT_TIMESTAMP(3))");
|
||||
}
|
||||
}
|
||||
|
||||
+6
-18
@@ -46,28 +46,16 @@ class FinanceG1G2PermissionSqlContractTest {
|
||||
|
||||
@Test
|
||||
void unifiedSqlMirrorsCatalogAndGrantsTheFinanceCatalogToPlatformAdmin() throws Exception {
|
||||
String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8)
|
||||
.replace("\r\n", "\n");
|
||||
String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"),
|
||||
sql.indexOf("-- Admin 原型菜单与财务五页权限补充"));
|
||||
String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(financeSection)
|
||||
.contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'")
|
||||
.contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'")
|
||||
.contains("NULL, 'POST', 150, 'ACTIVE', 1, 0")
|
||||
.contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0")
|
||||
.contains("permission_code IN (\n 'platform:finance',")
|
||||
.contains("Finance G1-G4");
|
||||
assertThat(financeSection)
|
||||
.contains("SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission")
|
||||
.contains("status='INACTIVE',deleted=1");
|
||||
assertThat(sql)
|
||||
.contains("(5105024,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:review'")
|
||||
.contains("(5105025,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:edit'")
|
||||
.contains("(4000001,5105024,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("(4000001,5105025,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("platform:finance:settlement-ar:list")
|
||||
.contains("platform:finance:settlement-ap:list")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34")
|
||||
.contains("rp.role_id=4000001")
|
||||
.contains("LIKE 'platform:finance:%')) <> 35")
|
||||
.contains("平台管理员 Finance 授权不完整");
|
||||
.doesNotContain("migrate_finance_g1_g4_permissions", "status='INACTIVE',deleted=1");
|
||||
}
|
||||
|
||||
private static Path resolveUnifiedSql() {
|
||||
|
||||
+4
-12
@@ -39,21 +39,13 @@ class FinanceG3G4PermissionSqlContractTest {
|
||||
@Test
|
||||
void unifiedSqlContainsCatalogAndGrantsItToPlatformAdmin() throws Exception {
|
||||
String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
|
||||
String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"),
|
||||
sql.indexOf("-- Admin 原型菜单与财务五页权限补充"));
|
||||
for (String code : CODES) assertThat(financeSection).contains(code);
|
||||
assertThat(financeSection)
|
||||
.contains("migrate_finance_g1_g4_permissions")
|
||||
.contains("finance G1-G4 permission id/code conflict");
|
||||
assertThat(financeSection)
|
||||
.contains("SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission")
|
||||
.contains("platform:finance:reconciliation:create");
|
||||
for (String code : CODES) assertThat(sql).contains(code);
|
||||
assertThat(sql)
|
||||
.contains("(4000001,5105026,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("(4000001,5105035,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("platform:finance:settlement-ar:list")
|
||||
.contains("platform:finance:settlement-ap:list")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34")
|
||||
.contains("rp.role_id=4000001")
|
||||
.contains("LIKE 'platform:finance:%')) <> 35");
|
||||
.doesNotContain("migrate_finance_g1_g4_permissions", "finance G1-G4 permission id/code conflict");
|
||||
}
|
||||
|
||||
private static Path resolveUnifiedSql() {
|
||||
|
||||
+5
-11
@@ -63,10 +63,11 @@ class InboundHoldPermissionSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedIncrementContainsTheSameMigrationBody() throws Exception {
|
||||
String marker = "DROP PROCEDURE IF EXISTS migrate_inbound_hold_permission;";
|
||||
|
||||
assertThat(migrationBody(unifiedSql(), marker)).isEqualTo(migrationBody(sql(), marker));
|
||||
void cleanInitializationContainsThePermissionAndDefaultGrant() throws Exception {
|
||||
assertThat(unifiedSql())
|
||||
.contains("(5102032,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:hold'")
|
||||
.contains("(4000001,5102032,0,CURRENT_TIMESTAMP(3))")
|
||||
.doesNotContain("migrate_inbound_hold_permission", "ON DUPLICATE KEY UPDATE");
|
||||
}
|
||||
|
||||
private static String sql() throws Exception {
|
||||
@@ -80,11 +81,4 @@ class InboundHoldPermissionSqlContractTest {
|
||||
return Files.readString(path.normalize(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String migrationBody(String sql, String marker) {
|
||||
int start = sql.indexOf(marker);
|
||||
int end = sql.indexOf(marker, start + marker.length());
|
||||
assertThat(start).isGreaterThanOrEqualTo(0);
|
||||
assertThat(end).isGreaterThan(start);
|
||||
return sql.substring(start, end + marker.length()).replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
}
|
||||
|
||||
+18
-2
@@ -18,9 +18,17 @@ class InitialIdentitySeedSqlContractTest {
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String unified = Files.readString(unifiedSql(), StandardCharsets.UTF_8);
|
||||
assertPlatformIdentitySeed(identitySeed);
|
||||
assertPlatformIdentitySeed(unified);
|
||||
assertCleanPlatformIdentitySeed(unified);
|
||||
assertGlobalMerchantTemplate(templateSeed);
|
||||
assertGlobalMerchantTemplate(unified);
|
||||
assertCleanGlobalMerchantTemplate(unified);
|
||||
}
|
||||
|
||||
private static void assertCleanPlatformIdentitySeed(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("(0,'PLATFORM','虫虫出海平台'")
|
||||
.contains("(2000001,1000001,'INTERNAL','平台系统管理员'")
|
||||
.contains("(3000001,0,2000001,'EMPLOYEE'")
|
||||
.doesNotContain("merchant_admin", "IT_TEST", "一期演示企业", "ON DUPLICATE KEY UPDATE");
|
||||
}
|
||||
|
||||
private static void assertPlatformIdentitySeed(String sql) {
|
||||
@@ -47,6 +55,14 @@ class InitialIdentitySeedSqlContractTest {
|
||||
.doesNotContain("WHERE tenant_id = 10001 AND app_code = 'MERCHANT'");
|
||||
}
|
||||
|
||||
private static void assertCleanGlobalMerchantTemplate(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("(4000100,0,'MERCHANT','MERCHANT_OWNER'")
|
||||
.contains("(5300000,0,'MERCHANT','MENU'")
|
||||
.contains("(5301024,0,'MERCHANT','BUTTON'")
|
||||
.doesNotContain("WHERE tenant_id = 10001 AND app_code = 'MERCHANT'");
|
||||
}
|
||||
|
||||
private static Path unifiedSql() {
|
||||
Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
|
||||
@@ -13,7 +13,16 @@ class LocationPermissionSqlContractTest {
|
||||
@Test
|
||||
void acceptsKnownPollutionAndRestoresCanonicalPermissionIds() throws Exception {
|
||||
assertRepairContract(moduleSql());
|
||||
assertRepairContract(unifiedSql());
|
||||
assertFinalInitializationContract(unifiedSql());
|
||||
}
|
||||
|
||||
private static void assertFinalInitializationContract(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("(5103040,0,'PLATFORM','MENU',5103000,'platform:download:list'")
|
||||
.contains("(5103041,0,'PLATFORM','BUTTON',5103040,'platform:download:all'")
|
||||
.contains("(5103060,0,'PLATFORM','MENU',5103000,'platform:location:list'")
|
||||
.contains("(5103061,0,'PLATFORM','BUTTON',5103060,'platform:location:create'")
|
||||
.doesNotContain("tmp_location_role_grant", "库位权限迁移失败");
|
||||
}
|
||||
|
||||
private static void assertRepairContract(String sql) {
|
||||
|
||||
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -18,7 +18,8 @@ class OverseasCustomerPermissionSqlContractTest {
|
||||
"merchant:cross-border:procurement",
|
||||
"merchant:cross-border:domestic-trucking",
|
||||
"merchant:cross-border:pallet-shipping",
|
||||
"merchant:warehouse:inbound:list");
|
||||
"merchant:warehouse:inbound:list",
|
||||
"merchant:overseas-warehouse:return-inventory:list");
|
||||
|
||||
@Test
|
||||
void declaresTheFrozenCustomerOverseasPermissionCatalog() throws Exception {
|
||||
@@ -32,11 +33,12 @@ class OverseasCustomerPermissionSqlContractTest {
|
||||
"'merchant:cross-border:procurement','海外采购','/cross-border/procurement'",
|
||||
"'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking'",
|
||||
"'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping'",
|
||||
"'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound'");
|
||||
"'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound'",
|
||||
"'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantsAllFourPermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception {
|
||||
void grantsAllFivePermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception {
|
||||
String sql = sql();
|
||||
int comment = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口");
|
||||
int grantStart = sql.indexOf("INSERT IGNORE INTO iam_role_permission", comment);
|
||||
@@ -64,7 +66,7 @@ class OverseasCustomerPermissionSqlContractTest {
|
||||
"海外客户权限初始化失败:客户默认管理员授权不完整",
|
||||
"permission.app_code='MERCHANT'",
|
||||
"role.role_code='MERCHANT_OWNER'",
|
||||
")) <> 4 THEN");
|
||||
")) <> 5 THEN");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+30
-22
@@ -64,10 +64,10 @@ class OverseasPhase4PermissionSqlContractTest {
|
||||
Set<String> moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8));
|
||||
assertThat(codes(sql)).containsAll(moduleCatalog);
|
||||
assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'");
|
||||
assertPdaPickerGrant(sql);
|
||||
assertCustomAdminGrant(sql);
|
||||
assertCustomAdminNavigationGrant(sql);
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE iam_permission", "CREATE TABLE iam_role_permission", "'lastleg:pda:operate'")
|
||||
.contains("(4000005,5107015,0,CURRENT_TIMESTAMP(3))")
|
||||
.doesNotContain("assert_overseas_pda_picker_permission", "DELETE role_permission FROM");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -78,25 +78,33 @@ class OverseasPhase4PermissionSqlContractTest {
|
||||
if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
String unifiedSql = Files.readString(path, StandardCharsets.UTF_8);
|
||||
|
||||
for (String sql : List.of(moduleSql, unifiedSql)) {
|
||||
String matrix = matrixBlock(sql);
|
||||
assertThat(matrix).contains("WAREHOUSE_OPERATOR", "WAREHOUSE_TALLY");
|
||||
assertThat(codes(matrix)).containsExactlyInAnyOrder(
|
||||
"platform:overseas-warehouse:last-mile-order:list",
|
||||
"platform:overseas-warehouse:inbound-order:list",
|
||||
"platform:overseas-warehouse:relabel-order:list",
|
||||
"platform:overseas-warehouse:inventory:list",
|
||||
"platform:overseas-warehouse:warehouse:list",
|
||||
"platform:overseas-warehouse:location:list",
|
||||
"platform:overseas-warehouse:pallet:list",
|
||||
"platform:overseas-business:purchase:list",
|
||||
"platform:overseas-business:truck:list",
|
||||
"platform:overseas-business:looseboard:list");
|
||||
assertThat(matrix).doesNotContain("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
|
||||
assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'");
|
||||
assertThat(sql).contains("assert_phase4_warehouse_role_minimum", "<> 20",
|
||||
"role.role_code IN ('WAREHOUSE_OPERATOR','OVERSEAS_WAREHOUSE_PDA_OPERATOR','WAREHOUSE_TALLY','CN_FIRSTLEG_PDA_OPERATOR')");
|
||||
String matrix = matrixBlock(moduleSql);
|
||||
assertThat(matrix).contains("WAREHOUSE_OPERATOR", "WAREHOUSE_TALLY");
|
||||
assertThat(codes(matrix)).containsExactlyInAnyOrder(
|
||||
"platform:overseas-warehouse:last-mile-order:list",
|
||||
"platform:overseas-warehouse:inbound-order:list",
|
||||
"platform:overseas-warehouse:relabel-order:list",
|
||||
"platform:overseas-warehouse:inventory:list",
|
||||
"platform:overseas-warehouse:warehouse:list",
|
||||
"platform:overseas-warehouse:location:list",
|
||||
"platform:overseas-warehouse:pallet:list",
|
||||
"platform:overseas-business:purchase:list",
|
||||
"platform:overseas-business:truck:list",
|
||||
"platform:overseas-business:looseboard:list");
|
||||
assertThat(matrix).doesNotContain("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
|
||||
assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'");
|
||||
assertThat(moduleSql).contains("assert_phase4_warehouse_role_minimum", "<> 20",
|
||||
"role.role_code IN ('WAREHOUSE_OPERATOR','OVERSEAS_WAREHOUSE_PDA_OPERATOR','WAREHOUSE_TALLY','CN_FIRSTLEG_PDA_OPERATOR')");
|
||||
|
||||
for (int permissionId : new int[] {5107000, 5107010, 5107020, 5107030, 5107040,
|
||||
5107050, 5107060, 5107100, 5107110, 5107120}) {
|
||||
assertThat(unifiedSql)
|
||||
.contains("(4000004," + permissionId + ",0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("(4000006," + permissionId + ",0,CURRENT_TIMESTAMP(3))");
|
||||
}
|
||||
assertThat(unifiedSql)
|
||||
.doesNotContain("(4000005,5107000,0,CURRENT_TIMESTAMP(3))")
|
||||
.doesNotContain("(4000007,5107000,0,CURRENT_TIMESTAMP(3))");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+14
-21
@@ -143,35 +143,28 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void domesticUnifiedEntryInlinesTheCompleteModuleSqlAfterPhase4Permissions() throws Exception {
|
||||
String moduleSql = sql().trim();
|
||||
void domesticCleanInitializationContainsTheCompletePermissionCatalog() throws Exception {
|
||||
String unifiedSql = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
int overseasCatalog = unifiedSql.indexOf("-- 海外权限统一目录");
|
||||
int outboundDelivery = unifiedSql.indexOf("-- 海外排车、出库与派送权限(来源:iam/db/phase5-delivery-permissions.sql)");
|
||||
int bodyStart = unifiedSql.indexOf("USE baiyue_iam;", outboundDelivery);
|
||||
int nextSection = unifiedSql.indexOf("-- IAM 客户提交即成功", bodyStart);
|
||||
|
||||
assertThat(overseasCatalog).isGreaterThanOrEqualTo(0);
|
||||
assertThat(outboundDelivery).isGreaterThan(overseasCatalog);
|
||||
assertThat(bodyStart).isGreaterThan(outboundDelivery);
|
||||
assertThat(nextSection).isGreaterThan(bodyStart);
|
||||
assertThat(unifiedSql.substring(bodyStart, nextSection).trim()).isEqualTo(moduleSql);
|
||||
assertThat(codes(unifiedSql.substring(outboundDelivery, nextSection)))
|
||||
.containsExactlyInAnyOrderElementsOf(REQUIRED);
|
||||
assertThat(codes(unifiedSql)).containsAll(REQUIRED);
|
||||
assertThat(unifiedSql)
|
||||
.contains("(4000004,5107064,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("(4000004,5107094,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("(4000100,5306000,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("(4000100,5306001,0,CURRENT_TIMESTAMP(3))")
|
||||
.doesNotContain("guard_phase5_delivery_permission_ids", "tmp_phase5_delivery_role_permission");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploymentGuideUsesOnlyTheDomesticUnifiedEntry() throws Exception {
|
||||
void deploymentGuideDefinesCleanRegionalInitializationOnly() throws Exception {
|
||||
String readme = repositoryFile("deploy/sql/README.md");
|
||||
|
||||
assertThat(readme).contains(
|
||||
"海外排车、出库与派送权限",
|
||||
"测试和部署只执行固定入口,不再单独执行模块脚本",
|
||||
"排车列表/编辑/取消/转派送",
|
||||
"异常订单列表/创建/处理和客户回访列表/编辑十六项权限",
|
||||
"merchant:warehouse:delivery:list",
|
||||
"merchant:warehouse:delivery:create",
|
||||
"PDA 岗位不得获得上述后台权限");
|
||||
"当前开发阶段只维护纯初始化",
|
||||
"两个 SQL 文件沿用原文件名",
|
||||
"国内 IAM 是全部平台与客户权限的唯一权威",
|
||||
"模块 `src/main/resources/db/` 用作结构来源和开发追溯",
|
||||
"不再向历史迁移文件追加发布步骤");
|
||||
}
|
||||
|
||||
private static String sql() throws Exception {
|
||||
|
||||
@@ -22,10 +22,7 @@ class QualificationSchemaContractTest {
|
||||
|
||||
assertThat(baseline).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'");
|
||||
assertThat(unified)
|
||||
.contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'")
|
||||
.contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'original_file_name',")
|
||||
.contains("table_name='iam_tenant_qualification'")
|
||||
.contains("column_name='original_file_name'")
|
||||
.contains("统一迁移失败:IAM客户资质缺少原始文件名列");
|
||||
.contains("original_file_name varchar(255) NOT NULL COMMENT '上传时原始文件名'")
|
||||
.doesNotContain("phase1_iam_add_column", "information_schema.columns");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,18 +26,17 @@ class SecurityEventSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedMigrationIncludesSecurityEventSchemaAndAssertion() throws Exception {
|
||||
void cleanInitializationIncludesSecurityEventSchema() throws Exception {
|
||||
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
Path migration = Files.exists(direct)
|
||||
? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql").normalize();
|
||||
String sql = Files.readString(migration, StandardCharsets.UTF_8);
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS iam_security_subject_version")
|
||||
.contains("CREATE TABLE IF NOT EXISTS iam_security_tenant_version")
|
||||
.contains("CREATE TABLE IF NOT EXISTS iam_security_event_outbox")
|
||||
.contains("CREATE TABLE iam_security_subject_version")
|
||||
.contains("CREATE TABLE iam_security_tenant_version")
|
||||
.contains("CREATE TABLE iam_security_event_outbox")
|
||||
.contains("idx_security_outbox_publish", "idx_security_outbox_aggregate")
|
||||
.contains("CALL assert_iam_security_event_schema();");
|
||||
assertThat(sql.indexOf("CALL assert_iam_security_event_schema();"))
|
||||
.isLessThan(sql.lastIndexOf("SELECT 'PASS' AS migration_status"));
|
||||
.contains("SELECT 'PASS' AS initialization_status")
|
||||
.doesNotContain("assert_iam_security_event_schema", "CREATE TABLE IF NOT EXISTS iam_security_event");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,19 @@ class WarehouseRoleSqlContractTest {
|
||||
@Test
|
||||
void unifiedDeploymentContainsTheSameWarehouseRoleContract() throws Exception {
|
||||
String sql = unifiedSql();
|
||||
assertWarehouseRoleContract(sql);
|
||||
assertThat(roleMigrationBlock(sql))
|
||||
.doesNotContain("\n ADD COLUMN IF NOT EXISTS")
|
||||
.doesNotContain("DROP CHECK IF EXISTS");
|
||||
assertThat(sql.lastIndexOf("SELECT 'PASS' AS migration_status"))
|
||||
.isGreaterThan(sql.indexOf("CALL assert_phase3_warehouse_roles_ready()"));
|
||||
assertThat(sql.trim()).endsWith("AS message;");
|
||||
assertThat(sql)
|
||||
.contains("(4000004,0,'PLATFORM','WAREHOUSE_OPERATOR','国内仓后台操作员','SYSTEM'")
|
||||
.contains("(4000005,0,'PLATFORM','OVERSEAS_WAREHOUSE_PDA_OPERATOR','海外仓 PDA 作业员','SYSTEM'")
|
||||
.contains("(4000006,0,'PLATFORM','WAREHOUSE_TALLY','国内仓理货复核员','SYSTEM'")
|
||||
.contains("(4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM'")
|
||||
.contains("(6000004,4000004,0,'TENANT',NULL")
|
||||
.contains("(6000005,4000005,0,'TENANT',NULL")
|
||||
.contains("(6000006,4000006,0,'TENANT',NULL")
|
||||
.contains("(6000007,4000007,0,'TENANT',NULL")
|
||||
.contains("(4000005,5107015,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("(4000007,5102190,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("SELECT 'PASS' AS initialization_status")
|
||||
.doesNotContain("assert_phase3_warehouse_roles_ready", "ON DUPLICATE KEY UPDATE");
|
||||
}
|
||||
|
||||
private static void assertWarehouseRoleContract(String sql) {
|
||||
@@ -100,15 +106,6 @@ class WarehouseRoleSqlContractTest {
|
||||
return sql.substring(start, end > start ? end : sql.length());
|
||||
}
|
||||
|
||||
private static String roleMigrationBlock(String sql) {
|
||||
String marker = "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql";
|
||||
String endMarker = "-- BEGIN FINANCE FEE SOURCE MODULE MIGRATIONS";
|
||||
int start = sql.indexOf(marker);
|
||||
int end = sql.indexOf(endMarker, start + marker.length());
|
||||
if (start < 0 || end < 0) throw new IllegalStateException("统一入口缺少仓库角色连续区块");
|
||||
return sql.substring(start, end);
|
||||
}
|
||||
|
||||
private static String moduleSql() throws Exception {
|
||||
return new ClassPathResource("db/phase3-warehouse-roles.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 |
|
||||
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
||||
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller |
|
||||
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
|
||||
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
|
||||
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
|
||||
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
|
||||
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” |
|
||||
@@ -132,6 +132,18 @@
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- `RelabelBindingGuard` 校验已保存组托历史与当前库存托盘一致,箱唛/混合换标正常完工、客户确认与排车提交均复验;纯 SKU 不要求移托,异常手动完工仍须在客户确认前补齐绑定。创建申请按库存余额串行检查箱唛既有申请,拒绝重复申请;历史扫码全局唯一约束尚未改为多轮复用。
|
||||
|
||||
- 已确认箱唛改绑复用 `PdaPalletOperationService.relabelDisassemble` 不带 `actualSource` 的精确来源分支,按现有绑定移动 HOLD 库存,不重新分配冻结锚点;成功同样记录 `PALLET_BIND` 以展示最新目标。`requireUnallocatedRelabel` 在入口与事务锁单后限制排车/出库占用,已创建出库单也不可直接改绑。回归 `PdaPalletOperationServiceTest`。
|
||||
|
||||
- PDA旧SKU单扫:`PdaRelabelService.scanSku` 和 `/relabel-tasks/{relabelNo}/skus/scan`,按箱itemId+skuIndex核对旧SKU,服务端取新SKU/数量,逐条记录现有 `SKU_SCAN` 历史,版本、幂等、认领和仓库校验不变;同箱全部SKU完成再推进SKU阶段,混合单需两阶段才生成一条箱事实。无新增字段;前端按历史当前轮次隐藏已完成子项,重复旧SKU须由操作员选实际箱。
|
||||
|
||||
- 数量拆托成功在现有 `ll_relabel_pda_history` 写 `PALLET_BIND`,保存本轮、箱唛和目标托盘;同轮已组托箱从候选排除并在事务内防重复,返工使用新的 reviewedAt 轮次。PDA 通过详情 history 展示组托记录;不需要新增库存表字段。
|
||||
|
||||
- 换标按数量拆合托:`palletops/PdaPalletOperationService.relabelSources` 返回原入仓库存池的托盘组成,不能视为箱唛定位;`relabel-disassemble` 的 `actualSource` 携带仓位版本及现场箱数,`cartonMarks` 为本批目标绑定确认。实际托盘与冻结锚点不同时,在同余额/批次内仅重新分配本单冻结桶后合托,库存余额及原入仓单归属不变,保留逐仓位流水、物理资源补偿及幂等。每次一条实际来源仓位,可分批多托;已生成出库单禁止此重分配。认领人处理中即可新增托盘,不需客户确认;此能力不等于已具备真机打印或整单强制组托门禁。
|
||||
|
||||
- 换标申请生成的 `RELABEL_APPLICATION` 尾程主单使用 `WAIT_RELABEL`(待换标),不进入普通入库计划池;子状态仍区分处理中/待客户确认。客户确认后统一为 `WAIT_OUTBOUND`(待出库),卡车分支按换标申请的 `WAIT_PRE_DISPATCH` 资格参与排车,不重复入库。存量修正脚本 `../scripts/db/relabel-wait-status-incremental.sql` 仅调整尚未生成入库单的遗留待分配换标主单。
|
||||
|
||||
- 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。
|
||||
|
||||
- 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。
|
||||
|
||||
+21
@@ -70,6 +70,27 @@ public class DeliveryTransferController {
|
||||
return ApiResponse.success(service.createReturnOutbound(requiredCustomer(user, customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping("/outbound-orders/warehouse-transfer/candidates")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
|
||||
@Operation(summary = "List warehouse-transfer pallets that can create an outbound order")
|
||||
public ApiResponse<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")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
|
||||
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,
|
||||
@Size(max=1000) String remark,
|
||||
@NotBlank @Size(max=255) String idempotencyKey) {}
|
||||
public record WarehouseTransferOutbound(
|
||||
@NotBlank @Size(max=64) String transferNo,
|
||||
@NotNull @Size(min=1, max=100) List<@NotNull Long> palletIds,
|
||||
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "FBA|PRIVATE") String destinationType,
|
||||
@Size(max=128) String destinationWarehouseId,
|
||||
@Size(max=128) String destinationWarehouseCode,
|
||||
@Size(max=255) String destinationWarehouseName,
|
||||
@Size(max=1000) String deliveryAddress,
|
||||
@Size(max=128) String contactName,
|
||||
@Size(max=64) String contactPhone,
|
||||
@Size(max=32) String deliveryMethod,
|
||||
LocalDateTime plannedDeliveryAt,
|
||||
@Size(max=1000) String remark,
|
||||
@NotBlank @Size(max=255) String idempotencyKey) {}
|
||||
public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages,
|
||||
@NotBlank @Size(max=128) String sku,
|
||||
@Size(max=200) List<@NotBlank @Size(max=128) String> cartonMarks) {
|
||||
|
||||
+12
-1
@@ -31,7 +31,8 @@ public final class DeliveryResponses {
|
||||
String remark, LocalDateTime createdAt, long version, List<String> allowedActions,
|
||||
String bolNo, DeliveryDocumentDtos.Summary documentSummary,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName) {}
|
||||
String destinationWarehouseName, String destinationType,
|
||||
String deliveryAddress, String contactName, String contactPhone, String postalCode) {}
|
||||
|
||||
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
|
||||
String receivingAddress, String shipmentNo, String fbaTrackingNo,
|
||||
@@ -59,6 +60,16 @@ public final class DeliveryResponses {
|
||||
String cartonMark, String fbaCartonMark,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
long totalQuantity, String status) {}
|
||||
public record WarehouseTransferOutboundCandidate(String customerId, String customerName,
|
||||
String transferNo, String overseasWarehouseId,
|
||||
String transferStatus, boolean needRelabel,
|
||||
String relabelNo, String destinationType,
|
||||
String destinationWarehouseCode,
|
||||
String privateDestinationAddress,
|
||||
String privateDestinationPostalCode,
|
||||
Long palletId, Long warehousePalletId, String palletNo,
|
||||
int packageCount, BigDecimal grossWeightKg,
|
||||
BigDecimal volumeCbm) {}
|
||||
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
|
||||
public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {}
|
||||
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
|
||||
|
||||
@@ -26,6 +26,11 @@ public class DeliveryOrder {
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String destinationType;
|
||||
private String deliveryAddress;
|
||||
private String postalCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private String deliveryGroupKey;
|
||||
private String status;
|
||||
private String waybillNo;
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ public class DeliveryCompletedOutboxService {
|
||||
Objects.requireNonNull(completedAt, "completedAt");
|
||||
Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>();
|
||||
for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) {
|
||||
if ("RELABEL_APPLICATION".equals(order.getOrderType())) continue;
|
||||
if (!StringUtils.hasText(order.getSourceInboundId())) continue;
|
||||
requireText(order.getSourceOrderId(), "sourceOrderId");
|
||||
requireText(order.getInboundCode(), "inboundCode");
|
||||
|
||||
+240
-4
@@ -60,7 +60,9 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
||||
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -97,6 +99,7 @@ public class DeliveryTransferService {
|
||||
@Resource private InventoryBalanceMapper inventoryBalances;
|
||||
@Resource private InventoryCommandService inventoryCommands;
|
||||
@Resource private WarehouseTransferPalletMapper warehouseTransferPallets;
|
||||
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
|
||||
@Resource private DropshipOrderProfileMapper dropshipProfiles;
|
||||
@Resource private DropshipCartonMapper dropshipCartons;
|
||||
@Resource private DropshipCartonSkuMapper dropshipCartonSkus;
|
||||
@@ -217,6 +220,11 @@ public class DeliveryTransferService {
|
||||
firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()),
|
||||
destination.getDestinationWarehouseId()));
|
||||
delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName()));
|
||||
delivery.setDestinationType(destination.getDestinationType());
|
||||
delivery.setDeliveryAddress(destination.getDeliveryAddress());
|
||||
delivery.setPostalCode(destination.getPostalCode());
|
||||
delivery.setContactName(destination.getContactName());
|
||||
delivery.setContactPhone(destination.getContactPhone());
|
||||
delivery.setDeliveryGroupKey(entry.getKey());
|
||||
LastlegOrder firstOrder = firstTailOrder(customerId, groupItems);
|
||||
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
|
||||
@@ -395,6 +403,155 @@ public class DeliveryTransferService {
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<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) {
|
||||
String warehouseId = null;
|
||||
Map<String, Long> requestedPackages = new LinkedHashMap<>();
|
||||
@@ -541,6 +698,40 @@ public class DeliveryTransferService {
|
||||
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
if (request.inventoryPositionId() == null) throw conflict("仓储转运出库必须确认实际库存位置");
|
||||
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
|
||||
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId())
|
||||
|| !warehouseId.equals(selectedPosition.getOverseasWarehouseId())
|
||||
|| !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) {
|
||||
throw conflict("确认的库存位置不属于该仓储转运出库托盘");
|
||||
}
|
||||
InventoryBalance balance = inventoryBalances.selectById(selectedPosition.getBalanceId());
|
||||
if (!matchesOutboundItem(customerId, warehouseId, item, balance)) {
|
||||
throw conflict("确认的库存SKU或入仓码与出库明细不一致");
|
||||
}
|
||||
if (request.packages() > value(item.getPackageCount()) - value(item.getPickedPackageCount())) {
|
||||
throw conflict("出库数量超过仓储转运出库明细余量");
|
||||
}
|
||||
inventoryCommands.allocate(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
|
||||
request.idempotencyKey() + ":ALLOCATE", "仓储转运出库分配"));
|
||||
inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
|
||||
request.idempotencyKey() + ":PICK", "仓储转运出库拣货"));
|
||||
inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
|
||||
request.idempotencyKey() + ":SHIP", "仓储转运出库"));
|
||||
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId);
|
||||
pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId());
|
||||
pick.setInventoryPositionId(selectedPosition.getId()); pick.setPackages(request.packages());
|
||||
pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
|
||||
item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue());
|
||||
item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED");
|
||||
outboundPallets.updateById(item); updateOutboundPickingStatus(customerId, outbound);
|
||||
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
|
||||
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet");
|
||||
if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) {
|
||||
@@ -612,8 +803,8 @@ public class DeliveryTransferService {
|
||||
if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
|
||||
throw notFound("Outbound order not found in the bound warehouse");
|
||||
}
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(outbound.getOutboundType())) {
|
||||
throw conflict("整托扫码仅适用于一件代发出库");
|
||||
if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND").contains(outbound.getOutboundType())) {
|
||||
throw conflict("整托扫码仅适用于一件代发或换标出库");
|
||||
}
|
||||
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
|
||||
throw conflict("PDA task is not available for picking");
|
||||
@@ -695,6 +886,23 @@ public class DeliveryTransferService {
|
||||
new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成"));
|
||||
}
|
||||
}
|
||||
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
List<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);
|
||||
}
|
||||
|
||||
@@ -724,6 +932,23 @@ public class DeliveryTransferService {
|
||||
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); }
|
||||
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
|
||||
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); }
|
||||
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
for (String transferNo : outboundItems.stream().map(OutboundPalletItem::getOrderRefs)
|
||||
.filter(StringUtils::hasText).distinct().toList()) {
|
||||
WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo);
|
||||
if (transfer == null || !"WAIT_OUTBOUND".equals(transfer.getStatus())) continue;
|
||||
String restored = StringUtils.hasText(transfer.getFbaWarehouseCode())
|
||||
? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION";
|
||||
transfer.setStatus(restored); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer);
|
||||
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
|
||||
if (order != null && customerId.equals(order.getCustomerId())) {
|
||||
order.setCurrentStatus(restored); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + restored);
|
||||
order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now);
|
||||
tailOrders.updateById(order);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!existingDeliveries.isEmpty()) {
|
||||
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<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);
|
||||
@@ -958,17 +1183,20 @@ public class DeliveryTransferService {
|
||||
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
|
||||
value(candidate.getAvailablePackages()));
|
||||
}
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName()); }
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
|
||||
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
|
||||
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); }
|
||||
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
|
||||
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
|
||||
private LastlegOrder firstTailOrder(String customerId, List<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) {
|
||||
if ("PRIVATE".equals(item.getDestinationType()) && StringUtils.hasText(item.getDeliveryAddress())) {
|
||||
return "PRIVATE:" + item.getDeliveryAddress().trim() + ":" + Objects.toString(item.getPostalCode(), "").trim();
|
||||
}
|
||||
String code = firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode());
|
||||
if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT);
|
||||
if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim();
|
||||
throw conflict("Dispatch pallet is missing its destination FBA warehouse");
|
||||
throw conflict("Dispatch pallet is missing its destination");
|
||||
}
|
||||
private String resolveLxCodes(String customerId, List<DispatchItem> items) {
|
||||
List<String> inboundCodes = items.stream().map(DispatchItem::getInboundCode)
|
||||
@@ -1029,6 +1257,14 @@ public class DeliveryTransferService {
|
||||
return measuredWeight.add(orderWeight);
|
||||
}
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (JsonProcessingException | NoSuchAlgorithmException error) {
|
||||
throw new IllegalStateException("无法生成请求摘要", error);
|
||||
}
|
||||
}
|
||||
private static Long parseLong(String value) {
|
||||
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
|
||||
catch (NumberFormatException ignored) { return null; }
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ public class SmartDispatchController {
|
||||
@RequestParam(required = false) String sourceStage,
|
||||
@RequestParam(required = false) String draftToken) {
|
||||
requirePlatform(user);
|
||||
if (planIds == null || planIds.isEmpty()) {
|
||||
if ("RELABEL".equals(sourceStage) || planIds == null || planIds.isEmpty()) {
|
||||
return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken));
|
||||
}
|
||||
return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo,
|
||||
|
||||
@@ -30,6 +30,11 @@ public class DispatchItem {
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String fbaWarehouseCode;
|
||||
private String destinationType;
|
||||
private String deliveryAddress;
|
||||
private String postalCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private Integer packageCount;
|
||||
private BigDecimal volumeCbm;
|
||||
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
|
||||
|
||||
+16
-6
@@ -64,16 +64,20 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
|
||||
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," +
|
||||
"MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," +
|
||||
"MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " +
|
||||
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||
"WHERE 1=1 " +
|
||||
"WHERE ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " +
|
||||
"(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " +
|
||||
"AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " +
|
||||
"<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 " +
|
||||
"<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='sourceStage != null and sourceStage != \"\"'> AND " +
|
||||
"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 " +
|
||||
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," +
|
||||
"MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," +
|
||||
"MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " +
|
||||
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
|
||||
"<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 " +
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
@@ -184,7 +193,8 @@ public interface DispatchCandidateMapper {
|
||||
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
|
||||
"SUM(ri.notified_package_count) AS packageCount,MAX(wtp.volume_cbm) AS volumeCbm,'RELABEL' AS sourceStage," +
|
||||
"NULL AS cartonMarks,GROUP_CONCAT(DISTINCT COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) " +
|
||||
"ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks " +
|
||||
"ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks," +
|
||||
"'FBA' AS destinationType,NULL AS deliveryAddress,NULL AS postalCode,NULL AS contactName,NULL AS contactPhone " +
|
||||
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
|
||||
"JOIN ll_inventory_position pos ON pos.id=ri.source_inventory_position_id AND pos.customer_id=a.customer_id " +
|
||||
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " +
|
||||
@@ -193,7 +203,7 @@ public interface DispatchCandidateMapper {
|
||||
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
|
||||
"<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='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 > 0 " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
|
||||
"ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " +
|
||||
|
||||
+8
-3
@@ -30,7 +30,12 @@ public record DispatchPalletCandidate(
|
||||
BigDecimal volumeCbm,
|
||||
String sourceStage,
|
||||
String cartonMarks,
|
||||
String relabelCartonMarks
|
||||
String relabelCartonMarks,
|
||||
String destinationType,
|
||||
String deliveryAddress,
|
||||
String postalCode,
|
||||
String contactName,
|
||||
String contactPhone
|
||||
) {
|
||||
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
|
||||
String overseasWarehouseId, String overseasWarehouseName, String orderNo,
|
||||
@@ -41,7 +46,7 @@ public record DispatchPalletCandidate(
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId,
|
||||
overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId,
|
||||
destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm,
|
||||
sourceStage, null, null);
|
||||
sourceStage, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
|
||||
@@ -51,7 +56,7 @@ public record DispatchPalletCandidate(
|
||||
BigDecimal volumeCbm, String sourceStage) {
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
|
||||
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null);
|
||||
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null, null, null, null, null, null);
|
||||
}
|
||||
public String sourcePalletKey() {
|
||||
// Once received, the physical pallet is the shared resource even when it contains
|
||||
|
||||
+32
-5
@@ -66,6 +66,7 @@ public class DispatchService {
|
||||
@Resource private LastlegOrderMapper tailOrders;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private RelabelApplicationMapper relabelApplications;
|
||||
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
|
||||
|
||||
public String resolveCustomerScopeForPlans(List<Long> planIds) {
|
||||
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
|
||||
@@ -107,12 +108,14 @@ public class DispatchService {
|
||||
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode,
|
||||
String sourceStage, String draftToken) {
|
||||
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));
|
||||
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);
|
||||
if (relabel != null && !relabel.isEmpty()) {
|
||||
result = java.util.stream.Stream.concat(result.stream(), relabel.stream()).toList();
|
||||
result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item ->
|
||||
!StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "")
|
||||
.toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -270,6 +273,11 @@ public class DispatchService {
|
||||
if (selected.size() != palletIds.size()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
|
||||
}
|
||||
if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) {
|
||||
boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType()));
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba
|
||||
? "所选托盘尚未设置 FBA 仓,不能智能排车" : "所选托盘尚未设置有效目的地,不能智能排车");
|
||||
}
|
||||
assertNoDetainedOrders(customerId, selected);
|
||||
boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
|
||||
.anyMatch(value -> value == null || value.isBlank());
|
||||
@@ -396,6 +404,11 @@ public class DispatchService {
|
||||
item.setDestinationWarehouseCode(candidate.destinationWarehouseCode());
|
||||
item.setDestinationWarehouseName(candidate.destinationWarehouseName());
|
||||
item.setFbaWarehouseCode(candidate.fbaWarehouseCode());
|
||||
item.setDestinationType(candidate.destinationType());
|
||||
item.setDeliveryAddress(candidate.deliveryAddress());
|
||||
item.setPostalCode(candidate.postalCode());
|
||||
item.setContactName(candidate.contactName());
|
||||
item.setContactPhone(candidate.contactPhone());
|
||||
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
|
||||
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
|
||||
item.setSourceStage(candidate.sourceStage());
|
||||
@@ -433,6 +446,7 @@ public class DispatchService {
|
||||
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
|
||||
}
|
||||
relabelBindingGuard.requireBound(application);
|
||||
List<DispatchPalletCandidate> selected = candidates.relabelAvailable(customerId, null, request.relabelNo());
|
||||
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
|
||||
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
|
||||
@@ -653,10 +667,23 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
private static String candidateFbaCode(DispatchPalletCandidate candidate) {
|
||||
if ("PRIVATE".equals(candidate.destinationType())) {
|
||||
return java.util.stream.Stream.of(candidate.deliveryAddress(), candidate.postalCode())
|
||||
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.joining(" "));
|
||||
}
|
||||
return StringUtils.hasText(candidate.fbaWarehouseCode())
|
||||
? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode());
|
||||
}
|
||||
|
||||
private static boolean hasValidDestination(DispatchPalletCandidate candidate) {
|
||||
if ("PRIVATE".equals(candidate.destinationType())) {
|
||||
return StringUtils.hasText(candidate.deliveryAddress()) && StringUtils.hasText(candidate.postalCode())
|
||||
&& StringUtils.hasText(candidate.contactName()) && StringUtils.hasText(candidate.contactPhone());
|
||||
}
|
||||
return StringUtils.hasText(candidate.fbaWarehouseCode())
|
||||
|| StringUtils.hasText(candidate.destinationWarehouseCode());
|
||||
}
|
||||
|
||||
private static String itemFbaCode(DispatchItem item) {
|
||||
return StringUtils.hasText(item.getFbaWarehouseCode())
|
||||
? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode());
|
||||
@@ -716,8 +743,8 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
private static void validateSourceStage(String sourceStage) {
|
||||
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
|
||||
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED", "RELABEL").contains(sourceStage)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN, RECEIVED or RELABEL");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,12 @@ public record LastlegOrderSummary(
|
||||
String nodeSource,
|
||||
int version,
|
||||
Integer palletCount,
|
||||
@Schema(description = "客户预报箱数")
|
||||
Integer packageCount,
|
||||
@Schema(description = "PDA 实收箱数;尚未完成收货时为空")
|
||||
Integer receivedPackageCount,
|
||||
@Schema(description = "实收减预报箱数;尚未完成收货时为空")
|
||||
Integer packageDifference,
|
||||
BigDecimal volumeCbm,
|
||||
Integer productQuantity,
|
||||
BigDecimal volumeWeightKg,
|
||||
|
||||
@@ -44,6 +44,7 @@ public class LastlegOrder {
|
||||
private Integer version;
|
||||
private Integer palletCount;
|
||||
private Integer packageCount;
|
||||
private Integer receivedPackageCount;
|
||||
private Integer pickedPackageCount;
|
||||
private Integer shippedPackageCount;
|
||||
private BigDecimal volumeCbm;
|
||||
|
||||
+4
@@ -21,6 +21,10 @@ public class InboundPlanLine {
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String deliveryAddress;
|
||||
private String postalCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private BigDecimal volumeCbm;
|
||||
private Integer requiredPackageCount;
|
||||
private Integer plannedPalletCount;
|
||||
|
||||
+5
@@ -22,6 +22,11 @@ public class InboundPlanPallet {
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String destinationType;
|
||||
private String deliveryAddress;
|
||||
private String postalCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private BigDecimal capacityCbm;
|
||||
private BigDecimal allocatedVolumeCbm;
|
||||
private Integer sequenceNo;
|
||||
|
||||
+23
@@ -6,6 +6,8 @@ import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -293,6 +295,27 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
return palletBatch("/internal/v1/warehouse-pallet-batches/compensate", request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrintableFile printPalletLabel(long warehouseId, long palletId, String transferNo, String requestId) {
|
||||
ResponseEntity<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) {
|
||||
PalletBatchEnvelope response = client.post().uri(path)
|
||||
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
|
||||
|
||||
+4
@@ -39,6 +39,9 @@ public interface WarehouseResourceGateway {
|
||||
default PalletBatchResponse compensatePalletBatch(PalletBatchCompensation request) {
|
||||
throw new UnsupportedOperationException("Warehouse pallet batch compensation is not configured");
|
||||
}
|
||||
default PrintableFile printPalletLabel(long warehouseId, long palletId, String transferNo, String requestId) {
|
||||
throw new UnsupportedOperationException("Warehouse pallet label printing is not configured");
|
||||
}
|
||||
|
||||
record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
|
||||
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {
|
||||
@@ -67,4 +70,5 @@ public interface WarehouseResourceGateway {
|
||||
record PalletBatchItem(long customerId, String businessKey, String inboundPlanNo, int sequenceNo,
|
||||
long palletId, String palletCode, long locationId, long resourceLockId,
|
||||
String status) { }
|
||||
record PrintableFile(byte[] content, String fileName, String contentType) { }
|
||||
}
|
||||
|
||||
-3
@@ -511,9 +511,6 @@ public class InventoryCommandService {
|
||||
String skuCode = sku(requestedSkuCode);
|
||||
String destinationId = trim(destinationWarehouseId);
|
||||
String destinationCode = trim(destinationWarehouseCode);
|
||||
if (destinationId == null && destinationCode == null) {
|
||||
throw badRequest("Destination warehouse ID or code is required");
|
||||
}
|
||||
if (destinationCode == null) destinationCode = "";
|
||||
InventoryBalance balance = balances.lockDimension(customerId, frozenWarehouseId,
|
||||
destinationCode, inboundOrderId, inboundCode);
|
||||
|
||||
@@ -2,9 +2,22 @@ package com.baiyue.lastleg.model;
|
||||
|
||||
/** Standard main-process statuses for a last-leg order. */
|
||||
public enum LastlegOrderStatus {
|
||||
PENDING_REVIEW("待审核"),
|
||||
INBOUND_MEASURING("入库测量中"),
|
||||
PENDING_ASSIGNMENT("待分配"),
|
||||
PENDING_INBOUND("待入库"),
|
||||
INBOUND_COMPLETED("入库完成"),
|
||||
WAIT_RELABEL("待换标"),
|
||||
WAIT_FBA_DESTINATION("待确认目的仓"),
|
||||
READY_FOR_DISPATCH("待创建出库单"),
|
||||
WAIT_OUTBOUND("待出库"),
|
||||
ALLOCATED("已分配库存"),
|
||||
OUTBOUNDED("已出库"),
|
||||
PARTIALLY_RECEIVED("部分收货"),
|
||||
RECEIVED("已收货"),
|
||||
IN_TRANSIT("运输中"),
|
||||
COMPLETED("已完成"),
|
||||
CANCELLED("已取消"),
|
||||
PENDING_DELIVERY("待派送"),
|
||||
DELIVERING("派送中"),
|
||||
DELIVERED("派送完成");
|
||||
|
||||
+7
@@ -28,6 +28,13 @@ public class PdaPalletOperationController {
|
||||
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")
|
||||
public ApiResponse<PalletOperationResponses.PrintablePallet> createRelabelPallet(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+9
-2
@@ -13,6 +13,7 @@ public final class PalletOperationRequests {
|
||||
private PalletOperationRequests() { }
|
||||
|
||||
public record Scan(@NotBlank @Size(max = 128) String code) { }
|
||||
public record RelabelSources(@NotBlank @Size(max = 64) String relabelNo) { }
|
||||
public record NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey) { }
|
||||
public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode,
|
||||
@@ -33,13 +34,19 @@ public final class PalletOperationRequests {
|
||||
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
|
||||
@Size(max = 500) String reason) { }
|
||||
|
||||
/** 换标场景按箱唛拆托并绑定到一个目标托盘。箱唛位置由服务端从换标单快照解析。 */
|
||||
/** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */
|
||||
public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey,
|
||||
@NotBlank @Size(max = 64) String relabelNo,
|
||||
@Valid PalletExpectation source,
|
||||
@Valid PalletExpectation target,
|
||||
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
|
||||
@Size(max = 500) String reason) { }
|
||||
@Size(max = 500) String reason,
|
||||
@Valid MoveLine actualSource) {
|
||||
public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source,
|
||||
PalletExpectation target, List<String> cartonMarks, String reason) {
|
||||
this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record Swap(@NotBlank @Size(max = 128) String idempotencyKey,
|
||||
@Valid PalletExpectation source,
|
||||
|
||||
+3
@@ -5,6 +5,9 @@ import java.util.List;
|
||||
|
||||
public final class PalletOperationResponses {
|
||||
private PalletOperationResponses() { }
|
||||
public record RelabelSource(long positionId, long positionVersion, String palletCode,
|
||||
String locationCode, String inboundCode, long stockPackages,
|
||||
long movablePackages, List<String> cartonMarks) { }
|
||||
|
||||
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
|
||||
String inboundCode, String skuCode, String productName,
|
||||
|
||||
+6
@@ -23,6 +23,12 @@ public interface PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation>
|
||||
PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey,
|
||||
@Param("idempotencyKey") String idempotencyKey);
|
||||
|
||||
@Select(CONTENT_SELECT + "WHERE b.customer_id=#{customerId} AND b.overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND b.id=#{balanceId} AND p.pallet_id>0 " +
|
||||
"AND (p.available_packages+p.hold_packages)>0 ORDER BY p.pallet_no,p.id")
|
||||
List<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} " +
|
||||
"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")
|
||||
|
||||
+211
-15
@@ -78,13 +78,42 @@ public class PdaPalletOperationService {
|
||||
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,
|
||||
PalletOperationRequests.NewRelabelPallet request) {
|
||||
String relabelNo = required(request.relabelNo(), "换标单号不能为空");
|
||||
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
|
||||
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) {
|
||||
throw conflict("换标单尚未完成客户确认,不能生成新托盘");
|
||||
}
|
||||
requireRelabelWork(identity, application);
|
||||
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
|
||||
throw conflict("换标单不属于 Token 冻结的海外仓");
|
||||
}
|
||||
@@ -153,9 +182,7 @@ public class PdaPalletOperationService {
|
||||
if (replay != null) return replay;
|
||||
String relabelNo = required(request.relabelNo(), "换标单号不能为空");
|
||||
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
|
||||
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) {
|
||||
throw conflict("换标单尚未完成客户确认,不能执行拆托");
|
||||
}
|
||||
requireRelabelWork(identity, application);
|
||||
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
|
||||
throw conflict("换标单不属于 Token 冻结的海外仓");
|
||||
}
|
||||
@@ -177,29 +204,61 @@ public class PdaPalletOperationService {
|
||||
}
|
||||
Map<Long, Long> packagesByPosition = 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) {
|
||||
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
|
||||
|| item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照");
|
||||
InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId());
|
||||
if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId())
|
||||
|| !Objects.equals(position.getPalletId(), source.palletId())
|
||||
|| (actual == null && !Objects.equals(position.getPalletId(), source.palletId()))
|
||||
|| (actual != null && (!Objects.equals(position.getBalanceId(), actual.getBalanceId())
|
||||
|| !Objects.equals(position.getBatchNo(), actual.getBatchNo())))
|
||||
|| !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
|
||||
throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致");
|
||||
throw conflict("来源托盘必须属于本次拆托的原入仓单和库存批次");
|
||||
}
|
||||
packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum);
|
||||
itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item);
|
||||
anchors.put(position.getId(), position);
|
||||
Long sourcePosition = actual == null ? position.getId() : actual.getId();
|
||||
packagesByPosition.merge(sourcePosition, item.getNotifiedPackageCount().longValue(), Long::sum);
|
||||
itemsByPosition.computeIfAbsent(sourcePosition, ignored -> new ArrayList<>()).add(item);
|
||||
}
|
||||
if (actual != null && request.actualSource().packages() != packagesByPosition.get(actual.getId()))
|
||||
throw conflict("拆出箱数必须等于本次确认绑定的换标箱数");
|
||||
List<PlannedMove> moves = packagesByPosition.entrySet().stream().map(entry -> {
|
||||
InventoryPosition position = positions.selectById(entry.getKey());
|
||||
if (value(position.getHoldPackages()) < entry.getValue()) throw conflict("换标冻结库存箱数不足");
|
||||
long own = selected.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), position.getId()))
|
||||
.mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
|
||||
if (actual == null ? value(position.getHoldPackages()) < entry.getValue()
|
||||
: value(position.getHoldPackages()) < own || value(position.getAvailablePackages()) < entry.getValue() - own)
|
||||
throw conflict("来源托盘可拆箱数不足,不能占用其他单据冻结或已分配库存");
|
||||
return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(),
|
||||
source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
|
||||
locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD);
|
||||
}).toList();
|
||||
requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target));
|
||||
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
|
||||
Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false);
|
||||
Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(),
|
||||
new RelabelMutation(application, itemsByPosition));
|
||||
new RelabelMutation(application, itemsByPosition, actual == null ? Map.of() : anchors));
|
||||
String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank()
|
||||
? "" : ":" + request.reason().trim());
|
||||
return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request,
|
||||
@@ -288,6 +347,10 @@ public class PdaPalletOperationService {
|
||||
throw conflict("库存所在托盘已变化,请重新扫码");
|
||||
}
|
||||
if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码");
|
||||
if (plan.relabelMutation() != null && !plan.relabelMutation().anchors().isEmpty()) {
|
||||
reconcileRelabelHold(identity, operation, plan.relabelMutation(), move, key);
|
||||
locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), move.positionId());
|
||||
}
|
||||
if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages());
|
||||
else requireMovable(locked, move.packages());
|
||||
customers.add(locked.getCustomerId());
|
||||
@@ -296,6 +359,8 @@ public class PdaPalletOperationService {
|
||||
}
|
||||
if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems(
|
||||
plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets);
|
||||
if (plan.relabelMutation() != null)
|
||||
recordRelabelBinding(identity, plan.relabelMutation(), relabelTargets);
|
||||
for (PalletChange change : plan.changes()) {
|
||||
long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId());
|
||||
boolean shouldBeEmpty = "IDLE".equals(change.targetStatus());
|
||||
@@ -320,7 +385,8 @@ public class PdaPalletOperationService {
|
||||
|
||||
private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) {
|
||||
RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo());
|
||||
if (locked == null || !"READY_FOR_OUTBOUND".equals(locked.getStatus())
|
||||
if (locked != null) requireUnallocatedRelabel(locked);
|
||||
if (locked == null || !Objects.equals(locked.getStatus(), mutation.application().getStatus())
|
||||
|| !Objects.equals(locked.getCustomerId(), mutation.application().getCustomerId())
|
||||
|| !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId))
|
||||
|| !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) {
|
||||
@@ -328,6 +394,52 @@ public class PdaPalletOperationService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Redistributes only this application's hold within the same inbound balance; physical totals stay unchanged. */
|
||||
private void reconcileRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation,
|
||||
RelabelMutation mutation, PlannedMove move, String key) {
|
||||
requireRelabelWork(identity, mutation.application());
|
||||
List<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,
|
||||
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
|
||||
InventoryPosition source = positions.selectById(sourceRow.getPositionId());
|
||||
@@ -458,6 +570,89 @@ public class PdaPalletOperationService {
|
||||
if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘");
|
||||
}
|
||||
|
||||
@jakarta.annotation.Resource
|
||||
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper relabelWorkTasks;
|
||||
@jakarta.annotation.Resource
|
||||
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper relabelWorkFacts;
|
||||
@jakarta.annotation.Resource
|
||||
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper relabelWorkHistory;
|
||||
|
||||
private Set<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,
|
||||
PalletOperationRequests.PalletExpectation first, List<PalletOperationRequests.PalletExpectation> 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,
|
||||
RelabelMutation relabelMutation) { }
|
||||
private record RelabelMutation(RelabelApplication application,
|
||||
Map<Long, List<RelabelItem>> itemsByPosition) { }
|
||||
Map<Long, List<RelabelItem>> itemsByPosition,
|
||||
Map<Long, InventoryPosition> anchors) { }
|
||||
private enum InventoryBucket { AVAILABLE, HOLD }
|
||||
}
|
||||
|
||||
+10
@@ -36,6 +36,16 @@ public class RelabelApplicationController {
|
||||
return ApiResponse.success(pdaService.returnForRework(operator(user), user.username(), relabelNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/confirm-completion")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
|
||||
public ApiResponse<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")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
|
||||
public ApiResponse<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> reviewTask(
|
||||
|
||||
@@ -39,9 +39,9 @@ public final class RelabelRequests {
|
||||
}
|
||||
|
||||
public record Item(
|
||||
@NotNull Long sourceInventoryBalanceId,
|
||||
@NotNull Long sourceInventoryPositionId,
|
||||
@NotNull Long sourceInboundOrderId,
|
||||
Long sourceInventoryBalanceId,
|
||||
Long sourceInventoryPositionId,
|
||||
Long sourceInboundOrderId,
|
||||
@Size(max = 128) String skuCode,
|
||||
@Size(max = 128) String productImageFileId,
|
||||
@Size(max = 255) String productImageFileName,
|
||||
@@ -61,8 +61,22 @@ public final class RelabelRequests {
|
||||
@Size(max = 255) String fbaCartonMark,
|
||||
@Size(max = 128) String destinationFbaWarehouseCode,
|
||||
@Size(max = 32) String operationType,
|
||||
@Size(max = 100) List<@Valid SkuItem> skuItems
|
||||
@Size(max = 100) List<@Valid SkuItem> skuItems,
|
||||
Long returnCartonId
|
||||
) {
|
||||
public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId,
|
||||
String skuCode, String productImageFileId, String productImageFileName, String productImageUrl,
|
||||
String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo,
|
||||
String trackingCode, String newSkuCode, String labelRequirement, String remark,
|
||||
String relabelFileId, String relabelFileName, String relabelFileUrl, String cartonMark,
|
||||
String fbaCartonMark, String destinationFbaWarehouseCode, String operationType,
|
||||
List<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,
|
||||
String skuCode, String productImageFileId, String productImageFileName, String productImageUrl,
|
||||
@@ -74,7 +88,7 @@ public final class RelabelRequests {
|
||||
productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount,
|
||||
notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark,
|
||||
relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark,
|
||||
destinationFbaWarehouseCode, "CARTON_LABEL", List.of());
|
||||
destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null);
|
||||
}
|
||||
public Item(String skuCode, String productImageFileId, String productImageFileName,
|
||||
String productImageUrl, String productNameZh, int totalPackageCount,
|
||||
@@ -85,7 +99,7 @@ public final class RelabelRequests {
|
||||
this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl,
|
||||
productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode,
|
||||
newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl,
|
||||
cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of());
|
||||
cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null);
|
||||
}
|
||||
public Item(String skuCode, String productImageFileId, String productImageFileName,
|
||||
String productImageUrl, String productNameZh, int totalPackageCount,
|
||||
@@ -94,7 +108,7 @@ public final class RelabelRequests {
|
||||
this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh,
|
||||
totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode,
|
||||
labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, "ONT8",
|
||||
"CARTON_LABEL", List.of());
|
||||
"CARTON_LABEL", List.of(), null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ public final class RelabelResponses {
|
||||
String inboundCode, String overseasWarehouseId, String overseasWarehouseCode,
|
||||
String overseasWarehouseName, String destinationFbaWarehouseCode,
|
||||
String destinationFbaWarehouseName, String skuCode, String productNameZh,
|
||||
long availablePackages, long version, List<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,
|
||||
String relabelOrderNo, String orderType, String status, Integer packageCount,
|
||||
LocalDateTime createdAt) {}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ public class HttpRelabelDocumentFileGateway implements RelabelDocumentFileGatewa
|
||||
@Resource private RestClient.Builder restClientBuilder;
|
||||
@Resource private LastlegRestClientFactory restClientFactory;
|
||||
@Resource private LastlegRestClientProperties restClientProperties;
|
||||
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
|
||||
@Value("${baiyue.clients.file.url:http://127.0.0.1:9206}") private String baseUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
private RestClient client;
|
||||
|
||||
|
||||
+10
@@ -6,4 +6,14 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
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);
|
||||
}
|
||||
|
||||
+26
@@ -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));
|
||||
}
|
||||
}
|
||||
+8
@@ -70,6 +70,14 @@ public class PdaRelabelController {
|
||||
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")
|
||||
public ApiResponse<List<PdaRelabelService.Document>> documents(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo) {
|
||||
|
||||
+4
@@ -14,6 +14,10 @@ public final class PdaRelabelRequests {
|
||||
private PdaRelabelRequests() { }
|
||||
|
||||
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record SkuScan(@NotNull @Positive Long itemId, @Min(0) int skuIndex,
|
||||
@NotBlank @Size(max = 255) String oldSku,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record CartonScan(@NotBlank @Size(max = 255) String cartonMark,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ public final class PdaRelabelResponses {
|
||||
String cartonMark, String fbaCartonMark, String operationType,
|
||||
List<SkuItem> skuItems,
|
||||
int expectedPackages, int relabelledPackages, int skippedPackages,
|
||||
int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages) { }
|
||||
int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages,
|
||||
String destinationFbaWarehouseCode) { }
|
||||
public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { }
|
||||
|
||||
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
|
||||
|
||||
+12
-2
@@ -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")
|
||||
PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
|
||||
|
||||
@Select("SELECT * FROM ll_relabel_pda_task WHERE operator_key=#{operatorKey} AND status<>'COMPLETED' ORDER BY claimed_at ASC LIMIT 1 FOR UPDATE")
|
||||
PdaRelabelTask lockUnfinishedByOperator(@Param("operatorKey") String operatorKey);
|
||||
@Select("""
|
||||
SELECT task.*
|
||||
FROM ll_relabel_pda_task task
|
||||
JOIN ll_relabel_application application
|
||||
ON application.id=task.application_id AND application.customer_id=task.customer_id
|
||||
WHERE task.operator_key=#{operatorKey}
|
||||
AND task.status='PROCESSING'
|
||||
AND application.status='PROCESSING'
|
||||
ORDER BY task.claimed_at ASC
|
||||
LIMIT 1 FOR UPDATE
|
||||
""")
|
||||
PdaRelabelTask lockProcessingByOperator(@Param("operatorKey") String operatorKey);
|
||||
|
||||
@Update("""
|
||||
UPDATE ll_relabel_pda_task
|
||||
|
||||
+57
-4
@@ -51,6 +51,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class PdaRelabelService {
|
||||
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard;
|
||||
public static final String PROCESSING = "PROCESSING";
|
||||
public static final String WAIT_REVIEW = "WAIT_REVIEW";
|
||||
public static final String COMPLETED = "COMPLETED";
|
||||
@@ -109,7 +110,7 @@ public class PdaRelabelService {
|
||||
throw conflict("Relabel application is not executable");
|
||||
}
|
||||
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
|
||||
PdaRelabelTask activeTask = tasks.lockUnfinishedByOperator(operatorKey);
|
||||
PdaRelabelTask activeTask = tasks.lockProcessingByOperator(operatorKey);
|
||||
if (activeTask != null && !Objects.equals(activeTask.getApplicationId(), application.getId())) {
|
||||
throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单");
|
||||
}
|
||||
@@ -194,6 +195,49 @@ public class PdaRelabelService {
|
||||
|
||||
public record Document(String scope, String fileId, String fileName, Long itemId) { }
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail scanSku(String operatorKey, String operatorName, long warehouseId,
|
||||
String relabelNo, PdaRelabelRequests.SkuScan request) {
|
||||
UnitCommand command = new UnitCommand(relabelNo, "SCAN_SKU", request);
|
||||
var replayed = replay(operatorKey, warehouseId, relabelNo, "SCAN_SKU", request.idempotencyKey(), command);
|
||||
if (replayed != null) return replayed;
|
||||
var app = requireApplication(relabelNo, warehouseId, true);
|
||||
var task = requireOwnedTask(app, operatorKey, true);
|
||||
requireVersion(task, request.expectedVersion());
|
||||
var item = requireItem(app, request.itemId());
|
||||
if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) throw conflict("该箱没有SKU换标任务");
|
||||
if (value(item.getNotifiedPackageCount()) != 1) throw conflict("SKU逐条作业要求每个仓库唛头独立一箱");
|
||||
var skus = readSkuItems(item.getSkuRelabelItemsJson());
|
||||
if (request.skuIndex() < 0 || request.skuIndex() >= skus.size()) throw conflict("SKU明细不存在");
|
||||
var sku = skus.get(request.skuIndex());
|
||||
if (!Objects.equals(sku.skuCode(), request.oldSku().trim()) || !StringUtils.hasText(sku.newSkuCode()) || sku.quantity() <= 0)
|
||||
throw conflict("旧SKU或绑定的新标签、数量不正确");
|
||||
if (factList(task).stream().anyMatch(fact -> item.getId().equals(fact.getItemId()))
|
||||
|| mixedStageDone(task, item.getId(), "SKU")) throw conflict("本箱SKU作业已完成");
|
||||
Set<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)
|
||||
public List<Document> documents(long warehouseId, String relabelNo) {
|
||||
RelabelApplication app = requireApplication(relabelNo, warehouseId, false);
|
||||
@@ -264,6 +308,7 @@ public class PdaRelabelService {
|
||||
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
|
||||
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
|
||||
requireVersion(task, request.expectedVersion());
|
||||
if (!request.manualCompletion()) bindingGuard.requireBound(application);
|
||||
List<RelabelItem> detailItems = itemList(application);
|
||||
List<PdaRelabelUnitFact> facts = factList(task);
|
||||
boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream()
|
||||
@@ -558,7 +603,7 @@ public class PdaRelabelService {
|
||||
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
|
||||
String operatorKey) {
|
||||
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
|
||||
application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
|
||||
application.getLabelDeadline(), application.getStatus(), effectiveTaskStatus(application, task),
|
||||
operationType(application), value(application.getItemCount()),
|
||||
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
|
||||
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
|
||||
@@ -580,7 +625,8 @@ public class PdaRelabelService {
|
||||
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
|
||||
count(itemFacts, "EXCEPTION"),
|
||||
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0),
|
||||
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0));
|
||||
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0),
|
||||
item.getDestinationFbaWarehouseCode());
|
||||
}).toList();
|
||||
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
|
||||
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
|
||||
@@ -593,7 +639,7 @@ public class PdaRelabelService {
|
||||
history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList();
|
||||
return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(),
|
||||
application.getLabelDeadline(), application.getContactName(), application.getContactPhone(),
|
||||
application.getStatus(), operationType(application), task == null ? "AVAILABLE" : task.getStatus(),
|
||||
application.getStatus(), operationType(application), effectiveTaskStatus(application, task),
|
||||
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
|
||||
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
|
||||
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
|
||||
@@ -605,6 +651,13 @@ public class PdaRelabelService {
|
||||
itemResponses, factResponses, historyResponses);
|
||||
}
|
||||
|
||||
private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) {
|
||||
if (task == null) return "AVAILABLE";
|
||||
if (RelabelApplicationService.CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())
|
||||
&& PROCESSING.equals(task.getStatus())) return WAIT_REVIEW;
|
||||
return task.getStatus();
|
||||
}
|
||||
|
||||
private void insertHistory(PdaRelabelTask task, String action, String from, String to,
|
||||
String operatorKey, String operatorName, Object detail) {
|
||||
PdaRelabelHistory history = new PdaRelabelHistory();
|
||||
|
||||
+178
-44
@@ -29,6 +29,10 @@ import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
|
||||
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
|
||||
@@ -84,6 +88,8 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
@Resource private InventoryCommandService inventoryCommands;
|
||||
@Resource private InventoryBalanceMapper inventoryBalances;
|
||||
@Resource private InventoryPositionMapper inventoryPositions;
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
|
||||
@Resource private RelabelDocumentFileGateway documentFiles;
|
||||
@Resource private PdaRelabelTaskMapper pdaTasks;
|
||||
@Resource private RelabelPhotoFileGateway photoFiles;
|
||||
@@ -238,7 +244,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
.gt(InventoryBalance::getAvailablePackages, 0)
|
||||
.like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode))
|
||||
.orderByDesc(InventoryBalance::getUpdatedAt));
|
||||
return balances.stream().map(balance -> {
|
||||
List<RelabelResponses.InventoryCandidate> result = new java.util.ArrayList<>(balances.stream().map(balance -> {
|
||||
List<RelabelResponses.InventoryPosition> positions = inventoryPositions.listBalance(customerId, balance.getId())
|
||||
.stream().filter(position -> value(position.getAvailablePackages()) > 0)
|
||||
.map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(),
|
||||
@@ -249,8 +255,27 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
balance.getInboundOrderNo(), balance.getInboundCode(), balance.getOverseasWarehouseId(),
|
||||
balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(),
|
||||
balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(),
|
||||
balance.getProductNameZh(), available, value(balance.getVersion()), positions);
|
||||
}).filter(candidate -> candidate.availablePackages() > 0).toList();
|
||||
balance.getProductNameZh(), available, value(balance.getVersion()), positions,
|
||||
"INBOUND", null, null, List.of());
|
||||
}).filter(candidate -> candidate.availablePackages() > 0).toList());
|
||||
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) {
|
||||
String keyword = trim(inboundCode);
|
||||
if (StringUtils.hasText(keyword)
|
||||
&& !containsIgnoreCase(carton.getCartonMark(), keyword)
|
||||
&& !containsIgnoreCase(carton.getSourceReturnNo(), keyword)) continue;
|
||||
List<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)
|
||||
@@ -280,6 +305,19 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
}
|
||||
@Transactional
|
||||
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
|
||||
return create(customerId, operatorId, request, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RelabelResponses.Detail createForExistingTailOrder(String customerId, String operatorId,
|
||||
Long existingTailOrderId,
|
||||
RelabelRequests.Create request) {
|
||||
if (existingTailOrderId == null) throw badRequest("Existing tail order is required");
|
||||
return create(customerId, operatorId, request, existingTailOrderId);
|
||||
}
|
||||
|
||||
private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request,
|
||||
Long existingTailOrderId) {
|
||||
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
|
||||
boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
|
||||
String deliveryMethod = trim(request.deliveryMethod());
|
||||
@@ -298,6 +336,13 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data");
|
||||
return detail(customerId, replay.getRelabelNo());
|
||||
}
|
||||
Set<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 notified = 0;
|
||||
List<SourceInventory> sources = new java.util.ArrayList<>();
|
||||
@@ -312,6 +357,25 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) {
|
||||
throw badRequest("The same carton mark cannot create more than one relabel task");
|
||||
}
|
||||
if (item.returnCartonId() != null) {
|
||||
ReturnInventoryCarton carton = returnCartons.lockId(item.returnCartonId());
|
||||
if (carton == null || !customerId.equals(carton.getCustomerId())
|
||||
|| !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) {
|
||||
throw conflict("所选退货箱不属于当前客户和操作仓");
|
||||
}
|
||||
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
|
||||
throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标");
|
||||
}
|
||||
if (!carton.getCartonMark().equalsIgnoreCase(item.cartonMark().trim())) {
|
||||
throw conflict("退货箱仓库唛头已变化,请重新查询");
|
||||
}
|
||||
sources.add(new SourceInventory(item, null, carton));
|
||||
continue;
|
||||
}
|
||||
if (item.sourceInventoryBalanceId() == null || item.sourceInventoryPositionId() == null
|
||||
|| item.sourceInboundOrderId() == null) {
|
||||
throw badRequest("正常入仓库存必须提供余额、仓位和入仓单");
|
||||
}
|
||||
InventoryPosition position = inventoryPositions.findTenantPosition(customerId, item.sourceInventoryPositionId());
|
||||
if (position == null || !Objects.equals(position.getBalanceId(), item.sourceInventoryBalanceId())) {
|
||||
throw conflict("Selected inventory position is no longer available");
|
||||
@@ -325,7 +389,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) {
|
||||
throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo());
|
||||
}
|
||||
sources.add(new SourceInventory(item, balance));
|
||||
sources.add(new SourceInventory(item, balance, null));
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
long applicationId = IdWorker.getId();
|
||||
@@ -335,13 +399,15 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
boolean requiresWallet = chargeAmount.signum() > 0;
|
||||
int holdIndex = 0;
|
||||
for (SourceInventory source : sources) {
|
||||
if (source.returnCarton() != null) continue;
|
||||
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo,
|
||||
null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++,
|
||||
"贴标申请冻结原入仓单库存"));
|
||||
}
|
||||
LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo,
|
||||
operationWarehouseId, total, now);
|
||||
LastlegOrder tailOrder = existingTailOrderId == null
|
||||
? insertTailOrder(customerId, request, requestHash, relabelNo, operationWarehouseId, total, now)
|
||||
: linkExistingTailOrder(customerId, existingTailOrderId, relabelNo, total, now);
|
||||
List<LastlegOrder> tailOrders = List.of(tailOrder);
|
||||
|
||||
RelabelApplication application = new RelabelApplication();
|
||||
@@ -372,6 +438,14 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setUpdatedAt(now);
|
||||
applications.insert(application);
|
||||
|
||||
for (SourceInventory source : sources) {
|
||||
if (source.returnCarton() == null) continue;
|
||||
ReturnInventoryCarton carton = source.returnCarton();
|
||||
carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo);
|
||||
carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now);
|
||||
returnCartons.updateById(carton);
|
||||
}
|
||||
|
||||
if (requiresWallet) {
|
||||
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
|
||||
relabelNo, chargeAmount, "换标订单费用冻结", 1L);
|
||||
@@ -383,8 +457,10 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
insertItem(customerId, applicationId, source, now);
|
||||
}
|
||||
insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
|
||||
tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null,
|
||||
order.getCurrentStatus(), "Relabel application submitted", now));
|
||||
if (existingTailOrderId == null) {
|
||||
tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null,
|
||||
order.getCurrentStatus(), "Relabel application submitted", now));
|
||||
}
|
||||
return detail(customerId, relabelNo);
|
||||
}
|
||||
|
||||
@@ -501,7 +577,8 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
}
|
||||
for (LastlegOrder order : linkedOrders) {
|
||||
String orderBefore = order.getCurrentStatus();
|
||||
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
|
||||
String mainStatus = "RELABEL_APPLICATION".equals(order.getOrderType())
|
||||
? "WAIT_RELABEL" : orderBefore;
|
||||
String orderStatus = switch (next) {
|
||||
case CUSTOMER_CONFIRM_PENDING -> mainStatus;
|
||||
case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType())
|
||||
@@ -606,23 +683,27 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
PdaRelabelTask reviewTask = pdaTasks.lock(customerId, application.getId());
|
||||
if (reviewTask == null || !Set.of("WAIT_REVIEW", "COMPLETED").contains(reviewTask.getStatus()))
|
||||
throw conflict("仓库尚未提交换标完工复核");
|
||||
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CUSTOMER_CONFIRMED");
|
||||
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
|
||||
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
|
||||
reviewTask.setUpdatedAt(LocalDateTime.now());
|
||||
if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新");
|
||||
if (Boolean.TRUE.equals(application.getPrintShippingLabel())
|
||||
&& !StringUtils.hasText(application.getShippingLabelFileId())) {
|
||||
throw conflict("Upload the shipping label before confirming relabel completion");
|
||||
}
|
||||
List<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();
|
||||
String before = application.getStatus();
|
||||
application.setStatus(READY_FOR_OUTBOUND);
|
||||
application.setStatus(requiresOutbound ? READY_FOR_OUTBOUND : "COMPLETED");
|
||||
application.setCustomerConfirmationKey(key);
|
||||
application.setCustomerConfirmedAt(now);
|
||||
application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod())
|
||||
? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION");
|
||||
if (!"TRUCK".equals(application.getDeliveryMethod())) {
|
||||
application.setFulfillmentStatus(requiresOutbound
|
||||
? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION")
|
||||
: "NOT_REQUIRED");
|
||||
if (requiresOutbound && !"TRUCK".equals(application.getDeliveryMethod())) {
|
||||
createDirectOutbound(application, operatorId, now);
|
||||
application.setFulfillmentStatus("OUTBOUND_CREATED");
|
||||
}
|
||||
@@ -630,7 +711,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
applications.updateById(application);
|
||||
for (LastlegOrder order : tailOrders(application)) {
|
||||
String orderBefore = order.getCurrentStatus();
|
||||
order.setCurrentStatus("PENDING_ASSIGNMENT");
|
||||
order.setCurrentStatus(requiresOutbound ? "PENDING_ASSIGNMENT" : "COMPLETED");
|
||||
order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus());
|
||||
order.setRelabelStatus("COMPLETED");
|
||||
order.setUpdatedAt(now);
|
||||
@@ -640,24 +721,34 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
request.remark(), now);
|
||||
}
|
||||
}
|
||||
insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId,
|
||||
insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId,
|
||||
request.remark(), now);
|
||||
// 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。
|
||||
if (warehouseTransfers != null) {
|
||||
WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo);
|
||||
if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) {
|
||||
transfer.setStatus("READY_FOR_DISPATCH");
|
||||
Set<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.setUpdatedAt(now);
|
||||
warehouseTransfers.updateById(transfer);
|
||||
application.setFulfillmentStatus(transferStatus);
|
||||
applications.updateById(application);
|
||||
// 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。
|
||||
LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId());
|
||||
if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) {
|
||||
String transferOrderBefore = transferOrder.getCurrentStatus();
|
||||
transferOrder.setCurrentStatus("READY_FOR_DISPATCH");
|
||||
transferOrder.setCurrentStatus(transferStatus);
|
||||
transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode());
|
||||
transferOrder.setRelabelStatus("COMPLETED");
|
||||
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:"
|
||||
+ "COMPLETED");
|
||||
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED");
|
||||
transferOrder.setUpdatedAt(now);
|
||||
orders.updateById(transferOrder);
|
||||
if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) {
|
||||
@@ -683,27 +774,28 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
|
||||
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND");
|
||||
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum());
|
||||
outbound.setPalletCount((int) relabelItems.stream().map(RelabelItem::getSourceInventoryPositionId).filter(Objects::nonNull).distinct().count());
|
||||
outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count());
|
||||
outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key);
|
||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
|
||||
Map<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()) {
|
||||
InventoryPosition position = inventoryPositions.selectById(entry.getKey());
|
||||
if (position == null || !application.getCustomerId().equals(position.getCustomerId())) {
|
||||
InventoryPosition position = entry.getKey() > 0 ? inventoryPositions.selectById(entry.getKey()) : null;
|
||||
if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId()))) {
|
||||
throw conflict("Relabel source inventory position no longer exists");
|
||||
}
|
||||
List<RelabelItem> group = entry.getValue();
|
||||
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId());
|
||||
item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId());
|
||||
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo());
|
||||
item.setWarehousePalletId(position == null ? null : position.getPalletId());
|
||||
item.setPalletNo(position == null ? null : position.getPalletNo());
|
||||
item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum());
|
||||
item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
|
||||
item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
|
||||
item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode)
|
||||
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
|
||||
item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
|
||||
item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), entry.getKey(), group));
|
||||
item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), position == null ? null : entry.getKey(), group));
|
||||
item.setCreatedAt(now); outboundItems.insert(item);
|
||||
}
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId());
|
||||
@@ -714,11 +806,14 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
|
||||
private String writeOutboundRefs(String relabelNo, Long positionId, List<RelabelItem> values) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo,
|
||||
"sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of(
|
||||
Map<String, Object> refs = new LinkedHashMap<>();
|
||||
refs.put("sourceType", "RELABEL_ORDER"); refs.put("relabelNo", relabelNo);
|
||||
if (positionId != null) refs.put("sourceInventoryPositionId", positionId);
|
||||
refs.put("items", values.stream().map(item -> Map.of(
|
||||
"itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
|
||||
"destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""),
|
||||
"packages", intValue(item.getNotifiedPackageCount()))).toList()));
|
||||
"packages", intValue(item.getNotifiedPackageCount()))).toList());
|
||||
return objectMapper.writeValueAsString(refs);
|
||||
} catch (Exception exception) { throw new IllegalStateException("Cannot save relabel outbound snapshot", exception); }
|
||||
}
|
||||
|
||||
@@ -740,14 +835,21 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
item.setDestinationFbaWarehouseCode(trim(source.destinationFbaWarehouseCode()));
|
||||
item.setOperationType(operationType(source));
|
||||
item.setSkuRelabelItemsJson(writeSkuItems(source.skuItems()));
|
||||
item.setSourceInventoryBalanceId(balance.getId());
|
||||
item.setSourceInventoryPositionId(source.sourceInventoryPositionId());
|
||||
item.setSourceInboundOrderId(balance.getInboundOrderId());
|
||||
item.setSourceInboundOrderNo(balance.getInboundOrderNo());
|
||||
item.setSourceInboundCode(balance.getInboundCode());
|
||||
item.setSourceWarehouseId(balance.getOverseasWarehouseId());
|
||||
item.setSourceWarehouseCode(balance.getOverseasWarehouseCode());
|
||||
item.setSourceWarehouseName(balance.getOverseasWarehouseName());
|
||||
if (balance != null) {
|
||||
item.setSourceInventoryBalanceId(balance.getId());
|
||||
item.setSourceInventoryPositionId(source.sourceInventoryPositionId());
|
||||
item.setSourceInboundOrderId(balance.getInboundOrderId());
|
||||
item.setSourceInboundOrderNo(balance.getInboundOrderNo());
|
||||
item.setSourceInboundCode(balance.getInboundCode());
|
||||
item.setSourceWarehouseId(balance.getOverseasWarehouseId());
|
||||
item.setSourceWarehouseCode(balance.getOverseasWarehouseCode());
|
||||
item.setSourceWarehouseName(balance.getOverseasWarehouseName());
|
||||
} else {
|
||||
ReturnInventoryCarton carton = sourceInventory.returnCarton();
|
||||
item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark());
|
||||
item.setSourceInboundOrderNo(carton.getSourceReturnNo());
|
||||
item.setSourceWarehouseId(carton.getOverseasWarehouseId());
|
||||
}
|
||||
item.setCreatedAt(now); items.insert(item);
|
||||
}
|
||||
|
||||
@@ -831,7 +933,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
order.setSourceInboundId(relabelNo + ":" + warehouseId);
|
||||
order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId);
|
||||
order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0);
|
||||
order.setShippedPackageCount(0); order.setCurrentStatus("PENDING_ASSIGNMENT");
|
||||
order.setShippedPackageCount(0); order.setCurrentStatus("WAIT_RELABEL");
|
||||
order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo);
|
||||
order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark()));
|
||||
order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId);
|
||||
@@ -840,6 +942,17 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
return order;
|
||||
}
|
||||
|
||||
private LastlegOrder linkExistingTailOrder(String customerId, Long orderId, String relabelNo,
|
||||
int packageCount, LocalDateTime now) {
|
||||
LastlegOrder order = orders.selectById(orderId);
|
||||
if (order == null || !customerId.equals(order.getCustomerId())) {
|
||||
throw conflict("Existing tail order does not belong to this customer");
|
||||
}
|
||||
order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING);
|
||||
order.setPackageCount(packageCount); order.setUpdatedAt(now); orders.updateById(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
private List<LastlegOrder> tailOrders(RelabelApplication application) {
|
||||
List<LastlegOrder> linked = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, application.getCustomerId())
|
||||
@@ -850,12 +963,27 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
return legacy == null ? List.of() : List.of(legacy);
|
||||
}
|
||||
|
||||
private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {}
|
||||
private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance,
|
||||
ReturnInventoryCarton returnCarton) {}
|
||||
private static long value(Long value) { return value == null ? 0L : value; }
|
||||
private static int intValue(Integer value) { return value == null ? 0 : value; }
|
||||
private static boolean containsIgnoreCase(String value, String fragment) {
|
||||
return value != null && fragment != null
|
||||
&& value.toLowerCase(java.util.Locale.ROOT).contains(fragment.toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
|
||||
private void releaseInventory(String customerId, String operatorId, RelabelApplication application) {
|
||||
for (RelabelItem item : itemList(application.getId())) {
|
||||
if (item.getReturnCartonId() != null) {
|
||||
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
|
||||
if (carton != null && customerId.equals(carton.getCustomerId())
|
||||
&& Objects.equals(application.getId(), carton.getRelabelApplicationId())
|
||||
&& "RELABEL_PENDING".equals(carton.getStatus())) {
|
||||
carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setRelabelStatus(null);
|
||||
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null) continue;
|
||||
inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null,
|
||||
@@ -938,9 +1066,15 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
|
||||
private static String businessRelabelStatus(String workflowStatus) {
|
||||
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
|
||||
return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
|
||||
return Set.of(CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
|
||||
? "COMPLETED" : PENDING_PROCESSING;
|
||||
}
|
||||
|
||||
private long outboundSourceKey(RelabelItem item) {
|
||||
if (item.getSourceInventoryPositionId() != null) return item.getSourceInventoryPositionId();
|
||||
if (item.getReturnCartonId() != null) return -item.getReturnCartonId();
|
||||
throw conflict("Relabel outbound item has no physical inventory source");
|
||||
}
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
|
||||
+52
@@ -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); }
|
||||
}
|
||||
+2
-1
@@ -6,6 +6,7 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
|
||||
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
|
||||
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -20,7 +21,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/last-leg/return-inventory")
|
||||
public class MerchantReturnInventoryController {
|
||||
public static final String LIST_PERMISSION = "merchant:overseas-warehouse:return-inventory:list";
|
||||
public static final String LIST_PERMISSION = OverseasPermissions.MERCHANT_RETURN_INVENTORY_LIST;
|
||||
@Resource private ReturnInventoryService service;
|
||||
|
||||
@GetMapping
|
||||
|
||||
@@ -43,6 +43,8 @@ public final class OverseasPermissions {
|
||||
public static final String MERCHANT_DELIVERY_LIST = "merchant:warehouse:delivery:list";
|
||||
public static final String MERCHANT_DELIVERY_CREATE = "merchant:warehouse:delivery:create";
|
||||
public static final String MERCHANT_INBOUND_LIST = "merchant:warehouse:inbound:list";
|
||||
public static final String MERCHANT_RETURN_INVENTORY_LIST =
|
||||
"merchant:overseas-warehouse:return-inventory:list";
|
||||
public static final String MERCHANT_PROCUREMENT = "merchant:cross-border:procurement";
|
||||
public static final String MERCHANT_TRUCKING = "merchant:cross-border:domestic-trucking";
|
||||
public static final String MERCHANT_LOOSEBOARD = "merchant:cross-border:pallet-shipping";
|
||||
|
||||
@@ -569,7 +569,7 @@ public class LastlegOrderService {
|
||||
|
||||
public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output)
|
||||
throws IOException {
|
||||
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,客户预报箱数,实收箱数,差异箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
final int batchSize = 1000;
|
||||
long page = 1;
|
||||
while (true) {
|
||||
@@ -585,7 +585,8 @@ public class LastlegOrderService {
|
||||
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
|
||||
csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId())
|
||||
? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()),
|
||||
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
|
||||
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getReceivedPackageCount()),
|
||||
csv(packageDifference(order)), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
|
||||
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
|
||||
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(relabelBusinessStatus(order.getRelabelStatus())),
|
||||
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
|
||||
@@ -677,7 +678,8 @@ public class LastlegOrderService {
|
||||
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode,
|
||||
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
|
||||
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
|
||||
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
|
||||
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(),
|
||||
o.getReceivedPackageCount(), packageDifference(o), o.getVolumeCbm(),
|
||||
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
|
||||
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()),
|
||||
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
|
||||
@@ -686,6 +688,11 @@ public class LastlegOrderService {
|
||||
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
|
||||
}
|
||||
|
||||
private static Integer packageDifference(LastlegOrder order) {
|
||||
if (order.getReceivedPackageCount() == null) return null;
|
||||
return order.getReceivedPackageCount() - java.util.Objects.requireNonNullElse(order.getPackageCount(), 0);
|
||||
}
|
||||
|
||||
static String displayOrderNo(LastlegOrder order) {
|
||||
return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo())
|
||||
? order.getSourceOrderNo() : order.getOrderNo();
|
||||
|
||||
+55
@@ -12,6 +12,14 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers")
|
||||
@@ -24,6 +32,30 @@ public class MerchantWarehouseTransferController {
|
||||
return ApiResponse.success(service.create(customer(user), operator(user), request));
|
||||
}
|
||||
|
||||
@PutMapping("/{transferNo}")
|
||||
public ApiResponse<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
|
||||
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid WarehouseTransferRequests.Query query) {
|
||||
@@ -36,6 +68,29 @@ public class MerchantWarehouseTransferController {
|
||||
return ApiResponse.success(service.detail(customer(user), transferNo));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}/carton-label")
|
||||
public ApiResponse<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")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> confirmMeasurement(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||
|
||||
+90
@@ -16,7 +16,16 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@@ -32,6 +41,42 @@ public class PdaWarehouseTransferController {
|
||||
return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId()));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}")
|
||||
public ApiResponse<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")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> palletize(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||
@@ -40,4 +85,49 @@ public class PdaWarehouseTransferController {
|
||||
return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(),
|
||||
transferNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{transferNo}/pallets/drafts")
|
||||
public ApiResponse<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());
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -24,14 +24,14 @@ public class WarehouseTransferController {
|
||||
@PostMapping
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid @RequestBody WarehouseTransferRequests.Create request) {
|
||||
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
|
||||
return ApiResponse.success(service.createAndSubmit(scope(user, customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) {
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query, false));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}")
|
||||
@@ -56,6 +56,17 @@ public class WarehouseTransferController {
|
||||
return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/bind-destination")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
|
||||
public ApiResponse<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")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user