test: align phase five dev acceptance flows
This commit is contained in:
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user