From 6296f032f0706caa993041c521d9fa1a773dcf8d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 17:57:35 +0800 Subject: [PATCH] fix: retain scanned carton traceability in measurement drafts --- .../service/MeasurementBatchService.java | 36 +++++++++++++ .../service/MeasurementBatchServiceTest.java | 35 ++++++++++++ scripts/admin-test-env-acceptance.mjs | 54 +++++++++++++++++-- .../inbound-measurement.mjs | 28 ++++++++-- .../admin-test-env-modules/phase3-loading.mjs | 6 +-- ...ase4-cross-region-events-contract.test.mjs | 8 +++ 6 files changed, 155 insertions(+), 12 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index 960dcb8b1..f9b6099e3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -14,8 +14,10 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -71,6 +73,7 @@ public class MeasurementBatchService { throw concurrentModification(); } } + preserveScannedTraceability(command, measurement, calculatedItems); calculatedItems.forEach(item -> { item.setTenantId(command.tenantId()); item.setMeasurementId(measurement.getId()); @@ -79,6 +82,39 @@ public class MeasurementBatchService { return measurement; } + /** + * 人工补录会整批替换可编辑测量项,但不能抹掉同一项已冻结的扫码身份与设备取证。 + * 只按入库货物明细和明细号精确匹配,避免把一个箱码猜测性地复制给其他测量项。 + */ + private void preserveScannedTraceability(MeasurementBatchCommand command, + InboundMeasurementEntity measurement, + List replacement) { + if (command.measurementId() == null || replacement.isEmpty()) { + return; + } + Map scannedByItem = new HashMap<>(); + for (InboundMeasurementItemEntity existing : repository.listItems(command.tenantId(), measurement.getId())) { + if (existing.getBoxCode() != null && !existing.getBoxCode().isBlank()) { + scannedByItem.put(itemKey(existing.getInboundDetailId(), existing.getItemNo()), existing); + } + } + for (InboundMeasurementItemEntity item : replacement) { + InboundMeasurementItemEntity scanned = scannedByItem.get(itemKey(item.getInboundDetailId(), item.getItemNo())); + if (scanned == null) { + continue; + } + item.setExternalDetailNo(scanned.getExternalDetailNo()); + item.setBoxCode(scanned.getBoxCode()); + item.setScannedShipmentId(scanned.getScannedShipmentId()); + item.setMachineNo(scanned.getMachineNo()); + item.setDeviceMeasuredAt(scanned.getDeviceMeasuredAt()); + } + } + + private String itemKey(Long inboundDetailId, String itemNo) { + return String.valueOf(inboundDetailId) + ':' + itemNo; + } + private void validateSource(MeasurementBatchCommand command) { if (!SOURCE_MANUAL.equals(command.sourceType()) && !SOURCE_THIRD_PARTY.equals(command.sourceType())) { throw new BizException(ResultCode.BAD_REQUEST, "测量数据来源不支持"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index 8116e890d..22a46434c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -138,6 +138,41 @@ class MeasurementBatchServiceTest { org.mockito.ArgumentMatchers.eq(100L), any()); } + @Test + void manualDraftKeepsExactScannedCartonTraceability() { + InboundMeasurementEntity existing = new InboundMeasurementEntity(); + existing.setId(100L); + existing.setTenantId(1L); + existing.setInboundId(10L); + existing.setStatus("RECEIVED"); + existing.setVersion(3); + InboundMeasurementItemEntity scanned = new InboundMeasurementItemEntity(); + scanned.setInboundDetailId(80001L); + scanned.setItemNo("001"); + scanned.setExternalDetailNo("INB-001#1-1"); + scanned.setBoxCode("INB-001#1-1"); + scanned.setScannedShipmentId("FBA-001"); + scanned.setMachineNo("MJ-01"); + scanned.setDeviceMeasuredAt(LocalDateTime.of(2026, 9, 2, 10, 0)); + when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing); + when(repository.updateMeasurement(existing)).thenReturn(true); + when(repository.listItems(1L, 100L)).thenReturn(List.of(scanned)); + + service.saveBatch(new MeasurementBatchCommand( + 1L, 10L, 100L, 3, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(item(BigDecimal.ONE)))); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity saved = items.getValue().get(0); + assertThat(saved.getBoxCode()).isEqualTo("INB-001#1-1"); + assertThat(saved.getExternalDetailNo()).isEqualTo("INB-001#1-1"); + assertThat(saved.getScannedShipmentId()).isEqualTo("FBA-001"); + assertThat(saved.getMachineNo()).isEqualTo("MJ-01"); + assertThat(saved.getDeviceMeasuredAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); + } + private MeasurementBatchCommand.Item item(BigDecimal unitWeight) { return new MeasurementBatchCommand.Item( 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 321e9e3eb..3795e7a9a 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -1276,7 +1276,25 @@ export class AcceptanceRun { } } const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) - await this.executeModule('orders-phase3-loading-fixture', () => orders.run(this)) + // One-click dropship has a physically indivisible sealed pallet. The phase-three loading + // fixture normally creates four one-carton SKUs so it can exercise partial loading, but that + // would intentionally leave three cartons outside the sealed pallet and make the later + // cross-region handover invalid. Keep this producer variant to one carton only; the separate + // STANDARD incremental fixture below continues to cover SKIP/PARTIAL/dynamic mutations. + const primaryDropship = this.context.orderFulfillmentType === 'ONE_CLICK_DROPSHIP' + const hadWaitSplitVariant = Object.hasOwn(this.context, 'waitSplitVariant') + const savedWaitSplitVariant = this.context.waitSplitVariant + if (primaryDropship) { + this.context.waitSplitVariant = { + ...(savedWaitSplitVariant || {}), skuCount: 1, packageCountPerSku: 1, packageCounts: [1], + } + } + try { + await this.executeModule('orders-phase3-loading-fixture', () => orders.run(this)) + } finally { + if (hadWaitSplitVariant) this.context.waitSplitVariant = savedWaitSplitVariant + else delete this.context.waitSplitVariant + } const fixtureInbound = this.createdResources.orders const warehouseId = String(fixtureInbound?.receivingWarehouseId || '') this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null, @@ -1321,6 +1339,15 @@ export class AcceptanceRun { delete this.context.measurementPalletOverride this.context.phase3LoadingIncrementalPackageCount = 2 this.context.phase3LoadingIncrementalDetailCount = 4 + // orders.mjs derives the actual SKU/package shape from waitSplitVariant. Override that + // shape for this mutation fixture as well, otherwise a one-carton dropship primary leaks + // into the STANDARD incremental order and removes the SKIP/PARTIAL coverage data. + const hadIncrementalWaitSplitVariant = Object.hasOwn(this.context, 'waitSplitVariant') + const savedIncrementalWaitSplitVariant = this.context.waitSplitVariant + this.context.waitSplitVariant = { + ...(savedIncrementalWaitSplitVariant || {}), skuCount: 4, packageCountPerSku: 2, + packageCounts: [2, 2, 2, 2], + } this.context.orderProfile = { ...(this.context.orderProfile || {}), // The incremental order exercises partial/SKIP/PARTIAL mutations. Keep it STANDARD so a @@ -1328,9 +1355,22 @@ export class AcceptanceRun { fulfillmentType: 'STANDARD', ...(taxModes?.incremental ? { taxMode: taxModes.incremental } : {}), } - await this.executeModule('orders-phase3-loading-incremental-fixture', () => orders.run(this)) const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) - await this.executeModule('inbound-measurement-phase3-loading-incremental-fixture', () => measurement.run(this)) + // orders.mjs reads this top-level context field rather than orderProfile. Switch it as well + // so this mutation fixture is genuinely STANDARD, then restore the producer variant before + // the enclosing phase-four workflow records its ONE_CLICK_DROPSHIP facts. + const hadOrderFulfillmentType = Object.hasOwn(this.context, 'orderFulfillmentType') + const savedOrderFulfillmentType = this.context.orderFulfillmentType + this.context.orderFulfillmentType = 'STANDARD' + try { + await this.executeModule('orders-phase3-loading-incremental-fixture', () => orders.run(this)) + await this.executeModule('inbound-measurement-phase3-loading-incremental-fixture', () => measurement.run(this)) + } finally { + if (hadOrderFulfillmentType) this.context.orderFulfillmentType = savedOrderFulfillmentType + else delete this.context.orderFulfillmentType + if (hadIncrementalWaitSplitVariant) this.context.waitSplitVariant = savedIncrementalWaitSplitVariant + else delete this.context.waitSplitVariant + } const incrementalSource = this.createdResources.inboundMeasurement || this.createdResources.orders const incrementalFixture = incrementalSource ? { marker: originalMarker, @@ -1392,7 +1432,10 @@ export class AcceptanceRun { this.context.orderFulfillmentType = fulfillmentType this.context.phase4ProducerMatrixMarker = matrixMarker try { - restoreFixture = await this.preparePhase3LoadingFixture('', false) + // Every cross-region producer later verifies that a PDA inventory snapshot is invalidated + // after a real, public A0 rebind. Keep an isolated overflow occupancy for each variant; + // otherwise the OVERSEAS branch has only ordinary locations and cannot perform that check. + restoreFixture = await this.preparePhase3LoadingFixture('', true) const loading = await import(new URL('./admin-test-env-modules/phase3-loading.mjs', import.meta.url)) await this.executeModule(`phase3-loading-phase4-${label}`, () => loading.run(this)) const finalize = await import(new URL('./admin-test-env-modules/phase3-loading-finalize.mjs', import.meta.url)) @@ -1618,7 +1661,8 @@ export class AcceptanceRun { process.env.MERCHANT_TEST_PASSWORD = this.options.merchantPassword } if (this.options.waitSplitCount > 1 || this.options.waitSplitPackageCount > 0 - || this.options.orderProfileMode === 'randomized' || this.options.routeMode === 'random') { + || this.options.orderProfileMode === 'randomized' || this.options.routeMode === 'random' + || this.options.originWarehouseName || this.options.channelName || this.options.channelCode) { this.applyWaitSplitVariant(this.options.waitSplitSequence || 1) } else if (this.options.originWarehouseName || this.options.channelName || this.options.channelCode) { this.context.waitSplitRoute = { diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index cc5115cbd..0268afacb 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -63,9 +63,31 @@ async function reusablePallets(context, warehouseId) { `warehouseId=${warehouseId}`, OPTIONS) const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey && String(item?.palletCode || '') === palletCode && asId(item?.id)) - context.assert(MODULE, `原有托盘 ${palletCode} 属于当前仓库且可复用`, response, Boolean(exact), - `warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}`, OPTIONS) - if (!exact) return null + context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true, + `warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=${exact ? 'not-needed' : 'create-marker-owned'}`, OPTIONS) + if (!exact) { + // A newly selected development warehouse may not have the historical fixture pallets. + // Create one marker-owned pallet through the public platform API rather than coupling + // measurement acceptance to another warehouse's seed data. + const createResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', { + warehouseId, palletType: 'STANDARD', capacityCbm: 1.5, weightKg: 25, + color: 'ORANGE', remark: `${context.marker}-measurement-fixture`, + }) + const createdId = asId(context.apiSuccess(MODULE, '创建本轮称重托盘', createResponse, + `warehouseId=${warehouseId}`, OPTIONS)) + if (!createdId) return null + const detailResponse = await context.request('GET', + `/api/platform/v1/pallets/${encodeURIComponent(createdId)}?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`) + const created = context.apiSuccess(MODULE, '回读本轮称重托盘', detailResponse, + `warehouseId=${warehouseId}, palletId=${createdId}`, OPTIONS) + const valid = asId(created?.id) === createdId && asId(created?.warehouseId) === cacheKey + && String(created?.palletCode || '').trim() + context.assert(MODULE, '新建称重托盘归属当前仓库且编码完整', detailResponse, valid, + `warehouseId=${warehouseId}, palletId=${createdId}, palletCode=${created?.palletCode || 'missing'}`, OPTIONS) + if (!valid) return null + pallets.push({ id: createdId, code: String(created.palletCode) }) + break + } pallets.push({ id: asId(exact.id), code: palletCode }) } reusablePalletsByWarehouse.set(scopedCacheKey, pallets) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 0c6973665..cd06de4cd 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -666,8 +666,7 @@ export async function run(context) { staleBatch.http === 200 && codeOf(staleBatch) === 'A0409', `HTTP=${staleBatch.http}, code=${codeOf(staleBatch)}`, OPTIONS) - const beforeInvalidTaskResponse = await context.request('GET', - `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const beforeInvalidTaskResponse = await context.request('GET', taskDetailPath) const beforeInvalidTask = context.apiSuccess(MODULE, '非法混合批次前读取 PDA 全量任务基线', beforeInvalidTaskResponse, '', OPTIONS) const beforeInvalidOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) @@ -682,8 +681,7 @@ export async function run(context) { context.addStep(MODULE, 'PDA 原子批次任一非法明细时整批拒绝', invalidBatch, invalidBatch.http === 200 && codeOf(invalidBatch) === 'A0409', `HTTP=${invalidBatch.http}, code=${codeOf(invalidBatch)}`, OPTIONS) - const unchangedAfterInvalidResponse = await context.request('GET', - `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const unchangedAfterInvalidResponse = await context.request('GET', taskDetailPath) const unchangedAfterInvalid = context.apiSuccess(MODULE, '回查失败批次后的 PDA 任务快照', unchangedAfterInvalidResponse, '', OPTIONS) const afterInvalidOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) const afterInvalidOperations = context.apiSuccess(MODULE, '非法混合批次后回查 PDA 操作历史', diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index dc99c6d41..ab099c736 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -90,6 +90,14 @@ test('phase-four producer variants restore the primary orchestration fixture aft ]) assert.match(createdKeys, new RegExp(`'${key}'`)) }) +test('every cross-region producer keeps an isolated A0 occupancy for PDA snapshot invalidation', async () => { + const source = await import('node:fs/promises').then((fs) => fs.readFile( + new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')) + const variantStart = source.indexOf('async runPhase4ProducerVariant') + const fixtureCall = source.slice(variantStart, source.indexOf('const loading =', variantStart)) + assert.match(fixtureCall, /preparePhase3LoadingFixture\('', true\)/) +}) + test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => { for (const token of [ 'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',