From 24e4a402a98df479a254a631687aff245bb046a9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 21 Aug 2026 17:58:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CarrierScheduleQueryService.java | 5 +- .../CarrierScheduleQueryServiceTest.java | 23 +++ scripts/admin-test-env-modules/README.md | 4 +- .../phase3-carrier-schedule-queries.mjs | 168 ++++++++++-------- .../phase3-carrier-schedule-queries.test.mjs | 51 ++++-- .../phase4-carrier-nodes-contract.test.mjs | 50 +++++- .../phase4-carrier-nodes.mjs | 25 ++- 7 files changed, 223 insertions(+), 103 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 336cc57e0..a0c6230b6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -187,7 +187,10 @@ public class CarrierScheduleQueryService { } boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), operatorId, operatorName); - CarrierScheduleQueryEntity after = advanced ? required(id) : value; + if (!advanced && repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) { + throw concurrent(); + } + CarrierScheduleQueryEntity after = required(id); addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after, operatorId, operatorName); return complete(id, operatorId, key, detail(after)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 554fa4435..a38e1799d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -182,6 +182,29 @@ class CarrierScheduleQueryServiceTest { verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); } + @Test + void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() { + CarrierScheduleQueryEntity before = task(0, 3); + before.setStatus("READY"); before.setLatestStandardNode("LOADED_ON_VESSEL"); + before.setLatestNodeObservedAt(NOW.minusDays(1)); + CarrierScheduleQueryEntity after = task(0, 4); + after.setStatus("READY"); after.setLatestStandardNode("LOADED_ON_VESSEL"); + after.setLatestNodeObservedAt(NOW.minusDays(1)); + when(repository.lock(1L)).thenReturn(before); when(repository.find(1L)).thenReturn(after); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + var result = service.addManualNode(1L, 7L, "admin", "manual-estimated-1", + new ManualNodeRequest(3, "ARRIVED_DESTINATION_PORT", NOW, NOW.plusDays(5), null, + "USLAX", "Los Angeles", "人工预计到港")); + + assertThat(result.query().query().version()).isEqualTo(4); + assertThat(result.query().query().latestActualNode()).isEqualTo("LOADED_ON_VESSEL"); + assertThat(result.query().query().latestActualAt()).isEqualTo(NOW.minusDays(1)); + verify(repository).updateState(before, 3, 7L, "admin"); + } + @Test void holdIsPersistedAsSeparateImmutableExceptionFact() { CarrierScheduleQueryEntity task = task(0, 1); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index d7e5f736e..20752ba46 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -49,7 +49,7 @@ export async function run(context) { | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | -| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;仅登记 `containerRequirements`) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | +| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | | 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` | | 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | @@ -57,7 +57,7 @@ export async function run(context) { | 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | | 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | -| 四期船节点只读冒烟 | `phase4-carrier-nodes.mjs` | `phase4-carrier-nodes-contract.test.mjs` | +| 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 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 d89e29f2d..0e4c67a6c 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -2,20 +2,13 @@ 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 PORT = '/api/platform/v1/port-schedules' +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) } -export function matchesManualScheduleScope(resource, expected) { - return Boolean(resource?.id) - && id(resource.customerId) === id(expected.customerId) - && id(resource.oceanBookingId) === id(expected.oceanBookingId) - && id(resource.containerBookingId) === id(expected.containerBookingId) -} 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 }) } @@ -39,12 +32,23 @@ async function request(context, method, path, body, headers = {}) { } } +export function ownsCarrierQuery(resource, marker) { + return Boolean(resource?.ownedByModule && resource?.queryId && resource?.marker === marker) +} + +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 oceanDraft(context, suffix, master) { return { ...oceanBookingDraft(context, suffix, master), - departurePortCode: 'CNSZX', - departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', + destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: context.marker, parties: [ { partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, @@ -56,115 +60,125 @@ function oceanDraft(context, suffix, master) { async function ensureOcean(context, suffix, master) { const existing = context.createdResources?.oceanBooking - if (existing?.id && id(existing.customerId) === id(master.customerId)) return { id: id(existing.id), containerBookingId: null } + 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) + const detail = context.apiSuccess(MODULE, '创建本轮船司查询隔离订舱', response, '', OPTIONS) return { id: id(detail?.oceanBooking?.id), containerBookingId: null } } -async function createManualSchedule(context, suffix, oceanBookingId, customerId) { - const existing = context.createdResources?.portSchedule - const expectedScope = { customerId, oceanBookingId, containerBookingId: null } - if (matchesManualScheduleScope(existing, expectedScope)) return id(existing.id) - const body = { - expectedVersion: 0, - data: { - customerId, oceanBookingId, containerBookingId: null, sourceType: 'MANUAL', - sourceName: `船司查询人工兜底-${suffix}`, sourceRef: `${context.marker}-CARRIER-MANUAL`, - vesselName: `人工船期船-${suffix}`, voyageNo: `CM${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: at(-1), expiresAt: at(3 * 24 * 60), remark: context.marker, - }, - reason: '船司官网人工查询兜底', - } - const response = await request(context, 'POST', PORT, body, - { 'Idempotency-Key': `${context.marker}-carrier-manual-schedule`.slice(0, 128) }) - const detail = context.apiSuccess(MODULE, '创建真实人工船期兜底结果', response, '', OPTIONS) - return id(detail?.portSchedule?.schedule?.id) +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, + })) } export async function run(context) { - context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官方API自动查询', - 'ZIM Cargo Tracking API 未配置订阅凭据或来源授权时返回MANUAL_REQUIRED;不记录或回显订阅键', - 'external-third-party') context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询', - 'COSCO Shipping Open Platform Cargo Tracking API 待注册订阅和凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF或伪造自动成功', + '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 byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item])) - context.assert(MODULE, '六家官方来源及合规模式完整', carriersResponse, + context.assert(MODULE, '五家自动Provider与COSCO覆盖缺口精确', carriersResponse, ['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code)) - && byCode.get('ZIM')?.accessMode === 'OFFICIAL_API' && byCode.get('ZIM')?.coverageGap === true - && byCode.get('ZIM')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED' - && byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' && byCode.get('COSCO')?.coverageGap === true + && 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') && item.automated === false), - `codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS) + && [...byCode.values()].every((item) => item.officialUrl?.startsWith('http')), + `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: 'BILL_OF_LADING', - trackingNo: `${context.marker}-ZIM`, remark: context.marker, + containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO', + trackingNo, remark: context.marker, } 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, '创建真实船司查询任务', createResponse, '', OPTIONS) + const created = context.apiSuccess(MODULE, '创建本轮marker专属船司查询任务', createResponse, '', OPTIONS) const queryId = id(created?.query?.query?.id) - context.assert(MODULE, '查询任务初始来源和过期字段稳定', createResponse, + context.assert(MODULE, '本轮查询任务使用ZIM柜号契约', createResponse, Boolean(queryId) && created?.query?.query?.status === 'READY' - && created?.query?.expired === true && created?.query?.query?.accessMode === 'OFFICIAL_API', - `queryId=${queryId || 'missing'}, status=${created?.query?.query?.status || 'missing'}`, OPTIONS) + && 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 + context.createdResources.phase3CarrierScheduleQuery = { + queryId, marker: context.marker, ownedByModule: true, customerId: id(master.customerId), + oceanBookingId: ocean.id, trackingNo, + } const replay = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) - context.assert(MODULE, '创建任务幂等重放返回同一资源', replay, + context.assert(MODULE, '创建任务幂等重放返回同一marker资源', replay, id(data(replay)?.query?.query?.id) === queryId, `first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS) - const version = Number(created.query.query.version) - const unknownCarrier = await request(context, 'POST', BASE, - { ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${context.marker}-UNKNOWN` }, + { ...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 forgedCustomer = await request(context, 'POST', BASE, - { ...createBody, customerId: '10002', trackingNo: `${context.marker}-FORGED` }, - { 'Idempotency-Key': `${context.marker}-carrier-forged-customer`.slice(0, 128) }) - step(context, '伪造客户与订舱关联被拒绝', forgedCustomer, - forgedCustomer.http === 200 && ['A0400', '400'].includes(codeOf(forgedCustomer)), - `HTTP=${forgedCustomer.http}, code=${codeOf(forgedCustomer)}`) + 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 scheduleId = await createManualSchedule(context, suffix, ocean.id, master.customerId) - if (!scheduleId) return - const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-results`, - { expectedVersion: version, portScheduleId: scheduleId, reason: '人工完成官网查询并复核船期' }, - { 'Idempotency-Key': `${context.marker}-carrier-manual-result`.slice(0, 128) }) - const completed = context.apiSuccess(MODULE, '绑定真实人工船期结果', manual, '', OPTIONS) - context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual, - completed?.query?.query?.status === 'READY' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId - && Boolean(completed?.query?.query?.lastSuccessAt) && Boolean(completed?.query?.query?.sourceUpdatedAt) - && completed?.query?.expired === false - && completed?.histories?.some((item) => item.action === 'MANUAL_RESULT'), - `status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, 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, '回查船司查询任务详情与历史', detailResponse, '', OPTIONS) - context.assert(MODULE, '平台详情不暴露调度尝试且保留业务版本历史', detailResponse, - !Object.hasOwn(detail || {}, 'attempts') + 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_RESULT'), - `attemptsExposed=${Object.hasOwn(detail || {}, 'attempts')}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS) + && 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 70155c9d1..1b4587d7e 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 @@ -1,24 +1,47 @@ import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' import test from 'node:test' +import { fileURLToPath } from 'node:url' -import { matchesManualScheduleScope } from './phase3-carrier-schedule-queries.mjs' +import { markerTrackingNo, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs' -const expected = { customerId: '10001', oceanBookingId: '20001', containerBookingId: null } +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase3-carrier-schedule-queries.mjs'), 'utf8') -test('does not reuse a schedule from the same booking when the container scope differs', () => { - assert.equal(matchesManualScheduleScope({ - id: '30001', customerId: '10001', oceanBookingId: '20001', containerBookingId: '40001', - }, expected), false) +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) }) -test('does not reuse a schedule whose customer scope is unknown', () => { - assert.equal(matchesManualScheduleScope({ - id: '30001', oceanBookingId: '20001', containerBookingId: null, - }, expected), false) +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('reuses a schedule only when customer, booking and container scopes all match', () => { - assert.equal(matchesManualScheduleScope({ - id: '30001', customerId: 10001, oceanBookingId: 20001, containerBookingId: null, - }, expected), true) +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('carrier capability assertion freezes five automated providers and COSCO only gap', () => { + assert.match(source, /\['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE'\]/) + assert.match(source, /AUTOMATED_CARRIERS\.every[\s\S]*automated === true[\s\S]*coverageGap === false/) + assert.match(source, /byCode\.get\('COSCO'\)\?\.automated === false/) + assert.match(source, /OFFICIAL_API_CREDENTIALS_REQUIRED/) }) 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 2cd67e92a..69c645794 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,17 +4,17 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs' +import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, 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') function response(data) { return { http: 200, body: { code: '200', data } } } -function contextFor(routes) { +function contextFor(routes, createdResources = {}) { const calls = [] return { calls, - marker: 'T-PHASE4', token: 'token', + 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)) @@ -84,6 +84,19 @@ test('canonical task node and exception sets are locked to the phase-4 contract' assert.deepEqual(EXCEPTION_CODES, ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) }) +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 }, + } }), null) + assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: { + phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: false }, + } }), 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, @@ -96,17 +109,40 @@ test('empty page validates read-only pagination without detail calls', async () ]) }) -test('existing task reads page, detail and history with nullable node times', async () => { +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}/manual-nodes`]: { query: { query: task, expired: false }, nodes: [node, manualNode], exceptions: [exception], histories: [history] }, [`${base}/${task.id}/history`]: [history], }) await run(context) - assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET']) - assert.equal(context.calls.find((item) => item.method === 'POST').headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${task.id}`) + 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 }) + + 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}`) +}) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index 14d16cc54..a9c17d7de 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -16,6 +16,12 @@ function nullableText(value) { return value == null || typeof value === 'string' function nullableId(value) { return value == null || Boolean(id(value)) } function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) } +export function markerOwnedCarrierQuery(context) { + const resource = context?.createdResources?.phase3CarrierScheduleQuery + return resource?.ownedByModule === true && Boolean(id(resource.queryId)) && resource.marker === context?.marker + ? resource : null +} + function validTask(task) { return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId)) && QUERY_STATUSES.includes(task?.status) @@ -73,14 +79,15 @@ export async function run(context) { && records.every((item) => typeof item?.expired === 'boolean' && validTask(item?.query)), `records=${records.length}, total=${page?.total ?? 'missing'}`, OPTIONS) - if (records.length === 0) { + 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) return } - const queryId = id(records[0]?.query?.id) + const queryId = ownedResource ? id(ownedResource.queryId) : id(records[0]?.query?.id) 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 : [] @@ -94,6 +101,20 @@ export async function run(context) { `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) + 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,