This commit is contained in:
2026-08-14 10:57:05 +08:00
parent 66089f9b99
commit 91e781b3ff
16 changed files with 219 additions and 119 deletions
@@ -19,10 +19,53 @@ function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null }
async function createBooking(context, suffix, key, master) { return request(context, 'POST', BASE, { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master) }, { 'Idempotency-Key': key }) }
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
async function releaseLoadingBooking(context, containerFlow, suffix) {
const flowOceanBookingId = id(containerFlow?.oceanBookingId)
if (!flowOceanBookingId || !id(containerFlow?.containerBookingId) || !id(containerFlow?.loadingPlanId)) return null
const flowPath = `${BASE}/${flowOceanBookingId}`
let flowResponse = await request(context, 'GET', flowPath)
let flow = data(flowResponse)
context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse,
`oceanBookingId=${flowOceanBookingId}`, OPTIONS)
const allocationMatches = flow?.containerAllocations?.some?.((item) =>
id(item?.containerBookingId) === id(containerFlow.containerBookingId))
context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse,
allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId),
`oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') {
const flowRelease = await request(context, 'POST', `${flowPath}/release`, {
expectedVersion: Number(flow.oceanBooking.version), billOfLadingNo: `BL-FLOW-${suffix}`,
vesselName: `装货船-${suffix}`, voyageNo: `FV${suffix}`,
estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) })
context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS)
flowResponse = await request(context, 'GET', flowPath)
flow = data(flowResponse)
}
context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse,
flow?.oceanBooking?.status === 'RELEASED'
&& flow?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)),
`status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS)
return flow?.oceanBooking?.status === 'RELEASED' && allocationMatches ? {
id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker,
containerBookingId: id(containerFlow.containerBookingId), loadingPlanId: id(containerFlow.loadingPlanId),
finalStatus: 'RELEASED',
} : null
}
export async function run(context) {
const containerFlow = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading
const suffix = context.marker.replace(/\D/g, '').slice(-10)
const existing = context.createdResources?.oceanBooking
if (existing?.marker === context.marker && existing?.finalStatus === 'RELEASED') {
if (!id(containerFlow?.containerBookingId)
|| id(existing.containerBookingId) === id(containerFlow.containerBookingId)) return
const reconciled = await releaseLoadingBooking(context, containerFlow, suffix)
if (reconciled) context.createdResources.oceanBooking = reconciled
return
}
const master = await resolveOceanBookingMaster(context, MODULE)
if (!master) return
const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`)
@@ -95,41 +138,8 @@ export async function run(context) {
loadingPlanId: null,
finalStatus: fd?.oceanBooking?.status || null,
}
const flowOceanBookingId = id(containerFlow?.oceanBookingId)
if (flowOceanBookingId && id(containerFlow?.containerBookingId) && id(containerFlow?.loadingPlanId)) {
const flowPath = `${BASE}/${flowOceanBookingId}`
let flowResponse = await request(context, 'GET', flowPath)
let flow = data(flowResponse)
context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse,
`oceanBookingId=${flowOceanBookingId}`, OPTIONS)
const allocationMatches = flow?.containerAllocations?.some?.((item) =>
id(item?.containerBookingId) === id(containerFlow.containerBookingId))
context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse,
allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId),
`oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') {
const flowRelease = await request(context, 'POST', `${flowPath}/release`, {
expectedVersion: Number(flow.oceanBooking.version), billOfLadingNo: `BL-FLOW-${suffix}`,
vesselName: `装货船-${suffix}`, voyageNo: `FV${suffix}`,
estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) })
context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS)
flowResponse = await request(context, 'GET', flowPath)
flow = data(flowResponse)
}
context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse,
flow?.oceanBooking?.status === 'RELEASED'
&& flow?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)),
`status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS)
if (flow?.oceanBooking?.status === 'RELEASED') {
downstreamBooking = {
id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker,
containerBookingId: id(containerFlow.containerBookingId), loadingPlanId: id(containerFlow.loadingPlanId),
finalStatus: 'RELEASED',
}
}
}
const releasedLoadingBooking = await releaseLoadingBooking(context, containerFlow, suffix)
if (releasedLoadingBooking) downstreamBooking = releasedLoadingBooking
context.createdResources.oceanBooking = downstreamBooking
const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`)
step(context, '不存在订舱详情安全失败', missingDetail, failure(missingDetail), `HTTP=${missingDetail.http}, code=${codeOf(missingDetail)}`)
@@ -31,7 +31,8 @@ test('入库通过真实订单测量链保留测量态并最终进入 WAIT_SPLIT
assert.match(source, new RegExp(`'${status}'`))
}
assert.match(source, /\.\/inbound-measurement\.mjs/)
assert.match(source, /path\.replace\('\/measurement\/submit', '\/measurements\/reject'\)/)
assert.match(source, /`\/api\/platform\/v1\/inbounds\/\$\{query\(inboundId\)\}\/measurements\/reject\?tenantId=\$\{query\(tenantId\)\}`/)
assert.doesNotMatch(source, /path\.replace\('\/measurement\/submit', '\/measurements\/reject'\)/)
})
test('FBA 状态样本为订单头和每条货物行提供四个必填标识', () => {
@@ -44,7 +45,7 @@ test('FBA 状态样本为订单头和每条货物行提供四个必填标识', (
test('测量状态截停发生在正确的 HTTP 边界', () => {
assert.match(source, /includes\(summary\.mainStatus\)/)
assert.doesNotMatch(source, /includes\(summary\.processStatus\)/)
assert.match(source, /target === 'MEASURING' && data\(response\)\?\.processStatus === 'MEASURING'/)
assert.match(source, /target === 'MEASURING' && ok\(response\) && data\(response\)\?\.measurementId/)
assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/)
assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/)
assert.match(source, /measurements\/reject/)
@@ -179,7 +179,7 @@ async function prepareInboundStatus(context, target) {
const isMeasurementQuery = method === 'GET' && path.includes(`/inbounds/${inboundId}/measurement?`)
if (isMeasurementQuery) {
const response = await originalRequest.call(context, method, path, body, token, headers)
if (target === 'MEASURING' && data(response)?.processStatus === 'MEASURING') throw stop
if (target === 'MEASURING' && ok(response) && data(response)?.measurementId) throw stop
return response
}
if (target === 'MEASURE_PENDING' && isSubmit) {
@@ -192,11 +192,11 @@ async function prepareInboundStatus(context, target) {
if (!ok(submitted)) throw new Error(`MEASURE_REJECTED 提交失败: HTTP=${submitted?.http}, code=${submitted?.body?.code}`)
const latest = await originalRequest.call(context, 'GET', detailPath, undefined, token)
const version = Number(data(latest)?.summary?.version)
const rejected = await originalRequest.call(context, 'POST',
path.replace('/measurement/submit', '/measurements/reject'),
const rejectPath = `/api/platform/v1/inbounds/${query(inboundId)}/measurements/reject?tenantId=${query(tenantId)}`
const rejected = await originalRequest.call(context, 'POST', rejectPath,
{ measurementId: body?.measurementId, expectedVersion: version, reason: `${fixtureMarker} 保留审核驳回样本` },
token, { 'Idempotency-Key': `${fixtureMarker}-reject`.slice(0, 128) })
if (!ok(rejected)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${rejected?.http}, code=${rejected?.body?.code}`)
if (!ok(rejected)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${rejected?.http}, code=${rejected?.body?.code}, requestId=${rejected?.requestId || 'missing'}, traceId=${rejected?.traceId || 'missing'}`)
throw stop
}
return originalRequest.call(context, method, path, body, token, headers)
@@ -26,6 +26,29 @@ test('PDA opens only after a real container number and uses stable plan identity
assert.doesNotMatch(source, /\$\{PDA\}\/tasks\?containerNo=/)
})
test('each remainder settles only through inventory snapshot, atomic batch and explicit complete', () => {
const claimIndex = source.indexOf("`${PDA}/tasks/${taskId}/claim`")
const snapshotIndex = source.indexOf('inventory-snapshot?warehouseId=')
const batchIndex = source.indexOf('const batchPath = `${PDA}/tasks/${taskId}/batch`')
const completeIndex = source.indexOf('const completePath = `${PDA}/tasks/${taskId}/complete`')
const treeIndex = source.indexOf('`回查 ${sourceOrderNo} 拆分后的根聚合`')
assert.ok(claimIndex >= 0 && snapshotIndex > claimIndex && batchIndex > snapshotIndex
&& completeIndex > batchIndex && treeIndex > completeIndex)
assert.match(source, /loadingPlanId: planId, containerBookingId: containerId/)
assert.match(source, /expectedVersion: Number\(inventory\.taskVersion\)/)
assert.match(source, /inventorySnapshotToken: inventory\.snapshotToken/)
assert.match(source, /action: 'PICK', pickedPackageCount: 1/)
assert.match(source, /version: Number\(claimedItem\.version\), releaseMode: 'MANUAL', locationIds: \[\]/)
assert.match(source, /'Idempotency-Key': batchKey/)
assert.match(source, /differentReplay/)
assert.match(source, /workflowStatus === 'AWAITING_REVIEW'/)
assert.match(source, /expectedTaskVersion: Number\(picked\?\.version\)/)
assert.match(source, /operationId: `\$\{operationBase\}-complete`/)
assert.match(source, /completeReplay/)
assert.match(source, /workflowStatus === 'COMPLETED'/)
assert.doesNotMatch(source, /\/items\/\$\{[^}]+\}\/pick/)
})
test('booking freezes the source customer channel and warehouse scope', () => {
assert.match(source, /customerId: source\.tenantId/)
assert.match(source, /channelCode: source\.channelCode/)
@@ -232,43 +232,83 @@ async function loadOneFragment(context, state) {
const claim = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, {
expectedAssignmentVersion: 0, operationId: `${operationBase}-claim`,
})
context.apiSuccess(MODULE, `领取 ${sourceOrderNo} 的 PDA 任务`, claim, '', OPTIONS)
const claimed = context.apiSuccess(MODULE, `领取 ${sourceOrderNo} 的 PDA 任务`, claim, '', OPTIONS)
const claimedItem = claimed?.items?.find?.((row) => id(row.id) === id(pickItem.id)) || pickItem
const inventoryResponse = await context.request('GET',
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const inventory = context.apiSuccess(MODULE, `冻结 ${sourceOrderNo} 的 PDA 库存快照`, inventoryResponse, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 库存快照绑定稳定任务身份`, inventoryResponse,
id(inventory?.taskId) === taskId && id(inventory?.loadingPlanId) === planId
&& id(inventory?.containerBookingId) === containerId && id(inventory?.warehouseId) === warehouseId
&& Number(inventory?.taskVersion) === Number(claimed?.version)
&& typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64,
`taskId=${id(inventory?.taskId) || 'missing'}, taskVersion=${inventory?.taskVersion ?? 'missing'}, loadingPlanId=${id(inventory?.loadingPlanId) || 'missing'}, containerBookingId=${id(inventory?.containerBookingId) || 'missing'}`, OPTIONS)
if (!inventory?.snapshotToken) return null
const stalePick = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, {
warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version) + 1,
operationId: `${operationBase}-pick-stale`,
})
context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧明细版本被拒绝`, stalePick,
isConflict(stalePick) && hasTrace(stalePick),
`HTTP=${stalePick.http}, code=${codeOf(stalePick)}, requestId=${stalePick.requestId || 'missing'}, traceId=${stalePick.traceId || 'missing'}`, OPTIONS)
const pickPayload = {
warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version),
operationId: `${operationBase}-pick`,
const batchPath = `${PDA}/tasks/${taskId}/batch`
const batchKey = `${operationBase}-batch`.slice(0, 128)
const batchPayload = {
warehouseId, loadingPlanId: planId, containerBookingId: containerId,
expectedVersion: Number(inventory.taskVersion), inventorySnapshotToken: inventory.snapshotToken,
items: [{
itemId: id(claimedItem.id), action: 'PICK', pickedPackageCount: 1,
version: Number(claimedItem.version), releaseMode: 'MANUAL', locationIds: [],
}],
}
const pick = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload)
const picked = context.apiSuccess(MODULE, `真实拣货并拆分 ${sourceOrderNo}`, pick, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 本分片只承接1箱`, pick,
picked?.items?.some?.((row) => id(row.id) === id(pickItem.id)
&& Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1),
const staleBatch = await request(context, 'POST', batchPath, {
...batchPayload, expectedVersion: Number(inventory.taskVersion) + 1,
}, { 'Idempotency-Key': `${operationBase}-batch-stale`.slice(0, 128) })
context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧任务版本被拒绝且不结算`, staleBatch,
isConflict(staleBatch) && hasTrace(staleBatch),
`HTTP=${staleBatch.http}, code=${codeOf(staleBatch)}, requestId=${staleBatch.requestId || 'missing'}, traceId=${staleBatch.traceId || 'missing'}`, OPTIONS)
const batch = await request(context, 'POST', batchPath, batchPayload, { 'Idempotency-Key': batchKey })
const picked = context.apiSuccess(MODULE, `原子提交 ${sourceOrderNo} 的完整 PDA 草稿`, batch, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 批次拣出1箱并等待人工确认`, batch,
picked?.taskStatus === 'LOADING' && picked?.workflowStatus === 'AWAITING_REVIEW'
&& picked?.items?.some?.((row) => id(row.id) === id(claimedItem.id)
&& Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1),
`workflow=${picked?.workflowStatus || 'missing'}, taskStatus=${picked?.taskStatus || 'missing'}`, OPTIONS)
const replay = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload)
const replayed = context.apiSuccess(MODULE, `${sourceOrderNo} 拆分幂等重放`, replay, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 幂等重放不重复累计`, replay,
id(replayed?.id) === taskId && replayed?.items?.some?.((row) => id(row.id) === id(pickItem.id)
&& Number(row.pickedPackageCount) === 1),
`taskId=${id(replayed?.id) || 'missing'}, picked=${replayed?.items?.find?.((row) => id(row.id) === id(pickItem.id))?.pickedPackageCount ?? 'missing'}`, OPTIONS)
const replay = await request(context, 'POST', batchPath, batchPayload, { 'Idempotency-Key': batchKey })
const replayed = context.apiSuccess(MODULE, `${sourceOrderNo} PDA 批次幂等重放`, replay, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 批次重放不重复累计`, replay,
id(replayed?.id) === taskId && Number(replayed?.version) === Number(picked?.version)
&& replayed?.items?.some?.((row) => id(row.id) === id(claimedItem.id)
&& Number(row.pickedPackageCount) === 1),
`taskId=${id(replayed?.id) || 'missing'}, taskVersion=${replayed?.version ?? 'missing'}`, OPTIONS)
if (sourcePackageCount > 1) {
const differentReplay = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, {
...pickPayload, pickedPackageCount: 2,
})
const differentReplay = await request(context, 'POST', batchPath, {
...batchPayload,
items: [{ ...batchPayload.items[0], pickedPackageCount: 2 }],
}, { 'Idempotency-Key': batchKey })
context.addStep(MODULE, `${sourceOrderNo} 同幂等键异参被拒绝`, differentReplay,
isConflict(differentReplay) && hasTrace(differentReplay),
`HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS)
}
const completePath = `${PDA}/tasks/${taskId}/complete`
const completePayload = {
expectedTaskVersion: Number(picked?.version), operationId: `${operationBase}-complete`.slice(0, 128),
remark: context.marker,
}
const complete = await request(context, 'POST', completePath, completePayload)
const completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, complete,
completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED'
&& completed?.items?.some?.((row) => id(row.id) === id(claimedItem.id)
&& Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1),
`workflow=${completed?.workflowStatus || 'missing'}, taskStatus=${completed?.taskStatus || 'missing'}`, OPTIONS)
if (completed?.taskStatus !== 'COMPLETED' || completed?.workflowStatus !== 'COMPLETED') return null
const completeReplay = await request(context, 'POST', completePath, completePayload)
const completedReplay = context.apiSuccess(MODULE, `${sourceOrderNo} PDA 完成幂等重放`, completeReplay, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 完成重放保持同一已结算任务`, completeReplay,
id(completedReplay?.id) === taskId && Number(completedReplay?.version) === Number(completed?.version)
&& completedReplay?.taskStatus === 'COMPLETED' && completedReplay?.workflowStatus === 'COMPLETED',
`taskId=${id(completedReplay?.id) || 'missing'}, taskVersion=${completedReplay?.version ?? 'missing'}`, OPTIONS)
const treeResponse = await context.request('GET', treePath(rootOrderId, tenantId))
const tree = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 拆分后的根聚合`, treeResponse, '', OPTIONS)
const candidatesAfter = await context.request('GET', candidatesPath)
@@ -57,7 +57,18 @@ function oceanDraft(context, suffix, master) {
}
async function ensureOceanBooking(context, suffix, master) {
if (context.createdResources?.oceanBooking?.id) return context.createdResources.oceanBooking
if (context.createdResources?.oceanBooking?.id) {
const existingId = id(context.createdResources.oceanBooking.id)
const response = await request(context, 'GET', `${OCEAN}/${existingId}`)
const detail = context.apiSuccess(MODULE, '读取船期关联订舱真实柜次分配', response,
`oceanBookingId=${existingId}`, OPTIONS)
return detail?.oceanBooking ? {
id: existingId,
customerId: id(detail.oceanBooking.customerId),
allocatedContainerBookingIds: (detail.containerAllocations || [])
.map((item) => id(item?.containerBookingId)).filter(Boolean),
} : null
}
const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) },
{ 'Idempotency-Key': `${context.marker}-port-ocean`.slice(0, 128) })
const detail = context.apiSuccess(MODULE, '创建本轮隔离船期关联订舱', response, '', OPTIONS)
@@ -65,7 +76,11 @@ async function ensureOceanBooking(context, suffix, master) {
context.assert(MODULE, '船期关联订舱客户归属明确', response,
Boolean(oceanBookingId) && id(detail?.oceanBooking?.customerId) === id(master.customerId),
`oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
return oceanBookingId ? { id: oceanBookingId, customerId: master.customerId } : null
return oceanBookingId ? {
id: oceanBookingId, customerId: id(detail.oceanBooking.customerId),
allocatedContainerBookingIds: (detail.containerAllocations || [])
.map((item) => id(item?.containerBookingId)).filter(Boolean),
} : null
}
function scheduleData(context, suffix, oceanBookingId, containerBookingId, customerId, expired = false) {
@@ -109,9 +124,15 @@ export async function run(context) {
|| context.createdResources?.phase3ContainerLoading
const containerBookingId = ['COMPLETED', 'LOADED'].includes(flow?.finalStatus)
? id(flow.containerBookingId) : null
const knownAllocation = context.createdResources?.oceanBooking?.id === ocean.id && containerBookingId
? containerBookingId : null
const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId), reason: context.marker }
const knownAllocation = containerBookingId
&& ocean.allocatedContainerBookingIds?.includes(containerBookingId) ? containerBookingId : null
if (containerBookingId && !knownAllocation) {
context.addStep(MODULE, '船期订舱真实分配包含本轮装货柜次', null, false,
`oceanBookingId=${ocean.id}, containerBookingId=${containerBookingId}, allocations=${ocean.allocatedContainerBookingIds?.join(',') || 'none'}`,
OPTIONS)
return
}
const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, ocean.customerId), reason: context.marker }
const createKey = `${context.marker}-port-create`.slice(0, 128)
const createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
const created = context.apiSuccess(MODULE, '新增独立港务船期观测', createResponse, '', OPTIONS)
@@ -135,7 +156,7 @@ export async function run(context) {
`first=${scheduleId}, replay=${id(data(replay)?.portSchedule?.schedule?.id) || 'missing'}`, OPTIONS)
for (const [name, query] of [
['按客户查询船期', `customerId=${encodeURIComponent(master.customerId)}`],
['按客户查询船期', `customerId=${encodeURIComponent(ocean.customerId)}`],
['按订舱查询船期', `oceanBookingId=${ocean.id}`],
...(knownAllocation ? [['按柜次查询船期', `containerBookingId=${knownAllocation}`]] : []),
]) {
@@ -225,7 +246,7 @@ export async function run(context) {
const forged = await request(context, 'POST', BASE, {
expectedVersion: 0,
data: { ...scheduleData(context, `${suffix}X`, ocean.id, null, master.customerId), customerId: `${master.customerId}999` },
data: { ...scheduleData(context, `${suffix}X`, ocean.id, null, ocean.customerId), customerId: `${ocean.customerId}999` },
reason: '伪造客户归属',
}, { 'Idempotency-Key': `${context.marker}-port-forged-customer`.slice(0, 128) })
step(context, '伪造船期客户归属被拒绝', forged,
@@ -279,10 +279,17 @@ test('downstream producers prefer the phase3Loading container fixture', () => {
assert.match(ocean, /request\(context, 'POST', `\$\{path\}\/release`/)
assert.match(ocean, /flowOceanBookingId = id\(containerFlow\?\.oceanBookingId\)/)
assert.match(ocean, /ocean-flow-release/)
assert.match(ocean, /existing\?\.marker === context\.marker && existing\?\.finalStatus === 'RELEASED'/)
assert.match(ocean, /const reconciled = await releaseLoadingBooking\(context, containerFlow, suffix\)/)
assert.match(ocean, /containerBookingId: id\(containerFlow\.containerBookingId\), loadingPlanId: id\(containerFlow\.loadingPlanId\)/)
assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/)
assert.doesNotMatch(ocean, /`\$\{context\.marker\}-\$\{endpoint\}`/)
assert.match(ocean, /`\$\{context\.marker\}-ocean-\$\{endpoint\}`/)
assert.match(serviceCenter, /if \(!allocationMatches\) return/)
const schedules = source('./phase3-port-schedules.mjs')
assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containerAllocations \|\| \[\]\)/)
assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/)
assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/)
})
test('default acceptance order verifies loading evidence before finalizing LOADED', () => {
@@ -61,9 +61,11 @@ export async function run(context) {
return
}
if (linkContainer) {
context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse,
oceanDetail?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)),
const allocationMatches = oceanDetail?.containerAllocations?.some?.((item) =>
id(item?.containerBookingId) === id(containerFlow.containerBookingId))
context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches,
`containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${oceanDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
if (!allocationMatches) return
}
const payload = {
outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null,