test(firstleg): cover phase3 outbound contracts
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
function resource(context, key) {
|
||||
const value = context.createdResources?.[key]
|
||||
if (!value) return { available: false }
|
||||
return {
|
||||
available: true,
|
||||
marker: value.marker || context.marker,
|
||||
tenantId: value.tenantId || null,
|
||||
inboundId: value.inboundId || null,
|
||||
orderId: value.orderId || null,
|
||||
loadingPlanId: value.loadingPlanId || value.preloadPlanId || null,
|
||||
containerBookingId: value.containerBookingId || null,
|
||||
oceanBookingId: value.oceanBookingId || (key === 'oceanBooking' ? value.id : null),
|
||||
truckAppointmentId: value.appointmentId || (key === 'truckAppointment' ? value.id : null),
|
||||
finalStatus: value.finalStatus || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPendingContract(context, spec) {
|
||||
const fixtures = Object.fromEntries(spec.fixtures.map((key) => [key, resource(context, key)]))
|
||||
context.addCoverageGap(spec.module, spec.scenario, spec.name, spec.reason, spec.category, {
|
||||
fixture: fixtures,
|
||||
activation: {
|
||||
state: 'pending-public-api',
|
||||
contractSource: spec.contractSource,
|
||||
assertions: spec.assertions,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
const MODULE = 'phase3-customs'
|
||||
const OPTIONS = { scenario: 'phase3-customs-collaboration', deploymentBlocker: true }
|
||||
const PLATFORM = '/api/platform/v1/customs'
|
||||
const MERCHANT = '/api/merchant/v1/customs'
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function failure(response) {
|
||||
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|
||||
|| (response?.http === 200 && codeOf(response) !== '200')
|
||||
}
|
||||
function key(context, operation) { return `${context.marker}-customs-${operation}`.slice(0, 128) }
|
||||
|
||||
async function request(context, method, path, body, token = context.token, headers = {}) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json', Authorization: token ? `Bearer ${token}` : '',
|
||||
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json; charset=utf-8' }),
|
||||
...headers,
|
||||
},
|
||||
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const raw = await response.text(); let payload = null
|
||||
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||||
return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(context, path, fields, content, fileName, token, idempotencyKey) {
|
||||
const form = new FormData()
|
||||
for (const [name, value] of Object.entries(fields)) if (value !== null && value !== undefined) form.append(name, String(value))
|
||||
form.append('file', new Blob([content], { type: 'text/plain' }), fileName)
|
||||
return request(context, 'POST', path, form, token, { 'Idempotency-Key': idempotencyKey })
|
||||
}
|
||||
|
||||
async function download(context, path, token) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
headers: { Accept: '*/*', Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const bytes = new Uint8Array(await response.arrayBuffer())
|
||||
let body = null
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/json')) {
|
||||
try { body = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* binary evidence remains bounded */ }
|
||||
}
|
||||
return { method: 'GET', path, http: response.status, bytes,
|
||||
body, contentType, requestId: body?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method: 'GET', path, http: 0, bytes: new Uint8Array(), contentType: '', raw: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async function detail(context, caseId) {
|
||||
const response = await context.request('GET', `${PLATFORM}/cases/${caseId}`)
|
||||
const data = context.apiSuccess(MODULE, '回查报关协同聚合与版本', response, `caseId=${caseId}`, OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.orchestrationFixtures?.phase3Container
|
||||
const container = context.createdResources?.phase3ContainerLoading
|
||||
const tenantId = id(fixture?.tenantId)
|
||||
const orderId = id(fixture?.orderId)
|
||||
const inboundId = id(fixture?.inboundId)
|
||||
const containerBookingId = id(container?.containerBookingId)
|
||||
if (!tenantId || !orderId || !inboundId || !containerBookingId || container?.finalStatus !== 'COMPLETED') {
|
||||
context.addStep(MODULE, '取得本轮客户/订单/入库单/柜次隔离 fixture', null, false,
|
||||
`tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
const loginResponse = await request(context, 'POST', '/api/auth/login', {
|
||||
username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
|
||||
}, '')
|
||||
const merchantLogin = context.apiSuccess(MODULE, '客户账号登录用于真实报关文件协同', loginResponse,
|
||||
`username=${MERCHANT_USERNAME}`, OPTIONS)
|
||||
const merchantToken = merchantLogin?.accessToken
|
||||
if (!merchantToken) return
|
||||
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
|
||||
const checklistPayload = {
|
||||
checklistCode: `USFBA${suffix}`.slice(0, 40), versionNo: `V${suffix}`.slice(0, 32),
|
||||
countryCode: 'US', channelCode: 'FBA', cargoType: 'GENERAL',
|
||||
items: [{ code: 'INVOICE', name: '商业发票', required: true }],
|
||||
}
|
||||
const checklistResponse = await request(context, 'POST', `${PLATFORM}/checklist-versions`, checklistPayload,
|
||||
context.token, { 'Idempotency-Key': key(context, 'checklist') })
|
||||
const checklist = context.apiSuccess(MODULE, '创建本轮不可变报关资料清单版本', checklistResponse, '', OPTIONS)
|
||||
const checklistVersionId = id(checklist?.version?.id)
|
||||
context.assert(MODULE, '资料清单保留国家、渠道、货物类型和必填项目版本', checklistResponse,
|
||||
Boolean(checklistVersionId) && checklist?.version?.countryCode === 'US' && checklist?.version?.channelCode === 'FBA'
|
||||
&& checklist?.version?.cargoType === 'GENERAL' && checklist?.items?.[0]?.code === 'INVOICE'
|
||||
&& checklist?.items?.[0]?.required === true,
|
||||
`checklistVersionId=${checklistVersionId || 'missing'}, items=${checklist?.items?.length ?? 'n/a'}`, OPTIONS)
|
||||
if (!checklistVersionId) return
|
||||
|
||||
const casePayload = { customerId: tenantId, orderId, inboundId, containerBookingId,
|
||||
checklistVersionId, remark: context.marker, expectedVersion: 0 }
|
||||
const caseResponse = await request(context, 'POST', `${PLATFORM}/cases`, casePayload, context.token,
|
||||
{ 'Idempotency-Key': key(context, 'case') })
|
||||
const created = context.apiSuccess(MODULE, '创建客户订单柜次关联报关协同单', caseResponse, '', OPTIONS)
|
||||
const caseId = id(created?.customsCase?.id)
|
||||
context.assert(MODULE, '报关单绑定本轮客户、订单、入库单、柜次和清单版本', caseResponse,
|
||||
Boolean(caseId) && id(created?.customsCase?.tenantId) === tenantId && id(created?.customsCase?.orderId) === orderId
|
||||
&& id(created?.customsCase?.inboundId) === inboundId && id(created?.customsCase?.containerBookingId) === containerBookingId
|
||||
&& id(created?.customsCase?.checklistVersionId) === checklistVersionId
|
||||
&& created?.customsCase?.status === 'DOCUMENTS_PENDING' && Number(created?.customsCase?.version) === 0,
|
||||
`caseId=${caseId || 'missing'}, status=${created?.customsCase?.status || 'missing'}`, OPTIONS)
|
||||
if (!caseId) return
|
||||
|
||||
const caseReplay = await request(context, 'POST', `${PLATFORM}/cases`, casePayload, context.token,
|
||||
{ 'Idempotency-Key': key(context, 'case') })
|
||||
context.assert(MODULE, '创建报关单幂等重放返回同一聚合', caseReplay,
|
||||
codeOf(caseReplay) === '200' && id(caseReplay.body?.data?.customsCase?.id) === caseId,
|
||||
`HTTP=${caseReplay.http}, code=${codeOf(caseReplay)}, caseId=${id(caseReplay.body?.data?.customsCase?.id) || 'missing'}`, OPTIONS)
|
||||
|
||||
const forgedCaseResponse = await request(context, 'POST', `${PLATFORM}/cases`, {
|
||||
...casePayload, containerBookingId: '999999999999999999', remark: `${context.marker}-forged-container`,
|
||||
}, context.token, { 'Idempotency-Key': key(context, 'forged-case') })
|
||||
context.addStep(MODULE, '伪造未承载客户订单的柜次被拒绝', forgedCaseResponse, failure(forgedCaseResponse),
|
||||
`HTTP=${forgedCaseResponse.http}, code=${codeOf(forgedCaseResponse)}`, OPTIONS)
|
||||
|
||||
const firstUploadPath = `${MERCHANT}/cases/${caseId}/documents/upload`
|
||||
const firstUpload = await upload(context, firstUploadPath, { itemCode: 'INVOICE', expectedVersion: 0 },
|
||||
`${context.marker} invoice version 1`, `${context.marker}-invoice-v1.txt`, merchantToken, key(context, 'invoice-v1'))
|
||||
const document1 = context.apiSuccess(MODULE, '客户上传首版报关发票', firstUpload, '', OPTIONS)
|
||||
const document1Id = id(document1?.id)
|
||||
context.assert(MODULE, '客户文件版本、哈希、上传主体和业务归属已持久化', firstUpload,
|
||||
Boolean(document1Id) && document1?.checklistItemCode === 'INVOICE' && Number(document1?.documentVersion) === 1
|
||||
&& document1?.status === 'UPLOADED' && document1?.uploadedByType === 'MERCHANT'
|
||||
&& /^[0-9a-f]{64}$/.test(String(document1?.fileHash || '')),
|
||||
`documentId=${document1Id || 'missing'}, version=${document1?.documentVersion ?? 'missing'}`, OPTIONS)
|
||||
if (!document1Id) return
|
||||
|
||||
const firstUploadReplay = await upload(context, firstUploadPath, { itemCode: 'INVOICE', expectedVersion: 0 },
|
||||
`${context.marker} invoice version 1`, `${context.marker}-invoice-v1.txt`, merchantToken, key(context, 'invoice-v1'))
|
||||
context.assert(MODULE, '客户文件上传在聚合版本变化后仍可幂等重放', firstUploadReplay,
|
||||
codeOf(firstUploadReplay) === '200' && id(firstUploadReplay.body?.data?.id) === document1Id
|
||||
&& Number(firstUploadReplay.body?.data?.documentVersion) === 1,
|
||||
`HTTP=${firstUploadReplay.http}, code=${codeOf(firstUploadReplay)}, documentId=${id(firstUploadReplay.body?.data?.id) || 'missing'}`, OPTIONS)
|
||||
|
||||
let current = await detail(context, caseId)
|
||||
context.assert(MODULE, '资料齐备后进入 REVIEWING 且版本递增', current.response,
|
||||
current.data?.customsCase?.status === 'REVIEWING' && Number(current.data?.customsCase?.version) === 1,
|
||||
`status=${current.data?.customsCase?.status || 'missing'}, version=${current.data?.customsCase?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const returnedResponse = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/documents/${document1Id}/review`, {
|
||||
decision: 'RETURN', reason: `${context.marker}-发票金额需补正`, supplementDueAt: '2026-09-30T18:00:00', expectedVersion: 1,
|
||||
}, context.token, { 'Idempotency-Key': key(context, 'return-v1') })
|
||||
const returned = context.apiSuccess(MODULE, '平台退回具体文件并创建补件请求', returnedResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '退回进入 SUPPLEMENT_REQUIRED 并保留原因、时限和历史', returnedResponse,
|
||||
returned?.customsCase?.status === 'SUPPLEMENT_REQUIRED' && Number(returned?.customsCase?.version) === 2
|
||||
&& returned?.supplements?.some?.((item) => item.status === 'OPEN' && item.checklistItemCode === 'INVOICE'
|
||||
&& String(item.reason || '').includes(context.marker) && Boolean(item.dueAt))
|
||||
&& returned?.history?.some?.((item) => item.action === 'REVIEW_DOCUMENT_RETURN' && id(item.resourceId) === document1Id),
|
||||
`status=${returned?.customsCase?.status || 'missing'}, supplements=${returned?.supplements?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const replacement = await upload(context, firstUploadPath, { itemCode: 'INVOICE', expectedVersion: 2 },
|
||||
`${context.marker} invoice version 2 corrected`, `${context.marker}-invoice-v2.txt`, merchantToken, key(context, 'invoice-v2'))
|
||||
const document2 = context.apiSuccess(MODULE, '客户补件替换发票并生成新版本', replacement, '', OPTIONS)
|
||||
const document2Id = id(document2?.id)
|
||||
context.assert(MODULE, '替换文件保留上一版本引用且版本递增', replacement,
|
||||
Boolean(document2Id) && Number(document2?.documentVersion) === 2 && id(document2?.replacesDocumentId) === document1Id
|
||||
&& document2?.status === 'UPLOADED',
|
||||
`documentId=${document2Id || 'missing'}, replaces=${id(document2?.replacesDocumentId) || 'missing'}`, OPTIONS)
|
||||
if (!document2Id) return
|
||||
|
||||
current = await detail(context, caseId)
|
||||
context.assert(MODULE, '补件任务解决且历史文件版本未覆盖', current.response,
|
||||
current.data?.supplements?.some?.((item) => item.status === 'RESOLVED' && id(item.resolvedDocumentId) === document2Id)
|
||||
&& current.data?.documents?.some?.((item) => id(item.id) === document1Id && item.status === 'SUPERSEDED')
|
||||
&& current.data?.documents?.some?.((item) => id(item.id) === document2Id && Number(item.documentVersion) === 2),
|
||||
`documents=${current.data?.documents?.length ?? 'n/a'}, supplements=${current.data?.supplements?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const approveResponse = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/documents/${document2Id}/review`, {
|
||||
decision: 'APPROVE', reason: '资料完整', supplementDueAt: null, expectedVersion: 3,
|
||||
}, context.token, { 'Idempotency-Key': key(context, 'approve-v2') })
|
||||
const approved = context.apiSuccess(MODULE, '平台审核通过补件版本', approveResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '全部必填文件通过后进入 READY_TO_SUBMIT', approveResponse,
|
||||
approved?.customsCase?.status === 'READY_TO_SUBMIT' && Number(approved?.customsCase?.version) === 4,
|
||||
`status=${approved?.customsCase?.status || 'missing'}, version=${approved?.customsCase?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const batchPayload = { customsAgentName: `代理-${suffix}`, externalReference: `EXT-${suffix}`,
|
||||
note: context.marker, expectedVersion: 4 }
|
||||
const batchResponse = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/submission-batches`, batchPayload,
|
||||
context.token, { 'Idempotency-Key': key(context, 'batch') })
|
||||
const batch = context.apiSuccess(MODULE, '生成代理提交批次并冻结文件版本', batchResponse, '', OPTIONS)
|
||||
const batchId = id(batch?.id)
|
||||
let snapshot = []
|
||||
try { snapshot = JSON.parse(batch?.fileSnapshotJson || '[]') } catch { /* asserted below */ }
|
||||
context.assert(MODULE, '代理批次冻结当前文件 ID、版本和哈希', batchResponse,
|
||||
Boolean(batchId) && batch?.status === 'SUBMITTED' && Number(batch?.batchVersion) === 1
|
||||
&& snapshot.some?.((item) => id(item.documentId) === document2Id && Number(item.documentVersion) === 2
|
||||
&& /^[0-9a-f]{64}$/.test(String(item.fileHash || ''))),
|
||||
`batchId=${batchId || 'missing'}, frozenFiles=${snapshot.length}`, OPTIONS)
|
||||
if (!batchId) return
|
||||
|
||||
const batchReplay = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/submission-batches`, batchPayload,
|
||||
context.token, { 'Idempotency-Key': key(context, 'batch') })
|
||||
context.assert(MODULE, '代理提交幂等重放返回同一冻结批次', batchReplay,
|
||||
codeOf(batchReplay) === '200' && id(batchReplay.body?.data?.id) === batchId,
|
||||
`HTTP=${batchReplay.http}, code=${codeOf(batchReplay)}, batchId=${id(batchReplay.body?.data?.id) || 'missing'}`, OPTIONS)
|
||||
|
||||
const staleSubmit = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/submission-batches`, {
|
||||
...batchPayload, customsAgentName: '不同请求',
|
||||
}, context.token, { 'Idempotency-Key': key(context, 'batch') })
|
||||
context.addStep(MODULE, '同一幂等键不同代理提交请求被拒绝', staleSubmit,
|
||||
staleSubmit.http === 200 && codeOf(staleSubmit) !== '200',
|
||||
`HTTP=${staleSubmit.http}, code=${codeOf(staleSubmit)}`, OPTIONS)
|
||||
|
||||
const receivedUpload = await upload(context, `${PLATFORM}/submission-batches/${batchId}/receipts/upload`, {
|
||||
receiptType: 'RECEIVED', visibility: 'PLATFORM', note: context.marker, expectedVersion: 5,
|
||||
}, `${context.marker} agent received`, `${context.marker}-received.txt`, context.token, key(context, 'receipt-received'))
|
||||
const received = context.apiSuccess(MODULE, '上传平台可见代理接收回执', receivedUpload, '', OPTIONS)
|
||||
const receivedId = id(received?.id)
|
||||
context.assert(MODULE, '接收回执版本和平台可见范围正确', receivedUpload,
|
||||
Boolean(receivedId) && received?.receiptType === 'RECEIVED' && received?.visibility === 'PLATFORM'
|
||||
&& Number(received?.receiptVersion) === 1,
|
||||
`receiptId=${receivedId || 'missing'}, visibility=${received?.visibility || 'missing'}`, OPTIONS)
|
||||
|
||||
const releaseUpload = await upload(context, `${PLATFORM}/submission-batches/${batchId}/receipts/upload`, {
|
||||
receiptType: 'RELEASED', visibility: 'CUSTOMER', note: `${context.marker}-放行`, expectedVersion: 6,
|
||||
}, `${context.marker} customs released`, `${context.marker}-released.txt`, context.token, key(context, 'receipt-released'))
|
||||
const released = context.apiSuccess(MODULE, '上传客户可见海关放行回执', releaseUpload, '', OPTIONS)
|
||||
const releasedId = id(released?.id)
|
||||
context.assert(MODULE, '放行回执类型、版本和客户可见范围正确', releaseUpload,
|
||||
Boolean(releasedId) && released?.receiptType === 'RELEASED' && released?.visibility === 'CUSTOMER'
|
||||
&& Number(released?.receiptVersion) === 1,
|
||||
`receiptId=${releasedId || 'missing'}, visibility=${released?.visibility || 'missing'}`, OPTIONS)
|
||||
if (!releasedId) return
|
||||
|
||||
current = await detail(context, caseId)
|
||||
context.assert(MODULE, '报关放行推进 CLEARANCE_COMPLETED 并保留完整稳定历史', current.response,
|
||||
current.data?.customsCase?.status === 'CLEARANCE_COMPLETED' && Number(current.data?.customsCase?.version) === 7
|
||||
&& current.data?.documents?.some?.((item) => id(item.id) === document2Id && item.status === 'FROZEN')
|
||||
&& current.data?.history?.some?.((item) => item.action === 'SUBMIT_AGENT_BATCH' && id(item.resourceId) === batchId)
|
||||
&& current.data?.history?.some?.((item) => item.action === 'UPLOAD_RECEIPT' && id(item.resourceId) === releasedId
|
||||
&& item.toStatus === 'CLEARANCE_COMPLETED' && Boolean(item.operatorId) && Boolean(item.createdAt)),
|
||||
`status=${current.data?.customsCase?.status || 'missing'}, history=${current.data?.history?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const platformDownload = await download(context, `${PLATFORM}/cases/${caseId}/receipts/${releasedId}/download`, context.token)
|
||||
context.assert(MODULE, '平台授权下载真实放行回执内容', platformDownload,
|
||||
platformDownload.http === 200 && platformDownload.contentType.includes('text/plain') && platformDownload.bytes.length > 0,
|
||||
`HTTP=${platformDownload.http}, bytes=${platformDownload.bytes.length}, contentType=${platformDownload.contentType || 'missing'}`, OPTIONS)
|
||||
|
||||
const merchantDetailResponse = await request(context, 'GET', `${MERCHANT}/cases/${caseId}`, undefined, merchantToken)
|
||||
const merchantDetail = context.apiSuccess(MODULE, '客户查询本企业报关进度和可见回执', merchantDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '客户详情过滤平台回执并保留客户放行回执', merchantDetailResponse,
|
||||
id(merchantDetail?.customsCase?.tenantId) === tenantId
|
||||
&& merchantDetail?.receipts?.some?.((item) => id(item.id) === releasedId)
|
||||
&& !merchantDetail?.receipts?.some?.((item) => id(item.id) === receivedId),
|
||||
`receipts=${merchantDetail?.receipts?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const merchantDownload = await download(context, `${MERCHANT}/cases/${caseId}/receipts/${releasedId}/download`, merchantToken)
|
||||
context.assert(MODULE, '客户授权下载客户可见放行回执', merchantDownload,
|
||||
merchantDownload.http === 200 && merchantDownload.bytes.length > 0,
|
||||
`HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes.length}`, OPTIONS)
|
||||
|
||||
const hiddenDownload = await download(context, `${MERCHANT}/cases/${caseId}/receipts/${receivedId}/download`, merchantToken)
|
||||
context.addStep(MODULE, '客户下载平台可见回执被拒绝', hiddenDownload,
|
||||
hiddenDownload.http !== 200 || String(hiddenDownload.body?.code || '') !== '200',
|
||||
`HTTP=${hiddenDownload.http}, code=${hiddenDownload.body?.code || 'binary'}, bytes=${hiddenDownload.bytes.length}`, OPTIONS)
|
||||
|
||||
const forgedReference = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/documents`, {
|
||||
itemCode: 'INVOICE', fileId: released?.fileId, expectedVersion: 7,
|
||||
}, context.token, { 'Idempotency-Key': key(context, 'forged-file-reference') })
|
||||
context.addStep(MODULE, '回执 fileId 不能伪造为客户报关资料引用', forgedReference, failure(forgedReference),
|
||||
`HTTP=${forgedReference.http}, code=${codeOf(forgedReference)}`, OPTIONS)
|
||||
|
||||
context.createdResources.phase3Customs = {
|
||||
marker: context.marker, tenantId, orderId, inboundId, containerBookingId,
|
||||
customsCaseId: caseId, submissionBatchId: batchId, releasedReceiptId: releasedId,
|
||||
finalStatus: 'CLEARANCE_COMPLETED',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { registerPendingContract } from './_phase3-pending-contract.mjs'
|
||||
|
||||
export async function run(context) {
|
||||
registerPendingContract(context, {
|
||||
module: 'phase3-dispatch-gate', scenario: 'domestic-dispatch-gate', name: '国内出仓统一门禁',
|
||||
reason: '当前无聚合订舱、拖车、拣货复核、柜封证据和报关条件的 check-gate/confirm-dispatch/reverse-dispatch 公开契约', category: 'not-implemented',
|
||||
fixtures: ['phase3Loading', 'phase3ContainerLoading', 'oceanBooking', 'truckAppointment'], contractSource: '待新增国内出仓门禁 Controller/DTO/OpenAPI',
|
||||
assertions: ['门禁逐项返回真实阻断原因', '旧 version 和重复确认安全冲突', '确认与撤销均保留审计时间线'],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { registerPendingContract } from './_phase3-pending-contract.mjs'
|
||||
|
||||
export async function run(context) {
|
||||
registerPendingContract(context, {
|
||||
module: 'phase3-evidence-gate', scenario: 'loading-evidence-gate', name: '四步照片与铅封证据完整性门禁',
|
||||
reason: '本轮真实 HTTP 上传并绑定 SEAL 文件即可进入 LOADED;服务端未要求 PHOTO_STEP_1..4 齐全,也未将柜次级 PDA 复核结果作为前置', category: 'contract-gap',
|
||||
fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待装柜证据完整性门禁 Controller/DTO/OpenAPI',
|
||||
assertions: ['四步照片和铅封均绑定本轮柜次', '缺任一证据时完成装柜被拒绝', '全部证据与 PDA 复核齐备后幂等完成'],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { registerPendingContract } from './_phase3-pending-contract.mjs'
|
||||
|
||||
export async function run(context) {
|
||||
registerPendingContract(context, {
|
||||
module: 'phase3-loading-model-bridge', scenario: 'parallel-loading-models', name: '入库单级预装与柜次级智能装柜串联',
|
||||
reason: 'fl_preload_plan 按入库单推进 LOADING/LOADED,fl_loading_plan 按柜次推进 EDITING/LOADING/COMPLETED;两套公开 API 不会互相创建、绑定或校验终态', category: 'contract-gap',
|
||||
fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待新增两套装柜模型关联键与联动契约',
|
||||
assertions: ['同一 inbound 与柜次计划双向可查', '任一侧版本冲突不产生部分更新', '双终态一致后方可进入出仓门禁'],
|
||||
})
|
||||
}
|
||||
@@ -22,11 +22,12 @@ async function request(context, method, path, body, headers = {}) {
|
||||
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
|
||||
}
|
||||
|
||||
async function uploadEvidence(context, tenantId, inboundId) {
|
||||
const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=SEAL`
|
||||
async function uploadEvidence(context, tenantId, inboundId, evidenceType) {
|
||||
const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}`
|
||||
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}-seal.png`)
|
||||
const fileName = `${context.marker}-${evidenceType.toLowerCase()}.png`
|
||||
form.append('file', new Blob([png], { type: 'image/png' }), fileName)
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
|
||||
@@ -43,6 +44,17 @@ async function uploadEvidence(context, tenantId, inboundId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function bindEvidence(context, base, tenantId, evidenceType, fileId, suffix, keySuffix = evidenceType) {
|
||||
return request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
|
||||
expectedVersion: 0,
|
||||
evidenceType,
|
||||
fileId,
|
||||
fileName: 'client-must-not-override.png',
|
||||
sealNo: evidenceType === 'SEAL' ? `SEAL-${suffix}` : null,
|
||||
remark: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-p3-evidence-${keySuffix}`.slice(0, 128) })
|
||||
}
|
||||
|
||||
function fixture(context) {
|
||||
return context.createdResources?.orchestrationFixtures?.inboundMeasurement
|
||||
|| context.createdResources?.inboundMeasurement
|
||||
@@ -124,50 +136,100 @@ export async function run(context) {
|
||||
`status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!Number.isInteger(loadingVersion)) return
|
||||
|
||||
const upload = await uploadEvidence(context, tenantId, inboundId)
|
||||
const uploaded = context.apiSuccess(MODULE, '上传本轮装柜铅封证据文件', upload, `fileName=${context.marker}-seal.png`, OPTIONS)
|
||||
const fileId = id(uploaded?.fileId)
|
||||
context.assert(MODULE, '装柜证据文件归属类型和媒体元数据正确', upload,
|
||||
Boolean(fileId) && uploaded?.businessType === 'LOADING_SEAL' && uploaded?.contentType === 'image/png'
|
||||
&& Number(uploaded?.sizeBytes) > 0,
|
||||
`fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}`, OPTIONS)
|
||||
if (!fileId) return
|
||||
const blockedBeforePicking = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: loadingVersion, remark: 'must require picking' },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-picking`.slice(0, 128) })
|
||||
context.addStep(MODULE, '未完成 PDA 拣货时拒绝完成装柜', blockedBeforePicking, failure(blockedBeforePicking),
|
||||
`HTTP=${blockedBeforePicking.http}, code=${codeOf(blockedBeforePicking)}`, OPTIONS)
|
||||
|
||||
const evidence = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
|
||||
expectedVersion: 0, evidenceType: 'SEAL', fileId, fileName: 'client-must-not-override.png', sealNo: `SEAL-${suffix}`, remark: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-p3-evidence`.slice(0, 128) })
|
||||
const evidenceData = context.apiSuccess(MODULE, '写入本轮装柜铅封证据', evidence, '', OPTIONS)
|
||||
const evidenceItem = evidenceData?.evidences?.evidences?.find?.((item) => item.evidenceType === 'SEAL')
|
||||
context.assert(MODULE, '装柜证据类型、铅封号、marker 和版本正确', evidence,
|
||||
evidenceData?.mainStatus === 'LOADING' && evidenceItem?.sealNo === `SEAL-${suffix}`
|
||||
&& id(evidenceItem?.fileId) === fileId && evidenceItem?.fileName === `${context.marker}-seal.png`
|
||||
&& evidenceItem?.remark === context.marker && Number(evidenceItem?.version) === 0,
|
||||
`mainStatus=${evidenceData?.mainStatus || 'missing'}, evidenceId=${id(evidenceItem?.id) || 'missing'}`, OPTIONS)
|
||||
if (!evidenceItem?.id) return
|
||||
const pickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-start`.slice(0, 128) })
|
||||
const pickingStarted = context.apiSuccess(MODULE, '开始本轮 PDA 拣货任务', pickingStart, '', OPTIONS)
|
||||
context.assert(MODULE, 'PDA 拣货任务处于 IN_PROGRESS 且计划数量正确', pickingStart,
|
||||
pickingStarted?.status === 'IN_PROGRESS' && Number(pickingStarted?.version) === 0
|
||||
&& Number(pickingStarted?.expectedCount) === 1 && Number(pickingStarted?.pickedCount) === 0,
|
||||
`status=${pickingStarted?.status || 'missing'}, version=${pickingStarted?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const download = await context.requestBinary('GET', `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`)
|
||||
context.assert(MODULE, '下载本轮装柜证据文件', download,
|
||||
download.http === 200 && download.contentType.toLowerCase().includes('image/png') && download.bytes.length > 0,
|
||||
`bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS)
|
||||
const pickingMismatch = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-mismatch`.slice(0, 128) })
|
||||
context.addStep(MODULE, 'PDA 拣货数量不一致时拒绝完成', pickingMismatch, failure(pickingMismatch),
|
||||
`HTTP=${pickingMismatch.http}, code=${codeOf(pickingMismatch)}`, OPTIONS)
|
||||
|
||||
const pickingComplete = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 1, remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-complete`.slice(0, 128) })
|
||||
const pickingCompleted = context.apiSuccess(MODULE, '完成本轮 PDA 拣货复核', pickingComplete, '', OPTIONS)
|
||||
context.assert(MODULE, 'PDA 拣货任务 COMPLETED 且数量一致', pickingComplete,
|
||||
pickingCompleted?.status === 'COMPLETED' && Number(pickingCompleted?.version) === 1
|
||||
&& Number(pickingCompleted?.expectedCount) === 1 && Number(pickingCompleted?.pickedCount) === 1,
|
||||
`status=${pickingCompleted?.status || 'missing'}, version=${pickingCompleted?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
|
||||
const evidenceItems = []
|
||||
for (const evidenceType of evidenceTypes) {
|
||||
const upload = await uploadEvidence(context, tenantId, inboundId, evidenceType)
|
||||
const uploaded = context.apiSuccess(MODULE, `上传本轮 ${evidenceType} 真实证据文件`, upload, '', OPTIONS)
|
||||
const fileId = id(uploaded?.fileId)
|
||||
context.assert(MODULE, `${evidenceType} 文件归属类型和媒体元数据正确`, upload,
|
||||
Boolean(fileId) && uploaded?.businessType === `LOADING_${evidenceType}`
|
||||
&& uploaded?.contentType === 'image/png' && Number(uploaded?.sizeBytes) > 0,
|
||||
`fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}`, OPTIONS)
|
||||
if (!fileId) return
|
||||
|
||||
const evidence = await bindEvidence(context, base, tenantId, evidenceType, fileId, suffix)
|
||||
const evidenceData = context.apiSuccess(MODULE, `绑定本轮 ${evidenceType} 装柜证据`, evidence, '', OPTIONS)
|
||||
const evidenceItem = evidenceData?.evidences?.evidences?.find?.((item) => item.evidenceType === evidenceType)
|
||||
context.assert(MODULE, `${evidenceType} 证据归属、marker 和版本正确`, evidence,
|
||||
evidenceData?.mainStatus === 'LOADING' && id(evidenceItem?.fileId) === fileId
|
||||
&& evidenceItem?.fileName === `${context.marker}-${evidenceType.toLowerCase()}.png`
|
||||
&& evidenceItem?.remark === context.marker && Number(evidenceItem?.version) === 0
|
||||
&& (evidenceType !== 'SEAL' || evidenceItem?.sealNo === `SEAL-${suffix}`),
|
||||
`mainStatus=${evidenceData?.mainStatus || 'missing'}, evidenceId=${id(evidenceItem?.id) || 'missing'}`, OPTIONS)
|
||||
if (!evidenceItem?.id) return
|
||||
evidenceItems.push(evidenceItem)
|
||||
|
||||
const download = await context.requestBinary('GET', `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`)
|
||||
context.assert(MODULE, `下载本轮 ${evidenceType} 证据文件`, download,
|
||||
download.http === 200 && download.contentType.toLowerCase().includes('image/png') && download.bytes.length > 0,
|
||||
`bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS)
|
||||
|
||||
if (evidenceType === 'PHOTO_STEP_4') {
|
||||
const missingSeal = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: loadingVersion, remark: 'must require seal' },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-seal`.slice(0, 128) })
|
||||
context.addStep(MODULE, '缺铅封照片和锁码时拒绝完成装柜', missingSeal, failure(missingSeal),
|
||||
`HTTP=${missingSeal.http}, code=${codeOf(missingSeal)}`, OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
const firstPhoto = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_1')
|
||||
const sealEvidence = evidenceItems.find((item) => item.evidenceType === 'SEAL')
|
||||
const wrongType = await bindEvidence(context, base, tenantId, 'PHOTO_STEP_2', id(firstPhoto?.fileId), suffix, 'wrong-type')
|
||||
context.addStep(MODULE, '不同证据类型的真实 fileId 不可交叉绑定', wrongType, failure(wrongType),
|
||||
`HTTP=${wrongType.http}, code=${codeOf(wrongType)}`, OPTIONS)
|
||||
|
||||
const forged = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
|
||||
expectedVersion: Number(evidenceItem.version), evidenceType: 'SEAL', fileId: '999999999999999999',
|
||||
expectedVersion: Number(sealEvidence?.version), evidenceType: 'SEAL', fileId: '999999999999999999',
|
||||
fileName: `${context.marker}-forged.png`, sealNo: `FORGED-${suffix}`, remark: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-p3-forged-evidence`.slice(0, 128) })
|
||||
context.addStep(MODULE, '伪造装柜证据 fileId 归属校验拒绝', forged, failure(forged),
|
||||
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
|
||||
|
||||
const evidenceList = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const evidenceListData = context.apiSuccess(MODULE, '回查装柜证据列表', evidenceList, '', OPTIONS)
|
||||
context.assert(MODULE, '装柜证据列表只读回显本轮铅封证据', evidenceList,
|
||||
evidenceListData?.evidences?.some?.((item) => id(item.id) === id(evidenceItem.id) && item.sealNo === `SEAL-${suffix}`),
|
||||
const evidenceListData = context.apiSuccess(MODULE, '回查五类装柜证据列表', evidenceList, '', OPTIONS)
|
||||
context.assert(MODULE, '五类装柜证据完整且铅封号持久化', evidenceList,
|
||||
evidenceListData?.evidences?.length === evidenceTypes.length
|
||||
&& evidenceTypes.every((type) => evidenceListData.evidences.some((item) => item.evidenceType === type))
|
||||
&& evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`),
|
||||
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const complete = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: loadingVersion, remark: context.marker }, { 'Idempotency-Key': `${context.marker}-p3-complete`.slice(0, 128) })
|
||||
const completed = context.apiSuccess(MODULE, '确认本轮入库单装柜完成', complete, '', OPTIONS)
|
||||
context.assert(MODULE, '装柜完成推进主状态 LOADED 且保留证据', complete,
|
||||
completed?.mainStatus === 'LOADED' && completed?.evidences?.evidences?.some?.((item) => id(item.id) === id(evidenceItem.id)),
|
||||
context.assert(MODULE, '装柜完成推进主状态 LOADED 且保留五类证据', complete,
|
||||
completed?.mainStatus === 'LOADED' && completed?.evidences?.evidences?.length === evidenceTypes.length,
|
||||
`mainStatus=${completed?.mainStatus || 'missing'}, evidences=${completed?.evidences?.evidences?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const finalResponse = await context.request('GET', detailPath)
|
||||
@@ -176,20 +238,9 @@ export async function run(context) {
|
||||
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) === loadingVersion + 1
|
||||
&& finalData?.statusHistory?.some?.((item) => item.action === 'COMPLETE_LOADING' && item.operatorName),
|
||||
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
|
||||
context.createdResources.phase3Loading = { tenantId, inboundId, preloadPlanId: planId, evidenceId: id(evidenceItem.id), marker: context.marker, finalStatus: 'LOADED' }
|
||||
context.createdResources.phase3Loading = {
|
||||
tenantId, inboundId, preloadPlanId: planId, evidenceIds: evidenceItems.map((item) => id(item.id)),
|
||||
pickingTaskId: id(pickingCompleted?.id), marker: context.marker, finalStatus: 'LOADED',
|
||||
}
|
||||
|
||||
context.addCoverageGap(MODULE, 'outbound-aggregate', '出仓单/柜次主聚合与整体时间线',
|
||||
'当前公开 API 只有入库单级分柜/预装/装柜状态和独立货柜、订舱、拖车对象;没有出仓单主资源、子流程关联或整体时间线端点', 'contract-gap')
|
||||
context.addCoverageGap(MODULE, 'customs-collaboration', '报关资料、补件、代理提交与回执闭环',
|
||||
'当前 Firstleg 无报关清单版本、客户文件版本、审核退回、代理提交批次、回执上传/下载的公开 Controller 契约', 'not-implemented')
|
||||
context.addCoverageGap(MODULE, 'port-schedule-query', '港务/船期独立查询与时效性',
|
||||
'订舱和货柜仅保存 ETD/ETA、船名航次等快照字段;无港务或船期数据源、更新时间、过期标识和独立查询端点', 'contract-gap')
|
||||
context.addCoverageGap(MODULE, 'domestic-dispatch-gate', '国内出仓统一门禁与 WAITING_FOR_LOADING 终态',
|
||||
'当前公开状态链仅能到 LOADED/DELIVERED 或装柜计划 COMPLETED;无聚合订舱、拖车、拣货复核、柜封证据和报关条件的 check-gate/confirm-dispatch/reverse-dispatch API,也无 WAITING_FOR_LOADING 状态', 'not-implemented')
|
||||
context.addCoverageGap(MODULE, 'parallel-loading-models', '入库单级预装与柜次级智能装柜未串联',
|
||||
'fl_preload_plan 按入库单推进 LOADING/LOADED,fl_loading_plan 按柜次推进 EDITING/LOADING/COMPLETED;两套公开 API 不会互相创建、绑定或校验终态', 'contract-gap')
|
||||
context.addCoverageGap(MODULE, 'pda-picking-convergence', 'PDA 柜次拣货与入库单汇总拣货未收敛',
|
||||
'柜次级 /first-leg/pda-loading 和入库单级 /inbounds/{id}/loading/picking 是两套独立任务;当前无领取、作业员分配、复核和统一完成门禁', 'contract-gap')
|
||||
context.addCoverageGap(MODULE, 'loading-evidence-gate', '四步照片与铅封证据完整性门禁',
|
||||
'本轮真实 HTTP 仅上传并绑定 SEAL 文件即可成功进入 LOADED;服务端未要求 PHOTO_STEP_1..4 齐全,也未将柜次级 PDA 复核结果作为前置', 'contract-gap')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { registerPendingContract } from './_phase3-pending-contract.mjs'
|
||||
|
||||
export async function run(context) {
|
||||
registerPendingContract(context, {
|
||||
module: 'phase3-outbound-aggregate', scenario: 'outbound-aggregate', name: '出仓单/柜次主聚合与整体时间线',
|
||||
reason: '当前公开 API 只有入库单级分柜/预装/装柜状态和独立货柜、订舱、拖车对象;没有出仓单主资源、子流程关联或整体时间线端点', category: 'contract-gap',
|
||||
fixtures: ['orders', 'phase3Loading', 'phase3ContainerLoading', 'oceanBooking', 'truckAppointment'],
|
||||
contractSource: '待新增出仓主聚合 Controller/DTO/OpenAPI',
|
||||
assertions: ['marker 所属主单与柜次关联唯一', '时间线按真实动作有序且含审计字段', '跨客户 ID 不可见'],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
const MODULE = 'phase3-outbound-dispatch'
|
||||
const OPTIONS = { scenario: 'phase3-domestic-outbound-gate', deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/first-leg/outbound-dispatches'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function rejected(response) {
|
||||
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|
||||
|| (response?.http === 200 && codeOf(response) !== '200')
|
||||
}
|
||||
|
||||
async function request(context, method, path, body, headers = {}) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method,
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const raw = await response.text(); let payload = null
|
||||
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||||
return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3ContainerLoading
|
||||
const inboundId = id(fixture?.inboundId)
|
||||
const containerBookingId = id(fixture?.containerBookingId)
|
||||
if (!inboundId || !containerBookingId || fixture?.marker !== context.marker) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '国内出仓主聚合真实闭环',
|
||||
'本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture')
|
||||
return
|
||||
}
|
||||
const selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(containerBookingId)}`
|
||||
const listResponse = await request(context, 'GET', `${BASE}?${selector}`)
|
||||
const aggregates = context.apiSuccess(MODULE, '按入库单和柜次查询出仓主聚合', listResponse, selector, OPTIONS)
|
||||
const aggregate = Array.isArray(aggregates) ? aggregates.find((item) => id(item.inboundId) === inboundId
|
||||
&& id(item.containerBookingId) === containerBookingId) : null
|
||||
context.assert(MODULE, '主聚合串联订单、入库单、柜次、计划与客户隔离键', listResponse,
|
||||
Boolean(aggregate?.orderId) && Boolean(aggregate?.tenantId) && Boolean(aggregate?.loadingPlanId)
|
||||
&& id(aggregate?.inboundId) === inboundId && id(aggregate?.containerBookingId) === containerBookingId,
|
||||
`orderId=${id(aggregate?.orderId) || 'missing'}, tenantId=${id(aggregate?.tenantId) || 'missing'}, planId=${id(aggregate?.loadingPlanId) || 'missing'}`, OPTIONS)
|
||||
if (!aggregate) return
|
||||
|
||||
const orderResponse = await request(context, 'GET', `${BASE}?orderId=${encodeURIComponent(id(aggregate.orderId))}`)
|
||||
const orderAggregates = context.apiSuccess(MODULE, '按 orderId 查询全部出仓柜次', orderResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'orderId 查询包含本轮入库单柜次', orderResponse,
|
||||
Array.isArray(orderAggregates) && orderAggregates.some((item) => id(item.inboundId) === inboundId
|
||||
&& id(item.containerBookingId) === containerBookingId), `records=${orderAggregates?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const detailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`)
|
||||
const detail = context.apiSuccess(MODULE, '查询出仓主聚合与整体时间线', detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '整体时间线返回稳定数组且聚合定位不漂移', detailResponse,
|
||||
Array.isArray(detail?.timeline) && id(detail?.aggregate?.inboundId) === inboundId
|
||||
&& id(detail?.aggregate?.containerBookingId) === containerBookingId,
|
||||
`timeline=${detail?.timeline?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const gateResponse = await request(context, 'GET', `${BASE}/check-gate?${selector}`)
|
||||
const gate = context.apiSuccess(MODULE, '执行国内出仓统一门禁', gateResponse, '', OPTIONS)
|
||||
const gateItems = Array.isArray(gate?.items) ? gate.items : []
|
||||
const expectedCodes = ['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED',
|
||||
'OCEAN_BOOKING_RELEASED', 'TRUCK_DEPARTED', 'LOADING_EVIDENCE_COMPLETE',
|
||||
'CUSTOMS_CLEARANCE', 'SAILING_SCHEDULE']
|
||||
context.assert(MODULE, '门禁逐项公开真实状态且不省略下游模块', gateResponse,
|
||||
expectedCodes.every((code) => gateItems.some((item) => item.code === code && typeof item.passed === 'boolean')),
|
||||
`codes=${gateItems.map((item) => item.code).join(',')}`, OPTIONS)
|
||||
|
||||
const expectedVersion = Number(aggregate.version)
|
||||
const actionBody = { inboundId, containerBookingId, expectedVersion, remark: context.marker }
|
||||
const confirmKey = `${context.marker}-outbound-confirm`.slice(0, 128)
|
||||
if (!gate?.passed) {
|
||||
const blocked = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody,
|
||||
{ 'Idempotency-Key': confirmKey })
|
||||
context.addStep(MODULE, '门禁未通过时 confirm-dispatch fail-closed', blocked, rejected(blocked),
|
||||
`HTTP=${blocked.http}, code=${codeOf(blocked)}`, OPTIONS)
|
||||
for (const item of gateItems.filter((value) => !value.passed)) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, item.name || item.code,
|
||||
`${item.code}: actual=${item.actual || 'missing'}, requirement=${item.requirement || 'missing'}`,
|
||||
item.actual === 'MODULE_UNAVAILABLE' ? 'not-implemented' : 'cross-module-linkage')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const confirmResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody,
|
||||
{ 'Idempotency-Key': confirmKey })
|
||||
const confirmed = context.apiSuccess(MODULE, '确认国内出仓进入等待装船', confirmResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'confirm-dispatch 推进 WAITING_FOR_LOADING 并递增版本', confirmResponse,
|
||||
confirmed?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
|
||||
&& confirmed?.aggregate?.inboundStatus === 'WAITING_FOR_LOADING'
|
||||
&& Number(confirmed?.aggregate?.version) === expectedVersion + 1,
|
||||
`dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbound=${confirmed?.aggregate?.inboundStatus || 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const replayResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody,
|
||||
{ 'Idempotency-Key': confirmKey })
|
||||
const replay = context.apiSuccess(MODULE, 'confirm-dispatch 幂等重放', replayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '确认重放保持同一终态和版本', replayResponse,
|
||||
replay?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
|
||||
&& Number(replay?.aggregate?.version) === Number(confirmed?.aggregate?.version),
|
||||
`version=${replay?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const hashConflict = await request(context, 'POST', `${BASE}/confirm-dispatch`, { ...actionBody, remark: 'forged' },
|
||||
{ 'Idempotency-Key': confirmKey })
|
||||
context.addStep(MODULE, '相同 Idempotency-Key 不同请求拒绝', hashConflict, rejected(hashConflict),
|
||||
`HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}`, OPTIONS)
|
||||
|
||||
const reverseKey = `${context.marker}-outbound-reverse`.slice(0, 128)
|
||||
const reverseResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, {
|
||||
inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker,
|
||||
}, { 'Idempotency-Key': reverseKey })
|
||||
const reversed = context.apiSuccess(MODULE, '撤销国内出仓恢复装柜完成', reverseResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'reverse-dispatch 恢复 READY_FOR_DISPATCH 与 LOADED', reverseResponse,
|
||||
reversed?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && reversed?.aggregate?.inboundStatus === 'LOADED'
|
||||
&& reversed?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'),
|
||||
`dispatch=${reversed?.aggregate?.dispatchStatus || 'missing'}, inbound=${reversed?.aggregate?.inboundStatus || 'missing'}`, OPTIONS)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { registerPendingContract } from './_phase3-pending-contract.mjs'
|
||||
|
||||
export async function run(context) {
|
||||
registerPendingContract(context, {
|
||||
module: 'phase3-pda-convergence', scenario: 'pda-picking-convergence', name: 'PDA 柜次拣货与入库单汇总拣货收敛',
|
||||
reason: '柜次级 /first-leg/pda-loading 和入库单级 /inbounds/{id}/loading/picking 是两套独立任务;当前无领取、作业员分配、复核和统一完成门禁', category: 'contract-gap',
|
||||
fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待新增 PDA 任务领取、分配、复核与汇总契约',
|
||||
assertions: ['任务领取和作业员隔离', '柜次明细与入库汇总数量一致', '复核完成幂等且成为装柜门禁前置'],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
const MODULE = 'phase3-port-schedules'
|
||||
const OPTIONS = { scenario: 'port-schedule-source-lifecycle', deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/port-schedules'
|
||||
const OCEAN = '/api/platform/v1/ocean-bookings'
|
||||
const OUTBOUND = '/api/platform/v1/first-leg/outbound-dispatches'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function data(response) { return codeOf(response) === '200' ? response.body.data : null }
|
||||
function localIso(value) { return value.toISOString().slice(0, 19) }
|
||||
function at(offsetMinutes) { return localIso(new Date(Date.now() + offsetMinutes * 60_000)) }
|
||||
function step(context, name, response, passed, evidence) {
|
||||
context.addStep(MODULE, name, response, passed, evidence, { ...OPTIONS, deploymentBlocker: !passed })
|
||||
}
|
||||
async function request(context, method, path, body, headers = {}) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const raw = await response.text(); let payload = null
|
||||
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||||
return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
function oceanDraft(context, suffix) {
|
||||
return {
|
||||
customerId: '10001', customerName: `船期验收客户-${suffix}`, billOfLadingNo: `PS-BL-${suffix}`,
|
||||
shippingCompanyName: `船期船司-${suffix}`, bookingAgentName: `船期代理-${suffix}`,
|
||||
vesselName: `船期船-${suffix}`, voyageNo: `PS${suffix}`, departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||
estimatedSailingAt: at(7 * 24 * 60), estimatedArrivalAt: at(28 * 24 * 60), remark: context.marker,
|
||||
parties: [
|
||||
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
|
||||
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
|
||||
],
|
||||
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1 }],
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOceanBooking(context, suffix) {
|
||||
if (context.createdResources?.oceanBooking?.id) return context.createdResources.oceanBooking
|
||||
const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix) },
|
||||
{ 'Idempotency-Key': `${context.marker}-port-ocean`.slice(0, 128) })
|
||||
const detail = context.apiSuccess(MODULE, '创建本轮隔离船期关联订舱', response, '', OPTIONS)
|
||||
const oceanBookingId = id(detail?.oceanBooking?.id)
|
||||
context.assert(MODULE, '船期关联订舱客户归属明确', response,
|
||||
Boolean(oceanBookingId) && id(detail?.oceanBooking?.customerId) === '10001',
|
||||
`oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
|
||||
return oceanBookingId ? { id: oceanBookingId, customerId: '10001' } : null
|
||||
}
|
||||
|
||||
function scheduleData(context, suffix, oceanBookingId, containerBookingId, expired = false) {
|
||||
return {
|
||||
customerId: '10001', oceanBookingId, containerBookingId: containerBookingId || null,
|
||||
sourceType: 'CARRIER', sourceName: `验收船司-${suffix}`, sourceRef: `${context.marker}-SOURCE-${expired ? 'EXPIRED' : 'CURRENT'}`,
|
||||
vesselName: `港务船-${suffix}`, voyageNo: `PV${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳',
|
||||
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', etdAt: at(10 * 24 * 60), etaAt: at(30 * 24 * 60),
|
||||
bookingCutoffAt: at(7 * 24 * 60), customsCutoffAt: at(8 * 24 * 60), portCutoffAt: at(9 * 24 * 60),
|
||||
observedAt: expired ? at(-120) : at(-1), expiresAt: expired ? at(-60) : at(60), remark: context.marker,
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const suffix = context.marker.replace(/\D/g, '').slice(-10)
|
||||
const probe = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`)
|
||||
if (probe.http === 404 || probe.http === 405 || probe.http === 503 || probe.http === 0) {
|
||||
step(context, '港务船期真实平台契约已部署', probe, false,
|
||||
`入口 ${BASE} 不可用:HTTP=${probe.http}, code=${codeOf(probe)}`)
|
||||
return
|
||||
}
|
||||
const page = context.apiSuccess(MODULE, '港务船期分页入口可用', probe, '', OPTIONS)
|
||||
context.assert(MODULE, '港务船期分页结构稳定', probe,
|
||||
Array.isArray(page?.records) && Number.isFinite(Number(page?.total)),
|
||||
`records=${page?.records?.length ?? 'n/a'}, total=${page?.total ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const ocean = await ensureOceanBooking(context, suffix)
|
||||
if (!ocean?.id) return
|
||||
const flow = context.createdResources?.phase3ContainerLoading
|
||||
const containerBookingId = flow?.finalStatus === 'COMPLETED' && id(flow?.tenantId) === '10001'
|
||||
? 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), 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)
|
||||
const scheduleId = id(created?.portSchedule?.schedule?.id)
|
||||
context.assert(MODULE, '船期来源、时效、截单截关截港和关联字段完整', createResponse,
|
||||
Boolean(scheduleId) && created?.portSchedule?.expired === false
|
||||
&& created?.portSchedule?.schedule?.sourceType === 'CARRIER'
|
||||
&& id(created?.portSchedule?.schedule?.oceanBookingId) === ocean.id
|
||||
&& Boolean(created?.portSchedule?.schedule?.observedAt)
|
||||
&& Boolean(created?.portSchedule?.schedule?.updatedAt)
|
||||
&& Boolean(created?.portSchedule?.schedule?.expiresAt)
|
||||
&& Boolean(created?.portSchedule?.schedule?.bookingCutoffAt)
|
||||
&& Boolean(created?.portSchedule?.schedule?.customsCutoffAt)
|
||||
&& Boolean(created?.portSchedule?.schedule?.portCutoffAt),
|
||||
`scheduleId=${scheduleId || 'missing'}, expired=${created?.portSchedule?.expired ?? 'missing'}`, OPTIONS)
|
||||
if (!scheduleId) return
|
||||
|
||||
const replay = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
|
||||
context.assert(MODULE, '新增船期 Idempotency-Key 重放返回同一资源', replay,
|
||||
id(data(replay)?.portSchedule?.schedule?.id) === scheduleId,
|
||||
`first=${scheduleId}, replay=${id(data(replay)?.portSchedule?.schedule?.id) || 'missing'}`, OPTIONS)
|
||||
|
||||
for (const [name, query] of [
|
||||
['按客户查询船期', `customerId=10001`],
|
||||
['按订舱查询船期', `oceanBookingId=${ocean.id}`],
|
||||
...(knownAllocation ? [['按柜次查询船期', `containerBookingId=${knownAllocation}`]] : []),
|
||||
]) {
|
||||
const response = await request(context, 'GET', `${BASE}?page=1&size=20&${query}`)
|
||||
const records = data(response)?.records
|
||||
step(context, name, response, response.http === 200 && codeOf(response) === '200'
|
||||
&& Array.isArray(records) && records.some((item) => id(item?.schedule?.id) === scheduleId),
|
||||
`records=${records?.length ?? 'n/a'}, scheduleId=${scheduleId}`)
|
||||
}
|
||||
|
||||
const initialVersion = Number(created.portSchedule.schedule.version)
|
||||
const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, true)
|
||||
expiredData.sourceRef = payload.data.sourceRef
|
||||
const updateResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
||||
{ expectedVersion: initialVersion, data: expiredData, reason: '来源刷新并过期' },
|
||||
{ 'Idempotency-Key': `${context.marker}-port-update`.slice(0, 128) })
|
||||
const updated = context.apiSuccess(MODULE, '更新船期并计算过期状态', updateResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '过期标识由expiresAt服务端计算且版本递增', updateResponse,
|
||||
updated?.portSchedule?.expired === true
|
||||
&& Number(updated?.portSchedule?.schedule?.version) === initialVersion + 1,
|
||||
`expired=${updated?.portSchedule?.expired ?? 'missing'}, version=${updated?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const stale = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
||||
{ expectedVersion: initialVersion, data: expiredData, reason: '旧版本覆盖' },
|
||||
{ 'Idempotency-Key': `${context.marker}-port-stale`.slice(0, 128) })
|
||||
step(context, '旧版本更新船期返回乐观锁冲突', stale,
|
||||
stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)),
|
||||
`HTTP=${stale.http}, code=${codeOf(stale)}`)
|
||||
|
||||
const expiredQuery = await request(context, 'GET', `${BASE}?page=1&size=20&expired=true&oceanBookingId=${ocean.id}`)
|
||||
const expiredRecords = data(expiredQuery)?.records
|
||||
step(context, '按过期标识查询船期', expiredQuery,
|
||||
expiredQuery.http === 200 && codeOf(expiredQuery) === '200' && Array.isArray(expiredRecords)
|
||||
&& expiredRecords.some((item) => id(item?.schedule?.id) === scheduleId && item?.expired === true),
|
||||
`records=${expiredRecords?.length ?? 'n/a'}`)
|
||||
|
||||
const detailResponse = await request(context, 'GET', `${BASE}/${scheduleId}`)
|
||||
const detail = context.apiSuccess(MODULE, '回查港务船期详情与历史', detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '船期详情保留来源更新时间和CREATE/UPDATE历史', detailResponse,
|
||||
detail?.portSchedule?.expired === true && detail?.histories?.length >= 2
|
||||
&& detail.histories.some((item) => item.action === 'CREATE')
|
||||
&& detail.histories.some((item) => item.action === 'UPDATE')
|
||||
&& detail.histories.every((item) => item.operatorId && item.createdAt),
|
||||
`histories=${detail?.histories?.length ?? 'n/a'}`, OPTIONS)
|
||||
const historyResponse = await request(context, 'GET', `${BASE}/${scheduleId}/history`)
|
||||
const histories = context.apiSuccess(MODULE, '独立查询船期不可变历史', historyResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '船期历史端点顺序和版本字段稳定', historyResponse,
|
||||
Array.isArray(histories) && histories.length >= 2
|
||||
&& histories[0]?.action === 'CREATE' && histories.at(-1)?.toVersion === initialVersion + 1,
|
||||
`histories=${histories?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
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 currentData = scheduleData(context, suffix, ocean.id, knownAllocation, false)
|
||||
currentData.sourceRef = payload.data.sourceRef
|
||||
const refreshResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
||||
{ expectedVersion: initialVersion + 1, data: currentData, reason: '刷新为当前有效船期' },
|
||||
{ 'Idempotency-Key': `${context.marker}-port-refresh`.slice(0, 128) })
|
||||
const refreshed = context.apiSuccess(MODULE, '刷新船期为国内出仓可用终态', refreshResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '刷新后船期有效且版本再次递增', refreshResponse,
|
||||
refreshed?.portSchedule?.expired === false
|
||||
&& Number(refreshed?.portSchedule?.schedule?.version) === initialVersion + 2,
|
||||
`expired=${refreshed?.portSchedule?.expired ?? '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 forged = await request(context, 'POST', BASE, {
|
||||
expectedVersion: 0,
|
||||
data: { ...scheduleData(context, `${suffix}X`, ocean.id, null), customerId: '10002' },
|
||||
reason: '伪造客户归属',
|
||||
}, { 'Idempotency-Key': `${context.marker}-port-forged-customer`.slice(0, 128) })
|
||||
step(context, '伪造船期客户归属被拒绝', forged,
|
||||
forged.http === 200 && ['A0400', '400'].includes(codeOf(forged)),
|
||||
`HTTP=${forged.http}, code=${codeOf(forged)}`)
|
||||
|
||||
context.createdResources.portSchedule = {
|
||||
id: scheduleId, oceanBookingId: ocean.id, containerBookingId: knownAllocation,
|
||||
inboundId: inboundId || null, expired: false, gateReady: Boolean(knownAllocation && inboundId),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { registerPendingContract } from './_phase3-pending-contract.mjs'
|
||||
|
||||
export async function run(context) {
|
||||
registerPendingContract(context, {
|
||||
module: 'phase3-waiting-for-loading', scenario: 'waiting-for-loading-status', name: 'WAITING_FOR_LOADING 统一终态',
|
||||
reason: '当前公开状态链只有 LOADED/DELIVERED 或柜次装柜计划 COMPLETED,没有 WAITING_FOR_LOADING 状态及其查询、筛选和状态历史契约', category: 'not-implemented',
|
||||
fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待状态机与统一查询 OpenAPI 明确 WAITING_FOR_LOADING',
|
||||
assertions: ['本轮出仓完成后进入 WAITING_FOR_LOADING', '列表与详情状态一致', '历史记录来源状态、操作者和时间完整'],
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user