diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 790c5c650..3e69f893a 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -45,7 +45,7 @@ export async function run(context) { | 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` | | 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | -| 三期柜次概览、渠道隔离、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | +| 三期模拟柜关联、柜次概览、渠道隔离、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-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` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | | 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs b/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs new file mode 100644 index 000000000..a669bfe65 --- /dev/null +++ b/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8') +const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java') +const containerDtos = read('../../firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java') +const containerService = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java') +const oceanService = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java') +const oceanMapper = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java') +const firstlegOpenApi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml') +const oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml') +const loadingFixture = read('phase3-container-loading.mjs') +const fullEntry = read('../admin-test-env-acceptance.mjs') +const matrix = read('../../../docs/三期/货柜管理全链验收矩阵-2026-08-13.md') + +test('operational ocean booking list excludes unconfirmed data', () => { + assert.match(oceanService, /OPERATIONAL_LIST_STATUSES\s*=\s*Set\.of\([\s\S]*CONFIRMED[\s\S]*CHANGE_PENDING[\s\S]*RELEASED[\s\S]*CANCELLED/) + assert.match(oceanMapper, /status IN \('CONFIRMED','CHANGE_PENDING','RELEASED','CANCELLED'\)/) + assert.match(oceanOpenApi, /enum: \[CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED\]/) + assert.match(oceanOpenApi, /DRAFT, SUBMITTED and FAILED are excluded/) + assert.doesNotMatch(oceanOpenApi.match(/name: status[^\n]*/)?.[0] || '', /DRAFT|SUBMITTED/) +}) + +test('simulation creation uses server-owned identity and formal master data', () => { + assert.match(controller, /@PostMapping[\s\S]*SimulationCreateRequest request[\s\S]*createSimulation/) + assert.match(containerDtos, /SimulationCreateRequest\(@NotBlank[\s\S]*String channelCode[\s\S]*Long warehouseId[\s\S]*String containerType[\s\S]*BigDecimal capacityCbm[\s\S]*BigDecimal maxWeightKg/) + assert.match(containerService, /requireDomesticTransitWarehouse\(request\.warehouseId\(\)\)/) + assert.match(containerService, /activeVersionId\(\)[\s\S]*priceBooks\.channels\(versionId, warehouse\.id\(\)\)/) + assert.match(containerService, /setCabinetNo\("CAB-" \+ value\.getId\(\)\)/) + assert.match(firstlegOpenApi, /服务端生成稳定 containerBookingId\/cabinetNo,不要求真实柜号或订舱/) +}) + +test('first link and unlink gates are intentionally asymmetric', () => { + assert.match(controller, /@PostMapping\("\/\{id\}\/ocean-booking-link"\)/) + assert.match(controller, /@DeleteMapping\("\/\{id\}\/ocean-booking-link"\)/) + assert.match(containerService, /requireFirstLinkMutable\(container\)/) + assert.match(containerService, /WAIT_LOADING && value != SmartLoadingStatus\.EDITING/) + assert.match(containerService, /hasPickTask[\s\S]*hasEvidence[\s\S]*hasDocuments/) + assert.match(containerService, /requireUnlinkMutable\(container\)/) + assert.match(containerService, /status\(container\) != SmartLoadingStatus\.WAIT_LOADING \|\| repository\.hasActiveLoadingItems/) + assert.match(firstlegOpenApi, /首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 柜/) + assert.match(firstlegOpenApi, /解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜/) +}) + +test('real booking association locks scope capacity and stable internal identity', () => { + for (const field of ['warehouseId', 'channelCode', 'containerType', 'capacityCbm', 'maxWeightKg']) { + assert.match(containerService, new RegExp(`container\\.get${field[0].toUpperCase()}${field.slice(1)}\\(\\)`), field) + } + assert.match(containerService, /hasMismatchedCustomerItems/) + assert.match(containerService, /真实订舱柜箱型分配已关联/) + assert.match(containerService, /setOceanBookingId[\s\S]*setOceanBookingRequirementId/) + assert.doesNotMatch(containerService.match(/linkOceanBooking[\s\S]*?return required\(id\);/)?.[0] || '', /container\.setId\(|container\.setCabinetNo\(/) +}) + +test('HTTP fixture follows simulation arrange link PDA sequence without auto-container fallback', () => { + const ordered = [ + '新增内部模拟柜', + '人工分配本轮候选货物', + '首次关联真实已确认订舱柜', + '查询本轮柜次 PDA 拣货任务', + ].map((label) => loadingFixture.indexOf(label)) + assert.ok(ordered.every((value) => value >= 0), `missing sequence label: ${ordered.join(',')}`) + assert.ok(ordered.every((value, index) => index === 0 || value > ordered[index - 1]), `invalid sequence: ${ordered.join(',')}`) + assert.match(loadingFixture, /ocean-booking-link/) + assert.match(loadingFixture, /关联前后内部柜次、计划和排货明细身份稳定/) + assert.match(loadingFixture, /同一真实订舱箱型容量拒绝重复关联/) + assert.match(loadingFixture, /已有排货后拒绝解绑或换绑/) + assert.match(loadingFixture, /空模拟柜解绑后允许重新关联/) + assert.doesNotMatch(loadingFixture, /确认订舱并自动生成|确认响应包含自动生成/) +}) + +test('default full acceptance preserves the linked simulation chain before downstream completion', () => { + const loading = fullEntry.indexOf("'phase3-container-loading'") + const pda = fullEntry.indexOf("'phase3-pda-convergence'") + const finalize = fullEntry.indexOf("'phase3-loading-finalize'") + const outbound = fullEntry.indexOf("'phase3-outbound-dispatch'") + assert.ok(loading >= 0 && pda > loading && finalize > pda && outbound > finalize) + assert.match(matrix, /内部模拟柜关联真实订舱增量/) + assert.doesNotMatch(matrix, /CONFIRMED` 自动建柜[\s\S]*既有冻结/) +}) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index b8ebc31a5..ae1609858 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -104,13 +104,20 @@ async function createConfirmedContainer(context, source, label) { warehouseName: summary.warehouseName, shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`, bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: `${context.marker}-${label}`, + placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: `${context.marker}-${label}`, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${value}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }], containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }], } + const simulationResponse = await context.request('POST', CONTAINERS, { + channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 28000, + }) + const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS) + const containerId = id(simulation?.id) + if (!containerId) throw new Error(`${label} 未生成内部模拟柜`) const createdResponse = await post(context, '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: draft }, `${label}-booking-create`) const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS) @@ -121,8 +128,22 @@ async function createConfirmedContainer(context, source, label) { const confirmedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/confirm`, { expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker }, `${label}-booking-confirm`) const confirmed = context.apiSuccess(MODULE, `${label} 确认订舱`, confirmedResponse, '', OPTIONS) - const containerId = id(confirmed?.containerAllocations?.[0]?.containerBookingId) - if (!containerId) throw new Error(`${label} 确认订舱未生成柜次`) + const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') + if (!requirement?.id) throw new Error(`${label} 确认订舱未返回箱型需求`) + const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { + oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version), + }) + const linked = context.apiSuccess(MODULE, `${label} 关联模拟柜与真实订舱`, linkedResponse, '', OPTIONS) + if (id(linked?.id) !== containerId || linked?.cabinetNo !== simulation.cabinetNo) { + throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`) + } + const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, { + expectedVersion: Number(confirmed?.oceanBooking?.version), billOfLadingNo: `BL-${value}`.slice(0, 128), + vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), + estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', + reason: context.marker, + }, `${label}-booking-release`) + context.apiSuccess(MODULE, `${label} 放舱已关联真实订舱`, releasedResponse, '', OPTIONS) return containerId } diff --git a/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs index 17db7be4d..9aab72802 100644 --- a/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs @@ -38,9 +38,10 @@ test('backend OpenAPI freezes a platform channel query scoped by originWarehouse 'BLOCKED_CONTRACT: platform logistics-channel query by originWarehouseId is not frozen in OpenAPI') }) -test('container confirmation acceptance locks the selected channel onto the generated container', () => { +test('explicit real booking association locks the selected channel onto the simulation', () => { const fixture = source(new URL('./phase3-container-loading.mjs', import.meta.url)) - assert.match(fixture, /containerAllocations\?\.\[0\]/) + assert.match(fixture, /ocean-booking-link/) + assert.match(fixture, /CONFIRMED 只产生真实订舱事实且尚未占用模拟柜/) assert.match(fixture, /containerDetail\?\.channelCode === channelCode/) assert.match(fixture, /selectPlatformChannelIsolationPair/) assert.doesNotMatch(fixture, /\$\{channelCode\}-OTHER|foreignChannelCode/) diff --git a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs index 90384dd07..c5d2b3a19 100644 --- a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs @@ -84,7 +84,7 @@ test('HTTP fixtures cover unassigned CONFIRMED persistence and assigned customer assert.match(fixture, /serviceTerm: 'CY-CY', freightTerm: 'NINGBO'/) assert.doesNotMatch(fixture, /serviceTerm: 'CY\/CY'|freightTerm: 'PREPAID'/) assert.match(bookingModule, /createdResources\.oceanBookingUnassignedCustomer/) - assert.match(bookingModule, /未指定客户订舱保持空客户并在 CONFIRMED 生成稳定柜次/) + assert.match(bookingModule, /未指定客户订舱保持空客户且 CONFIRMED 不自动生成内部柜/) assert.match(bookingModule, /按真实订舱号查询未指定客户订舱/) assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/) assert.doesNotMatch(unassignedFlow, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/) diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 67af1ae0e..a2eb7ed68 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -25,6 +25,7 @@ async function createConfirmedUnassignedBooking(context, suffix, master) { const createdResponse = await request(context, 'POST', BASE, { expectedVersion: 0, data: draft }, { 'Idempotency-Key': key }) const created = data(createdResponse) const bookingId = id(created?.oceanBooking?.id) + const createdBookingNo = id(created?.oceanBooking?.bookingNo) context.apiSuccess(MODULE, '创建未指定客户订舱', createdResponse, `marker=${context.marker}, customerFields=${'customerId' in draft || 'customerName' in draft ? 'present' : 'omitted'}`, OPTIONS) context.assert(MODULE, '未指定客户订舱不伪造客户快照', createdResponse, @@ -32,6 +33,12 @@ async function createConfirmedUnassignedBooking(context, suffix, master) { `bookingId=${bookingId || 'missing'}, customerId=${id(created?.oceanBooking?.customerId) || 'null'}, customerName=${created?.oceanBooking?.customerName || 'null'}`, OPTIONS) if (!bookingId) return null + const hiddenResponse = await request(context, 'GET', `${BASE}?page=1&size=20&keyword=${encodeURIComponent(createdBookingNo)}`) + const hiddenPage = context.apiSuccess(MODULE, '未确认订舱不进入正式订舱列表', hiddenResponse, '', OPTIONS) + context.assert(MODULE, 'DRAFT 未指定客户订舱仅可按详情维护', hiddenResponse, + !hiddenPage?.records?.some?.((item) => id(item.id) === bookingId), + `bookingId=${bookingId}, records=${hiddenPage?.records?.length ?? 'n/a'}`, OPTIONS) + const path = `${BASE}/${bookingId}` const submitResponse = await request(context, 'POST', `${path}/submit`, { expectedVersion: Number(created.oceanBooking.version), reason: context.marker, @@ -40,16 +47,15 @@ async function createConfirmedUnassignedBooking(context, suffix, master) { const confirmResponse = await request(context, 'POST', `${path}/confirm`, { expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker, }, { 'Idempotency-Key': `${context.marker}-ocean-unassigned-confirm`.slice(0, 128) }) - context.apiSuccess(MODULE, '确认未指定客户订舱并生成柜次', confirmResponse, '', OPTIONS) + context.apiSuccess(MODULE, '确认未指定客户真实订舱', confirmResponse, '', OPTIONS) const detailResponse = await request(context, 'GET', path) const detail = context.apiSuccess(MODULE, '回查未指定客户订舱持久化结果', detailResponse, '', OPTIONS) - const containerBookingId = id(detail?.containerAllocations?.[0]?.containerBookingId) const bookingNo = id(detail?.oceanBooking?.bookingNo || created?.oceanBooking?.bookingNo) - context.assert(MODULE, '未指定客户订舱保持空客户并在 CONFIRMED 生成稳定柜次', detailResponse, + context.assert(MODULE, '未指定客户订舱保持空客户且 CONFIRMED 不自动生成内部柜', detailResponse, detail?.oceanBooking?.status === 'CONFIRMED' && detail?.oceanBooking?.customerId == null - && detail?.oceanBooking?.customerName == null && Boolean(containerBookingId) && Boolean(bookingNo), - `status=${detail?.oceanBooking?.status || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'null'}, bookingNo=${bookingNo || 'missing'}, containerBookingId=${containerBookingId || 'missing'}`, OPTIONS) + && detail?.oceanBooking?.customerName == null && detail?.containerAllocations?.length === 0 && Boolean(bookingNo), + `status=${detail?.oceanBooking?.status || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'null'}, bookingNo=${bookingNo || 'missing'}, allocations=${detail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) const listResponse = await request(context, 'GET', `${BASE}?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`) const page = context.apiSuccess(MODULE, '按真实订舱号查询未指定客户订舱', listResponse, `bookingNo=${bookingNo}`, OPTIONS) @@ -57,7 +63,7 @@ async function createConfirmedUnassignedBooking(context, suffix, master) { page?.records?.some?.((item) => id(item.id) === bookingId && item.customerId == null && item.customerName == null), `bookingId=${bookingId}, bookingNo=${bookingNo}, records=${page?.records?.length ?? 'n/a'}`, OPTIONS) const resource = { id: bookingId, marker: context.marker, customerId: null, customerName: null, - bookingNo, channelCode: draft.channelCode, warehouseId: draft.warehouseId, containerBookingId, + bookingNo, channelCode: draft.channelCode, warehouseId: draft.warehouseId, containerBookingId: null, finalStatus: detail?.oceanBooking?.status || null } context.createdResources.oceanBookingUnassignedCustomer = resource return resource @@ -119,8 +125,9 @@ export async function run(context) { } const page = context.apiSuccess(MODULE, '订舱列表分页查询', list, '', OPTIONS) context.assert(MODULE, '订舱列表分页结构正确', list, Array.isArray(page?.records) && Number.isFinite(Number(page?.total)), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS) - const statuses = ['DRAFT', 'SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CANCELLED'] + const statuses = ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'] for (const status of statuses) { const r = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const d = data(r); step(context, `订舱 ${status} 状态筛选`, r, r.http === 200 && codeOf(r) === '200' && Array.isArray(d?.records) && d.records.every((x) => x.status === status), `HTTP=${r.http}, code=${codeOf(r)}, records=${d?.records?.length ?? 'n/a'}`) } + for (const status of ['DRAFT', 'SUBMITTED']) { const r = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); step(context, `正式订舱列表拒绝 ${status} 状态`, r, failure(r), `HTTP=${r.http}, code=${codeOf(r)}`) } await createConfirmedUnassignedBooking(context, suffix, master) @@ -154,7 +161,7 @@ export async function run(context) { const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` }) context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version) const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change') - let allocatedContainerBookingId = id(changeConfirmed?.containerAllocations?.[0]?.containerBookingId) + const allocatedContainerBookingId = '' if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') { context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed, id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId), @@ -163,9 +170,9 @@ export async function run(context) { context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', changed, Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS) if (!requirement?.id || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return - context.assert(MODULE, '确认订舱自动生成并关联柜次', changed, - Boolean(allocatedContainerBookingId) && Number.isInteger(version), - `containerBookingId=${allocatedContainerBookingId || 'missing'}, version=${version}`, OPTIONS) + context.assert(MODULE, '独立订舱生命周期不占用本轮已装货柜次', changed, + changeConfirmed?.containerAllocations?.length === 0 && Number.isInteger(version), + `allocations=${changeConfirmed?.containerAllocations?.length ?? 'n/a'}, version=${version}`, OPTIONS) } const released = await request(context, 'POST', `${path}/release`, { expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`, @@ -177,9 +184,9 @@ export async function run(context) { version = Number(releasedDetail?.oceanBooking?.version) const final = await request(context, 'GET', path); const fd = data(final) context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS) - context.assert(MODULE, '订舱状态历史、审计操作者、柜次关联和终态完整', final, + context.assert(MODULE, '独立订舱状态历史、审计操作者和终态完整', final, fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName) - && fd?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === allocatedContainerBookingId), + && fd?.containerAllocations?.length === 0, `status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) let downstreamBooking = { id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker, 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 f9b6e8d09..343543517 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -45,7 +45,7 @@ function oceanDraft(context, suffix, master) { shippingCompanyName: 'ZIM', bookingAgentName: `船司查询代理-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', + placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: context.marker, parties: [ { partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 35088630f..832e42614 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -108,6 +108,7 @@ export async function run(context) { OPTIONS) if (!selectedChannel) return const channelName = selectedChannel.channelName + const primaryRequirement = { containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100, maxWeightKg: 100000, quantity: 1 } const bookingDraft = { customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, channelCode, warehouseId, warehouseName, @@ -115,31 +116,27 @@ export async function run(context) { bookingAgentId: null, bookingAgentName: `验收代理-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', - serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: context.marker, + serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }], - containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100, maxWeightKg: 100000, quantity: 1 }], + containerRequirements: [primaryRequirement, + { containerType: '20GP', containerKind: 'FCL', capacityCbm: 28, maxWeightKg: 22000, quantity: 1 }], } context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName), `channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS) - const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', - { expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` }) - const bookingCreated = context.apiSuccess(MODULE, '创建含渠道仓库快照的隔离订舱', bookingCreate, '', OPTIONS) - const bookingId = id(bookingCreated?.oceanBooking?.id) - if (!bookingId) return - const bookingVersion = Number(bookingCreated.oceanBooking.version) - const submit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/submit`, - { expectedVersion: bookingVersion, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-loading-booking-submit` }) - const submitted = context.apiSuccess(MODULE, '提交隔离订舱', submit, '', OPTIONS) - const confirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/confirm`, - { expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker }, { 'Idempotency-Key': `${context.marker}-loading-booking-confirm` }) - const confirmedBooking = context.apiSuccess(MODULE, '确认订舱并自动生成真实柜次', confirm, '', OPTIONS) - const allocation = confirmedBooking?.containerAllocations?.[0] - const containerId = id(allocation?.containerBookingId) - context.assert(MODULE, '确认响应包含自动生成的真实柜次', confirm, - Boolean(containerId) && id(confirmedBooking?.oceanBooking?.id) === bookingId, - `bookingId=${bookingId}, containerBookingId=${containerId || 'missing'}`, OPTIONS) + const simulationResponse = await request(context, 'POST', CONTAINERS, { + channelCode, warehouseId, containerType: primaryRequirement.containerType, + capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg, + }) + const simulation = context.apiSuccess(MODULE, '新增内部模拟柜', simulationResponse, + `warehouseId=${warehouseId}, channelCode=${channelCode}`, OPTIONS) + const containerId = id(simulation?.id) + context.assert(MODULE, '模拟柜由服务端生成稳定身份且不伪造真实柜号或订舱', simulationResponse, + Boolean(containerId) && Boolean(simulation?.cabinetNo) && simulation?.containerNo == null + && simulation?.oceanBookingId == null && simulation?.channelCode === channelCode + && id(simulation?.warehouseId) === warehouseId, + `containerBookingId=${containerId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}, oceanBookingId=${id(simulation?.oceanBookingId) || 'null'}`, OPTIONS) if (!containerId) return const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=${encodeURIComponent(containerId)}&warehouseId=${encodeURIComponent(warehouseId)}`) @@ -160,22 +157,12 @@ export async function run(context) { `${channelIsolation.reason}; originWarehouseId=${warehouseId}, primaryChannelCode=${channelCode}`, OPTIONS) } else { const foreignChannel = channelIsolation.pair.foreign - const foreignBookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { - expectedVersion: 0, - data: { ...bookingDraft, channelCode: foreignChannel.channelCode, remark: `${context.marker}-other-channel` }, - }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-create`.slice(0, 128) }) - const foreignBookingCreated = context.apiSuccess(MODULE, '创建真实异渠道隔离订舱', foreignBookingCreate, '', OPTIONS) - const foreignBookingId = id(foreignBookingCreated?.oceanBooking?.id) - if (!foreignBookingId) return - const foreignSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/submit`, { - expectedVersion: Number(foreignBookingCreated?.oceanBooking?.version), reason: context.marker, - }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-submit`.slice(0, 128) }) - const foreignSubmitted = context.apiSuccess(MODULE, '提交真实异渠道隔离订舱', foreignSubmit, '', OPTIONS) - const foreignConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/confirm`, { - expectedVersion: Number(foreignSubmitted?.oceanBooking?.version), reason: context.marker, - }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-confirm`.slice(0, 128) }) - const foreignConfirmed = context.apiSuccess(MODULE, '确认真实异渠道订舱并自动生成柜次', foreignConfirm, '', OPTIONS) - const foreignContainerId = id(foreignConfirmed?.containerAllocations?.[0]?.containerBookingId) + const foreignContainerResponse = await request(context, 'POST', CONTAINERS, { + channelCode: foreignChannel.channelCode, warehouseId, containerType: primaryRequirement.containerType, + capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg, + }) + const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS) + const foreignContainerId = id(foreignContainer?.id) if (!foreignContainerId) return const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: foreignContainerId, remark: `${context.marker}-other-channel`, @@ -240,6 +227,108 @@ export async function run(context) { staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)), `HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS) + const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', + { expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` }) + const bookingCreated = context.apiSuccess(MODULE, '创建含渠道仓库快照的真实订舱草稿', bookingCreate, '', OPTIONS) + const bookingId = id(bookingCreated?.oceanBooking?.id) + const bookingNo = id(bookingCreated?.oceanBooking?.bookingNo) + if (!bookingId) return + const hiddenDraftResponse = await context.request('GET', + `/api/platform/v1/ocean-bookings?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`) + const hiddenDraftPage = context.apiSuccess(MODULE, '正式订舱列表查询不含草稿', hiddenDraftResponse, '', OPTIONS) + context.assert(MODULE, '真实订舱确认前不进入正式列表', hiddenDraftResponse, + !records(hiddenDraftPage).some((item) => id(item.id) === bookingId), + `bookingId=${bookingId}, records=${records(hiddenDraftPage).length}`, OPTIONS) + const draftFilterResponse = await request(context, 'GET', '/api/platform/v1/ocean-bookings?page=1&size=5&status=DRAFT') + context.addStep(MODULE, '正式订舱列表拒绝 DRAFT 状态筛选', draftFilterResponse, + failure(draftFilterResponse), `HTTP=${draftFilterResponse.http}, code=${codeOf(draftFilterResponse)}`, OPTIONS) + + const submit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/submit`, + { expectedVersion: Number(bookingCreated.oceanBooking.version), reason: context.marker }, + { 'Idempotency-Key': `${context.marker}-loading-booking-submit` }) + const submitted = context.apiSuccess(MODULE, '提交隔离订舱', submit, '', OPTIONS) + const submittedFilterResponse = await request(context, 'GET', '/api/platform/v1/ocean-bookings?page=1&size=5&status=SUBMITTED') + context.addStep(MODULE, '正式订舱列表拒绝 SUBMITTED 状态筛选', submittedFilterResponse, + failure(submittedFilterResponse), `HTTP=${submittedFilterResponse.http}, code=${codeOf(submittedFilterResponse)}`, OPTIONS) + const confirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/confirm`, + { expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker }, + { 'Idempotency-Key': `${context.marker}-loading-booking-confirm` }) + const confirmedBooking = context.apiSuccess(MODULE, '确认真实订舱但不自动创建内部柜', confirm, '', OPTIONS) + context.assert(MODULE, 'CONFIRMED 只产生真实订舱事实且尚未占用模拟柜', confirm, + confirmedBooking?.oceanBooking?.status === 'CONFIRMED' + && Array.isArray(confirmedBooking?.containerAllocations) && confirmedBooking.containerAllocations.length === 0, + `status=${confirmedBooking?.oceanBooking?.status || 'missing'}, allocations=${confirmedBooking?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) + const confirmedListResponse = await context.request('GET', + `/api/platform/v1/ocean-bookings?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`) + const confirmedList = context.apiSuccess(MODULE, '按真实订舱号查询正式列表', confirmedListResponse, '', OPTIONS) + context.assert(MODULE, 'CONFIRMED 后真实订舱进入正式列表', confirmedListResponse, + records(confirmedList).some((item) => id(item.id) === bookingId && item.status === 'CONFIRMED'), + `bookingId=${bookingId}, records=${records(confirmedList).length}`, OPTIONS) + + const requirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType === primaryRequirement.containerType) + const wrongTypeRequirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType !== primaryRequirement.containerType) + if (!requirement?.id || !wrongTypeRequirement?.id) return + const wrongTypeLinkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { + oceanBookingId: bookingId, requirementId: id(wrongTypeRequirement.id), expectedVersion: Number(containerDetail.version), + }) + context.addStep(MODULE, '箱型不匹配拒绝关联真实订舱柜', wrongTypeLinkResponse, + failure(wrongTypeLinkResponse), `HTTP=${wrongTypeLinkResponse.http}, code=${codeOf(wrongTypeLinkResponse)}`, OPTIONS) + + const emptySimulationResponse = await request(context, 'POST', CONTAINERS, { + channelCode, warehouseId, containerType: primaryRequirement.containerType, + capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg, + }) + let emptySimulation = context.apiSuccess(MODULE, '新增空模拟柜验证解绑换绑', emptySimulationResponse, '', OPTIONS) + const emptyContainerId = id(emptySimulation?.id) + if (!emptyContainerId) return + const emptyLink = await request(context, 'POST', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, { + oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(emptySimulation.version), + }) + emptySimulation = context.apiSuccess(MODULE, '空模拟柜首次关联真实订舱', emptyLink, '', OPTIONS) + const emptyUnlink = await request(context, 'DELETE', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, { + expectedVersion: Number(emptySimulation.version), reason: context.marker, + }) + emptySimulation = context.apiSuccess(MODULE, '空模拟柜允许解除关联', emptyUnlink, '', OPTIONS) + const emptyRelink = await request(context, 'POST', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, { + oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(emptySimulation.version), + }) + emptySimulation = context.apiSuccess(MODULE, '空模拟柜解绑后允许重新关联', emptyRelink, '', OPTIONS) + const emptyFinalUnlink = await request(context, 'DELETE', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, { + expectedVersion: Number(emptySimulation.version), reason: `${context.marker}-release-capacity`, + }) + context.apiSuccess(MODULE, '空模拟柜最终解绑释放真实订舱容量', emptyFinalUnlink, '', OPTIONS) + + const beforeLinkItems = JSON.stringify(assigned.items?.map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), packageCount: Number(item.packageCount) })) || []) + const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { + oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(containerDetail.version), + }) + const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse, '', OPTIONS) + const afterLinkPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) + const afterLinkPlan = context.apiSuccess(MODULE, '关联后回查原装柜计划与明细', afterLinkPlanResponse, '', OPTIONS) + const afterLinkItems = JSON.stringify(afterLinkPlan?.items?.map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), packageCount: Number(item.packageCount) })) || []) + context.assert(MODULE, '关联前后内部柜次、计划和排货明细身份稳定', linkResponse, + id(linkedContainer?.id) === containerId && linkedContainer?.cabinetNo === simulation.cabinetNo + && id(linkedContainer?.oceanBookingId) === bookingId && id(afterLinkPlan?.id) === planId + && beforeLinkItems === afterLinkItems, + `containerBookingId=${containerId}, cabinetNo=${linkedContainer?.cabinetNo || 'missing'}, loadingPlanId=${id(afterLinkPlan?.id) || 'missing'}`, OPTIONS) + + const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS, { + channelCode, warehouseId, containerType: primaryRequirement.containerType, + capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg, + }) + const duplicateSimulation = context.apiSuccess(MODULE, '新增重复容量占用负向模拟柜', duplicateSimulationResponse, '', OPTIONS) + const duplicateLinkResponse = await request(context, 'POST', `${CONTAINERS}/${id(duplicateSimulation?.id)}/ocean-booking-link`, { + oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(duplicateSimulation?.version), + }) + context.addStep(MODULE, '同一真实订舱箱型容量拒绝重复关联', duplicateLinkResponse, + failure(duplicateLinkResponse), `HTTP=${duplicateLinkResponse.http}, code=${codeOf(duplicateLinkResponse)}`, OPTIONS) + + const unlinkAfterLoadingResponse = await request(context, 'DELETE', `${CONTAINERS}/${containerId}/ocean-booking-link`, { + expectedVersion: Number(linkedContainer.version), reason: context.marker, + }) + context.addStep(MODULE, '已有排货后拒绝解绑或换绑', unlinkAfterLoadingResponse, + failure(unlinkAfterLoadingResponse), `HTTP=${unlinkAfterLoadingResponse.http}, code=${codeOf(unlinkAfterLoadingResponse)}`, OPTIONS) + const pushKey = `${context.marker}-container-push`.slice(0, 128) const pushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, { expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 1406170fa..ed73c5a82 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -123,7 +123,7 @@ export async function run(context) { channelCode: initial?.summary?.channelCode, shippingCompanyName: `P3-SHIPPING-${suffix}`, bookingAgentName: `P3-AGENT-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', - destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', + destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` }, @@ -131,6 +131,17 @@ export async function run(context) { containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100, maxWeightKg: 100000, quantity: 1 }], } + const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', { + channelCode: initial?.summary?.channelCode, warehouseId, containerType: '40HQ', + capacityCbm: 100, maxWeightKg: 100000, + }) + const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS) + const containerBookingId = id(simulation?.id) + context.assert(MODULE, '三期模拟柜不要求真实柜号或订舱', simulationResponse, + Boolean(containerBookingId) && Boolean(simulation?.cabinetNo) && simulation?.containerNo == null + && simulation?.oceanBookingId == null, + `containerBookingId=${containerBookingId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}`, OPTIONS) + if (!containerBookingId) return const bookingCreateResponse = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) }) @@ -144,9 +155,21 @@ export async function run(context) { const bookingConfirmResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/confirm`, { expectedVersion: Number(bookingSubmitted?.oceanBooking?.version), reason: context.marker }, { 'Idempotency-Key': `${context.marker}-p3-booking-confirm`.slice(0, 128) }) - const bookingConfirmed = context.apiSuccess(MODULE, '确认订舱并自动生成三期柜次', bookingConfirmResponse, '', OPTIONS) - const containerBookingId = id(bookingConfirmed?.containerAllocations?.[0]?.containerBookingId) - if (!containerBookingId) return + const bookingConfirmed = context.apiSuccess(MODULE, '确认真实订舱且不自动生成三期内部柜', bookingConfirmResponse, '', OPTIONS) + const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') + context.assert(MODULE, '订舱确认仅冻结真实订舱及箱型需求', bookingConfirmResponse, + bookingConfirmed?.oceanBooking?.status === 'CONFIRMED' && Boolean(requirement?.id) + && bookingConfirmed?.containerAllocations?.length === 0, + `status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, requirementId=${id(requirement?.id) || 'missing'}, allocations=${bookingConfirmed?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) + if (!requirement?.id) return + const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, { + oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version), + }) + const linked = context.apiSuccess(MODULE, '关联三期模拟柜与真实订舱', linkResponse, '', OPTIONS) + context.assert(MODULE, '三期订舱关联保持内部柜次身份', linkResponse, + id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo + && id(linked?.oceanBookingId) === bookingId, + `containerBookingId=${containerBookingId}, cabinetNo=${linked?.cabinetNo || 'missing'}`, OPTIONS) const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 7c53f78de..fe6babe07 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -4,14 +4,14 @@ import test from 'node:test' const source = readFileSync(new URL('./phase3-order-splitting.mjs', import.meta.url), 'utf8') -test('each remainder follows confirmed booking before scoped smart-loading candidates', () => { +test('each remainder uses a simulation explicitly linked to a confirmed booking', () => { + assert.match(source, /POST', CONTAINERS/) assert.match(source, /POST', '\/api\/platform\/v1\/ocean-bookings'/) assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/submit/) assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/confirm/) - assert.match(source, /containerAllocations\?\.\[0\]\?\.containerBookingId/) + assert.match(source, /ocean-booking-link/) assert.match(source, /candidates\?bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/) assert.match(source, /`\$\{SMART\}\/plans`, \{ bookingId: containerId/) - assert.doesNotMatch(source, /request\(context, 'POST', CONTAINERS/) assert.doesNotMatch(source, /candidates\?warehouseId=/) }) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index c0487690b..508b95526 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -125,7 +125,7 @@ function bookingPayload(marker, sequence, source) { shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128), bookingAgentId: null, bookingAgentName: `SPLIT-AGENT-${suffix}`.slice(0, 128), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: marker, + placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: marker, parties: [{ partyType: 'SHIPPER', partyName: `SPLIT-SHIPPER-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `SPLIT-CONSIGNEE-${suffix}` }, { partyType: 'NOTIFY_PARTY', partyName: `SPLIT-NOTIFY-${suffix}` }], @@ -137,6 +137,13 @@ function bookingPayload(marker, sequence, source) { async function loadOneFragment(context, state) { const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100) + const simulationResponse = await request(context, 'POST', CONTAINERS, { + channelCode: state.channelCode, warehouseId, containerType: '40HQ', capacityCbm: 100, maxWeightKg: 100000, + }) + const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS) + const containerId = id(simulation?.id) + if (!containerId) return null + context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId) const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: bookingPayload(context.marker, sequence, state), }, { 'Idempotency-Key': `${operationBase}-booking-create` }) @@ -152,13 +159,16 @@ async function loadOneFragment(context, state) { const bookingConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/confirm`, { expectedVersion: Number(bookingSubmitted?.oceanBooking?.version), reason: context.marker, }, { 'Idempotency-Key': `${operationBase}-booking-confirm` }) - const bookingConfirmed = context.apiSuccess(MODULE, `确认 ${sourceOrderNo} 的订舱并自动分配柜次`, bookingConfirm, '', OPTIONS) - const containerId = id(bookingConfirmed?.containerAllocations?.[0]?.containerBookingId) - context.assert(MODULE, `${sourceOrderNo} 订舱确认后取得稳定柜次`, bookingConfirm, - isPositiveId(containerId) && id(bookingConfirmed?.oceanBooking?.id) === oceanBookingId, - `oceanBookingId=${oceanBookingId}, containerBookingId=${containerId || 'missing'}`, OPTIONS) - if (!containerId) return null - context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId) + const bookingConfirmed = context.apiSuccess(MODULE, `确认 ${sourceOrderNo} 的真实订舱`, bookingConfirm, '', OPTIONS) + const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') + if (!requirement?.id) return null + const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { + oceanBookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version), + }) + const linked = context.apiSuccess(MODULE, `关联 ${sourceOrderNo} 的模拟柜与真实订舱`, linkResponse, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 关联后保持稳定内部柜次`, linkResponse, + isPositiveId(containerId) && id(linked?.id) === containerId && linked?.cabinetNo === simulation.cabinetNo, + `oceanBookingId=${oceanBookingId}, containerBookingId=${containerId}`, OPTIONS) const candidatesPath = `${SMART}/candidates?bookingId=${encodeURIComponent(containerId)}&warehouseId=${encodeURIComponent(warehouseId)}` const candidatesResponse = await context.request('GET', candidatesPath) diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index db2fe4da2..f6e052a05 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -45,7 +45,7 @@ function oceanDraft(context, suffix, master) { shippingCompanyName: `船期船司-${suffix}`, bookingAgentName: `船期代理-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', + placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: context.marker, parties: [ { partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index c9587f339..68b35a4da 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -130,30 +130,33 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap' assert.match(convergence, /fixture\?\.inventoryMutationVerified !== true/) }) -test('container loading creates and confirms booking before scoped candidates', () => { +test('container loading arranges a simulation before linking a confirmed real booking', () => { const value = source('./phase3-container-loading.mjs') assert.match(value, /candidates\.find\(\(candidate\) => candidate\.marker === context\.marker\)/) assertOrder(value, [ - "const bookingCreate = await request", - "const submit = await request", - "const confirm = await request", + "const simulationResponse = await request(context, 'POST', CONTAINERS", "const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=", "const planResponse = await request(context, 'POST', `${SMART}/plans`", "const assignResponse = await request", + "const bookingCreate = await request", + "const submit = await request", + "const confirm = await request", + "const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`", ]) assert.match(value, /channelCode, warehouseId, warehouseName/) assert.match(value, /publishPlatformLogisticsChannels\(context, warehouseId, MODULE\)/) assert.match(value, /warehouseId, warehouseName/) const draft = value.slice(value.indexOf('const bookingDraft = {'), value.indexOf('context.assert(MODULE, \'订舱渠道与仓库快照完整\'')) assert.doesNotMatch(draft, /billOfLadingNo|vesselName|voyageNo|estimatedSailingAt|estimatedArrivalAt/) - assert.match(value, /containerAllocations\?\.\[0\]/) + assert.match(value, /containerAllocations\.length === 0/) + assert.match(value, /关联前后内部柜次、计划和排货明细身份稳定/) assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/) assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/) assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/) assert.ok(value.includes("'PATCH', `${CONTAINERS}/${containerId}/transport")) assert.match(value, /在放舱前绑定本轮真实箱号/) assert.match(value, /订舱放舱并开放现场 PDA 任务/) - assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/) + assert.match(value, /POST', CONTAINERS/) }) test('container loading selects the current marker instead of a stale CAB fixture', () => { @@ -220,9 +223,11 @@ test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second ch test('container loading enforces channel isolation and stable PDA identity across container number changes', () => { const value = source('./phase3-container-loading.mjs') assertOrder(value, [ - '确认订舱并自动生成真实柜次', - '创建真实异渠道隔离订舱', + '新增内部模拟柜', + '新增真实异渠道模拟柜', '两个真实有效渠道之间拒绝跨渠道装柜', + '人工分配本轮候选货物', + '首次关联真实已确认订舱柜', '无真实柜号时计划可排柜但 PDA 任务不开放', '在放舱前绑定本轮真实箱号', '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index 69f287bb0..5d94c88c5 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -55,7 +55,7 @@ test('test-environment full acceptance keeps assigned and unassigned customer bo assert.match(bookingModule, /oceanBookingDraftWithoutCustomer/) assert.match(bookingModule, /createdResources\.oceanBookingUnassignedCustomer/) assert.match(bookingModule, /customerId == null[\s\S]*customerName == null/) - assert.match(bookingModule, /status === 'CONFIRMED'[\s\S]*containerBookingId/) + assert.match(bookingModule, /status === 'CONFIRMED'[\s\S]*containerAllocations\?\.length === 0/) assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/) assert.doesNotMatch(bookingModule, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/) assert.match(bookingModule, /指定客户订舱持久化本轮客户范围快照/)