diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index acf8dced0..5d6fbc010 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2657,7 +2657,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数', carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标', - label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增', + label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '内部兼容批次字段,固定为1且不进入箱码', batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废', cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹', voided_at DATETIME(3) NULL COMMENT '批次作废时间', @@ -2674,7 +2674,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; --- 存量环境可能已在箱唛版本化前建表;CREATE TABLE IF NOT EXISTS 不会补列。 +-- 存量环境可能缺少稳定箱码映射兼容字段;CREATE TABLE IF NOT EXISTS 不会补列。 DROP PROCEDURE IF EXISTS unified_carton_label_add_column; DELIMITER $$ CREATE PROCEDURE unified_carton_label_add_column(IN p_column VARCHAR(64), IN p_definition TEXT) @@ -2688,7 +2688,7 @@ BEGIN END IF; END$$ DELIMITER ; -CALL unified_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''箱唛批次版本,从1递增'' AFTER carton_index'); +CALL unified_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index'); CALL unified_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version'); CALL unified_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status'); CALL unified_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint'); @@ -2699,9 +2699,12 @@ UPDATE fl_inbound_carton_label batch_status='ACTIVE' WHERE label_version IS NULL OR batch_status IS NULL; +DELETE FROM fl_inbound_carton_label WHERE batch_status='VOID'; + UPDATE fl_inbound_carton_label - SET box_code=CONCAT(inbound_code, '#V', label_version, '#', total_carton_count, '-', carton_index) - WHERE box_code NOT LIKE CONCAT(inbound_code, '#V%#%'); + SET label_version=1, + box_code=CONCAT(inbound_code, '#', total_carton_count, '-', carton_index) + WHERE batch_status='ACTIVE'; SET @ddl = IF( EXISTS(SELECT 1 FROM information_schema.statistics @@ -13760,7 +13763,7 @@ BEGIN AND table_name = 'fl_inbound_carton_label' AND column_name IN ('label_version', 'batch_status', 'cargo_fingerprint', 'voided_at')) <> 4 THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:箱唛版本化字段未完整创建'; + SET MESSAGE_TEXT = '统一迁移失败:稳定箱码映射兼容字段未完整创建'; END IF; IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundCartonLabelRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundCartonLabelRepository.java index 2195c3364..7cac757ec 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundCartonLabelRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundCartonLabelRepository.java @@ -52,25 +52,6 @@ public class InboundCartonLabelRepository { .eq(InboundCartonLabelEntity::getBoxCode, boxCode)); } - public int nextVersion(long tenantId, long inboundId) { - InboundCartonLabelEntity latest = labels.selectOne(new LambdaQueryWrapper() - .select(InboundCartonLabelEntity::getLabelVersion) - .eq(InboundCartonLabelEntity::getTenantId, tenantId) - .eq(InboundCartonLabelEntity::getInboundId, inboundId) - .orderByDesc(InboundCartonLabelEntity::getLabelVersion) - .last("LIMIT 1")); - return latest == null || latest.getLabelVersion() == null ? 1 : latest.getLabelVersion() + 1; - } - - public void voidActive(long tenantId, long inboundId, java.time.LocalDateTime at) { - labels.update(null, new LambdaUpdateWrapper() - .eq(InboundCartonLabelEntity::getTenantId, tenantId) - .eq(InboundCartonLabelEntity::getInboundId, inboundId) - .eq(InboundCartonLabelEntity::getBatchStatus, "ACTIVE") - .set(InboundCartonLabelEntity::getBatchStatus, "VOID") - .set(InboundCartonLabelEntity::getVoidedAt, at)); - } - public void initializeLegacyFingerprint(long tenantId, long inboundId, int version, String fingerprint) { labels.update(null, new LambdaUpdateWrapper() .eq(InboundCartonLabelEntity::getTenantId, tenantId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java index 3dafa0a89..71794fc5b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java @@ -59,8 +59,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { return batch(inbound, consistent); } if (existingFingerprint.equals(fingerprint)) return batch(inbound, consistent); - repository.voidActive(tenantId, inboundId, LocalDateTime.now()); - existing.clear(); + return batch(inbound, consistent); } int total = cargoes.stream().mapToInt(this::requirePackageCount).sum(); if (total < 1 || total > MAX_LABELS) { @@ -68,8 +67,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { } repository.updatePackageCount(tenantId, inboundId, total); inbound.setTotalPackageCount(total); - int labelVersion = repository.nextVersion(tenantId, inboundId); - requirePrintableInboundCode(inbound.getInboundCode(), total, labelVersion); + requirePrintableInboundCode(inbound.getInboundCode(), total); int cartonIndex = 0; LocalDateTime now = LocalDateTime.now(); @@ -77,7 +75,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { int detailCount = requirePackageCount(cargo); for (int offset = 0; offset < detailCount; offset++) { cartonIndex++; - InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, labelVersion, fingerprint, now); + InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, fingerprint, now); repository.insert(label); existing.add(label); } @@ -148,7 +146,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { InboundCartonLabelEntity label = repository.findByBoxCode(tenantId, boxCode.trim()); if (label == null) throw new BizException(ResultCode.NOT_FOUND, "箱号不存在"); if ("VOID".equals(label.getBatchStatus())) { - throw new BizException(ResultCode.CONFLICT, "箱唛版本已作废,请重新打印"); + throw new BizException(ResultCode.CONFLICT, "箱码已失效,请重新打印"); } return label; } @@ -182,11 +180,11 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { return cargo.packageCount(); } - private void requirePrintableInboundCode(String inboundCode, int total, int version) { + private void requirePrintableInboundCode(String inboundCode, int total) { if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) { throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码"); } - String longest = inboundCode + "#V" + version + "#" + total + "-" + total; + String longest = inboundCode + "#" + total + "-" + total; if (longest.codePointCount(0, longest.length()) > MAX_QR_CODE_POINTS) { throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码和箱号组合超过二维码长度限制"); } @@ -194,7 +192,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { private InboundCartonLabelEntity label(InboundOrderEntity inbound, OrderCargoClient.CargoLabelBinding cargo, - int total, int index, int labelVersion, String fingerprint, + int total, int index, String fingerprint, LocalDateTime now) { InboundCartonLabelEntity label = new InboundCartonLabelEntity(); label.setId(IdWorker.getId()); @@ -206,10 +204,10 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { label.setCargoName(cargo.cargoName()); label.setDetailPackageCount(cargo.packageCount()); label.setInboundCode(inbound.getInboundCode()); - label.setBoxCode(inbound.getInboundCode() + "#V" + labelVersion + "#" + total + "-" + index); + label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index); label.setTotalCartonCount(total); label.setCartonIndex(index); - label.setLabelVersion(labelVersion); + label.setLabelVersion(1); label.setBatchStatus("ACTIVE"); label.setCargoFingerprint(fingerprint); label.setLengthCm(cargo.lengthCm()); diff --git a/firstleg/src/main/resources/db/phase2-carton-label-versioning-migration.sql b/firstleg/src/main/resources/db/phase2-carton-label-versioning-migration.sql index 789afb4ef..b15f57089 100644 --- a/firstleg/src/main/resources/db/phase2-carton-label-versioning-migration.sql +++ b/firstleg/src/main/resources/db/phase2-carton-label-versioning-migration.sql @@ -12,7 +12,7 @@ BEGIN END IF; END$$ DELIMITER ; -CALL phase2_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''箱唛批次版本,从1递增'' AFTER carton_index'); +CALL phase2_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index'); CALL phase2_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version'); CALL phase2_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status'); CALL phase2_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint'); @@ -23,9 +23,12 @@ UPDATE fl_inbound_carton_label batch_status = 'ACTIVE' WHERE label_version IS NULL OR batch_status IS NULL; +DELETE FROM fl_inbound_carton_label WHERE batch_status = 'VOID'; + UPDATE fl_inbound_carton_label - SET box_code = CONCAT(inbound_code, '#V', label_version, '#', total_carton_count, '-', carton_index) - WHERE box_code NOT LIKE CONCAT(inbound_code, '#V%#%'); + SET label_version = 1, + box_code = CONCAT(inbound_code, '#', total_carton_count, '-', carton_index) + WHERE batch_status = 'ACTIVE'; SET @has_old_index := (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_carton_label' diff --git a/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql b/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql index 86cb6869c..e67fc4327 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql @@ -15,7 +15,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数', carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标', - label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增', + label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '内部兼容批次字段,固定为1且不进入箱码', batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废', cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹', voided_at DATETIME(3) NULL COMMENT '批次作废时间', diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 074c1f433..bb9bc4393 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -153,7 +153,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数', carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标', - label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增', + label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '内部兼容批次字段,固定为1且不进入箱码', batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废', cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹', voided_at DATETIME(3) NULL COMMENT '批次作废时间', diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java index 7b5d1ec8e..604b8b1a9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java @@ -38,7 +38,7 @@ class InboundCartonLabelSqlContractTest { .contains("uk_carton_label_box_code") .contains("uk_carton_label_index") .contains("稳定箱号映射表未创建") - .contains("箱唛版本化字段未完整创建") + .contains("稳定箱码映射兼容字段未完整创建") .contains("箱唛有效批次索引未完整创建"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java index 3114d3e8c..dd53f1372 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -50,7 +50,6 @@ class InboundCartonLabelServiceTest { var first = cargo(80001L, 1, 3, "60", "40", "30"); when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) .thenReturn(ApiResponse.success(List.of(second, first))); - when(repository.nextVersion(10001L, 70001L)).thenReturn(1); List inserted = new ArrayList<>(); org.mockito.Mockito.doAnswer(invocation -> { inserted.add(invocation.getArgument(0)); @@ -61,8 +60,8 @@ class InboundCartonLabelServiceTest { assertThat(batch.totalCount()).isEqualTo(5); assertThat(batch.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#V1#5-1", "PCHK2608040174#V1#5-2", "PCHK2608040174#V1#5-3", - "PCHK2608040174#V1#5-4", "PCHK2608040174#V1#5-5"); + .containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3", + "PCHK2608040174#5-4", "PCHK2608040174#5-5"); assertThat(batch.labels()).extracting(label -> label.inboundDetailId()) .containsExactly(80001L, 80001L, 80001L, 80002L, 80002L); assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm"); @@ -73,7 +72,7 @@ class InboundCartonLabelServiceTest { } @Test - void repeatedAndPartialPrintsReadCurrentVersionWithoutReallocation() { + void repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation() { InboundOrderEntity inbound = inbound(); inbound.setTotalPackageCount(5); List existing = labels(inbound, 5); @@ -92,7 +91,7 @@ class InboundCartonLabelServiceTest { } @Test - void changedCargoCreatesNewVersion() { + void changedCargoKeepsTheFirstFrozenBoxCodes() { InboundOrderEntity inbound = inbound(); inbound.setTotalPackageCount(4); when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); @@ -101,13 +100,11 @@ class InboundCartonLabelServiceTest { when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing); when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); - when(repository.nextVersion(10001L, 70001L)).thenReturn(2); - org.mockito.Mockito.doAnswer(invocation -> null).when(repository).insert(any()); var result = service.getOrCreate(10001L, 70001L); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#V2#2-1", "PCHK2608040174#V2#2-2"); - verify(repository).voidActive(org.mockito.ArgumentMatchers.eq(10001L), - org.mockito.ArgumentMatchers.eq(70001L), any()); + .containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3", + "PCHK2608040174#5-4", "PCHK2608040174#5-5"); + verify(repository, never()).insert(any()); } @Test @@ -119,14 +116,13 @@ class InboundCartonLabelServiceTest { when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); - when(repository.nextVersion(10001L, 70001L)).thenReturn(1); org.mockito.Mockito.doAnswer(invocation -> null).when(repository).insert(any()); var result = service.getOrCreate(10001L, 70001L); assertThat(result.totalCount()).isEqualTo(2); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#V1#2-1", "PCHK2608040174#V1#2-2"); + .containsExactly("PCHK2608040174#2-1", "PCHK2608040174#2-2"); } @Test @@ -143,7 +139,7 @@ class InboundCartonLabelServiceTest { } @Test - void changedCargoAfterLoadingVoidsCurrentBatchAndCreatesNextVersion() { + void changedCargoAfterLoadingKeepsTheFirstFrozenBoxCodes() { InboundOrderEntity inbound = inbound(); inbound.setProcessStatus("LOADING"); List existing = labels(inbound, 1); @@ -155,22 +151,19 @@ class InboundCartonLabelServiceTest { when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing); when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); - when(repository.nextVersion(10001L, 70001L)).thenReturn(2); - var result = service.getOrCreate(10001L, 70001L); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#V2#2-1", "PCHK2608040174#V2#2-2"); - verify(repository).voidActive(org.mockito.ArgumentMatchers.eq(10001L), - org.mockito.ArgumentMatchers.eq(70001L), any()); + .containsExactly("PCHK2608040174#1-1"); + verify(repository, never()).insert(any()); } @Test void resolvesBoxCodeInsideCustomerBoundary() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); - when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); - var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#V1#1-1 "); + var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 "); assertThat(resolved.inboundId()).isEqualTo(70001L); assertThat(resolved.inboundDetailId()).isEqualTo(80001L); @@ -178,30 +171,30 @@ class InboundCartonLabelServiceTest { assertThat(resolved.cartonIndex()).isEqualTo(1); assertThat(resolved.totalCartons()).isEqualTo(1); assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm"); - verify(repository).findByBoxCode(10001L, "PCHK2608040174#V1#1-1"); + verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1"); } @Test void exposesDetailedResolutionForFirstlegConsumers() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); - when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); - var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#V1#1-1"); + var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#1-1"); assertThat(resolved.orderId()).isEqualTo(60001L); - assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#V1#1-1"); + assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#1-1"); assertThat(resolved.cargoItemNo()).isEqualTo(1); } @Test - void rejectsVoidedLabelVersionDuringScanResolution() { + void rejectsInactiveBoxCodeDuringScanResolution() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); label.setBatchStatus("VOID"); - when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); - assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608040174#V1#1-1")) + assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608040174#1-1")) .isInstanceOf(BizException.class) - .hasMessage("箱唛版本已作废,请重新打印"); + .hasMessage("箱码已失效,请重新打印"); } @Test @@ -284,7 +277,7 @@ class InboundCartonLabelServiceTest { label.setCargoName("货物1"); label.setDetailPackageCount(total); label.setInboundCode(inbound.getInboundCode()); - label.setBoxCode(inbound.getInboundCode() + "#V1#" + total + "-" + index); + label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index); label.setTotalCartonCount(total); label.setCartonIndex(index); label.setLabelVersion(1); diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs new file mode 100644 index 000000000..84f59e8ef --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8') + +test('measurement acceptance scans the stable box code frozen by first print', () => { + assert.match(source, /const boxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-1`/) + assert.doesNotMatch(source, /#V\d+#/) +}) + +test('reprint and Mojia scan share the same frozen box code variable', () => { + assert.match(source, /重复整单打印复用已冻结箱码映射[\s\S]*boxCode=\$\{boxCode\}/) + assert.match(source, /const firstScanResponse = await multipartRequest[\s\S]*boxCode, \.\.\.firstDimensions/) +}) diff --git a/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs new file mode 100644 index 000000000..5e3eda40c --- /dev/null +++ b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { findPricedOrderRoute } from './orders.mjs' + +const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8') + +test('orders fixture verifies the complete pricing tuple before selecting an FBA warehouse', () => { + assert.match(source, /\/api\/merchant\/v1\/first-leg\/pricing\/estimate/) + for (const field of ['originWarehouseId', 'channelCode', 'destinationCode', 'destinationType', + 'destinationWarehouseId', 'pricingTaxMode', 'actualWeightKg', 'actualVolumeCbm']) { + assert.match(source, new RegExp(`${field}:`)) + } + assert.match(source, /estimateMatches\(response, candidate\)/) + assert.doesNotMatch(source, /const destination = records\(fbaPage\)\[0\]/) +}) + +test('order payload reuses the tax mode from the successful estimate', () => { + assert.match(source, /pricingTaxMode: master\.pricingTaxMode/) + assert.doesNotMatch(source, /pricingTaxMode: master\.channel\.pricingTaxModes\[0\]/) + assert.match(source, /没有可计价组合/) +}) + +test('pricing route accepts only an estimate matching the active version channel and tax tuple', async () => { + const requests = [] + const result = await findPricedOrderRoute({ + receiving: { id: '100' }, + destinations: [ + { id: '201', warehouseCode: 'ABE2', warehouseName: 'ABE2' }, + { id: '202', warehouseCode: 'ABE3', warehouseName: 'ABE3' }, + ], + channels: [{ priceBookVersionId: '300', channelCode: 'EXX_RAIL_NY', + pricingTaxModes: ['TAX_INCLUDED'] }], + concurrency: 1, + estimate: async (request) => { + requests.push(request) + if (request.destinationCode === 'ABE2') { + return { http: 200, body: { code: 'A0404' }, requestId: 'request-abe2', traceId: 'trace-abe2' } + } + return { http: 200, body: { code: '200', data: { + priceBookVersionId: '300', channelCode: 'EXX_RAIL_NY', pricingTaxMode: 'TAX_INCLUDED', + } } } + }, + }) + assert.equal(result.priced.destination.warehouseCode, 'ABE3') + assert.equal(result.attempts, 2) + assert.deepEqual(requests[1], { + originWarehouseId: '100', channelCode: 'EXX_RAIL_NY', destinationCode: 'ABE3', + destinationType: 'FBA', destinationWarehouseId: '202', destinationName: 'ABE3', + pricingTaxMode: 'TAX_INCLUDED', actualWeightKg: 46, actualVolumeCbm: 0.278, + }) + assert.equal(result.failures[0].requestId, 'request-abe2') +}) + +test('pricing route rejects a nominal success from a different version or tuple', async () => { + const result = await findPricedOrderRoute({ + receiving: { id: '100' }, + destinations: [{ id: '202', warehouseCode: 'ABE3', warehouseName: 'ABE3' }], + channels: [{ priceBookVersionId: '300', channelCode: 'EXX_RAIL_NY', + pricingTaxModes: ['TAX_INCLUDED'] }], + estimate: async () => ({ http: 200, body: { code: '200', data: { + priceBookVersionId: 'old-version', channelCode: 'OTHER', pricingTaxMode: 'TAX_EXCLUDED', + } }, requestId: 'request-drift', traceId: 'trace-drift' }), + }) + assert.equal(result.priced, null) + assert.equal(result.attempts, 1) + assert.match(result.failures[0].mismatch, /version=old-version,channel=OTHER,tax=TAX_EXCLUDED/) +}) + +test('pricing route bounds parallel probes and keeps deterministic candidate selection', async () => { + let active = 0; let maximumActive = 0 + const result = await findPricedOrderRoute({ + receiving: { id: '100' }, concurrency: 50, + destinations: Array.from({ length: 8 }, (_, index) => ({ + id: String(200 + index), warehouseCode: `FBA${index}`, warehouseName: `FBA ${index}`, + })), + channels: [{ priceBookVersionId: '300', channelCode: 'CHANNEL', pricingTaxModes: ['TAX_INCLUDED'] }], + estimate: async (request) => { + active += 1; maximumActive = Math.max(maximumActive, active) + await new Promise((resolve) => setTimeout(resolve, request.destinationCode === 'FBA0' ? 5 : 1)) + active -= 1 + return { http: 200, body: { code: '200', data: { + priceBookVersionId: '300', channelCode: 'CHANNEL', pricingTaxMode: 'TAX_INCLUDED', + } } } + }, + }) + assert.equal(maximumActive, 6) + assert.equal(result.priced.destination.warehouseCode, 'FBA0') + assert.equal(result.attempts, 6) +}) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 48de6e458..ad3b8f3a6 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -9,6 +9,10 @@ const BOXES = [ { boxType: 'EXTRA_LARGE', lengthCm: 60, widthCm: 50, heightCm: 50 }, ] const MEASURABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED']) +const PRICING_PROBE_CONCURRENCY = 6 +const ORDER_ESTIMATE_WEIGHT_KG = BOXES.reduce((total, _box, index) => total + 10 + index, 0) +const ORDER_ESTIMATE_VOLUME_CBM = BOXES.reduce((total, box) => + total + box.lengthCm * box.widthCm * box.heightCm / 1_000_000, 0) function id(value) { return value == null ? '' : String(value) } function records(value) { return Array.isArray(value?.records) ? value.records : [] } @@ -29,6 +33,77 @@ function tomorrow(days = 30) { return date.toISOString().slice(0, 10) } +function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) { + return { + originWarehouseId: receiving.id, + channelCode: channel.channelCode, + destinationCode: destination.warehouseCode, + destinationType: 'FBA', + destinationWarehouseId: destination.id, + destinationName: destination.warehouseName, + pricingTaxMode, + actualWeightKg: ORDER_ESTIMATE_WEIGHT_KG, + actualVolumeCbm: ORDER_ESTIMATE_VOLUME_CBM, + } +} + +function estimateMatches(response, candidate) { + const estimate = success(response) ? response.body?.data : null + return Boolean(estimate) + && id(estimate.priceBookVersionId) === id(candidate.channel.priceBookVersionId) + && id(estimate.channelCode) === id(candidate.channel.channelCode) + && id(estimate.pricingTaxMode) === id(candidate.pricingTaxMode) +} + +export async function findPricedOrderRoute({ receiving, destinations, channels, estimate, + concurrency = PRICING_PROBE_CONCURRENCY }) { + const candidates = [] + for (const channel of channels) { + if (!channel?.priceBookVersionId || !channel?.channelCode || !Array.isArray(channel.pricingTaxModes)) continue + for (const pricingTaxMode of channel.pricingTaxModes) { + if (!pricingTaxMode) continue + for (const destination of destinations) { + if (!destination?.id || !destination?.warehouseCode) continue + candidates.push({ channel, pricingTaxMode, destination, + request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) }) + } + } + } + const failures = [] + let lastResponse = null + const batchSize = Math.max(1, Math.min(Number(concurrency) || 1, PRICING_PROBE_CONCURRENCY)) + let attempts = 0 + for (let offset = 0; offset < candidates.length; offset += batchSize) { + const batch = candidates.slice(offset, offset + batchSize) + const results = await Promise.all(batch.map(async (candidate) => ({ + candidate, response: await estimate(candidate.request), + }))) + attempts += results.length + lastResponse = results.at(-1)?.response || lastResponse + const match = results.find(({ candidate, response }) => estimateMatches(response, candidate)) + if (match) return { priced: match.candidate, response: match.response, attempts, failures } + for (const { candidate, response } of results) { + const returned = success(response) ? response.body?.data : null + failures.push({ + tuple: `${candidate.channel.channelCode}/${candidate.pricingTaxMode}/${candidate.destination.warehouseCode}`, + http: response?.http ?? 'missing', code: response?.body?.code ?? 'missing', + requestId: response?.requestId ?? 'missing', traceId: response?.traceId ?? 'missing', + mismatch: returned + ? `version=${id(returned.priceBookVersionId) || 'missing'},channel=${id(returned.channelCode) || 'missing'},tax=${id(returned.pricingTaxMode) || 'missing'}` + : null, + }) + } + } + return { priced: null, response: lastResponse, attempts, failures } +} + +function pricingFailureEvidence(result) { + const describe = (failure) => failure + ? `${failure.tuple}[HTTP=${failure.http},code=${failure.code},requestId=${failure.requestId},traceId=${failure.traceId}${failure.mismatch ? `,response=${failure.mismatch}` : ''}]` + : 'missing' + return `没有可计价组合;attempts=${result.attempts}, first=${describe(result.failures[0])}, last=${describe(result.failures.at(-1))}` +} + async function request(context, method, path, body, token, extraHeaders = {}) { const headers = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...extraHeaders } let payload = body @@ -96,19 +171,32 @@ async function loadMasterData(context, merchantToken) { const fbaPage = context.apiSuccess(MODULE, 'Client 查询 FBA 目的仓', fbaResponse) context.assert(MODULE, '存在可用 FBA 目的仓', fbaResponse, records(fbaPage).length > 0, `count=${records(fbaPage).length}`) - const receiving = records(domesticPage)[0]; const destination = records(fbaPage)[0] - if (!receiving || !destination) throw new Error('缺少创建测试订单所需的收货仓或目的仓') + const receiving = records(domesticPage)[0] + const destinations = records(fbaPage) + if (!receiving || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓') const channelResponse = await request(context, 'GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, undefined, merchantToken) const channels = context.apiSuccess(MODULE, 'Client 查询可用计费渠道', channelResponse) - const channel = Array.isArray(channels) - ? channels.find((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0) - : null - context.assert(MODULE, '存在带计价税制的可用渠道', channelResponse, Boolean(channel), + const priceableChannels = Array.isArray(channels) + ? channels.filter((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0) + : [] + context.assert(MODULE, '存在带计价税制的可用渠道', channelResponse, priceableChannels.length > 0, `count=${Array.isArray(channels) ? channels.length : 'n/a'}`) - if (!channel) throw new Error('缺少创建测试订单所需的可用渠道') + if (priceableChannels.length === 0) throw new Error('缺少创建测试订单所需的可用渠道') + + const pricingResult = await findPricedOrderRoute({ + receiving, destinations, channels: priceableChannels, + estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), + }) + const priced = pricingResult.priced + context.addStep(MODULE, 'Client 选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced), + priced + ? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination.warehouseCode}, attempts=${pricingResult.attempts}` + : pricingFailureEvidence(pricingResult), + CONTRACT_OPTIONS) + if (!priced) throw new Error('当前起运仓、渠道、报价税制和FBA目的仓没有可计价组合') const partnerResponse = await request(context, 'GET', '/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken) @@ -116,7 +204,8 @@ async function loadMasterData(context, merchantToken) { const partner = Array.isArray(partners) ? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER')) : null - return { receiving, destination, channel, partner } + return { receiving, destination: priced.destination, channel: priced.channel, + pricingTaxMode: priced.pricingTaxMode, partner } } function buildPayload(context, profile, master, productImageFileId) { @@ -135,7 +224,7 @@ function buildPayload(context, profile, master, productImageFileId) { receivingWarehouseName: master.receiving.warehouseName, channelCode: master.channel.channelCode, channelName: master.channel.channelName, carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION', - pricingTaxMode: master.channel.pricingTaxModes[0], containerMode: 'LCL', + pricingTaxMode: master.pricingTaxMode, containerMode: 'LCL', customsInspection: false, destinationType: 'FBA', amazonWarehouseCode: master.destination.warehouseCode, shipmentId, referenceId,