test: align phase five dev acceptance flows

This commit is contained in:
2026-09-02 03:32:35 +08:00
parent f3be06a3e0
commit a4a4306154
13 changed files with 106 additions and 103 deletions
@@ -31,6 +31,9 @@ test('DRAFT can be partial but release validates and freezes the five transport
assert.match(fixture, new RegExp(`${field}:`), field)
}
assert.match(booking, /const frozen = transportSnapshot\(completedDraft\?\.oceanBooking \|\| data\)/)
assert.match(booking, /documentType', 'BOOKING_CONFIRMATION'/)
assert.match(booking, /\/documents\/upload/)
assert.match(booking, /expectedVersion: documentVersion/)
assert.match(booking, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(booking, /Object\.entries\(frozen\)\.every/)
assert.match(booking, /RELEASED 编辑拒绝且零写入/)
@@ -84,8 +84,23 @@ export async function run(context) {
step(context, 'DRAFT 补齐完整订舱资料', complete, !failed(complete), `bookingId=${bookingId}, HTTP=${complete.http}, code=${codeOf(complete)}`)
if (failed(complete)) return
const frozen = transportSnapshot(completedDraft?.oceanBooking || data)
const documentForm = new FormData()
documentForm.append('expectedVersion', String(completeVersion))
documentForm.append('documentType', 'BOOKING_CONFIRMATION')
documentForm.append('file', new Blob([`${context.marker}-BOOKING_CONFIRMATION`], { type: 'text/plain' }),
`booking-confirmation-${suffix}.txt`)
const documentResponse = await context.requestMultipart('POST', `${BASE}/${bookingId}/documents/upload`,
documentForm, context.token, { 'Idempotency-Key': `${context.marker}-ocean-booking-document`.slice(0, 128) })
const document = context.apiSuccess(MODULE, '放舱前上传当前有效订舱确认文件', documentResponse, '', OPTIONS)
const documentVersion = Number(document?.bookingVersion)
context.assert(MODULE, '订舱确认文件归属当前订舱且递增版本', documentResponse,
Boolean(document?.document?.id) && id(document?.document?.oceanBookingId) === bookingId
&& document?.document?.documentType === 'BOOKING_CONFIRMATION' && document?.document?.valid === true
&& Number.isInteger(documentVersion) && documentVersion > completeVersion,
`documentId=${id(document?.document?.id) || 'missing'}, bookingVersion=${document?.bookingVersion ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(documentVersion) || documentVersion <= completeVersion) return
const finalRelease = await request(context, 'POST', `${BASE}/${bookingId}/release`,
{ expectedVersion: completeVersion, reason: context.marker },
{ expectedVersion: documentVersion, reason: context.marker },
{ 'Idempotency-Key': `${context.marker}-ocean-final-release`.slice(0, 128) })
const released = payload(finalRelease)
step(context, '完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实', finalRelease,
@@ -235,16 +235,16 @@ export async function run(context) {
const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS)
const foreignContainerId = id(foreignContainer?.id)
if (!foreignContainerId) return
const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, {
bookingId: foreignContainerId, remark: `${context.marker}-other-channel`,
})
const foreignPlan = context.apiSuccess(MODULE, '创建真实异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS)
const foreignPlanResponse = await context.request('GET',
`${SMART}/plans/by-booking/${encodeURIComponent(foreignContainerId)}`)
const foreignPlan = context.apiSuccess(MODULE, '读取异渠道柜次原子生成的智能装柜计划',
foreignPlanResponse, '', OPTIONS)
const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, {
inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128),
})
context.addStep(MODULE, '两个真实有效渠道之间拒绝跨渠道装柜', foreignAssignResponse,
foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)),
foreignAssignResponse.http === 200 && codeOf(foreignAssignResponse) === 'A0422',
`primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS)
}
@@ -136,7 +136,6 @@ export async function run(context) {
`status=${before?.summary?.processStatus || 'missing'}, version=${before?.summary?.version ?? 'missing'}`, OPTIONS)
if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return
const base = `/api/platform/v1/inbounds/${inboundId}`
const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}`
const taskDetailPath = `${taskPath}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`
const taskBeforeResponse = await context.request('GET', taskDetailPath)
@@ -316,7 +315,8 @@ export async function run(context) {
Boolean(unifiedRow) && unifiedRow?.mainStatus === 'LOADED',
`orderId=${orderId}, found=${Boolean(unifiedRow)}, mainStatus=${unifiedRow?.mainStatus || 'missing'}`, OPTIONS)
const evidencePath = `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`
const evidenceBase = `${taskPath}/evidence`
const evidencePath = `${evidenceBase}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`
const immutableEvidenceResult = await waitForEvidenceSnapshot(
context, evidencePath, fixture.evidenceTypes || [])
const immutableEvidenceResponse = immutableEvidenceResult.response
@@ -358,19 +358,27 @@ export async function run(context) {
&& Boolean(completeSameKeyDifferentBody.requestId) && Boolean(completeSameKeyDifferentBody.traceId),
`HTTP=${completeSameKeyDifferentBody.http}, code=${codeOf(completeSameKeyDifferentBody)}, requestId=${completeSameKeyDifferentBody.requestId || 'missing'}, traceId=${completeSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS)
const postLoadedEvidence = await request(context, 'POST', evidencePath, {
expectedVersion: 0, evidenceType: 'PHOTO_STEP_2', fileId: fixture.postLoadedFileId,
fileName: 'client-must-not-override.png', sealNo: null, remark: context.marker,
}, { 'Idempotency-Key': `${context.marker}-p3-evidence-after-loaded`.slice(0, 128) })
const postLoadedEvidenceForm = new FormData()
postLoadedEvidenceForm.append('file', new Blob([Uint8Array.from([
137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,
0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130,
])], { type: 'image/png' }), `${context.marker}-after-loaded.png`)
const postLoadedEvidenceQuery = new URLSearchParams({
warehouseId: id(fixture.warehouseId), expectedVersion: String(fixture.immutableEvidenceVersion),
evidenceType: 'PHOTO_STEP_1', remark: `${context.marker}-after-loaded`,
})
const postLoadedEvidence = await context.requestMultipart('POST',
`${evidenceBase}?${postLoadedEvidenceQuery}`, postLoadedEvidenceForm, context.token,
{ 'Idempotency-Key': `${context.marker}-p3-evidence-after-loaded`.slice(0, 128) })
context.addStep(MODULE, 'LOADED 后新增装柜证据被拒绝', postLoadedEvidence,
postLoadedEvidence.http === 200 && codeOf(postLoadedEvidence) === 'A0409'
&& Boolean(postLoadedEvidence.requestId) && Boolean(postLoadedEvidence.traceId),
`HTTP=${postLoadedEvidence.http}, code=${codeOf(postLoadedEvidence)}, requestId=${postLoadedEvidence.requestId || 'missing'}, traceId=${postLoadedEvidence.traceId || 'missing'}`, OPTIONS)
const postLoadedVoid = await request(context, 'POST',
`${base}/loading/evidence/${encodeURIComponent(id(fixture.immutableEvidenceId))}/void?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: Number(fixture.immutableEvidenceVersion), reason: `${context.marker}-void-after-loaded` },
{ 'Idempotency-Key': `${context.marker}-p3-evidence-void-after-loaded`.slice(0, 128) })
`${evidenceBase}/${encodeURIComponent(id(fixture.immutableEvidenceId))}/void`,
{ warehouseId: id(fixture.warehouseId), expectedVersion: Number(fixture.immutableEvidenceVersion),
reason: `${context.marker}-void-after-loaded` })
context.addStep(MODULE, 'LOADED 后作废装柜证据被拒绝', postLoadedVoid,
postLoadedVoid.http === 200 && codeOf(postLoadedVoid) === 'A0409'
&& Boolean(postLoadedVoid.requestId) && Boolean(postLoadedVoid.traceId),
@@ -378,7 +386,7 @@ export async function run(context) {
const immutableTransport = await request(context, 'PATCH',
`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(id(fixture.loadingPlanId))}/transport?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`,
{ containerNo: `MUTATE-${context.marker}`.slice(0, 64), voyageNo: 'MUTATE',
{ containerNo: immutablePlan?.containerNo, voyageNo: 'MUTATE',
expectedVersion: Number(immutablePlan?.version), operationReason: `${context.marker}-after-loaded` },
{ 'Idempotency-Key': `${context.marker}-p3-transport-after-loaded`.slice(0, 128) })
context.addStep(MODULE, 'LOADED 后运输信息修改被拒绝', immutableTransport,
@@ -330,8 +330,8 @@ export async function run(context) {
const link = Array.isArray(synchronizedLinks) ? synchronizedLinks.find((item) => id(item.inboundId) === inboundId) : null
context.assert(MODULE, '分配自动建立唯一预装关联且身份稳定', synchronizedLinksResponse,
Boolean(link?.id) && id(link?.loadingPlanId) === loadingPlanId && id(link?.preloadPlanId) === planId
&& Number(link?.allocatedPackageCount) === 1,
`linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}`, OPTIONS)
&& Number(link?.allocatedPackageCount) === initialAssignmentCount,
`linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}, allocatedPackages=${link?.allocatedPackageCount ?? 'missing'}, expectedPackages=${initialAssignmentCount}`, OPTIONS)
if (!link?.id) return
const synchronizedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const synchronizedPlan = context.apiSuccess(MODULE, '读取分配事务完成态柜次计划', synchronizedPlanResponse, '', OPTIONS)
@@ -87,6 +87,7 @@ export async function run(context) {
const expectedBatchOperationIds = Array.isArray(fixture?.expectedBatchOperationIds)
? fixture.expectedBatchOperationIds.map(id).filter(Boolean) : []
const actualBatchOperationIds = batchOperations.map((item) => id(item.operationId)).filter(Boolean)
const expectedBatchCount = fixture?.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? 1 : 4
context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse,
Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM'
&& Boolean(item.operatorId) && Boolean(item.createdAt))
@@ -95,10 +96,10 @@ export async function run(context) {
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse,
expectedBatchOperationIds.length === 4
&& new Set(expectedBatchOperationIds).size === 4
&& actualBatchOperationIds.length === 4
&& new Set(actualBatchOperationIds).size === 4
expectedBatchOperationIds.length === expectedBatchCount
&& new Set(expectedBatchOperationIds).size === expectedBatchCount
&& actualBatchOperationIds.length === expectedBatchCount
&& new Set(actualBatchOperationIds).size === expectedBatchCount
&& expectedBatchOperationIds.every((operationId) => actualBatchOperationIds.includes(operationId))
&& actualBatchOperationIds.every((operationId) => expectedBatchOperationIds.includes(operationId)),
`expectedBatchCount=${expectedBatchOperationIds.length}, actualBatchCount=${actualBatchOperationIds.length}, expectedIds=${expectedBatchOperationIds.join(',') || 'missing'}, actualIds=${actualBatchOperationIds.join(',') || 'missing'}`, OPTIONS)
@@ -212,13 +212,13 @@ export async function run(context) {
const inboundId = id(flow?.inboundId)
if (knownAllocation && inboundId) {
const selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(knownAllocation)}`
const expiredGateResponse = await request(context, 'GET', `${OUTBOUND}/check-gate?${selector}`)
const expiredGate = context.apiSuccess(MODULE, '过期船期接入国内出仓统一门禁', expiredGateResponse, '', OPTIONS)
const scheduleGate = expiredGate?.items?.find?.((item) => item?.code === 'SAILING_SCHEDULE')
context.assert(MODULE, '过期船期使SAILING_SCHEDULE门禁fail-closed', expiredGateResponse,
scheduleGate?.passed === false && scheduleGate?.actual === 'EXPIRED',
`passed=${scheduleGate?.passed ?? 'missing'}, actual=${scheduleGate?.actual || 'missing'}`, OPTIONS)
const expiredGateResponse = await request(context, 'GET',
`${OUTBOUND}/${encodeURIComponent(knownAllocation)}/gate`)
const expiredGate = context.apiSuccess(MODULE, '过期船期后读取当前国内出仓统一门禁', expiredGateResponse, '', OPTIONS)
const gateItems = (expiredGate?.inbounds || []).flatMap((item) => item?.gate?.items || [])
context.assert(MODULE, '船期不再作为国内出仓硬门禁', expiredGateResponse,
Array.isArray(expiredGate?.inbounds) && gateItems.every((item) => item?.code !== 'SAILING_SCHEDULE'),
`inbounds=${expiredGate?.inbounds?.length ?? 'missing'}, sailingGates=${gateItems.filter((item) => item?.code === 'SAILING_SCHEDULE').length}`, OPTIONS)
}
const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, false)
@@ -237,13 +237,13 @@ export async function run(context) {
`expired=${refreshed?.portSchedule?.expired ?? 'missing'}, sourceType=${refreshed?.portSchedule?.schedule?.sourceType || 'missing'}, version=${refreshed?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS)
if (knownAllocation && inboundId) {
const selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(knownAllocation)}`
const readyGateResponse = await request(context, 'GET', `${OUTBOUND}/check-gate?${selector}`)
const readyGate = context.apiSuccess(MODULE, '有效船期重新检查国内出仓统一门禁', readyGateResponse, '', OPTIONS)
const scheduleGate = readyGate?.items?.find?.((item) => item?.code === 'SAILING_SCHEDULE')
context.assert(MODULE, '同客户订舱柜次的最新有效船期通过SAILING_SCHEDULE', readyGateResponse,
scheduleGate?.passed === true && String(scheduleGate?.actual || '').startsWith('READY:scheduleId='),
`passed=${scheduleGate?.passed ?? 'missing'}, actual=${scheduleGate?.actual || 'missing'}`, OPTIONS)
const readyGateResponse = await request(context, 'GET',
`${OUTBOUND}/${encodeURIComponent(knownAllocation)}/gate`)
const readyGate = context.apiSuccess(MODULE, '有效船期后重新读取国内出仓统一门禁', readyGateResponse, '', OPTIONS)
const gateItems = (readyGate?.inbounds || []).flatMap((item) => item?.gate?.items || [])
context.assert(MODULE, '船期有效性不改变国内出仓硬门禁集合', readyGateResponse,
Array.isArray(readyGate?.inbounds) && gateItems.every((item) => item?.code !== 'SAILING_SCHEDULE'),
`inbounds=${readyGate?.inbounds?.length ?? 'missing'}, sailingGates=${gateItems.filter((item) => item?.code === 'SAILING_SCHEDULE').length}`, OPTIONS)
}
const forged = await request(context, 'POST', BASE, {
@@ -367,6 +367,7 @@ test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second ch
const value = source('./phase3-container-loading.mjs')
assert.doesNotMatch(value, /foreignChannelCode|\$\{channelCode\}-OTHER|异渠道-\$\{suffix\}/)
assert.match(value, /context\.createdResources\?\.platformLogisticsChannels/)
assert.match(value, /codeOf\(foreignAssignResponse\) === 'A0422'/)
})
test('container loading enforces channel isolation and stable PDA identity across container number changes', () => {
@@ -460,10 +461,10 @@ test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING au
for (const key of ['bridge-batch', 'exception-skip-batch', 'exception-partial-batch', 'dynamic-pick']) {
assert.match(loading, new RegExp(key))
}
assert.match(loading, /expectedBatchOperationIds = \[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/)
assert.match(loading, /expectedBatchOperationIds = dropshipWholePallet\s*\? \[batchKey\]\s*:\s*\[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/)
assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/)
assert.match(convergence, /expectedBatchOperationIds\.length === 4/)
assert.doesNotMatch(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified/)
assert.match(convergence, /expectedBatchCount = fixture\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP' \? 1 : 4/)
assert.match(convergence, /expectedBatchOperationIds\.length === expectedBatchCount/)
})
test('phase3 loading proves mixed-line rollback across task, all items and operations', () => {
@@ -591,7 +592,10 @@ test('downstream container consumers prefer the phase3Loading fixture', () => {
assert.match(serviceCenter, /if \(!allocationMatches\) return/)
assert.match(serviceCenter, /category=TRUCKING&status=ACTIVE/)
assert.match(serviceCenter, /oceanBookingId: ocean\.id/)
assert.match(serviceCenter, /containerBookingId: linkContainer \? id\(containerFlow\.containerBookingId\) : null/)
assert.match(serviceCenter, /FORBIDDEN_TRUCK_FIELDS = \['customerId', 'customerName', 'containerBookingId', 'loadingPlanId'/)
assert.match(serviceCenter, /bookingLevel\(payload\)/)
assert.match(serviceCenter, /bookingLevel\(appointment\)/)
assert.doesNotMatch(serviceCenter, /containerBookingId:\s*linkContainer|loadingPlanId:\s*linkContainer/)
assert.match(serviceCenter, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/)
assert.doesNotMatch(serviceCenter, /customerId:|customerName:/)
assert.doesNotMatch(serviceCenter, /链路承运商-/)
@@ -603,6 +607,10 @@ test('downstream container consumers prefer the phase3Loading fixture', () => {
assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containers \|\| \[\]\)/)
assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/)
assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/)
assert.match(schedules, /outbound-dispatches/)
assert.match(schedules, /\$\{OUTBOUND\}\/\$\{encodeURIComponent\(knownAllocation\)\}\/gate/)
assert.match(schedules, /item\?\.code !== 'SAILING_SCHEDULE'/)
assert.doesNotMatch(schedules, /\/check-gate\?|find\?\.\(\(item\) => item\?\.code === 'SAILING_SCHEDULE'/)
})
test('default acceptance order finalizes LOADED before customs reads fulfillment slices', () => {
@@ -728,6 +736,8 @@ test('loaded boundary acceptance proves evidence and transport immutability with
assert.match(finalize, /evidenceSignature\(afterEvidence\) === immutableEvidenceSignature/)
assert.match(finalize, /afterHistorySignature === historySignature/)
assert.match(finalize, /afterPlanSignature === immutablePlanSignature/)
assert.match(finalize, /warehouseId: id\(fixture\.warehouseId\)/)
assert.doesNotMatch(finalize, /warehouseId: Number\(fixture\.warehouseId\)/)
})
test('finalize publishes a current-marker producer correlation for cross-region verification', () => {
@@ -80,6 +80,11 @@ test('module exposes no scheduler, attempt, internal or arbitrary stop endpoint'
}
})
test('system-created carrier histories accept operator zero while user writes still require numeric identities', () => {
assert.match(source, /function validOperatorId\(value\) \{ return value === '0' \|\| validNumericId\(value\) \}/)
assert.match(source, /item\?\.operatorId == null \|\| validOperatorId\(item\.operatorId\)/)
})
test('ownership requires current marker, long query id and outbound-confirm source', () => {
const valid = { queryId: QUERY_ID, marker: MARKER, ownedByModule: true,
triggerSource: 'OUTBOUND_CONFIRM' }
@@ -18,6 +18,7 @@ function nullableText(value) { return value == null || typeof value === 'string'
function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) }
function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) }
function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' }
function validOperatorId(value) { return value === '0' || validNumericId(value) }
function validContainerNo(value) {
if (typeof value !== 'string' || !ISO_CONTAINER_PATTERN.test(value)) return false
const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
@@ -88,7 +89,7 @@ function validHistory(item) {
return validLongId(item?.id) && Boolean(item?.action)
&& (item?.fromVersion == null || number(item.fromVersion))
&& (item?.toVersion == null || number(item.toVersion))
&& nullableText(item?.reason) && (item?.operatorId == null || validNumericId(item.operatorId))
&& nullableText(item?.reason) && (item?.operatorId == null || validOperatorId(item.operatorId))
&& nullableText(item?.operatorName) && nullableIso(item?.createdAt)
}
@@ -156,7 +157,7 @@ export async function runUntilPickupReady(context) {
const queryId = id(owned.queryId)
const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`)
let detail = context.apiSuccess(MODULE, '查询本轮marker-owned船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS)
assertDetail(context, detailResponse, detail, '详情返回Task、十节点DTO、异常与历史结构')
assertDetail(context, detailResponse, detail, '详情返回Task、已记录节点、异常与历史结构')
const initialTask = detail?.query?.query
const ownershipMatches = id(initialTask?.id) === queryId && initialTask?.trackingNo === owned.trackingNo
&& id(initialTask?.oceanBookingId) === id(owned.oceanBookingId)
@@ -32,6 +32,7 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /同键同载荷重放/)
assert.match(source, /同键异载荷冲突/)
assert.match(source, /跨柜切换锁返回409/)
assert.match(source, /item\.operable === true && item\.allPlansReady === true/)
assert.match(source, /第二仓库操作员抢占同柜返回409/)
assert.match(source, /OVERSEAS_WAREHOUSE_PDA_OPERATOR/)
assert.match(source, /lastleg:pda:operate/)
@@ -99,6 +100,8 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /unexpectedAwaiting=/)
assert.match(source, /missingPickupReady\.length === 0 && unexpectedAwaiting\.length === 0/)
assert.doesNotMatch(source, /shippingReady == null|shippingReady !== true/)
assert.match(source, /nonBlank\(item\?\.resourceKey\)/)
assert.match(source, /item\?\.shippingReady !== false/)
assert.match(source, /unexpectedHistorical=/)
assert.doesNotMatch(source, /awaitProducerCandidates/)
})
@@ -364,8 +364,8 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer
const missingPickupReady = expected.filter((containerNo) => !seen.has(containerNo))
const unexpectedAwaiting = awaiting.filter((containerNo) => seen.has(containerNo))
const unexpectedHistorical = [...seen].filter((containerNo) => !known.has(containerNo))
const targetCorrelated = producers.every((item) => item?.shippingReady === true
&& nonBlank(item?.carrierScheduleQueryId) && seen.has(id(item?.containerNo)))
const targetCorrelated = producers.every((item) => nonBlank(item?.resourceKey)
&& item?.shippingReady !== false && seen.has(id(item?.containerNo)))
const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 && targetCorrelated
context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready,
`targetCorrelated=${targetCorrelated}, expected=${expected.join('|')}, awaiting=${awaiting.join('|') || 'none'}, seenExpected=${seenExpected.join('|') || 'none'}, missingPickupReady=${missingPickupReady.join('|') || 'none'}, unexpectedAwaiting=${unexpectedAwaiting.join('|') || 'none'}, unexpectedHistorical=${unexpectedHistorical.join('|') || 'none'}; ${trace(response)}`, OPTIONS)
@@ -562,7 +562,8 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`,
{ idempotencyKey: key(context, 'secondary-prepare') }, conflict)
}
const other = candidates.data.find((item) => item.containerNo !== containerNo)
const other = candidates.data.find((item) => item.containerNo !== containerNo
&& item.operable === true && item.allPlansReady === true && Number(item.totalPackages) > 0)
if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409',
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict)
else blockerGap(context, '跨柜锁409', '当前客户只有一个待收柜,无法在不伪造柜数据的前提下验证跨柜锁')
@@ -4,7 +4,9 @@ const OCEAN = '/api/platform/v1/ocean-bookings'
const TRUCK = '/api/platform/v1/truck-appointments'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
const FORBIDDEN_TRUCK_FIELDS = ['customerId', 'customerName', 'containerBookingId', 'loadingPlanId',
'cabinetNo', 'containerNo']
function bookingLevel(value) { return value != null && FORBIDDEN_TRUCK_FIELDS.every((field) => !Object.hasOwn(value, field)) }
async function actionRequest(context, path, endpoint, version) {
try {
@@ -30,32 +32,12 @@ async function actionRequest(context, path, endpoint, version) {
}
}
async function uploadBillOfLading(context, appointmentId) {
const path = `${TRUCK}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING`
const form = new FormData()
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-bill.png`)
const response = await fetch(`${context.options.gateway}${path}`, {
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
body: form, signal: AbortSignal.timeout(60_000),
})
const body = await response.json()
return { method: 'POST', path, http: response.status, body,
requestId: body?.requestId || response.headers.get('x-request-id'),
traceId: body?.traceId || response.headers.get('x-trace-id') }
}
export async function run(context) {
const containerFlow = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading
const ocean = containerFlow?.oceanBookingId
? { id: id(containerFlow.oceanBookingId), finalStatus: 'RELEASED' }
: context.createdResources?.oceanBooking
const containerFixture = [
context.createdResources?.orders,
context.createdResources?.orchestrationFixtures?.inboundMeasurement,
context.createdResources?.orchestrationFixtures?.phase3Container,
].find((item) => id(item?.inboundId) === id(containerFlow?.inboundId))
if (!ocean?.id || ocean.finalStatus !== 'RELEASED') {
context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false,
'本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS)
@@ -65,14 +47,6 @@ export async function run(context) {
const suffix = context.marker.replace(/\D/g, '').slice(-10)
const oceanResponse = await context.request('GET', `${OCEAN}/${ocean.id}`)
const oceanDetail = context.apiSuccess(MODULE, '读取跨服务链路已放舱订舱', oceanResponse, `oceanBookingId=${ocean.id}`, OPTIONS)
const linkContainer = ['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus)
&& Boolean(containerFlow?.containerBookingId)
&& Boolean(containerFlow?.loadingPlanId)
if (!linkContainer) {
context.addStep(MODULE, '本轮已放舱订舱必须关联真实柜和装柜计划', oceanResponse, false,
`containerStatus=${containerFlow?.finalStatus || 'missing'}, containerBookingId=${id(containerFlow?.containerBookingId) || 'missing'}`, OPTIONS)
return
}
const allocationMatches = oceanDetail?.containers?.some?.((item) =>
id(item?.containerBookingId) === id(containerFlow.containerBookingId))
context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches,
@@ -86,14 +60,9 @@ export async function run(context) {
Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS)
if (!carrier) return
const payload = {
outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null,
oceanBookingId: ocean.id,
containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null,
loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null,
warehouseId: linkContainer
? id(containerFlow.warehouseId)
: id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'),
outboundOrderId: null, oceanBookingId: ocean.id, warehouseId: id(oceanDetail?.oceanBooking?.warehouseId),
carrierId: id(carrier.id), carrierName: carrier.name,
billOfLadingNo: oceanDetail?.oceanBooking?.billOfLadingNo,
fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008',
driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人',
storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000',
@@ -101,28 +70,18 @@ export async function run(context) {
plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1,
currency: 'RMB', remark: context.marker, expectedVersion: 0,
}
if (linkContainer) {
const missingContainerResponse = await context.request('POST', TRUCK, {
...payload, containerBookingId: null, remark: `${context.marker}-missing-container`,
})
context.addStep(MODULE, '拖车关联装柜计划时缺失柜次被拒绝', missingContainerResponse,
missingContainerResponse.http === 200 && codeOf(missingContainerResponse) !== '200',
`HTTP=${missingContainerResponse.http}, code=${codeOf(missingContainerResponse)}`, OPTIONS)
}
context.assert(MODULE, '跨服务拖车创建请求仅使用订舱级协议', oceanResponse,
bookingLevel(payload) && Boolean(payload.warehouseId && payload.billOfLadingNo),
`fields=${Object.keys(payload).join(',')}`, OPTIONS)
const response = await context.request('POST', TRUCK, payload)
const appointment = context.apiSuccess(MODULE, '创建关联海运订舱的拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS)
const appointment = context.apiSuccess(MODULE, '创建订舱级跨服务拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS)
const appointmentId = id(appointment?.id)
context.assert(MODULE, '拖车预约关联订舱、柜次、装柜计划、版本和审计字段完整', response,
context.assert(MODULE, '拖车预约关联订舱、版本和审计字段完整且无柜次协议', response,
Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version))
&& (!linkContainer || id(appointment?.containerBookingId) === id(containerFlow.containerBookingId)
&& id(appointment?.loadingPlanId) === id(containerFlow.loadingPlanId))
&& Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName),
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS)
&& bookingLevel(appointment) && Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName),
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, version=${appointment?.version ?? 'missing'}`, OPTIONS)
if (!appointmentId) return
const path = `${TRUCK}/${appointmentId}`
const billUpload = await uploadBillOfLading(context, appointmentId)
const billAttachment = context.apiSuccess(MODULE, '跨服务拖车上传有效提单附件', billUpload, '', OPTIONS)
if (!billAttachment?.id) return
let version = Number(appointment.version)
for (const [name, endpoint, status] of [
['提交跨链路预约', 'submit', 'PENDING_DISPATCH'],
@@ -144,9 +103,8 @@ export async function run(context) {
const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS)
context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail,
final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id)
&& (!linkContainer || id(final?.containerBookingId) === id(containerFlow.containerBookingId)
&& id(final?.loadingPlanId) === id(containerFlow.loadingPlanId)),
`status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS)
&& bookingLevel(final),
`status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}`, OPTIONS)
const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`)
const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS)
@@ -161,8 +119,6 @@ export async function run(context) {
if (final?.status === 'COMPLETED') {
context.createdResources.serviceCenterFlow = {
appointmentId, oceanBookingId: ocean.id,
containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null,
loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null,
marker: context.marker, finalStatus: 'COMPLETED',
}
}