fix(dropship): generate pallet numbers atomically and extend full regressions

This commit is contained in:
2026-09-09 12:19:16 +08:00
100 changed files with 2889 additions and 234 deletions
+4
View File
@@ -37,6 +37,10 @@ export async function run(context) {
## 可用上下文
货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。
一件代发的 `inbound-measurement` 前置通过 PDA 正式 `pallets/create-and-bind` 创建系统 DP 码并绑定待清点箱,不再按测试 marker 拼托盘号;默认一件代发后续链复用该前置。验证清点前及绑定后无待绑箱请求均拒绝且无空托、系统发码同操作重放保持托盘/计数不变、封托完成后原操作可重放;离线场景反向注入重复发码和空托,保证回归确实识别这两类故障。
- `request(method, path, body?, token?, extraHeaders?)`:JSON HTTP 请求,自动使用平台 Token;幂等写操作通过 `extraHeaders` 传 `Idempotency-Key`。
- `requestBinary(method, path)`:文件下载,不把二进制写入报告。
- `apiSuccess(module, name, response, evidence?, options?)`:断言 HTTP 200 + body code `200`,成功时返回 data。
@@ -224,9 +224,9 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
if (!requirement?.id) return null
for (let index = 0; index < chains.length; index += 1) {
const chain = chains[index]
const detailResponse = await context.request('GET', `${CONTAINERS}/${chain.containerBookingId}`)
const detailResponse = await context.request('GET', `${CONTAINERS}/domestic/${chain.containerBookingId}`)
const detail = context.apiSuccess(MODULE, `${chain.label} 关联前读取柜版本`, detailResponse,
evidence(detailResponse), OPTIONS)
evidence(detailResponse), OPTIONS)?.container
const containerNo = iso6346ContainerNumber(`${context.marker}-coload-${index + 1}`)
const linkResponse = await context.request('POST', `${CONTAINERS}/${chain.containerBookingId}/ocean-booking-link`, {
oceanBookingId, requirementId: id(requirement.id), containerNo,
@@ -1,9 +1,52 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { containerWorkbenchGaps } from './phase3-container-loading.mjs'
const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8')
test('workbench DTO rejects the old flat entity and incomplete action/state projections', () => {
const container = { id: '71', cabinetNo: 'CB-20260909-01', version: 0 }
const row = { container, workbenchStage: 'WAIT_LOADING', statusSummary: {}, allowedActions: [] }
assert.deepEqual(containerWorkbenchGaps(row), [])
assert.ok(containerWorkbenchGaps(container).includes('container.id'))
for (const field of ['container', 'workbenchStage', 'statusSummary', 'allowedActions']) {
const invalid = { ...row }
delete invalid[field]
assert.ok(containerWorkbenchGaps(invalid).length > 0, `missing ${field} must fail`)
}
assert.ok(containerWorkbenchGaps({ ...row, container: { ...container, version: null } }).includes('container.version'))
})
test('Admin read routes match controller workbench endpoints and default HTTP regression exercises the page DTO', () => {
const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java')
const admin = read('../../../apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts')
assert.match(controller, /@GetMapping\("\/domestic"\)/)
assert.match(controller, /@GetMapping\("\/domestic\/\{id\}"\)/)
assert.match(controller, /ApiResponse<PageResult<WorkbenchItem>>/)
assert.match(admin, /listContainers\([^\n]*workbench: ContainerWorkbenchKind = 'DOMESTIC'/)
assert.ok(admin.includes('`${containerPath}/${workbench.toLowerCase()}`'))
assert.ok(admin.includes('`${containerPath}/${workbench.toLowerCase()}/${encodeURIComponent(containerId)}`'))
const loading = read('./phase3-container-loading.mjs')
assert.ok(loading.includes('size=20&scope=ACTIVE&status='))
assert.ok(loading.includes('containerWorkbenchGaps(containerDetailResponse.body?.data)'))
assert.ok(loading.includes('records(page).every((item) => containerWorkbenchGaps(item).length === 0)'))
assert.ok(!loading.includes('value?.container || value'))
})
test('manual and coload consumers read domestic WorkbenchItem rather than the removed flat routes', () => {
for (const name of ['manual-loading-complete-fixture', 'manual-order-splitting-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'coload-groups']) {
const source = read(`./${name}.mjs`)
assert.ok(source.includes("context.request('GET', `${CONTAINERS}/domestic/${"), name)
assert.ok(!source.includes("context.request('GET', `${CONTAINERS}/${"), name)
assert.match(source, /OPTIONS\)\?\.container/, name)
}
const states = read('./manual-state-loading-pda.mjs')
assert.ok(states.includes('`${CONTAINERS}/domestic?scope=ACTIVE&status='))
assert.ok(states.includes('item.container?.planStatus === status'))
})
test('domestic container fixtures use the current workbench detail route', () => {
const containerLoading = read('./phase3-container-loading.mjs')
assert.ok(containerLoading.includes("context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)"))
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { completeDropshipPalletization } from './inbound-measurement.mjs'
const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8')
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
@@ -102,7 +103,10 @@ test('dropship measurement completes the formal palletization workflow before sm
assert.match(source, /pda-dropship\/tasks\/\$\{taskId\}\/claim/)
assert.match(source, /cartons\/resolve/)
assert.match(source, /cartons\/count/)
assert.match(source, /pallets\/bind-pending/)
assert.match(source, /pallets\/create-and-bind/)
assert.match(source, /系统发码重放不新增托盘且业务码和计数稳定/)
assert.match(source, /拒绝创建没有空托或计数漂移/)
assert.doesNotMatch(source, /palletHash|palletDate/)
assert.match(source, /pallets\/\$\{palletId\}\/seal/)
assert.match(source, /重复清点操作按operationId幂等返回/)
assert.doesNotMatch(source, /pallets\/open/)
@@ -117,3 +121,73 @@ test('confirmed package count reconciles an uncertain transport result before re
assert.match(source, /Number\(detail\?\.version\) > Number\(body\.measurementVersion\)/)
assert.match(source, /await updateConfirmedPackageCount\(context, measurementPath/)
})
async function exerciseSystemPalletScenario(fault = '') {
const task = { taskId: '81', inboundId: '82', warehouseId: '83', taskStatus: 'CLAIMED',
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [] }
const seenCounts = new Map()
const seenCreates = new Set()
const checks = []
const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) } })
const context = {
marker: 'fixture-system-pallet',
apiSuccess(_module, name, result) {
assert.equal(result.body.code, '200', name)
return result.body.data
},
assert(_module, name, _response, condition) { checks.push({ name, passed: Boolean(condition) }) },
async request(method, path, body) {
if (path.includes('/pending?')) return response([task])
if (path.endsWith('/claim') || (method === 'GET' && path.includes('/tasks/81?'))) return response(task)
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, expectedSkuQuantity: 10 })
if (path.endsWith('/cartons/count')) {
if (seenCounts.has(body.operationId)) return seenCounts.get(body.operationId)
task.countedCartons += 1
const targetPallet = task.pallets.find(pallet => pallet.palletNo === body.palletNo)
if (body.palletNo) { task.boundCartons += 1; targetPallet.cartonCount += 1; targetPallet.version += 1 }
else task.unboundCartons += 1
const result = response({ ...task, carton: { boxCode: body.boxCode, expectedSkuQuantity: 10, actualSkuQuantity: 10,
discrepancyStatus: 'MATCH' }, pallet: body.palletNo ? targetPallet : null })
seenCounts.set(body.operationId, result)
return result
}
if (path.endsWith('/create-and-bind')) {
assert.deepEqual(Object.keys(body).sort(), ['operationId', 'warehouseId'])
if (seenCreates.has(body.operationId)) {
if (fault === 'duplicate-replay') task.pallets.push({ ...task.pallets[0], palletId: '999', palletNo: 'DP-260909-0999' })
return response(task)
}
if (!task.unboundCartons) {
if (fault === 'empty-pallet') task.pallets.push({ palletId: '998', palletNo: 'DP-260909-0998', cartonCount: 0, status: 'OPEN', version: 0 })
return response(null, 'A0409')
}
seenCreates.add(body.operationId)
task.pallets.push({ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: task.unboundCartons, status: 'OPEN', version: 0 })
task.boundCartons += task.unboundCartons
task.unboundCartons = 0
return response(task)
}
if (path.endsWith('/84/seal')) {
task.taskStatus = 'COMPLETED'
task.pallets.forEach(pallet => { pallet.status = 'SEALED'; pallet.version += 1 })
return response(task)
}
throw new Error(`Unexpected scenario request: ${method} ${path}`)
},
}
await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } })
return checks
}
test('system-issued pallet HTTP scenario covers count, replay, empty rejection and completed replay', async () => {
const checks = await exerciseSystemPalletScenario()
assert.ok(checks.length >= 15)
assert.deepEqual(checks.filter(check => !check.passed), [])
})
test('system-issued pallet HTTP regression detects duplicate replay and orphan empty pallets', async () => {
const replayChecks = await exerciseSystemPalletScenario('duplicate-replay')
assert.ok(replayChecks.some(check => check.name.includes('系统发码重放不新增托盘') && !check.passed))
const emptyChecks = await exerciseSystemPalletScenario('empty-pallet')
assert.ok(emptyChecks.some(check => check.name.includes('拒绝创建没有空托或计数漂移') && !check.passed))
})
@@ -113,7 +113,7 @@ async function waitForCompletedMeasurement(context, measurementPath, expectedSta
return { response, measurement, attempts }
}
async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) {
export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) {
const pendingResponse = await context.request('GET',
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?keyword=${encodeURIComponent(inboundCode)}`)
const pending = context.apiSuccess(MODULE, '查询一件代发待合托任务', pendingResponse,
@@ -135,15 +135,31 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
`taskStatus=${claimed?.taskStatus ?? 'missing'}`, OPTIONS)
const totalCartons = Number(measurement?.actualPackageCount)
const palletDate = new Date().toISOString().slice(2, 10).replace(/-/g, '')
let palletHash = 0
for (const character of `${context.marker}:${taskId}`) {
palletHash = (Math.imul(palletHash, 31) + character.charCodeAt(0)) >>> 0
}
const palletNo = `DP-${palletDate}-${String(palletHash % 10_000).padStart(4, '0')}`
const createPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/create-and-bind`
const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
const createPayload = { warehouseId, operationId: `${context.marker}-dropship-create-bind`.slice(0, 128) }
let palletNo = ''
let countedCartons = 0
let pallet = null
const palletSnapshot = (value) => JSON.stringify({
counted: value?.countedCartons, bound: value?.boundCartons, unbound: value?.unboundCartons,
pallets: value?.pallets?.map((item) => ({ id: asId(item.palletId), no: item.palletNo,
count: item.cartonCount, status: item.status, version: item.version })),
})
async function rejectEmptyCreation(before, suffix) {
const response = await context.request('POST', createPath,
{ ...createPayload, operationId: `${context.marker}-dropship-empty-${suffix}`.slice(0, 128) })
context.assert(MODULE, `无待绑箱创建托盘被拒绝-${suffix}`, response,
response.http === 200 && String(response.body?.code) === 'A0409',
`HTTP=${response.http}, code=${response.body?.code || 'missing'}`, OPTIONS)
const afterResponse = await context.request('GET', taskPath)
const after = context.apiSuccess(MODULE, `拒绝创建后回查任务-${suffix}`, afterResponse, '', OPTIONS)
context.assert(MODULE, `拒绝创建没有空托或计数漂移-${suffix}`, afterResponse,
palletSnapshot(after) === palletSnapshot(before), `pallets=${after?.pallets?.length ?? 'missing'}`, OPTIONS)
}
await rejectEmptyCreation(claimed, 'before-count')
for (let index = 0; index < totalCartons; index += 1) {
const boxCode = `${inboundCode}#${totalCartons}-${index + 1}`
const resolveResponse = await context.request('GET',
@@ -189,18 +205,21 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
&& Number(replayed?.unboundCartons) === Number(counted?.unboundCartons),
`counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS)
const bindResponse = await context.request('POST',
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, {
warehouseId, palletNo,
operationId: `${context.marker}-dropship-bind-pending`.slice(0, 128),
})
const bound = context.apiSuccess(MODULE, '扫描托盘并绑定全部待组托箱', bindResponse,
`taskId=${taskId}, palletNo=${palletNo}`, OPTIONS)
pallet = bound?.pallets?.find((item) => item?.palletNo === palletNo) || null
const bindResponse = await context.request('POST', createPath, createPayload)
const bound = context.apiSuccess(MODULE, '系统发码创建托盘并绑定全部待组托箱', bindResponse,
`taskId=${taskId}`, OPTIONS)
pallet = bound?.pallets?.find((item) => Number(item.cartonCount) === 1) || null
palletNo = pallet?.palletNo || ''
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
&& pallet?.status === 'OPEN',
&& pallet?.status === 'OPEN' && /^DP-\d{6}-\d{4}$/.test(palletNo)
&& bound?.pallets?.length === 1,
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
const createReplayResponse = await context.request('POST', createPath, createPayload)
const createReplay = context.apiSuccess(MODULE, '系统发码同operationId重放', createReplayResponse, '', OPTIONS)
context.assert(MODULE, '系统发码重放不新增托盘且业务码和计数稳定', createReplayResponse,
palletSnapshot(createReplay) === palletSnapshot(bound), `palletNo=${palletNo}`, OPTIONS)
await rejectEmptyCreation(bound, 'after-bind')
} else {
pallet = counted?.pallet || pallet
context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse,
@@ -229,6 +248,11 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
&& Number(sealed?.unboundCartons) === 0
&& sealed?.pallets?.every((item) => item?.status === 'SEALED'),
`taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS)
const sealedReplayResponse = await context.request('POST', createPath, createPayload)
const sealedReplay = context.apiSuccess(MODULE, '封托完成后系统发码原操作仍可重放', sealedReplayResponse, '', OPTIONS)
context.assert(MODULE, '完成后重放不重开任务或新建空托', sealedReplayResponse,
sealedReplay?.taskStatus === 'COMPLETED' && palletSnapshot(sealedReplay) === palletSnapshot(sealed),
`taskStatus=${sealedReplay?.taskStatus || 'missing'}, palletNo=${palletNo}`, OPTIONS)
return sealed
}
@@ -86,8 +86,8 @@ export async function run(context) {
&& !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)),
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`, OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(source.containerBookingId)}`)
const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(source.containerBookingId)}`)
const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS)?.container
const bookingResponse = container?.oceanBookingId
? await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(container.oceanBookingId)}`)
: containerResponse
@@ -41,8 +41,8 @@ export async function run(context) {
OPTIONS)
if (!bookingId) return
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(bookingId)}`)
const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(bookingId)}`)
const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '柜次保持准备清单的仓库和渠道范围', containerResponse,
id(container?.id) === bookingId && id(container?.warehouseId) === id(manifest.warehouseId)
&& container?.channelCode === manifest.channelCode,
@@ -46,8 +46,8 @@ export async function run(context) {
OPTIONS)
if (!plan || beforeItems.length === 0) return
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '操作前柜次仍无真实柜号和订舱关联', containerResponse,
id(container?.id) === containerId && container?.cabinetNo === source.cabinetNo
&& container?.containerNo == null && container?.oceanBookingId == null
@@ -47,8 +47,8 @@ export async function run(context) {
`status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`,
OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS)?.container
const oceanBookingId = id(container?.oceanBookingId)
const realContainerNo = String(container?.containerNo || '')
context.assert(MODULE, '真实柜号和订舱原地补全且内部柜次身份不变', containerResponse,
@@ -47,8 +47,8 @@ export async function run(context) {
context.assert(MODULE, '未点击推送前不存在预装 bridge', linksResponse,
rows(links).length === 0, `linkCount=${rows(links).length}`, OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '当前仍是无真实柜号且未关联订舱的内部模拟柜', containerResponse,
id(container?.id) === containerId && container?.containerNo == null && container?.oceanBookingId == null
&& id(container?.warehouseId) === id(source.warehouseId) && container?.channelCode === source.channelCode,
@@ -87,8 +87,8 @@ export async function run(context) {
`HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}, requestId=${taskResponse.requestId || 'missing'}, traceId=${taskResponse.traceId || 'missing'}`,
OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '推送预装不伪造真实柜号或订舱关联', containerResponse,
id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo
&& container?.containerNo == null && container?.oceanBookingId == null,
@@ -316,10 +316,10 @@ async function complete(context, chain, label) {
}
async function verifyContainer(context, status, fixture) {
const response = await context.request('GET', `${CONTAINERS}?status=${status}&page=1&size=100`)
const response = await context.request('GET', `${CONTAINERS}/domestic?scope=ACTIVE&status=${status}&page=1&size=100`)
const page = context.apiSuccess(MODULE, `${status} 货柜可按列表状态筛选`, response, '', OPTIONS)
context.assert(MODULE, `${status} 货柜样本保留`, response,
records(page).some((item) => id(item.id) === fixture.id && item.planStatus === status),
records(page).some((item) => id(item.container?.id) === fixture.id && item.container?.planStatus === status),
`id=${fixture.id}`, OPTIONS)
}
@@ -22,7 +22,18 @@ function failure(response) {
|| (response?.http === 200 && codeOf(response) !== '200')
}
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
function containerRecord(value) { return value?.container || value }
function containerRecord(value) { return value?.container }
export function containerWorkbenchGaps(value) {
return [
!value?.container?.id && 'container.id',
!value?.container?.cabinetNo && 'container.cabinetNo',
!Number.isInteger(value?.container?.version) && 'container.version',
!value?.workbenchStage && 'workbenchStage',
(!value?.statusSummary || typeof value.statusSummary !== 'object') && 'statusSummary',
!Array.isArray(value?.allowedActions) && 'allowedActions',
].filter(Boolean)
}
async function waitForCompletedTask(context, taskId, warehouseId, attempts = 45) {
let response = null
@@ -262,6 +273,18 @@ export async function run(context) {
const containerDetailResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)
const containerDetail = containerRecord(context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS))
const workbenchGaps = containerWorkbenchGaps(containerDetailResponse.body?.data)
context.assert(MODULE, '货柜详情返回正式工作台 DTO 而非旧扁平实体', containerDetailResponse,
workbenchGaps.length === 0, `missing=${workbenchGaps.join('|') || 'none'}`, OPTIONS)
const pageResponse = await context.request('GET',
`${DOMESTIC_CONTAINERS}?page=1&size=20&scope=ACTIVE&status=${encodeURIComponent(containerDetail?.planStatus || '')}&keyword=${encodeURIComponent(containerDetail?.cabinetNo || '')}`)
const page = context.apiSuccess(MODULE, '按货柜页面筛选参数查询本轮模拟柜', pageResponse, '', OPTIONS)
const ownRows = records(page).filter((item) => id(item?.container?.id) === containerId)
context.assert(MODULE, '货柜页面列表返回唯一模拟柜和工作台动作状态', pageResponse,
ownRows.length === 1 && records(page).every((item) => containerWorkbenchGaps(item).length === 0)
&& ownRows[0]?.container?.cabinetNo === containerDetail?.cabinetNo
&& ownRows[0]?.container?.planStatus === containerDetail?.planStatus,
`matches=${ownRows.length}, rows=${records(page).length}`, OPTIONS)
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
(!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode
|| containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode)
@@ -475,7 +498,7 @@ export async function run(context) {
const activeContainersResponse = await context.request('GET',
`${DOMESTIC_CONTAINERS}?page=1&size=50&scope=ACTIVE&keyword=${encodeURIComponent(realContainerNo)}`)
const activeContainers = context.apiSuccess(MODULE, '关联后按真实柜号回查正常货柜列表', activeContainersResponse, '', OPTIONS)
const activeRealRows = records(activeContainers).map(containerRecord).filter((item) => item.containerNo === realContainerNo)
const activeRealRows = records(activeContainers).map(containerRecord).filter((item) => item?.containerNo === realContainerNo)
context.assert(MODULE, '关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份', activeContainersResponse,
activeRealRows.length === 1 && id(activeRealRows[0]?.id) === containerId,
`activeIds=${activeRealRows.map((item) => id(item?.id)).join('|') || 'none'}`, OPTIONS)
@@ -592,7 +615,7 @@ export async function run(context) {
if (!taskId || !pickItem?.id) return
const releasedContainerResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)
const releasedContainer = context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS)
const releasedContainer = containerRecord(context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS))
const stableIdentity = {
taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId),
@@ -26,7 +26,8 @@ const matrix = read('../../../docs/国内货柜履约/货柜管理全链验收
test('container acceptance reads the canonical domestic workbench route', () => {
assert.match(containerLoadingAcceptance, /const DOMESTIC_CONTAINERS = `\$\{CONTAINERS\}\/domestic`/)
assert.match(containerLoadingAcceptance, /function containerRecord\(value\) \{ return value\?\.container \|\| value \}/)
assert.match(containerLoadingAcceptance, /function containerRecord\(value\) \{ return value\?\.container \}/)
assert.doesNotMatch(containerLoadingAcceptance, /value\?\.container \|\| value/)
assert.match(containerLoadingAcceptance, /DOMESTIC_CONTAINERS\}\?page=1&size=50&scope=ACTIVE&keyword=/)
assert.doesNotMatch(containerLoadingAcceptance, /request\('GET', `\$\{CONTAINERS\}\/\$\{containerId\}`\)/)
assert.match(phase3LoadingAcceptance, /\/api\/platform\/v1\/containers\/domestic\/\$\{containerBookingId\}/)
@@ -25,7 +25,7 @@ function failure(response) {
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|| (response?.http === 200 && codeOf(response) !== '200')
}
function containerRecord(value) { return value?.container || value }
function containerRecord(value) { return value?.container }
async function ensureMatsonSupplier(context, records) {
const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME)
@@ -79,7 +79,8 @@ test('container API uses one stable long string identity', () => {
assert.match(endpoint, /sourceContainerBookingId:\s*\{ type: string/)
assert.match(controller, /convertContainer/)
assert.match(adminApi, /sourceContainerBookingId: string/)
assert.match(adminView, /sourceContainerBookingId: container\.sourceContainerBookingId/)
assert.match(adminView, /const bookingId = preview\.sourceContainerBookingId/)
assert.match(adminView, /convertLastMileContainerToInboundPlans\(\{\s*sourceContainerBookingId: bookingId,\s*snapshotVersion: preview\.snapshotVersion,\s*snapshotHash: preview\.snapshotHash,/)
})
test('backend batch conversion is one transaction and never merges same-customer orders', () => {