This commit is contained in:
2026-08-14 19:59:26 +08:00
parent 7d4256dbd2
commit 993b3dfe03
15 changed files with 282 additions and 30 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ export async function run(context) {
| 入库测量、首次打印冻结箱码与墨家扫码 | `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` |
| 分柜与父子入库单数量守恒 | `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-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` | `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` |
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { oceanBookingDraft, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
test('ocean booking fixture resolves the current inbound customer channel and warehouse', async () => {
const steps = []
@@ -40,11 +40,31 @@ test('shared booking draft omits server-owned channelName and release transport
assert.ok(draft[key], `${key} is required`)
}
assert.equal(draft.channelName, undefined)
assert.equal(draft.serviceTerm, 'CY-CY')
assert.equal(draft.freightTerm, 'NINGBO')
assert.equal(draft.containerRequirements[0].capacityCbm, 68)
assert.equal(draft.containerRequirements[0].maxWeightKg, 28000)
for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.equal(draft[key], undefined, `${key} belongs to release, not draft`)
assert.equal(Object.hasOwn(draft, key), false, `${key} belongs to release, not draft`)
}
const fixtureSource = readFileSync(new URL('./ocean-booking-fixture.mjs', import.meta.url), 'utf8')
const builderSource = fixtureSource.slice(fixtureSource.indexOf('export function oceanBookingDraft'), fixtureSource.indexOf('export function oceanBookingDraftWithoutCustomer'))
for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.doesNotMatch(builderSource, new RegExp(`${key}\\s*:`), `${key} must not be emitted by the shared draft builder`)
}
})
test('shared booking draft can omit customer identity without inventing placeholders', () => {
const draft = oceanBookingDraftWithoutCustomer({ marker: 'ADMIN-RUN-OPTIONAL' }, 'optional', {
customerId: '21', customerName: '不应写入', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
})
assert.equal(Object.hasOwn(draft, 'customerId'), false)
assert.equal(Object.hasOwn(draft, 'customerName'), false)
assert.equal(draft.channelCode, 'SEA-US')
assert.equal(draft.warehouseId, '41')
assert.equal(draft.containerRequirements[0].capacityCbm, 68)
assert.equal(draft.containerRequirements[0].maxWeightKg, 28000)
})
test('platform channel producer queries the selected origin warehouse and publishes current-marker resources', async () => {
@@ -58,10 +58,8 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
...writableMaster,
shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`,
bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`,
vesselName: labels.vesselName, voyageNo: labels.voyageNo,
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
estimatedSailingAt: labels.estimatedSailingAt, estimatedArrivalAt: labels.estimatedArrivalAt,
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
remark: context.marker,
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
@@ -69,3 +67,10 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
}
}
export function oceanBookingDraftWithoutCustomer(context, suffix, master, labels = {}) {
const draft = oceanBookingDraft(context, suffix, master, labels)
delete draft.customerId
delete draft.customerName
return draft
}
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const adminPage = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue', import.meta.url), 'utf8')
const adminTest = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts', import.meta.url), 'utf8')
const adminTypes = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/types.ts', import.meta.url), 'utf8')
const prototype = readFileSync(new URL('../../../docs/悦/货代SAAS平台后台/prototype-src/modules/06-containers.html', import.meta.url), 'utf8')
const dto = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', import.meta.url), 'utf8')
const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml', import.meta.url), 'utf8')
const schema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8')
const unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8')
const fixture = readFileSync(new URL('./ocean-booking-fixture.mjs', import.meta.url), 'utf8')
const bookingModule = readFileSync(new URL('./ocean-bookings.mjs', import.meta.url), 'utf8')
const containerLoading = readFileSync(new URL('./phase3-container-loading.mjs', import.meta.url), 'utf8')
const releaseFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
test('prototype evidence keeps service term freight term and optional customer fields', () => {
assert.match(prototype, /主要运输条款 Service Term \*<\/label><select><option>CY-CY<\/option>/)
assert.match(prototype, /运费条款 Freight Term \*<\/label><select><option>NINGBO<\/option>/)
assert.match(prototype, /<label>客户\/公司名称<\/label><input id="booking-customer"/)
})
test('Admin keeps source-backed free text terms without invented option dictionaries', () => {
assert.match(adminPage, /v-model="form\.data\.serviceTerm"[^>]*maxlength="32"/)
assert.match(adminPage, /v-model="form\.data\.freightTerm"[^>]*maxlength="32"/)
assert.doesNotMatch(adminPage, /serviceTermOptions|freightTermOptions/)
for (const invented of ['CY-DOOR', 'DOOR-CY', 'DOOR-DOOR', 'PREPAID', 'COLLECT']) {
assert.doesNotMatch(adminPage, new RegExp(invented), `${invented} must not be an invented Admin option`)
}
assert.match(adminTest, /not\.toContain\('serviceTermOptions'\)/)
assert.match(adminTest, /not\.toContain\('freightTermOptions'\)/)
})
test('service and freight terms are required across Java OpenAPI and SQL', () => {
assert.match(dto, /@NotBlank @Size\(max = 32\) String serviceTerm/)
assert.match(dto, /@NotBlank @Size\(max = 32\) String freightTerm/)
const required = openapi.match(/DraftData:\s*\n\s*type: object\s*\n\s*required: \[([^\]]*)\]/)?.[1] || ''
assert.match(required, /serviceTerm/)
assert.match(required, /freightTerm/)
assert.match(openapi, /serviceTerm: \{type: string, maxLength: 32, description: Required free-text Service Term snapshot/)
assert.match(openapi, /freightTerm: \{type: string, maxLength: 32, description: Required free-text Freight Term snapshot/)
assert.doesNotMatch(openapi, /(?:serviceTerm|freightTerm): \{[^\n]*nullable: true/)
for (const sql of [schema, unifiedSql]) {
assert.match(sql, /service_term VARCHAR\(32\) NOT NULL/)
assert.match(sql, /freight_term VARCHAR\(32\) NOT NULL/)
}
})
test('customer is optional across Admin DraftData DTO and OpenAPI', () => {
assert.match(adminPage, /label="客户(可选)"[\s\S]*?filterable clearable/)
assert.doesNotMatch(adminPage, /customerId:\s*\[\{\s*required:\s*true/)
assert.match(adminTypes, /customerId:\s*string \| null;\s*customerName:\s*string \| null/)
assert.match(dto, /record DraftData\(@Positive Long customerId,\s*@Size\(max = 128\) String customerName/)
assert.doesNotMatch(dto, /record DraftData\(@NotNull @Positive Long customerId/)
const required = openapi.match(/DraftData:\s*\n\s*type: object\s*\n\s*required: \[([^\]]*)\]/)?.[1] || ''
assert.ok(required, 'BLOCKED_CONTRACT: DraftData.required is missing from OpenAPI')
assert.doesNotMatch(required, /customerId|customerName/)
assert.match(openapi, /customerId: \{[^\n]*nullable: true/)
assert.match(openapi, /customerName: \{[^\n]*nullable: true/)
})
test('draft save omits release-only transport values while release still requires all five', () => {
const draftType = adminTypes.slice(adminTypes.indexOf('export interface OceanBookingDraftData'), adminTypes.indexOf('export interface OceanBookingResource'))
for (const field of releaseFields) {
assert.doesNotMatch(adminPage, new RegExp(`v-model="form\\.data\\.${field}"`),
`${field} must be read-only outside the release dialog`)
assert.doesNotMatch(draftType, new RegExp(`${field}\\??:`), `${field} must not belong to OceanBookingDraftData`)
assert.match(adminPage, new RegExp(`(?:v-model="releaseForm\\.${field}"|releaseForm\\.${field})`))
}
assert.match(adminTest, /\['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'\]/)
assert.match(adminTest, /not\.toContain\(`v-model="form\.data\.\$\{field\}"`\)/)
const saveRegion = adminPage.slice(adminPage.indexOf('async function save'), adminPage.indexOf('function requireReason'))
assert.doesNotMatch(saveRegion, /data:\s*form\.data/)
const releaseCall = adminPage.slice(adminPage.indexOf('releaseOceanBooking(id.value'), adminPage.indexOf('releaseDialogOpen.value = false'))
for (const field of releaseFields) assert.match(releaseCall, new RegExp(field))
})
test('HTTP fixtures cover unassigned CONFIRMED persistence and assigned customer isolation', () => {
const unassignedFlow = bookingModule.slice(bookingModule.indexOf('async function createConfirmedUnassignedBooking'), bookingModule.indexOf('async function releaseLoadingBooking'))
assert.match(fixture, /oceanBookingDraftWithoutCustomer/)
assert.match(fixture, /delete draft\.customerId[\s\S]*delete draft\.customerName/)
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, /按真实订舱号查询未指定客户订舱/)
assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/)
assert.doesNotMatch(unassignedFlow, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.doesNotMatch(bookingModule, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.match(bookingModule, /指定客户订舱持久化本轮客户范围快照/)
assert.match(bookingModule, /phase3Loading[\s\S]*phase3ContainerLoading/)
assert.match(containerLoading, /source\?\.marker !== context\.marker/)
assert.match(containerLoading, /id\(candidate\?\.accountId\) === tenantId/)
assert.match(containerLoading, /id\(item\.accountId\) === tenantId/)
})
@@ -1,7 +1,7 @@
const MODULE = 'ocean-bookings'
const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/ocean-bookings'
import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
function id(value) { return value == null ? '' : String(value) }
function codeOf(r) { return r?.body?.code == null ? 'missing' : String(r.body.code) }
@@ -19,6 +19,50 @@ function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null }
async function createBooking(context, suffix, key, master) { return request(context, 'POST', BASE, { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master) }, { 'Idempotency-Key': key }) }
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
async function createConfirmedUnassignedBooking(context, suffix, master) {
const draft = oceanBookingDraftWithoutCustomer(context, `${suffix}U`, master)
const key = `${context.marker}-ocean-unassigned-create`.slice(0, 128)
const createdResponse = await request(context, 'POST', BASE, { expectedVersion: 0, data: draft }, { 'Idempotency-Key': key })
const created = data(createdResponse)
const bookingId = id(created?.oceanBooking?.id)
context.apiSuccess(MODULE, '创建未指定客户订舱', createdResponse,
`marker=${context.marker}, customerFields=${'customerId' in draft || 'customerName' in draft ? 'present' : 'omitted'}`, OPTIONS)
context.assert(MODULE, '未指定客户订舱不伪造客户快照', createdResponse,
Boolean(bookingId) && created?.oceanBooking?.customerId == null && created?.oceanBooking?.customerName == null,
`bookingId=${bookingId || 'missing'}, customerId=${id(created?.oceanBooking?.customerId) || 'null'}, customerName=${created?.oceanBooking?.customerName || 'null'}`, OPTIONS)
if (!bookingId) return null
const path = `${BASE}/${bookingId}`
const submitResponse = await request(context, 'POST', `${path}/submit`, {
expectedVersion: Number(created.oceanBooking.version), reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-ocean-unassigned-submit`.slice(0, 128) })
const submitted = context.apiSuccess(MODULE, '提交未指定客户订舱', submitResponse, '', OPTIONS)
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)
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,
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)
const listResponse = await request(context, 'GET', `${BASE}?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`)
const page = context.apiSuccess(MODULE, '按真实订舱号查询未指定客户订舱', listResponse, `bookingNo=${bookingNo}`, OPTIONS)
context.assert(MODULE, '未指定客户订舱可按真实订舱号从正式列表查询', listResponse,
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,
finalStatus: detail?.oceanBooking?.status || null }
context.createdResources.oceanBookingUnassignedCustomer = resource
return resource
}
async function releaseLoadingBooking(context, containerFlow, suffix) {
const flowOceanBookingId = id(containerFlow?.oceanBookingId)
if (!flowOceanBookingId || !id(containerFlow?.containerBookingId) || !id(containerFlow?.loadingPlanId)) return null
@@ -68,21 +112,26 @@ export async function run(context) {
}
const master = await resolveOceanBookingMaster(context, MODULE)
if (!master) return
const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`)
const list = await request(context, 'GET', `${BASE}?page=1&size=5`)
if (missing(list) || list.http === 0) {
step(context, '海运订舱服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`)
return
}
const page = context.apiSuccess(MODULE, '订舱列表分页与 keyword 查询', list, '', OPTIONS)
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']
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'}`) }
await createConfirmedUnassignedBooking(context, suffix, master)
const key = `${context.marker}-ocean-create`
const createResponse = await createBooking(context, suffix, key, master)
const detail = data(createResponse); const bookingId = id(detail?.oceanBooking?.id)
context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS)
context.assert(MODULE, '订舱 DTO 字段、DRAFT 和9类关联字段完整', createResponse, Boolean(bookingId) && detail?.oceanBooking?.status === 'DRAFT' && detail?.parties?.length === 3 && detail?.containerRequirements?.length === 1, `bookingId=${bookingId || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, requirements=${detail?.containerRequirements?.length ?? 'n/a'}`, OPTIONS)
context.assert(MODULE, '指定客户订舱持久化本轮客户范围快照', createResponse,
id(detail?.oceanBooking?.customerId) === id(master.customerId) && detail?.oceanBooking?.customerName === master.customerName,
`expectedCustomerId=${id(master.customerId)}, actualCustomerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
if (!bookingId) return
context.createdResources.oceanBooking = { id: bookingId, customerId: master.customerId, marker: context.marker,
containerBookingId: null, loadingPlanId: null, finalStatus: 'DRAFT' }