From a811c2ca068347a5a12fd9f47378d3b14ffdda5a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:44:28 +0800 Subject: [PATCH] test: harden carrier node HTTP acceptance --- .../phase3-carrier-schedule-queries.mjs | 192 ++++-------- .../phase3-carrier-schedule-queries.test.mjs | 60 ++-- .../phase4-carrier-nodes-contract.test.mjs | 295 +++++++++++------- .../phase4-carrier-nodes.mjs | 217 +++++++++---- 4 files changed, 422 insertions(+), 342 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index 0e4c67a6c..d9d46f29c 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -1,17 +1,11 @@ const MODULE = 'phase3-carrier-schedule-queries' const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true } const BASE = '/api/platform/v1/carrier-schedule-queries' -const OCEAN = '/api/platform/v1/ocean-bookings' const AUTOMATED_CARRIERS = Object.freeze(['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE']) -import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs' 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 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}`, { @@ -32,153 +26,87 @@ async function request(context, method, path, body, headers = {}) { } } +export function currentOutboundFixture(context) { + const loading = context?.createdResources?.phase3Loading + const outbound = context?.createdResources?.phase3OutboundDispatch + const marker = context?.marker + const matches = loading?.marker === marker && outbound?.marker === marker + && id(loading?.containerBookingId) && id(loading?.containerBookingId) === id(outbound?.containerBookingId) + && id(loading?.oceanBookingId) && id(loading?.containerNo) + && outbound?.confirmedStatus === 'WAITING_FOR_LOADING' + return matches ? { + marker, customerId: id(outbound.tenantId || loading.tenantId), oceanBookingId: id(loading.oceanBookingId), + containerBookingId: id(loading.containerBookingId), trackingNo: id(loading.containerNo), + } : null +} + export function ownsCarrierQuery(resource, marker) { - return Boolean(resource?.ownedByModule && resource?.queryId && resource?.marker === marker) + return Boolean(resource?.ownedByModule && resource?.queryId && resource?.marker === marker + && resource?.triggerSource === 'OUTBOUND_CONFIRM') } -export function markerTrackingNo(marker, explicit = process.env.ADMIN_TEST_CARRIER_CONTAINER_NO) { - const configured = String(explicit || '').trim().toUpperCase() - if (configured) return configured - let hash = 0x811c9dc5 - for (const char of String(marker || 'carrier-marker')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193) - return `TSTU${String(hash >>> 0).padStart(10, '0').slice(-7)}` +function blocker(context, name, evidence) { + context.addStep(MODULE, name, null, false, evidence, OPTIONS) } -function oceanDraft(context, suffix, master) { - return { - ...oceanBookingDraft(context, suffix, master), - departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', - destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', - remark: context.marker, - parties: [ - { partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, - { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, - { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }, - ], - } -} - -async function ensureOcean(context, suffix, master) { - const existing = context.createdResources?.oceanBooking - if (existing?.id && existing.marker === context.marker && id(existing.customerId) === id(master.customerId)) { - return { id: id(existing.id), containerBookingId: null } - } - const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) }, - { 'Idempotency-Key': `${context.marker}-carrier-query-ocean`.slice(0, 128) }) - const detail = context.apiSuccess(MODULE, '创建本轮船司查询隔离订舱', response, '', OPTIONS) - return { id: id(detail?.oceanBooking?.id), containerBookingId: null } -} - -function nodeSnapshot(detail) { - return (Array.isArray(detail?.nodes) ? detail.nodes : []).map((item) => ({ - id: id(item.id), providerCode: item.providerCode, providerEventId: item.providerEventId, - standardNode: item.standardNode, observedAt: item.observedAt, estimatedAt: item.estimatedAt, actualAt: item.actualAt, - })) -} +function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) } export async function run(context) { context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询', 'COSCO Shipping Open Platform 待正式凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF', 'external-third-party') - const suffix = context.marker.replace(/\D/g, '').slice(-10) const carriersResponse = await request(context, 'GET', `${BASE}/carriers`) - const carriers = context.apiSuccess(MODULE, '六家船司查询策略入口已部署', carriersResponse, '', OPTIONS) + const carriers = context.apiSuccess(MODULE, '查询六家船司自动能力', carriersResponse, '', OPTIONS) const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item])) context.assert(MODULE, '五家自动Provider与COSCO覆盖缺口精确', carriersResponse, ['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code)) && AUTOMATED_CARRIERS.every((code) => byCode.get(code)?.automated === true && byCode.get(code)?.coverageGap === false) && byCode.get('COSCO')?.automated === false && byCode.get('COSCO')?.coverageGap === true - && byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' - && byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED' - && [...byCode.values()].every((item) => item.officialUrl?.startsWith('http')), + && byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED', `automated=${AUTOMATED_CARRIERS.filter((code) => byCode.get(code)?.automated).join(',') || 'none'}`, OPTIONS) - const master = await resolveOceanBookingMaster(context, MODULE) - if (!master) return - const ocean = await ensureOcean(context, suffix, master) - if (!ocean.id) return - const trackingNo = markerTrackingNo(context.marker) - const createBody = { - expectedVersion: 0, customerId: master.customerId, oceanBookingId: ocean.id, - containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO', - trackingNo, remark: context.marker, + const fixture = currentOutboundFixture(context) + if (!fixture) { + blocker(context, '缺少本轮phase3出仓fixture,禁止借历史Query验收', + `marker=${context.marker}, loading=${context.createdResources?.phase3Loading?.marker || 'missing'}, outbound=${context.createdResources?.phase3OutboundDispatch?.marker || 'missing'}`) + return } - const createKey = `${context.marker}-carrier-query-create`.slice(0, 128) - const createResponse = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) - const created = context.apiSuccess(MODULE, '创建本轮marker专属船司查询任务', createResponse, '', OPTIONS) - const queryId = id(created?.query?.query?.id) - context.assert(MODULE, '本轮查询任务使用ZIM柜号契约', createResponse, - Boolean(queryId) && created?.query?.query?.status === 'READY' - && created?.query?.query?.carrierCode === 'ZIM' - && created?.query?.query?.trackingType === 'CONTAINER_NO' - && created?.query?.query?.trackingNo === trackingNo, - `queryId=${queryId || 'missing'}, trackingNo=${created?.query?.query?.trackingNo || 'missing'}`, OPTIONS) - if (!queryId) return + + const selector = new URLSearchParams({ + customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId, + containerBookingId: fixture.containerBookingId, keyword: fixture.trackingNo, page: '1', size: '20', + }) + const pageResponse = await request(context, 'GET', `${BASE}?${selector}`) + const page = context.apiSuccess(MODULE, '查询本轮出仓自动创建的船司任务', pageResponse, '', OPTIONS) + const matches = (Array.isArray(page?.records) ? page.records : []).filter((item) => { + const task = item?.query + return id(task?.customerId) === fixture.customerId && id(task?.oceanBookingId) === fixture.oceanBookingId + && id(task?.containerBookingId) === fixture.containerBookingId && task?.trackingNo === fixture.trackingNo + && task?.triggerSource === 'OUTBOUND_CONFIRM' + }) + context.assert(MODULE, '本轮出仓范围唯一命中CarrierScheduleQuery', pageResponse, + matches.length === 1, `matches=${matches.length}, total=${page?.total ?? 'missing'}`, OPTIONS) + if (matches.length !== 1) return + + const task = matches[0].query + const queryId = id(task.id) + const detailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(queryId)}`) + const detail = context.apiSuccess(MODULE, '回查本轮出仓船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS) + const departed = (detail?.nodes || []).filter((item) => item.standardNode === 'DEPARTED_WAREHOUSE' + && item.actualAt != null && item.estimatedAt == null) + context.assert(MODULE, '出仓自动任务携带实际DEPARTED_WAREHOUSE事实与长ID', detailResponse, + id(detail?.query?.query?.id) === queryId && validLongId(detail?.query?.query?.id) + && departed.length >= 1 && departed.every((item) => validLongId(item.id) + && item.providerCode === 'FIRSTLEG' && item.providerEventId && item.providerNodeCode + && typeof item.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(item.rawPayloadHash)), + `queryId=${queryId}, departed=${departed.length}`, OPTIONS) + context.createdResources.phase3CarrierScheduleQuery = { - queryId, marker: context.marker, ownedByModule: true, customerId: id(master.customerId), - oceanBookingId: ocean.id, trackingNo, + queryId, marker: context.marker, ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM', + customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId, + containerBookingId: fixture.containerBookingId, trackingNo: fixture.trackingNo, + version: Number(data(detailResponse)?.query?.query?.version), } - - const replay = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) - context.assert(MODULE, '创建任务幂等重放返回同一marker资源', replay, - id(data(replay)?.query?.query?.id) === queryId, - `first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS) - - const unknownCarrier = await request(context, 'POST', BASE, - { ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${trackingNo}U` }, - { 'Idempotency-Key': `${context.marker}-carrier-unknown`.slice(0, 128) }) - step(context, '非法船司代码被拒绝', unknownCarrier, - unknownCarrier.http === 200 && ['A0400', '400'].includes(codeOf(unknownCarrier)), - `HTTP=${unknownCarrier.http}, code=${codeOf(unknownCarrier)}`) - - const initialTask = created.query.query - const observedAt = localIso(new Date()) - const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000)) - const manualBody = { - expectedVersion: Number(initialTask.version), standardNode: 'ARRIVED_DESTINATION_PORT', - observedAt, estimatedAt, actualAt: null, locationCode: 'USLAX', locationName: 'Los Angeles', - reason: `本轮marker预计节点验收 ${context.marker}`, - } - const manualKey = `${context.marker}-carrier-manual-node`.slice(0, 128) - const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, - { 'Idempotency-Key': manualKey }) - const manualDetail = context.apiSuccess(MODULE, '仅向本轮marker任务补录预计节点', manual, '', OPTIONS) - const manualTask = manualDetail?.query?.query - context.assert(MODULE, '预计事实可见、版本前进且实际进度零推进', manual, - id(manualTask?.id) === queryId && Number(manualTask?.version) > Number(initialTask.version) - && manualTask?.status === initialTask.status - && manualTask?.latestActualNode === initialTask.latestActualNode - && manualTask?.latestActualAt === initialTask.latestActualAt - && manualDetail?.nodes?.some((item) => item.providerCode === 'MANUAL' - && item.standardNode === 'ARRIVED_DESTINATION_PORT' && item.estimatedAt === estimatedAt && item.actualAt == null), - `version=${manualTask?.version ?? 'missing'}, latest=${manualTask?.latestActualNode || 'none'}`, OPTIONS) - - const replayManual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, - { 'Idempotency-Key': manualKey }) - const replayDetail = context.apiSuccess(MODULE, '人工节点同key同payload幂等重放', replayManual, '', OPTIONS) - context.assert(MODULE, '人工节点重放版本和事实集合不漂移', replayManual, - Number(replayDetail?.query?.query?.version) === Number(manualTask?.version) - && JSON.stringify(nodeSnapshot(replayDetail)) === JSON.stringify(nodeSnapshot(manualDetail)), - `version=${replayDetail?.query?.query?.version ?? 'missing'}, nodes=${replayDetail?.nodes?.length ?? 'missing'}`, OPTIONS) - - const stale = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, - { 'Idempotency-Key': `${context.marker}-carrier-manual-stale`.slice(0, 128) }) - step(context, '旧version配合新幂等键被A0409拒绝', stale, - stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)), - `HTTP=${stale.http}, code=${codeOf(stale)}`) - - const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`) - const detail = context.apiSuccess(MODULE, '回查本轮marker任务零漂移', detailResponse, '', OPTIONS) - context.assert(MODULE, '并发拒绝后版本、实际进度和事实集合零漂移', detailResponse, - Number(detail?.query?.query?.version) === Number(manualTask?.version) - && detail?.query?.query?.latestActualNode === manualTask?.latestActualNode - && detail?.query?.query?.latestActualAt === manualTask?.latestActualAt - && JSON.stringify(nodeSnapshot(detail)) === JSON.stringify(nodeSnapshot(manualDetail)) - && !Object.hasOwn(detail || {}, 'attempts') - && detail?.histories?.some((item) => item.action === 'CREATE') - && detail?.histories?.some((item) => item.action === 'MANUAL_NODE'), - `version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS) - context.createdResources.phase3CarrierScheduleQuery.version = Number(manualTask?.version) } diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs index 1b4587d7e..1917c0ccb 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs @@ -4,39 +4,49 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { markerTrackingNo, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs' +import { currentOutboundFixture, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase3-carrier-schedule-queries.mjs'), 'utf8') -test('marker ownership requires explicit module ownership and the exact current marker', () => { - assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'M', ownedByModule: true }, 'M'), true) - assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'OLD', ownedByModule: true }, 'M'), false) - assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'M', ownedByModule: false }, 'M'), false) - assert.equal(ownsCarrierQuery({ marker: 'M', ownedByModule: true }, 'M'), false) +function context(marker = 'RUN') { + return { marker, createdResources: { + phase3Loading: { marker, tenantId: '10001', oceanBookingId: '20001', containerBookingId: '30001', containerNo: 'TSTU1234567' }, + phase3OutboundDispatch: { marker, tenantId: '10001', containerBookingId: '30001', confirmedStatus: 'WAITING_FOR_LOADING' }, + } } +} + +test('outbound fixture requires exact current marker and matching loading/outbound container', () => { + assert.deepEqual(currentOutboundFixture(context()), { + marker: 'RUN', customerId: '10001', oceanBookingId: '20001', containerBookingId: '30001', trackingNo: 'TSTU1234567', + }) + const old = context(); old.createdResources.phase3OutboundDispatch.marker = 'OLD' + assert.equal(currentOutboundFixture(old), null) + const mismatch = context(); mismatch.createdResources.phase3OutboundDispatch.containerBookingId = 'OTHER' + assert.equal(currentOutboundFixture(mismatch), null) }) -test('default tracking number is deterministic marker data while explicit samples are opt-in', () => { - const first = markerTrackingNo('RUN-20260821-001', '') - const second = markerTrackingNo('RUN-20260821-001', '') - assert.equal(first, second) - assert.match(first, /^TSTU\d{7}$/) - assert.equal(markerTrackingNo('RUN', ' zcsu-test-only '), 'ZCSU-TEST-ONLY') +test('carrier query ownership requires explicit outbound-confirm marker registration', () => { + assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'RUN', ownedByModule: true, + triggerSource: 'OUTBOUND_CONFIRM' }, 'RUN'), true) + assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'RUN', ownedByModule: true, + triggerSource: 'MANUAL' }, 'RUN'), false) + assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'OLD', ownedByModule: true, + triggerSource: 'OUTBOUND_CONFIRM' }, 'RUN'), false) }) -test('phase3 contract creates and mutates only its marker-owned container query', () => { - assert.match(source, /carrierCode:\s*'ZIM',\s*trackingType:\s*'CONTAINER_NO'/) - assert.match(source, /phase3CarrierScheduleQuery\s*=\s*\{[\s\S]*queryId,[\s\S]*marker:\s*context\.marker,[\s\S]*ownedByModule:\s*true/) - assert.match(source, /\/manual-nodes/) - assert.match(source, /'Idempotency-Key'/) - assert.match(source, /expectedVersion:\s*Number\(initialTask\.version\)/) - assert.match(source, /actualAt:\s*null/) - assert.match(source, /A0409/) - assert.match(source, /nodeSnapshot\(detail\)[\s\S]*nodeSnapshot\(manualDetail\)/) - assert.doesNotMatch(source, /\/manual-results/) - assert.doesNotMatch(source, /\/attempts|\/scheduler|\/internal|\/pda/) - assert.doesNotMatch(source, /BILL_OF_LADING/) - assert.doesNotMatch(source, /createManualSchedule|portScheduleId|\/api\/platform\/v1\/port-schedules/) +test('phase3 script only discovers the current outbound task and blocks missing prerequisites', () => { + assert.match(source, /phase3Loading/) + assert.match(source, /phase3OutboundDispatch/) + assert.match(source, /缺少本轮phase3出仓fixture,禁止借历史Query验收/) + assert.match(source, /deploymentBlocker:\s*true/) + assert.match(source, /triggerSource === 'OUTBOUND_CONFIRM'/) + assert.match(source, /DEPARTED_WAREHOUSE/) + assert.match(source, /providerEventId/) + assert.match(source, /\^\\d\{16,/) + assert.doesNotMatch(source, /request\(context,\s*'POST',\s*BASE/) + assert.doesNotMatch(source, /manual-nodes|manual-results|BILL_OF_LADING/) + assert.doesNotMatch(source, /records\[0\]/) }) test('carrier capability assertion freezes five automated providers and COSCO only gap', () => { diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 69c645794..7f64e8e53 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -4,145 +4,202 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs' +import { EXCEPTION_CODES, NODE_SEQUENCES, QUERY_STATUSES, STANDARD_NODES, + manualNodePayload, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8') +const BASE = '/api/platform/v1/carrier-schedule-queries' +const QUERY_ID = '9007199254740993001' +const CUSTOMER_ID = '9007199254740993002' +const OCEAN_ID = '9007199254740993003' +const CONTAINER_ID = '9007199254740993004' +const MARKER = 'T-PHASE4' -function response(data) { return { http: 200, body: { code: '200', data } } } -function contextFor(routes, createdResources = {}) { - const calls = [] - return { - calls, - marker: 'T-PHASE4', token: 'token', createdResources, - request: async (method, requestPath, body, _token, headers = {}) => { - calls.push(method === 'POST' ? { method, path: requestPath, body, headers } : { method, path: requestPath }) - assert.ok(['GET', 'POST'].includes(method)) - assert.ok(Object.hasOwn(routes, requestPath), `unexpected path: ${requestPath}`) - return response(routes[requestPath]) - }, - apiSuccess: (_module, _name, result) => { - assert.equal(result.http, 200) - assert.equal(result.body.code, '200') - return result.body.data - }, - assert: (_module, name, _result, condition, evidence) => { - assert.ok(condition, `${name}: ${evidence}`) - }, - } +function ok(data, requestId = 'req-ok') { + return { http: 200, body: { code: '200', data, requestId }, requestId, traceId: 'trace-ok' } } +function conflict(requestId) { + return { http: 200, body: { code: 'A0409', message: 'conflict', requestId }, requestId, traceId: 'trace-conflict' } +} +function clone(value) { return structuredClone(value) } -const task = { - id: '9007199254740993', queryNo: 'CSQ9007199254740993', customerId: '10001', - oceanBookingId: '20001', containerBookingId: '30001', carrierCode: 'COSCO', - trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567', status: 'STOPPED', - latestActualNode: 'PICKED_UP', latestActualAt: '2026-08-20T12:00:00', version: 5, - lastSuccessAt: '2026-08-20T12:00:00', sourceUpdatedAt: '2026-08-20T12:00:00', - expiresAt: null, stoppedAt: null, stopReason: null, -} -const node = { - id: '40001', providerCode: 'COSCO', providerEventId: 'event-40001', providerNodeCode: 'OUTGATE', standardNode: 'PICKED_UP', - nodeSequence: 90, observedAt: '2026-08-20T12:00:00', estimatedAt: null, - actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles', - sourceReference: null, rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T12:00:01', -} -const manualNode = { ...node, id: '40002', providerCode: 'MANUAL', providerEventId: 'manual-40002', - providerNodeCode: 'PICKED_UP', estimatedAt: '2026-08-20T12:00:00', actualAt: null, rawPayloadHash: 'b'.repeat(64) } -const exception = { id: '45001', providerCode: 'MATSON_PUBLIC_TRACKING', providerEventId: 'hold-1', - providerStatus: 'CONTAINER ON HOLD - Customs Clearance', exceptionCode: 'CUSTOMS_HOLD', active: true, - observedAt: '2026-08-20T12:00:00', locationCode: 'USLAX', locationName: 'Long Beach', - sourceReference: 'MATSON:9974749', rawPayloadHash: 'c'.repeat(64), createdAt: '2026-08-20T12:00:01' } -const history = { - id: '50001', action: 'STOP', fromVersion: 4, toVersion: 5, - reason: null, operatorId: null, operatorName: '系统', createdAt: '2026-08-20T12:01:00', -} -const carriers = [{ - carrierCode: 'COSCO', carrierName: 'COSCO', officialUrl: 'https://example.invalid', - accessMode: 'INTERNAL_PROVIDER', automated: true, coverageGap: false, -}] - -test('module only permits the documented manual-node write and forbids internal execution endpoints', () => { - assert.match(source, /context\.request\('GET'/) - assert.doesNotMatch(source, /\bfetch\s*\(/) - assert.match(source, /context\.request\('POST',[\s\S]*\/manual-nodes/) - assert.match(source, /'Idempotency-Key'/) - for (const method of ['PUT', 'PATCH', 'DELETE']) { - assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`)) - } - for (const endpoint of ['/scheduler', '/attempts', '/manual-results', '/internal', '/pda']) { - assert.doesNotMatch(source, new RegExp(endpoint.replace('/', '\\/'), 'i')) - } -}) - -test('canonical task node and exception sets are locked to the phase-4 contract', () => { +test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => { assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED']) assert.deepEqual(STANDARD_NODES, [ 'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED', ]) + assert.deepEqual(Object.values(NODE_SEQUENCES), [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) assert.deepEqual(EXCEPTION_CODES, ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) + assert.deepEqual(Object.keys(manualNodePayload({ expectedVersion: 7, standardNode: 'PICKED_UP', + observedAt: '2026-08-24T12:00:00', actualAt: '2026-08-24T11:59:00', marker: MARKER })), + ['expectedVersion', 'standardNode', 'observedAt', 'estimatedAt', 'actualAt', + 'locationCode', 'locationName', 'reason']) }) -test('marker ownership requires explicit module ownership, query id and exact current marker', () => { - assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: {} }), null) - assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: { - phase3CarrierScheduleQuery: { queryId: '1', marker: 'OLD', ownedByModule: true }, +test('module exposes no scheduler, attempt, internal or arbitrary stop endpoint', () => { + assert.match(source, /\/manual-nodes/) + assert.match(source, /'Idempotency-Key'/) + assert.doesNotMatch(source, /\/manual-results|\/attempts|\/scheduler|\/internal|\/pda|\/stop/) + assert.doesNotMatch(source, /\bfetch\s*\(/) + for (const method of ['PUT', 'PATCH', 'DELETE']) { + assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`)) + } +}) + +test('ownership requires current marker, long query id and outbound-confirm source', () => { + const valid = { queryId: QUERY_ID, marker: MARKER, ownedByModule: true, + triggerSource: 'OUTBOUND_CONFIRM' } + assert.equal(markerOwnedCarrierQuery({ marker: MARKER, + createdResources: { phase3CarrierScheduleQuery: valid } }).queryId, QUERY_ID) + assert.equal(markerOwnedCarrierQuery({ marker: 'OLD', + createdResources: { phase3CarrierScheduleQuery: valid } }), null) + assert.equal(markerOwnedCarrierQuery({ marker: MARKER, createdResources: { + phase3CarrierScheduleQuery: { ...valid, queryId: '1' }, } }), null) - assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: { - phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: false }, + assert.equal(markerOwnedCarrierQuery({ marker: MARKER, createdResources: { + phase3CarrierScheduleQuery: { ...valid, triggerSource: 'MANUAL' }, } }), null) - assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: { - phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: true }, - } }).queryId, '1') }) -test('empty page validates read-only pagination without detail calls', async () => { - const context = contextFor({ - '/api/platform/v1/carrier-schedule-queries/carriers': carriers, - '/api/platform/v1/carrier-schedule-queries?page=1&size=20': { records: [], page: 1, size: 20, total: 0, pages: 0 }, - }) - await run(context) - assert.deepEqual(context.calls, [ - { method: 'GET', path: '/api/platform/v1/carrier-schedule-queries/carriers' }, - { method: 'GET', path: '/api/platform/v1/carrier-schedule-queries?page=1&size=20' }, - ]) -}) - -test('standalone existing historical task is read-only and never receives manual nodes', async () => { - const base = '/api/platform/v1/carrier-schedule-queries' - const context = contextFor({ - [`${base}/carriers`]: carriers, - [`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 }, - [`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], exceptions: [exception], histories: [history] }, - [`${base}/${task.id}/history`]: [history], - }) - await run(context) - assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'GET']) - assert.equal(context.calls.some((item) => item.method === 'POST'), false) - assert.equal(context.calls.at(-1).path, `${base}/${task.id}/history`) -}) - -test('phase4 writes only the exact current-marker query registered by phase3', async () => { - const base = '/api/platform/v1/carrier-schedule-queries' - const ownedTask = { ...task, id: '9007199254740994', queryNo: 'CSQ9007199254740994', - status: 'READY', trackingNo: 'TSTU1234567', version: 6 } - const resource = { queryId: ownedTask.id, marker: 'T-PHASE4', ownedByModule: true, trackingNo: ownedTask.trackingNo } - const context = contextFor({ - [`${base}/carriers`]: carriers, - [`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 }, - [`${base}/${ownedTask.id}`]: { query: { query: ownedTask, expired: false }, nodes: [node], exceptions: [exception], histories: [history] }, - [`${base}/${ownedTask.id}/manual-nodes`]: { query: { query: { ...ownedTask, version: 7 }, expired: false }, - nodes: [node, manualNode], exceptions: [exception], histories: [history] }, - [`${base}/${ownedTask.id}/history`]: [history], - }, { phase3CarrierScheduleQuery: resource }) +test('missing phase3 outbound query is an explicit deployment blocker and performs no write', async () => { + const calls = []; const steps = [] + const context = { + marker: MARKER, token: 'token', createdResources: {}, calls, + request: async (method, requestPath) => { + calls.push({ method, path: requestPath }) + if (requestPath === `${BASE}/carriers`) return ok([]) + if (requestPath === `${BASE}?page=1&size=20`) return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + throw new Error(`unexpected path ${requestPath}`) + }, + apiSuccess: (_module, _name, response) => response.body.data, + assert: (_module, name, _response, condition, evidence) => assert.ok(condition, `${name}: ${evidence}`), + addStep: (_module, name, _response, passed, evidence, options) => steps.push({ name, passed, evidence, options }), + } await run(context) - assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET']) - const write = context.calls.find((item) => item.method === 'POST') - assert.equal(write.path, `${base}/${ownedTask.id}/manual-nodes`) - assert.equal(write.body.expectedVersion, ownedTask.version) - assert.equal(write.body.actualAt, null) - assert.equal(write.headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${ownedTask.id}`) + assert.deepEqual(calls.map((item) => item.method), ['GET', 'GET']) + assert.equal(steps.length, 1) + assert.equal(steps[0].passed, false) + assert.equal(steps[0].options.deploymentBlocker, true) + assert.match(steps[0].name, /禁止借历史任务写节点/) +}) + +function markerOwnedContext() { + let nextId = 9007199254740994000n + const calls = []; const steps = []; const idempotency = new Map() + const task = { + id: QUERY_ID, queryNo: `CSQ${QUERY_ID}`, customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID, + containerBookingId: CONTAINER_ID, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO', + trackingNo: 'TSTU1234567', status: 'READY', triggerSource: 'OUTBOUND_CONFIRM', + latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-08-24T08:00:00', version: 0, + lastSuccessAt: null, sourceUpdatedAt: '2026-08-24T08:00:00', expiresAt: null, + stoppedAt: null, stopReason: null, + } + const nodes = [{ + id: String(nextId++), providerCode: 'FIRSTLEG', providerEventId: 'OUTBOUND_CONFIRM', + providerNodeCode: 'OUTBOUND_CONFIRM', standardNode: 'DEPARTED_WAREHOUSE', nodeSequence: 10, + observedAt: '2026-08-24T08:00:00', estimatedAt: null, actualAt: '2026-08-24T08:00:00', + locationCode: null, locationName: null, sourceReference: 'OUTBOUND_CONFIRM', + rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-24T08:00:01', + }] + const exceptions = [{ + id: String(nextId++), providerCode: 'MATSON_PUBLIC_TRACKING', providerEventId: 'hold-1', + providerStatus: 'CONTAINER ON HOLD - Customs Clearance', exceptionCode: 'CUSTOMS_HOLD', active: true, + observedAt: '2026-08-24T08:01:00', locationCode: 'USLAX', locationName: 'Long Beach', + sourceReference: 'MATSON:MASKED', rawPayloadHash: 'b'.repeat(64), createdAt: '2026-08-24T08:01:01', + }] + const histories = [{ + id: String(nextId++), action: 'AUTO_CREATE', fromVersion: null, toVersion: 0, + reason: '国内确认出仓自动创建', operatorId: CUSTOMER_ID, operatorName: 'fixture', + createdAt: '2026-08-24T08:00:01', + }] + const detail = () => ({ query: { query: clone(task), expired: true }, + nodes: clone(nodes), exceptions: clone(exceptions), histories: clone(histories) }) + const context = { + marker: MARKER, token: 'token', calls, steps, + createdResources: { phase3CarrierScheduleQuery: { + queryId: QUERY_ID, marker: MARKER, ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM', + customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID, containerBookingId: CONTAINER_ID, + trackingNo: task.trackingNo, version: 0, + } }, + request: async (method, requestPath, body, _token, headers = {}) => { + calls.push(method === 'POST' ? { method, path: requestPath, body: clone(body), headers: clone(headers) } + : { method, path: requestPath }) + if (method === 'GET' && requestPath === `${BASE}/carriers`) return ok([{ + carrierCode: 'ZIM', carrierName: 'ZIM', officialUrl: 'https://api.zim.com/', + accessMode: 'OFFICIAL_API', automated: true, coverageGap: false, + }]) + if (method === 'GET' && requestPath === `${BASE}?page=1&size=20`) return ok({ records: [{ + query: { ...task, id: '9007199254740993999', trackingNo: 'HISTORICAL000' }, expired: false, + }], page: 1, size: 20, total: 2, pages: 1 }) + if (method === 'GET' && requestPath === `${BASE}/${QUERY_ID}`) return ok(detail()) + if (method === 'GET' && requestPath === `${BASE}/${QUERY_ID}/history`) return ok(clone(histories)) + if (method !== 'POST' || requestPath !== `${BASE}/${QUERY_ID}/manual-nodes`) { + throw new Error(`unexpected ${method} ${requestPath}`) + } + const key = headers['Idempotency-Key']; const signature = JSON.stringify(body) + if (idempotency.has(key)) { + const previous = idempotency.get(key) + return previous.signature === signature ? ok(clone(previous.detail), `req-replay-${key}`) + : conflict(`req-hash-${key}`) + } + if (Number(body.expectedVersion) !== Number(task.version)) return conflict(`req-version-${key}`) + const providerEventId = `manual-${String(nextId)}` + nodes.push({ + id: String(nextId++), providerCode: 'MANUAL', providerEventId, providerNodeCode: body.standardNode, + standardNode: body.standardNode, nodeSequence: NODE_SEQUENCES[body.standardNode], + observedAt: body.observedAt, estimatedAt: body.estimatedAt, actualAt: body.actualAt, + locationCode: body.locationCode, locationName: body.locationName, sourceReference: `MANUAL:${CUSTOMER_ID}`, + rawPayloadHash: 'c'.repeat(64), createdAt: body.observedAt, + }) + task.version += 1 + if (body.actualAt != null) { + task.latestActualNode = body.standardNode; task.latestActualAt = body.actualAt + if (body.standardNode === 'EMPTY_RETURNED') { + task.status = 'STOPPED'; task.stopReason = '已还柜'; task.stoppedAt = body.observedAt + } else task.status = 'READY' + } + histories.push({ id: String(nextId++), action: 'MANUAL_NODE', fromVersion: task.version - 1, + toVersion: task.version, reason: body.reason, operatorId: CUSTOMER_ID, operatorName: 'fixture', + createdAt: body.observedAt }) + const saved = detail(); idempotency.set(key, { signature, detail: clone(saved) }) + return ok(saved, `req-${key}`) + }, + apiSuccess: (_module, _name, response) => { + assert.equal(response.http, 200); assert.equal(response.body.code, '200'); return response.body.data + }, + assert: (_module, name, _response, condition, evidence) => assert.ok(condition, `${name}: ${evidence}`), + addStep: (_module, name, response, passed, evidence, options) => { + steps.push({ name, passed, evidence, options, requestId: response?.requestId || null }) + assert.ok(passed, `${name}: ${evidence}`) + }, + } + return { context, task, nodes, exceptions, histories, steps } +} + +test('marker-owned chain covers idempotency conflicts, all actual nodes, exception DTO and empty-return stop', async () => { + const fixture = markerOwnedContext() + + await run(fixture.context) + + const posts = fixture.context.calls.filter((item) => item.method === 'POST') + assert.equal(posts.length, 14) + assert.ok(posts.every((item) => item.path === `${BASE}/${QUERY_ID}/manual-nodes`)) + assert.ok(posts.every((item) => item.headers['Idempotency-Key'])) + assert.ok(posts.every((item) => Object.keys(item.body).length === 8)) + assert.equal(fixture.steps.filter((item) => /A0409/.test(item.name)).length, 2) + assert.equal(fixture.task.status, 'STOPPED') + assert.equal(fixture.task.latestActualNode, 'EMPTY_RETURNED') + assert.equal(fixture.task.stopReason, '已还柜') + const actualNodes = new Set(fixture.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) + assert.deepEqual([...actualNodes].sort(), [...STANDARD_NODES].sort()) + assert.equal(fixture.exceptions[0].exceptionCode, 'CUSTOMS_HOLD') + assert.ok(fixture.nodes.every((item) => /^\d{16,}$/.test(item.id))) + assert.equal(fixture.context.calls.some((item) => /attempts|scheduler|internal|\/stop/.test(item.path)), false) + const firstPageQuery = fixture.context.calls.find((item) => item.path === `${BASE}?page=1&size=20`) + assert.ok(firstPageQuery) }) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index a9c17d7de..07e8202eb 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -9,24 +9,37 @@ export const STANDARD_NODES = Object.freeze([ 'PICKED_UP', 'EMPTY_RETURNED', ]) export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) +export const NODE_SEQUENCES = Object.freeze(Object.fromEntries(STANDARD_NODES.map((node, index) => [node, (index + 1) * 10]))) function id(value) { return value == null ? '' : String(value) } function number(value) { return Number.isFinite(Number(value)) } function nullableText(value) { return value == null || typeof value === 'string' } -function nullableId(value) { return value == null || Boolean(id(value)) } +function nullableId(value) { return value == null || validLongId(value) } 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 codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) } +function localIso(value) { return value.toISOString().slice(0, 19) } export function markerOwnedCarrierQuery(context) { const resource = context?.createdResources?.phase3CarrierScheduleQuery - return resource?.ownedByModule === true && Boolean(id(resource.queryId)) && resource.marker === context?.marker - ? resource : null + return resource?.ownedByModule === true && validLongId(resource.queryId) + && resource.marker === context?.marker && resource.triggerSource === 'OUTBOUND_CONFIRM' ? resource : null +} + +export function manualNodePayload({ expectedVersion, standardNode, observedAt, estimatedAt = null, + actualAt = null, marker, index = 0 }) { + return { + expectedVersion, standardNode, observedAt, estimatedAt, actualAt, + locationCode: 'USLAX', locationName: 'Los Angeles', + reason: `${marker} manual node ${String(index).padStart(2, '0')} ${standardNode}`, + } } function validTask(task) { - return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId)) - && QUERY_STATUSES.includes(task?.status) - && nullableId(task?.containerBookingId) - && nullableText(task?.latestActualNode) + return validLongId(task?.id) && Boolean(task?.queryNo) && validLongId(task?.customerId) + && validLongId(task?.oceanBookingId) && QUERY_STATUSES.includes(task?.status) + && nullableId(task?.containerBookingId) && nullableText(task?.latestActualNode) && (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode)) && number(task?.version) && ['latestActualAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt'] @@ -35,9 +48,8 @@ function validTask(task) { } function validNode(node) { - return Boolean(id(node?.id) && node?.providerCode && node?.providerEventId && node?.providerNodeCode) - && STANDARD_NODES.includes(node?.standardNode) - && number(node?.nodeSequence) && Number(node.nodeSequence) > 0 + return validLongId(node?.id) && Boolean(node?.providerCode && node?.providerEventId && node?.providerNodeCode) + && STANDARD_NODES.includes(node?.standardNode) && Number(node?.nodeSequence) === NODE_SEQUENCES[node.standardNode] && nullableIso(node?.observedAt) && node?.observedAt != null && nullableIso(node?.estimatedAt) && nullableIso(node?.actualAt) && nullableText(node?.locationCode) && nullableText(node?.locationName) @@ -46,7 +58,7 @@ function validNode(node) { } function validException(value) { - return Boolean(id(value?.id) && value?.providerCode && value?.providerEventId && value?.providerStatus) + return validLongId(value?.id) && Boolean(value?.providerCode && value?.providerEventId && value?.providerStatus) && EXCEPTION_CODES.includes(value?.exceptionCode) && typeof value?.active === 'boolean' && nullableIso(value?.observedAt) && value?.observedAt != null && nullableText(value?.locationCode) && nullableText(value?.locationName) @@ -55,17 +67,42 @@ function validException(value) { } function validHistory(item) { - return Boolean(id(item?.id) && item?.action) + return validLongId(item?.id) && Boolean(item?.action) && (item?.fromVersion == null || number(item.fromVersion)) && (item?.toVersion == null || number(item.toVersion)) && nullableText(item?.reason) && nullableId(item?.operatorId) && nullableText(item?.operatorName) && nullableIso(item?.createdAt) } +function nodeSnapshot(detail) { + return (Array.isArray(detail?.nodes) ? detail.nodes : []).map((item) => ({ + id: id(item.id), providerEventId: item.providerEventId, standardNode: item.standardNode, + observedAt: item.observedAt, estimatedAt: item.estimatedAt, actualAt: item.actualAt, + })) +} + +function assertDetail(context, response, detail, name) { + const nodes = Array.isArray(detail?.nodes) ? detail.nodes : [] + const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : [] + const histories = Array.isArray(detail?.histories) ? detail.histories : [] + context.assert(MODULE, name, response, + validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean' + && Array.isArray(detail?.nodes) && nodes.every(validNode) + && Array.isArray(detail?.exceptions) && exceptions.every(validException) + && Array.isArray(detail?.histories) && histories.every(validHistory) + && !Object.hasOwn(detail || {}, 'attempts'), + `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, + OPTIONS) +} + +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, evidence, OPTIONS) +} + export async function run(context) { const carriersResponse = await context.request('GET', `${BASE}/carriers`) const carriers = context.apiSuccess(MODULE, '查询船司只读辅助信息', carriersResponse, '', OPTIONS) - context.assert(MODULE, '船司辅助信息保持公开只读描述结构', carriersResponse, + context.assert(MODULE, '船司辅助信息结构稳定', carriersResponse, Array.isArray(carriers) && carriers.every((item) => item?.carrierCode && item?.carrierName && nullableText(item?.officialUrl) && nullableText(item?.accessMode) && typeof item?.automated === 'boolean' && typeof item?.coverageGap === 'boolean'), @@ -74,68 +111,116 @@ export async function run(context) { const pageResponse = await context.request('GET', `${BASE}?page=1&size=20`) const page = context.apiSuccess(MODULE, '查询四期船节点任务分页', pageResponse, '', OPTIONS) const records = Array.isArray(page?.records) ? page.records : [] - context.assert(MODULE, '船节点分页结构稳定', pageResponse, + context.assert(MODULE, '船节点分页与长ID结构稳定', pageResponse, Array.isArray(page?.records) && ['page', 'size', 'total', 'pages'].every((field) => number(page?.[field])) && records.every((item) => typeof item?.expired === 'boolean' && validTask(item?.query)), `records=${records.length}, total=${page?.total ?? 'missing'}`, OPTIONS) - const ownedResource = markerOwnedCarrierQuery(context) - if (records.length === 0 && !ownedResource) { - context.assert(MODULE, '无真实任务时返回稳定分页空态', pageResponse, - Number(page?.total) === 0 && Number(page?.pages) === 0, - `records=0, total=${page?.total ?? 'missing'}, pages=${page?.pages ?? 'missing'}`, OPTIONS) + const owned = markerOwnedCarrierQuery(context) + if (!owned) { + blocker(context, '缺少本轮phase3出仓CarrierScheduleQuery,禁止借历史任务写节点', + `marker=${context.marker}, registered=${context.createdResources?.phase3CarrierScheduleQuery?.marker || 'missing'}`, pageResponse) return } - const queryId = ownedResource ? id(ownedResource.queryId) : id(records[0]?.query?.id) + const queryId = id(owned.queryId) const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) - const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS) - const nodes = Array.isArray(detail?.nodes) ? detail.nodes : [] - const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : [] - const histories = Array.isArray(detail?.histories) ? detail.histories : [] - context.assert(MODULE, '详情返回Task、标准节点与业务历史', detailResponse, - validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean' - && Array.isArray(detail?.nodes) && nodes.every(validNode) - && Array.isArray(detail?.exceptions) && exceptions.every(validException) - && Array.isArray(detail?.histories) && histories.every(validHistory), - `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, OPTIONS) - - const task = detail.query.query - if (!ownedResource) { - context.assert(MODULE, 'standalone无本轮marker资源时严格只读并跳过人工写', detailResponse, - true, `readOnlyQueryId=${queryId}`, OPTIONS) - const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`) - const history = context.apiSuccess(MODULE, '只读查询历史任务历史', historyResponse, `queryId=${queryId}`, OPTIONS) - context.assert(MODULE, 'standalone历史详情只读且结构稳定', historyResponse, - Array.isArray(history) && history.every(validHistory), - `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) - return - } - const ownershipMatches = id(task?.id) === queryId && task?.trackingNo === ownedResource.trackingNo - context.assert(MODULE, 'phase4写入目标严格匹配本轮phase3登记', detailResponse, - ownershipMatches, `registered=${queryId}, actual=${id(task?.id) || 'missing'}`, OPTIONS) + let detail = context.apiSuccess(MODULE, '查询本轮marker-owned船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS) + assertDetail(context, detailResponse, detail, '详情返回Task、十节点DTO、异常与历史结构') + const initialTask = detail?.query?.query + const ownershipMatches = id(initialTask?.id) === queryId && initialTask?.trackingNo === owned.trackingNo + && id(initialTask?.oceanBookingId) === id(owned.oceanBookingId) + && id(initialTask?.containerBookingId) === id(owned.containerBookingId) + && initialTask?.triggerSource === 'OUTBOUND_CONFIRM' + context.assert(MODULE, '写入目标严格匹配本轮phase3出仓登记', detailResponse, + ownershipMatches, `registered=${queryId}, actual=${id(initialTask?.id) || 'missing'}`, OPTIONS) if (!ownershipMatches) return - const observedAt = task.latestActualAt || '2026-08-21T00:00:00' - const manualResponse = await context.request('POST', `${BASE}/${encodeURIComponent(queryId)}/manual-nodes`, { - expectedVersion: task.version, - standardNode: task.latestActualNode || 'DEPARTED_WAREHOUSE', - observedAt, - estimatedAt: observedAt, - actualAt: null, - locationCode: null, - locationName: null, - reason: '四期船节点人工预计事实验收', - }, context.token, { 'Idempotency-Key': `${context.marker || 'phase4'}-carrier-manual-${queryId}`.slice(0, 128) }) - const manualDetail = context.apiSuccess(MODULE, '人工补录预计节点事实', manualResponse, `queryId=${queryId}`, OPTIONS) - context.assert(MODULE, '人工预计事实可见且不改变任务操作态', manualResponse, - QUERY_STATUSES.includes(manualDetail?.query?.query?.status) - && manualDetail?.nodes?.some((item) => item?.providerCode === 'MANUAL' - && item?.estimatedAt != null && item?.actualAt == null), - `status=${manualDetail?.query?.query?.status || 'missing'}, nodes=${manualDetail?.nodes?.length ?? 'missing'}`, OPTIONS) - const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`) - const history = context.apiSuccess(MODULE, '查询真实船节点任务历史', historyResponse, `queryId=${queryId}`, OPTIONS) - context.assert(MODULE, '独立历史端点返回可审计操作者与nullable字段', historyResponse, - Array.isArray(history) && history.every(validHistory), + const observedAt = localIso(new Date()) + const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000)) + const estimatedBody = manualNodePayload({ expectedVersion: Number(initialTask.version), + standardNode: 'ARRIVED_DESTINATION_PORT', observedAt, estimatedAt, marker: context.marker }) + const estimatedKey = `${context.marker}-carrier-estimated`.slice(0, 128) + const estimatedResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody, + context.token, { 'Idempotency-Key': estimatedKey }) + const estimatedDetail = context.apiSuccess(MODULE, '完整payload补录预计到港事实', estimatedResponse, + `queryId=${queryId}`, OPTIONS) + assertDetail(context, estimatedResponse, estimatedDetail, '预计事实响应保持完整DTO') + context.assert(MODULE, '预计节点递增版本但不推进latestActual', estimatedResponse, + Number(estimatedDetail?.query?.query?.version) > Number(initialTask.version) + && estimatedDetail?.query?.query?.status !== 'STOPPED' + && estimatedDetail?.query?.query?.latestActualNode === initialTask.latestActualNode + && estimatedDetail?.query?.query?.latestActualAt === initialTask.latestActualAt + && estimatedDetail?.nodes?.some((item) => item.providerCode === 'MANUAL' + && item.standardNode === 'ARRIVED_DESTINATION_PORT' && item.estimatedAt === estimatedAt && item.actualAt == null), + `version=${estimatedDetail?.query?.query?.version ?? 'missing'}, latest=${estimatedDetail?.query?.query?.latestActualNode || 'none'}`, + OPTIONS) + + const replayResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody, + context.token, { 'Idempotency-Key': estimatedKey }) + const replayDetail = context.apiSuccess(MODULE, '同Idempotency-Key同payload重放', replayResponse, '', OPTIONS) + context.assert(MODULE, '重放版本和节点集合零漂移', replayResponse, + Number(replayDetail?.query?.query?.version) === Number(estimatedDetail?.query?.query?.version) + && JSON.stringify(nodeSnapshot(replayDetail)) === JSON.stringify(nodeSnapshot(estimatedDetail)), + `version=${replayDetail?.query?.query?.version ?? 'missing'}, nodes=${replayDetail?.nodes?.length ?? 'missing'}`, OPTIONS) + + const hashConflict = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, + { ...estimatedBody, reason: `${estimatedBody.reason} altered` }, context.token, + { 'Idempotency-Key': estimatedKey }) + context.addStep(MODULE, '同Idempotency-Key异payload返回A0409', hashConflict, rejected(hashConflict), + `HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}`, OPTIONS) + + const staleResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody, + context.token, { 'Idempotency-Key': `${context.marker}-carrier-stale-version`.slice(0, 128) }) + context.addStep(MODULE, '旧expectedVersion配合新key返回A0409', staleResponse, rejected(staleResponse), + `HTTP=${staleResponse.http}, code=${codeOf(staleResponse)}`, OPTIONS) + + const zeroDriftResponse = await context.request('GET', `${BASE}/${queryId}`) + detail = context.apiSuccess(MODULE, '冲突请求后回查零漂移', zeroDriftResponse, '', OPTIONS) + context.assert(MODULE, '两类A0409后版本与节点集合零漂移', zeroDriftResponse, + Number(detail?.query?.query?.version) === Number(estimatedDetail?.query?.query?.version) + && JSON.stringify(nodeSnapshot(detail)) === JSON.stringify(nodeSnapshot(estimatedDetail)), + `version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS) + + let version = Number(detail.query.query.version) + const baseActual = Date.now() + 60 * 60_000 + for (let index = 0; index < STANDARD_NODES.length; index += 1) { + const standardNode = STANDARD_NODES[index] + const actualAt = localIso(new Date(baseActual + index * 60_000)) + const body = manualNodePayload({ expectedVersion: version, standardNode, + observedAt: localIso(new Date()), actualAt, marker: context.marker, index: index + 1 }) + const key = `${context.marker}-carrier-actual-${String(index + 1).padStart(2, '0')}`.slice(0, 128) + const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body, + context.token, { 'Idempotency-Key': key }) + const updated = context.apiSuccess(MODULE, `补录实际节点${standardNode}`, response, `queryId=${queryId}`, OPTIONS) + assertDetail(context, response, updated, `实际节点${standardNode}响应DTO完整`) + const updatedTask = updated?.query?.query + const isEmptyReturned = standardNode === 'EMPTY_RETURNED' + context.assert(MODULE, `${standardNode}实际事实单调推进且停止语义正确`, response, + updatedTask?.latestActualNode === standardNode && updatedTask?.latestActualAt === actualAt + && (isEmptyReturned + ? updatedTask.status === 'STOPPED' && updatedTask.stopReason === '已还柜' && updatedTask.stoppedAt != null + : updatedTask.status !== 'STOPPED') + && updated?.nodes?.some((item) => item.providerCode === 'MANUAL' + && item.standardNode === standardNode && item.actualAt === actualAt && item.estimatedAt == null + && item.providerEventId && item.providerNodeCode && item.sourceReference + && /^[0-9a-f]{64}$/.test(item.rawPayloadHash)), + `status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS) + version = Number(updatedTask.version) + detail = updated + } + + const finalTask = detail.query.query + const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) + context.assert(MODULE, '最终详情覆盖十标准实际节点且只有EMPTY_RETURNED触发停止', detailResponse, + STANDARD_NODES.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED' + && finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜', + `actualNodes=${[...actualNodes].join(',')}, status=${finalTask.status}`, OPTIONS) + + const historyResponse = await context.request('GET', `${BASE}/${queryId}/history`) + const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS) + context.assert(MODULE, '历史只含业务动作且人工节点完整留痕', historyResponse, + Array.isArray(history) && history.every(validHistory) + && history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length + 1, `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) }