test(fba): preserve verified fleet and fulfillment acceptance workflows
This commit is contained in:
@@ -2,6 +2,91 @@ const MODULE = 'phase3-carrier-schedule-queries'
|
||||
const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/carrier-schedule-queries'
|
||||
const AUTOMATED_CARRIERS = Object.freeze(['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE'])
|
||||
export const CARRIER_EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
|
||||
|
||||
function stable(value) {
|
||||
if (Array.isArray(value)) return value.map(stable)
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]))
|
||||
return value
|
||||
}
|
||||
const equal = (a, b) => JSON.stringify(stable(a)) === JSON.stringify(stable(b))
|
||||
const rows = value => [...value].sort((a, b) => id(a.id).localeCompare(id(b.id)))
|
||||
|
||||
export function carrierExceptionShapeErrors(detail) {
|
||||
const errors = []
|
||||
if (!Array.isArray(detail?.nodes) || !Array.isArray(detail?.exceptions) || !Array.isArray(detail?.histories)) return ['missing detail arrays']
|
||||
const identities = new Set()
|
||||
for (const fact of detail.exceptions) {
|
||||
const identity = `${fact.providerCode}|${fact.providerEventId}`
|
||||
if (!validLongId(fact.id) || !CARRIER_EXCEPTION_CODES.includes(fact.exceptionCode)
|
||||
|| typeof fact.active !== 'boolean' || !fact.providerCode || !fact.providerEventId
|
||||
|| !fact.providerStatus || !fact.observedAt || !fact.createdAt
|
||||
|| !/^[a-f0-9]{64}$/.test(fact.rawPayloadHash || '')) errors.push(`invalid exception ${id(fact.id)}`)
|
||||
for (const [key, max] of [['locationCode', 32], ['locationName', 128], ['sourceReference', 500]]) {
|
||||
if (fact[key] != null && (typeof fact[key] !== 'string' || fact[key].length > max)) errors.push(`invalid optional ${key}`)
|
||||
}
|
||||
if (identities.has(identity)) errors.push(`duplicate provider event ${identity}`)
|
||||
identities.add(identity)
|
||||
}
|
||||
if (detail.nodes.some(node => CARRIER_EXCEPTION_CODES.includes(node.standardNode))) errors.push('exception stored as normal node')
|
||||
return errors
|
||||
}
|
||||
|
||||
// A GET is evidence only. Supplier writes/replays must be orchestrated separately
|
||||
// through Integration + the real scheduler; no synthetic platform write endpoint.
|
||||
export async function captureCarrierExceptionEvidence(context, resource = context.createdResources?.phase3CarrierScheduleQuery) {
|
||||
if (!ownsCarrierQuery(resource, context.marker)) throw new Error('requires current-marker owned carrier query')
|
||||
const response = await request(context, 'GET', `${BASE}/${encodeURIComponent(resource.queryId)}`)
|
||||
const detail = context.apiSuccess(MODULE, 'C66读取原船任务正常节点和独立异常事实', response, '', OPTIONS)
|
||||
if (!detail || id(detail.query?.query?.id) !== id(resource.queryId)) throw new Error('carrier detail identity mismatch')
|
||||
const errors = carrierExceptionShapeErrors(detail)
|
||||
context.assert(MODULE, 'C66异常编码来源地点摘要完整且不混入正常节点', response, errors.length === 0, errors.join('; '), OPTIONS)
|
||||
if (errors.length) throw new Error('invalid carrier exception facts')
|
||||
return { marker: context.marker, queryId: id(resource.queryId), capturedAt: new Date().toISOString(),
|
||||
requestId: response.requestId, traceId: response.traceId, detail }
|
||||
}
|
||||
|
||||
export function carrierExceptionTransitionErrors(before, after, { kind, expected }) {
|
||||
const errors = [...carrierExceptionShapeErrors(before?.detail), ...carrierExceptionShapeErrors(after?.detail)]
|
||||
if (!before?.marker || before.marker !== after?.marker || !before.queryId || before.queryId !== after.queryId
|
||||
|| id(before.detail?.query?.query?.id) !== before.queryId || id(after.detail?.query?.query?.id) !== before.queryId) errors.push('checkpoint identity mismatch')
|
||||
if (errors.length) return errors
|
||||
const a = before.detail, b = after.detail
|
||||
if (!a.nodes.some(node => node.actualAt) || !a.nodes.some(node => node.estimatedAt)) errors.push('requires actual and estimated normal baseline')
|
||||
if (!equal(rows(a.nodes), rows(b.nodes))) errors.push('normal actual/estimated facts changed')
|
||||
for (const key of ['id', 'customerId', 'oceanBookingId', 'containerBookingId', 'latestStandardNode', 'latestNodeObservedAt', 'stoppedAt', 'stopReason']) {
|
||||
if (!equal(a.query.query[key], b.query.query[key])) errors.push(`normal query scope/progress changed: ${key}`)
|
||||
}
|
||||
const histories = detail => rows(detail.histories.filter(item => item.action === 'EXCEPTION_OBSERVED'))
|
||||
if (kind === 'REPLAY' || kind === 'CONFLICT') {
|
||||
const original = expected && a.exceptions.find(item => item.providerCode === expected.providerCode && item.providerEventId === expected.providerEventId)
|
||||
if (!original) errors.push('original event absent before replay/conflict')
|
||||
if (!/^[a-f0-9]{64}$/.test(expected?.rawPayloadHash || '')
|
||||
|| (original && (kind === 'REPLAY' ? original.rawPayloadHash !== expected.rawPayloadHash : original.rawPayloadHash === expected.rawPayloadHash))) errors.push('replay/conflict payload hash does not match scenario')
|
||||
if (!equal(rows(a.exceptions), rows(b.exceptions)) || !equal(histories(a), histories(b))) errors.push('replay/conflict changed exception facts/history')
|
||||
} else if (kind === 'APPEND' || kind === 'RELEASE') {
|
||||
if (!expected || !CARRIER_EXCEPTION_CODES.includes(expected.exceptionCode) || expected.active !== (kind === 'APPEND')) errors.push('invalid expected supplier fact')
|
||||
const added = b.exceptions.filter(item => !a.exceptions.some(old => id(old.id) === id(item.id)))
|
||||
if (added.length !== 1 || !expected || !Object.entries(expected).every(([key, value]) => equal(added[0]?.[key], value))) errors.push('expected independent fact not appended exactly once')
|
||||
if (!a.exceptions.every(old => b.exceptions.some(item => equal(old, item)))) errors.push('original exception overwritten/deleted')
|
||||
if (kind === 'RELEASE' && !a.exceptions.some(item => item.providerCode === expected?.providerCode && item.exceptionCode === expected?.exceptionCode && item.active === true)) errors.push('release lacks original active fact')
|
||||
const oldHistory = histories(a), newHistory = histories(b)
|
||||
if (newHistory.length !== oldHistory.length + 1 || !oldHistory.every(old => newHistory.some(item => equal(old, item)))) errors.push('exception history not append-only')
|
||||
} else errors.push('unsupported transition')
|
||||
return errors
|
||||
}
|
||||
|
||||
export async function verifyCarrierExceptionTransition(context, before, transition) {
|
||||
const after = await captureCarrierExceptionEvidence(context)
|
||||
const errors = carrierExceptionTransitionErrors(before, after, transition)
|
||||
context.assert(MODULE, `C66 ${transition.kind}异常独立保存且正常实际预计节点零漂移`, null,
|
||||
errors.length === 0, errors.join('; '), OPTIONS)
|
||||
context.createdResources.carrierExceptionTransitions ??= []
|
||||
// Zero drift does not prove that an upstream replay/conflict was actually sent.
|
||||
context.createdResources.carrierExceptionTransitions.push({ before, after, transition, errors,
|
||||
supplierDeliveryProofRequired: true, completionClaim: false })
|
||||
return after
|
||||
}
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
@@ -86,6 +171,10 @@ export async function run(context) {
|
||||
return
|
||||
}
|
||||
|
||||
const bookingResponse = await request(context, 'GET', `/api/platform/v1/ocean-bookings/${fixture.oceanBookingId}`)
|
||||
const booking = context.apiSuccess(MODULE, '读取船任务所属订舱权威客户范围', bookingResponse, '', OPTIONS)
|
||||
if (!booking?.oceanBooking || id(booking.oceanBooking.id) !== fixture.oceanBookingId) return
|
||||
fixture.customerId = booking.oceanBooking.customerId == null ? '0' : id(booking.oceanBooking.customerId)
|
||||
const selector = new URLSearchParams({
|
||||
customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId,
|
||||
containerBookingId: fixture.containerBookingId, keyword: fixture.trackingNo, page: '1', size: '20',
|
||||
@@ -131,4 +220,8 @@ export async function run(context) {
|
||||
carrierCode: task?.carrierCode, initialStatus: task?.status,
|
||||
version: Number(data(detailResponse)?.query?.query?.version),
|
||||
}
|
||||
context.createdResources.carrierExceptionBaseline = await captureCarrierExceptionEvidence(context)
|
||||
context.addCoverageGap(MODULE, 'carrier-exception-provider-lifecycle', 'C66供应商异常新增解除及真实重放冲突',
|
||||
'正式平台API仅支持正常manual-nodes/manual-results;异常来自Integration query结果经真实scheduler写入。需总控受控供应商投递APPEND/REPLAY/CONFLICT/RELEASE并保存执行证据后调用只读checkpoint核验,空数组或重复GET不证明幂等。',
|
||||
'external-third-party')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user