Test #1
@@ -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<InboundMeasurementItemEntity> replacement) {
|
||||
if (command.measurementId() == null || replacement.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, InboundMeasurementItemEntity> 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, "测量数据来源不支持");
|
||||
|
||||
@@ -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<List<InboundMeasurementItemEntity>> 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"),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 操作历史',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user