test: strengthen overseas warehouse PDA contracts
This commit is contained in:
@@ -459,7 +459,7 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
required: [code, message, timestamp]
|
required: [code, message, timestamp]
|
||||||
properties:
|
properties:
|
||||||
code: { type: string, example: '00000' }
|
code: { type: string, example: '200' }
|
||||||
message: { type: string }
|
message: { type: string }
|
||||||
requestId: { type: string, nullable: true }
|
requestId: { type: string, nullable: true }
|
||||||
traceId: { type: string, nullable: true }
|
traceId: { type: string, nullable: true }
|
||||||
|
|||||||
+2
-2
@@ -133,8 +133,8 @@ class InboundStockReceiptServiceTest {
|
|||||||
|
|
||||||
ArgumentCaptor<InventoryCommandRequests.StockIn> requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
|
ArgumentCaptor<InventoryCommandRequests.StockIn> requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
|
||||||
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture());
|
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture());
|
||||||
assertEquals(List.of("SKU-1:2001", "SKU-2:2002"), requests.getAllValues().stream()
|
assertEquals(java.util.Set.of("SKU-1:2001", "SKU-2:2002"), requests.getAllValues().stream()
|
||||||
.map(value -> value.skuCode() + ":" + value.warehousePalletId()).toList());
|
.map(value -> value.skuCode() + ":" + value.warehousePalletId()).collect(java.util.stream.Collectors.toSet()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+14
-2
@@ -166,7 +166,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
|
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
|
||||||
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
|
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
|
||||||
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
||||||
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 9, "over-key")))
|
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key")))
|
||||||
.isInstanceOf(ResponseStatusException.class);
|
.isInstanceOf(ResponseStatusException.class);
|
||||||
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
||||||
|
|
||||||
@@ -186,7 +186,6 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void containerCompletionRequiresEveryInboundCodeToBalanceExactly() {
|
void containerCompletionRequiresEveryInboundCodeToBalanceExactly() {
|
||||||
stubCurrent("FBA", "CONT-A");
|
stubCurrent("FBA", "CONT-A");
|
||||||
stubSku();
|
|
||||||
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
|
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
|
||||||
partial.setLineId(10L); partial.setPickedPackageCount(1);
|
partial.setLineId(10L); partial.setPickedPackageCount(1);
|
||||||
when(allocations.selectList(any())).thenReturn(List.of(partial));
|
when(allocations.selectList(any())).thenReturn(List.of(partial));
|
||||||
@@ -232,6 +231,19 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
verify(unloadPhotos).validate("100", 8L, 3L);
|
verify(unloadPhotos).validate("100", 8L, 3L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signRejectsDuplicateRolesBeforeFileValidation() {
|
||||||
|
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||||
|
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
||||||
|
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||||
|
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L),
|
||||||
|
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.sign("100", "7", "receiver", request))
|
||||||
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
|
verifyNoInteractions(unloadPhotos);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() {
|
void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() {
|
||||||
stubCurrent("FBA", "CONT-A");
|
stubCurrent("FBA", "CONT-A");
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'),
|
|||||||
function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } }
|
function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } }
|
||||||
function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } }
|
function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } }
|
||||||
function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) {
|
function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) {
|
||||||
const calls = []; const steps = []; const gaps = []; let createdPallet = null
|
const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null
|
||||||
const context = {
|
const context = {
|
||||||
context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps,
|
context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps,
|
||||||
async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) },
|
async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) },
|
||||||
@@ -20,11 +20,23 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
|||||||
calls.push({ channel: 'overseas', method, path: requestPath, body })
|
calls.push({ channel: 'overseas', method, path: requestPath, body })
|
||||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
|
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
|
||||||
|
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') }
|
||||||
|
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return response(null) }
|
||||||
|
if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return response(null) }
|
||||||
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') }
|
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') }
|
||||||
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) {
|
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) {
|
||||||
if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409')
|
if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409')
|
||||||
createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return response(null)
|
createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return response(null)
|
||||||
}
|
}
|
||||||
|
if (method === 'POST' && requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) {
|
||||||
|
if (createdRelabel && body.remark !== createdRelabel.remark) return response(null, 'A0409')
|
||||||
|
if (!createdRelabel) createdRelabel = { ...body, relabelNo: 'FBR-TEST', status: 'PENDING_PROCESSING' }
|
||||||
|
return response({ application: createdRelabel, items: body.items, history: [] })
|
||||||
|
}
|
||||||
|
if (method === 'PUT' && requestPath.endsWith('/relabel-applications/FBR-TEST/status')) {
|
||||||
|
createdRelabel = { ...createdRelabel, status: body.status }
|
||||||
|
return response({ application: createdRelabel, items: createdRelabel.items, history: [] })
|
||||||
|
}
|
||||||
if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }]))
|
if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }]))
|
||||||
if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] })
|
if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] })
|
||||||
if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }]))
|
if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }]))
|
||||||
@@ -37,6 +49,9 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
|||||||
if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }]))
|
if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }]))
|
||||||
if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
|
if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
|
||||||
if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } })
|
if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } })
|
||||||
|
if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }]))
|
||||||
|
if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' })
|
||||||
|
if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return response(createdLocation)
|
||||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }]))
|
if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }]))
|
||||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' })
|
if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' })
|
||||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet)
|
if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet)
|
||||||
@@ -59,16 +74,18 @@ test('模块只依赖注入请求,不读取环境或自行联网', () => {
|
|||||||
assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/)
|
assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('既有仓库场景只写本轮托盘生命周期', () => {
|
test('既有仓库场景只写本轮库位托盘与换标生命周期', () => {
|
||||||
const context = contextFixture()
|
const context = contextFixture()
|
||||||
return run(context).then(() => {
|
return run(context).then(() => {
|
||||||
assert.ok(context.calls.length >= 12)
|
assert.ok(context.calls.length >= 12)
|
||||||
assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT'].includes(call.method)))
|
assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method)))
|
||||||
assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1)
|
assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1)
|
||||||
assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/')))
|
assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/')))
|
||||||
const writes = context.calls.filter((call) => call.method !== 'GET')
|
const writes = context.calls.filter((call) => call.method !== 'GET')
|
||||||
assert.deepEqual(writes.map((call) => call.method), ['POST', 'PUT', 'PUT', 'PUT'])
|
assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002')))
|
||||||
assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/pallets')))
|
assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets')))
|
||||||
|
assert.ok(writes.some((call) => call.path.includes('/relabel-applications')))
|
||||||
|
assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/') || call.path.includes('/relabel-applications')))
|
||||||
assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
|
assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -76,21 +93,29 @@ test('既有仓库场景只写本轮托盘生命周期', () => {
|
|||||||
test('覆盖冻结的海外只读列表与有记录才查详情', async () => {
|
test('覆盖冻结的海外只读列表与有记录才查详情', async () => {
|
||||||
const context = contextFixture()
|
const context = contextFixture()
|
||||||
await run(context)
|
await run(context)
|
||||||
for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/pallets?']) {
|
for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/locations?', '/pallets?']) {
|
||||||
assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken)
|
assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken)
|
||||||
}
|
}
|
||||||
for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/warehouse-admin/20001?', '/pallets/70001']) {
|
for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/warehouse-admin/20001?', '/pallets/70001']) {
|
||||||
assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken)
|
assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken)
|
||||||
}
|
}
|
||||||
assert.deepEqual(context.context.phase4OverseasWarehouse, { customerId: '10001', warehouseId: '20001', palletId: '71001' })
|
for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) {
|
||||||
assert.deepEqual(context.createdResources.phase4OverseasWarehouse, { marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED' })
|
const params = new URL(`http://acceptance${call.path}`).searchParams
|
||||||
|
assert.equal(params.has('page'), false, call.path)
|
||||||
|
assert.equal(params.has('size'), false, call.path)
|
||||||
|
}
|
||||||
|
assert.deepEqual(context.context.phase4OverseasWarehouse, { customerId: '10001', warehouseId: '20001', palletId: '71001', relabelNo: 'FBR-TEST' })
|
||||||
|
assert.deepEqual(context.createdResources.phase4OverseasWarehouse, {
|
||||||
|
marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED',
|
||||||
|
locationId: '72002', locationTerminalStatus: 'DELETED', relabelNo: 'FBR-TEST', relabelTerminalStatus: 'COMPLETED',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('缺少客户时记录缺口,缺少仓库时通过正式接口创建并停用fixture', async () => {
|
test('缺少客户时明确失败,缺少仓库时通过正式接口创建并停用fixture', async () => {
|
||||||
const noCustomer = contextFixture({ tenants: [] })
|
const noCustomer = contextFixture({ tenants: [] })
|
||||||
await run(noCustomer)
|
await run(noCustomer)
|
||||||
assert.equal(noCustomer.gaps.length, 1)
|
assert.equal(noCustomer.gaps.length, 0)
|
||||||
assert.match(noCustomer.gaps[0][3], /requestId=request-test.*traceId=trace-test/)
|
assert.ok(noCustomer.steps.some((step) => step.name === '海外仓验收必须取得ACTIVE客户' && !step.passed))
|
||||||
assert.equal(noCustomer.calls.filter((call) => call.channel === 'overseas').length, 0)
|
assert.equal(noCustomer.calls.filter((call) => call.channel === 'overseas').length, 0)
|
||||||
|
|
||||||
const noWarehouse = contextFixture({ warehouseRecords: [] })
|
const noWarehouse = contextFixture({ warehouseRecords: [] })
|
||||||
@@ -104,14 +129,17 @@ test('缺少客户时记录缺口,缺少仓库时通过正式接口创建并
|
|||||||
test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', () => {
|
test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', () => {
|
||||||
assert.match(source, /inventory\/balances\/export/)
|
assert.match(source, /inventory\/balances\/export/)
|
||||||
assert.match(source, /inventory\/ledger\/export/)
|
assert.match(source, /inventory\/ledger\/export/)
|
||||||
assert.match(source, /inventory\/summary[^\n]+customerId, page: 1, size: 200/)
|
assert.match(source, /inventory\/summary[^\n]+\{ customerId \}/)
|
||||||
assert.match(source, /inventory\/balances\/export[^\n]+customerId, page: 1, size: 200/)
|
assert.match(source, /inventory\/balances\/export[^\n]+\{ customerId \}/)
|
||||||
assert.match(source, /inventory\/ledger\/export[^\n]+customerId, page: 1, size: 200/)
|
assert.match(source, /inventory\/ledger\/export[^\n]+\{ customerId \}/)
|
||||||
for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden)
|
for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden)
|
||||||
assert.match(source, /requestId=/)
|
assert.match(source, /requestId=/)
|
||||||
assert.match(source, /traceId=/)
|
assert.match(source, /traceId=/)
|
||||||
assert.match(source, /longId\(plan\.sourceOrderId\)/)
|
assert.match(source, /longId\(plan\.sourceOrderId\)/)
|
||||||
assert.match(source, /id\(pallet\.customerId\) === '0'/)
|
assert.match(source, /id\(pallet\.customerId\) === '0'/)
|
||||||
assert.match(source, /换标创建与状态生命周期/)
|
assert.match(source, /创建本轮换标单/)
|
||||||
|
assert.match(source, /换标单同幂等键不同请求返回A0409/)
|
||||||
|
assert.match(source, /创建本轮公共库位/)
|
||||||
assert.match(source, /公共托盘旧版本编辑返回冲突/)
|
assert.match(source, /公共托盘旧版本编辑返回冲突/)
|
||||||
|
assert.doesNotMatch(source, /deploymentBlocker|addCoverageGap|coverageGap/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const MODULE = 'phase4-overseas-warehouse'
|
const MODULE = 'phase4-overseas-warehouse'
|
||||||
const SCENARIO = 'phase4-overseas-warehouse'
|
const SCENARIO = 'phase4-overseas-warehouse'
|
||||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
const OPTIONS = { scenario: SCENARIO }
|
||||||
|
|
||||||
function id(value) { return value == null ? '' : String(value) }
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
||||||
@@ -52,8 +52,8 @@ function assertPage(context, name, result) {
|
|||||||
context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data),
|
context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data),
|
||||||
`records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS)
|
`records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS)
|
||||||
}
|
}
|
||||||
function coverageGap(context, name, reason, category = 'prerequisite') {
|
function businessConflict(response) {
|
||||||
context.addCoverageGap(MODULE, SCENARIO, name, reason, category)
|
return response?.http === 409 || (response?.http === 200 && String(response?.body?.code) === 'A0409')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function run(context) {
|
export async function run(context) {
|
||||||
@@ -63,7 +63,8 @@ export async function run(context) {
|
|||||||
const customer = records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE')
|
const customer = records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE')
|
||||||
const customerId = id(customer?.id)
|
const customerId = id(customer?.id)
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
coverageGap(context, '海外只读接口显式客户范围', `IAM ACTIVE 客户列表未返回可用 customerId;${trace(tenantsResult.response)}`)
|
context.assert(MODULE, '海外仓验收必须取得ACTIVE客户', tenantsResult.response, false,
|
||||||
|
`IAM ACTIVE 客户列表未返回可用 customerId;${trace(tenantsResult.response)}`, OPTIONS)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export async function run(context) {
|
|||||||
`listNo=${plan.planNo}, detailNo=${detail.data?.planNo || 'missing'}, sourceOrderId=${id(detail.data?.sourceOrderId) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
`listNo=${plan.planNo}, detailNo=${detail.data?.planNo || 'missing'}, sourceOrderId=${id(detail.data?.sourceOrderId) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = await overseasGet(context, '查询库存汇总', query('/api/platform/v1/last-leg/inventory/summary', { customerId, page: 1, size: 200 }))
|
const summary = await overseasGet(context, '查询库存汇总(不传分页参数)', query('/api/platform/v1/last-leg/inventory/summary', { customerId }))
|
||||||
context.assert(MODULE, '库存汇总关键数量非负', summary.response,
|
context.assert(MODULE, '库存汇总关键数量非负', summary.response,
|
||||||
['inboundOrderCount', 'palletCount', 'totalPackages', 'availablePackages', 'holdPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages']
|
['inboundOrderCount', 'palletCount', 'totalPackages', 'availablePackages', 'holdPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages']
|
||||||
.every((field) => nonNegative(summary.data?.[field])),
|
.every((field) => nonNegative(summary.data?.[field])),
|
||||||
@@ -136,11 +137,8 @@ export async function run(context) {
|
|||||||
&& Number.isFinite(Number(ledgerRow.beforePackages)) && Number.isFinite(Number(ledgerRow.deltaPackages))
|
&& Number.isFinite(Number(ledgerRow.beforePackages)) && Number.isFinite(Number(ledgerRow.deltaPackages))
|
||||||
&& Number.isFinite(Number(ledgerRow.afterPackages)) && nonBlank(ledgerRow.occurredAt),
|
&& Number.isFinite(Number(ledgerRow.afterPackages)) && nonBlank(ledgerRow.occurredAt),
|
||||||
`id=${id(ledgerRow.id) || 'missing'}, type=${ledgerRow.businessType || 'missing'}, delta=${ledgerRow.deltaPackages ?? 'missing'}; ${trace(ledger.response)}`, OPTIONS)
|
`id=${id(ledgerRow.id) || 'missing'}, type=${ledgerRow.businessType || 'missing'}, delta=${ledgerRow.deltaPackages ?? 'missing'}; ${trace(ledger.response)}`, OPTIONS)
|
||||||
await overseasCsv(context, '下载SKU库存余额CSV', query('/api/platform/v1/last-leg/inventory/balances/export', { customerId, page: 1, size: 200 }))
|
await overseasCsv(context, '下载SKU库存余额CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/balances/export', { customerId }))
|
||||||
await overseasCsv(context, '下载进销存流水CSV', query('/api/platform/v1/last-leg/inventory/ledger/export', { customerId, page: 1, size: 200 }))
|
await overseasCsv(context, '下载进销存流水CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/ledger/export', { customerId }))
|
||||||
|
|
||||||
coverageGap(context, '换标创建与状态生命周期',
|
|
||||||
'当前 Admin 换标新增/审核仍使用不可提交的契约提示弹框,前端未真实接入写 API;验收模块不越过页面范围直接创建换标单', 'frontend-scope')
|
|
||||||
|
|
||||||
let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId)
|
let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId)
|
||||||
let ownedWarehouse = false
|
let ownedWarehouse = false
|
||||||
@@ -152,6 +150,19 @@ export async function run(context) {
|
|||||||
const warehouseId = id(warehouse.id)
|
const warehouseId = id(warehouse.id)
|
||||||
context.createdResources.phase4OverseasWarehouse.customerId = customerId
|
context.createdResources.phase4OverseasWarehouse.customerId = customerId
|
||||||
context.createdResources.phase4OverseasWarehouse.warehouseId = warehouseId
|
context.createdResources.phase4OverseasWarehouse.warehouseId = warehouseId
|
||||||
|
const locations = await overseasGet(context, '查询公共库位列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { page: 1, size: 20 }))
|
||||||
|
assertPage(context, '公共库位列表', locations)
|
||||||
|
const location = records(locations.data)[0]
|
||||||
|
if (location) {
|
||||||
|
context.assert(MODULE, '公共库位关键字段正确', locations.response,
|
||||||
|
longId(location.id) && id(location.warehouseId) === warehouseId && id(location.customerId) === '0'
|
||||||
|
&& nonBlank(location.locationCode) && nonBlank(location.status),
|
||||||
|
`id=${id(location.id) || 'missing'}, customerId=${id(location.customerId) || 'missing'}, status=${location.status || 'missing'}; ${trace(locations.response)}`, OPTIONS)
|
||||||
|
const detail = await overseasGet(context, '查询公共库位详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(id(location.id))}`)
|
||||||
|
context.assert(MODULE, '公共库位详情对应列表记录', detail.response,
|
||||||
|
id(detail.data?.id) === id(location.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0',
|
||||||
|
`listId=${id(location.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||||
|
}
|
||||||
const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 }))
|
const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 }))
|
||||||
assertPage(context, '公共托盘列表', pallets)
|
assertPage(context, '公共托盘列表', pallets)
|
||||||
const pallet = records(pallets.data)[0]
|
const pallet = records(pallets.data)[0]
|
||||||
@@ -167,13 +178,123 @@ export async function run(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await verifyLocationLifecycle(context, warehouseId)
|
||||||
await verifyPalletLifecycle(context, warehouseId)
|
await verifyPalletLifecycle(context, warehouseId)
|
||||||
context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: context.createdResources.phase4OverseasWarehouse.palletId }
|
await verifyRelabelLifecycle(context, customerId, warehouseId)
|
||||||
|
context.context.phase4OverseasWarehouse = {
|
||||||
|
customerId, warehouseId,
|
||||||
|
palletId: context.createdResources.phase4OverseasWarehouse.palletId,
|
||||||
|
relabelNo: context.createdResources.phase4OverseasWarehouse.relabelNo,
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (ownedWarehouse) await retireWarehouseFixture(context, customerId, warehouseId)
|
if (ownedWarehouse) await retireWarehouseFixture(context, customerId, warehouseId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyLocationLifecycle(context, warehouseId) {
|
||||||
|
const marker = String(context.marker || '').replace(/[^A-Za-z0-9_-]/g, '').slice(-40).toUpperCase() || 'PHASE4'
|
||||||
|
const locationCode = `ACC-${marker}`.slice(0, 64)
|
||||||
|
let locationId = ''
|
||||||
|
try {
|
||||||
|
const created = await overseasMutation(context, 'POST', '创建本轮公共库位',
|
||||||
|
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
|
||||||
|
zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1000,
|
||||||
|
status: 'ACTIVE', remark: context.marker,
|
||||||
|
})
|
||||||
|
locationId = id(created.data)
|
||||||
|
context.createdResources.phase4OverseasWarehouse.locationId = locationId || null
|
||||||
|
context.assert(MODULE, '公共库位创建返回长ID', created.response, longId(locationId),
|
||||||
|
`locationId=${locationId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS)
|
||||||
|
if (!locationId) return
|
||||||
|
const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`
|
||||||
|
const detail = await overseasGet(context, '查询本轮公共库位详情', detailPath)
|
||||||
|
context.assert(MODULE, '本轮公共库位创建事实正确', detail.response,
|
||||||
|
id(detail.data?.id) === locationId && id(detail.data?.customerId) === '0'
|
||||||
|
&& detail.data?.locationCode === locationCode && detail.data?.remark === context.marker,
|
||||||
|
`locationId=${id(detail.data?.id) || 'missing'}, code=${detail.data?.locationCode || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||||
|
const updated = await overseasMutation(context, 'PUT', '编辑本轮公共库位', detailPath, {
|
||||||
|
zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1200,
|
||||||
|
status: 'ACTIVE', remark: `${context.marker}-edited`,
|
||||||
|
})
|
||||||
|
context.assert(MODULE, '公共库位编辑成功', updated.response, updated.data === null,
|
||||||
|
`locationId=${locationId}; ${trace(updated.response)}`, OPTIONS)
|
||||||
|
const after = await overseasGet(context, '回查公共库位编辑结果', detailPath)
|
||||||
|
context.assert(MODULE, '公共库位编辑字段已生效', after.response,
|
||||||
|
Number(after.data?.maxLoadKg) === 1200 && after.data?.remark === `${context.marker}-edited`,
|
||||||
|
`maxLoadKg=${after.data?.maxLoadKg ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS)
|
||||||
|
} finally {
|
||||||
|
if (locationId) {
|
||||||
|
const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位',
|
||||||
|
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||||
|
context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = removed.data === null ? 'DELETED' : null
|
||||||
|
context.assert(MODULE, '本轮公共库位已删除收口', removed.response, removed.data === null,
|
||||||
|
`locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; ${trace(removed.response)}`, OPTIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyRelabelLifecycle(context, customerId, warehouseId) {
|
||||||
|
const marker = String(context.marker || 'PHASE4')
|
||||||
|
const idempotencyKey = `${marker}:RELABEL`.slice(0, 255)
|
||||||
|
const skuSuffix = marker.replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() || 'PHASE4'
|
||||||
|
const payload = {
|
||||||
|
customerOrderNo: `${marker}-RELABEL`, overseasWarehouseId: warehouseId,
|
||||||
|
labelDeadline: new Date(Date.now() + 86400000).toISOString().slice(0, 19),
|
||||||
|
contactName: 'Phase4 QA', contactPhone: '+1-213-555-0199', remark: marker, idempotencyKey,
|
||||||
|
items: [{ skuCode: `OLD-${skuSuffix}`.slice(0, 128), productNameZh: `${marker} 换标商品`,
|
||||||
|
totalPackageCount: 2, notifiedPackageCount: 2, shipmentNo: `SHIP-${skuSuffix}`.slice(0, 128),
|
||||||
|
newSkuCode: `NEW-${skuSuffix}`.slice(0, 128), labelRequirement: marker }],
|
||||||
|
}
|
||||||
|
let relabelNo = ''
|
||||||
|
let terminalStatus = ''
|
||||||
|
try {
|
||||||
|
const created = await overseasMutation(context, 'POST', '创建本轮换标单',
|
||||||
|
query('/api/platform/v1/last-leg/relabel-applications', { customerId }), payload)
|
||||||
|
relabelNo = id(created.data?.application?.relabelNo)
|
||||||
|
context.createdResources.phase4OverseasWarehouse.relabelNo = relabelNo || null
|
||||||
|
context.assert(MODULE, '换标单创建事实正确', created.response,
|
||||||
|
nonBlank(relabelNo) && created.data?.application?.customerOrderNo === payload.customerOrderNo
|
||||||
|
&& created.data?.application?.status === 'PENDING_PROCESSING'
|
||||||
|
&& Array.isArray(created.data?.items) && created.data.items.length === 1,
|
||||||
|
`relabelNo=${relabelNo || 'missing'}, status=${created.data?.application?.status || 'missing'}; ${trace(created.response)}`, OPTIONS)
|
||||||
|
if (!relabelNo) return
|
||||||
|
|
||||||
|
const replay = await overseasMutation(context, 'POST', '换标单幂等精确重放',
|
||||||
|
query('/api/platform/v1/last-leg/relabel-applications', { customerId }), payload)
|
||||||
|
context.assert(MODULE, '换标单精确重放返回同一单号', replay.response,
|
||||||
|
replay.data?.application?.relabelNo === relabelNo,
|
||||||
|
`expected=${relabelNo}, actual=${replay.data?.application?.relabelNo || 'missing'}; ${trace(replay.response)}`, OPTIONS)
|
||||||
|
|
||||||
|
const conflict = await context.requestOverseas('POST',
|
||||||
|
query('/api/platform/v1/last-leg/relabel-applications', { customerId }), { ...payload, remark: `${marker}-DIFFERENT` })
|
||||||
|
context.addStep(MODULE, '换标单同幂等键不同请求返回A0409', conflict, businessConflict(conflict), trace(conflict), OPTIONS)
|
||||||
|
|
||||||
|
const processing = await overseasMutation(context, 'PUT', '换标单进入处理中',
|
||||||
|
`/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`,
|
||||||
|
{ status: 'PROCESSING', remark: marker })
|
||||||
|
context.assert(MODULE, '换标单状态推进为PROCESSING', processing.response,
|
||||||
|
processing.data?.application?.status === 'PROCESSING',
|
||||||
|
`status=${processing.data?.application?.status || 'missing'}; ${trace(processing.response)}`, OPTIONS)
|
||||||
|
const completed = await overseasMutation(context, 'PUT', '完成本轮换标单',
|
||||||
|
`/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`,
|
||||||
|
{ status: 'COMPLETED', remark: `${marker}-completed` })
|
||||||
|
terminalStatus = completed.data?.application?.status || ''
|
||||||
|
context.createdResources.phase4OverseasWarehouse.relabelTerminalStatus = terminalStatus || null
|
||||||
|
context.assert(MODULE, '本轮换标单已完成收口', completed.response, terminalStatus === 'COMPLETED',
|
||||||
|
`relabelNo=${relabelNo}, status=${terminalStatus || 'missing'}; ${trace(completed.response)}`, OPTIONS)
|
||||||
|
} finally {
|
||||||
|
if (relabelNo && terminalStatus !== 'COMPLETED') {
|
||||||
|
const cancelled = await context.requestOverseas('PUT',
|
||||||
|
`/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`,
|
||||||
|
{ status: 'CANCELLED', remark: `${marker}-acceptance-cleanup` })
|
||||||
|
const status = cancelled?.body?.data?.application?.status
|
||||||
|
context.createdResources.phase4OverseasWarehouse.relabelTerminalStatus = status || null
|
||||||
|
context.addStep(MODULE, '异常路径换标单取消收口', cancelled,
|
||||||
|
cancelled?.http === 200 && String(cancelled?.body?.code) === '200' && status === 'CANCELLED', trace(cancelled), OPTIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createWarehouseFixture(context, customerId) {
|
async function createWarehouseFixture(context, customerId) {
|
||||||
const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase()
|
const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase()
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
Reference in New Issue
Block a user