This commit is contained in:
2026-08-18 16:09:52 +08:00
parent 82ee80226e
commit fa77170304
6 changed files with 92 additions and 82 deletions
@@ -80,7 +80,7 @@ export function oceanBookingFieldGaps(record) {
return gaps(record, {
id: isPositiveId,
bookingNo: isNonBlank,
status: (value) => ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'].includes(value),
status: (value) => ['DRAFT', 'RELEASED'].includes(value),
vesselName: isNonBlank,
voyageNo: isNonBlank,
estimatedSailingAt: isIsoDateTime,
@@ -2,7 +2,7 @@ const MODULE = 'manual-state-ocean-trucking'
const OPTIONS = { scenario: 'manual-ocean-trucking-state-matrix', deploymentBlocker: true }
const OCEAN_BASE = '/api/platform/v1/ocean-bookings'
const TRUCK_BASE = '/api/platform/v1/truck-appointments'
const OCEAN_LIST_STATUSES = ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED']
const OCEAN_LIST_STATUSES = ['DRAFT', 'RELEASED']
const OCEAN_STATUSES = [...OCEAN_LIST_STATUSES]
const TRUCK_STATUSES = ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', 'ARRIVED_WAREHOUSE',
'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
@@ -36,20 +36,20 @@ function oceanPayload(context, template, index) {
remark: `${context.marker} 人工列表状态 ${index}`,
parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) =>
({ partyType, partyName, address, contactName, contactPhone })),
containers: template.containers.map(({ containerType, containerKind, capacityCbm, maxWeightKg }, containerIndex) =>
({ containerNo: `MAN-${suffix(context, index)}-${String(containerIndex + 1).padStart(2, '0')}`.slice(0, 64),
containerType, containerKind, capacityCbm, maxWeightKg })),
containerRequirements: template.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) =>
({ containerType, containerKind, quantity, capacityCbm, maxWeightKg })),
} }
}
async function createOcean(context, template, index) {
const response = await post(context, OCEAN_BASE, oceanPayload(context, template, index), `ocean-create-${index}`)
const value = context.apiSuccess(MODULE, `创建订舱状态样本 ${index}`, response, '', OPTIONS)
context.assert(MODULE, `订舱样本 ${index} 一次创建直接返回 CONFIRMED`, response,
Boolean(value?.oceanBooking?.id) && value?.oceanBooking?.status === 'CONFIRMED',
context.assert(MODULE, `订舱样本 ${index} 创建为 DRAFT 且无真实柜`, response,
Boolean(value?.oceanBooking?.id) && value?.oceanBooking?.status === 'DRAFT'
&& (!value?.containers || value.containers.length === 0),
`id=${id(value?.oceanBooking?.id) || 'missing'}, status=${value?.oceanBooking?.status || 'missing'}`, OPTIONS)
if (!value?.oceanBooking?.id || value?.oceanBooking?.status !== 'CONFIRMED') {
throw new Error(`创建订舱样本 ${index} 未直接进入 CONFIRMED`)
if (!value?.oceanBooking?.id || value?.oceanBooking?.status !== 'DRAFT') {
throw new Error(`创建订舱样本 ${index} 未进入 DRAFT`)
}
return value
}
@@ -64,15 +64,8 @@ async function oceanAction(context, value, endpoint, body = {}) {
async function buildOcean(context, template, status, index) {
let value = await createOcean(context, template, index)
if (status === 'CONFIRMED') return value
if (status === 'CANCELLED') return oceanAction(context, value, 'cancel')
if (status === 'CHANGE_PENDING') return oceanAction(context, value, 'change-request', {
vesselName: `变更轮-${suffix(context, index)}`, voyageNo: `CV${suffix(context, index)}`,
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' })
if (status === 'RELEASED') return oceanAction(context, value, 'release', {
billOfLadingNo: value.oceanBooking.billOfLadingNo, vesselName: value.oceanBooking.vesselName,
voyageNo: value.oceanBooking.voyageNo, estimatedSailingAt: value.oceanBooking.estimatedSailingAt,
estimatedArrivalAt: value.oceanBooking.estimatedArrivalAt })
if (status === 'DRAFT') return value
if (status === 'RELEASED') return oceanAction(context, value, 'release')
throw new Error(`unsupported ocean status ${status}`)
}
@@ -139,8 +132,8 @@ export async function run(context, matrix = context.createdResources.manualState
if (!sourceOceanId) throw new Error('缺少现有订舱生命周期模块生成的模板资源')
const templateResponse = await context.request('GET', `${OCEAN_BASE}/${sourceOceanId}`)
const template = context.apiSuccess(MODULE, '读取订舱状态矩阵模板', templateResponse, '', OPTIONS)
if (!template?.oceanBooking || !template?.parties?.length || !template?.containers?.length) {
throw new Error('订舱模板缺少渠道、仓库、参与方或真实柜明细')
if (!template?.oceanBooking || !template?.parties?.length || !template?.containerRequirements?.length) {
throw new Error('订舱模板缺少渠道、仓库、参与方或柜型需求')
}
matrix.pages.oceanBookings = { requiredStatuses: OCEAN_LIST_STATUSES, fixtures: {} }
for (const [index, status] of OCEAN_STATUSES.entries()) {
@@ -161,9 +161,10 @@ export async function run(context) {
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }],
containers: [
{ containerNo: realContainerNo, ...primaryRequirement },
{ containerNo: wrongContainerNo, containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 },
containerRequirements: [
{ containerType: primaryRequirement.containerType, containerKind: primaryRequirement.containerKind, quantity: 1,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg },
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 },
],
}
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
@@ -373,8 +374,12 @@ export async function run(context) {
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 bookingCreatedDraft = context.apiSuccess(MODULE, '登记含渠道仓库和运输快照的 DRAFT 订舱', bookingCreate, '', OPTIONS)
const bookingId = id(bookingCreatedDraft?.oceanBooking?.id)
const bookingRelease = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(bookingCreatedDraft?.oceanBooking?.version), reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-loading-booking-release`.slice(0, 128) })
const bookingCreated = context.apiSuccess(MODULE, '放舱订舱并冻结运输事实', bookingRelease, '', OPTIONS)
const bookingNo = id(bookingCreated?.oceanBooking?.bookingNo)
if (!bookingId) return
const confirmedBooking = bookingCreated
@@ -383,35 +388,31 @@ export async function run(context) {
const frozenTransportMatches = transportFields.every((field) => frozenTransport[field] != null
&& String(frozenTransport[field]).trim() !== ''
&& String(frozenTransport[field]) === String(bookingDraft[field]))
context.assert(MODULE, '一次登记直接返回 CONFIRMED 且尚未占用模拟柜', bookingCreate,
confirmedBooking?.oceanBooking?.status === 'CONFIRMED'
&& Array.isArray(confirmedBooking?.containers) && confirmedBooking.containers.length === 2,
`status=${confirmedBooking?.oceanBooking?.status || 'missing'}, containers=${confirmedBooking?.containers?.length ?? 'n/a'}`, OPTIONS)
context.assert(MODULE, '已确认订舱回显完整且一致的冻结运输快照', bookingCreate,
context.assert(MODULE, '订舱放舱为 RELEASED 且不预生成真实货柜', bookingRelease,
confirmedBooking?.oceanBooking?.status === 'RELEASED'
&& Array.isArray(confirmedBooking?.containerRequirements) && confirmedBooking.containerRequirements.length === 2
&& (!confirmedBooking?.containers || confirmedBooking.containers.length === 0),
`status=${confirmedBooking?.oceanBooking?.status || 'missing'}, requirements=${confirmedBooking?.containerRequirements?.length ?? 'n/a'}, containers=${confirmedBooking?.containers?.length ?? 0}`, OPTIONS)
context.assert(MODULE, 'RELEASED 订舱回显完整且一致的冻结运输快照', bookingRelease,
frozenTransportMatches,
transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', '), OPTIONS)
if (!frozenTransportMatches) return
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'),
context.assert(MODULE, 'RELEASED 订舱进入正式列表', confirmedListResponse,
records(confirmedList).some((item) => id(item.id) === bookingId && item.status === 'RELEASED'),
`bookingId=${bookingId}, records=${records(confirmedList).length}`, OPTIONS)
const requirement = confirmedBooking?.containers?.find?.((item) => item.containerNo === realContainerNo)
const wrongTypeRequirement = confirmedBooking?.containers?.find?.((item) => item.containerNo === wrongContainerNo)
if (!requirement?.containerBookingId || !wrongTypeRequirement?.containerBookingId) return
const wrongTypeLinkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
realContainerBookingId: id(wrongTypeRequirement.containerBookingId), expectedVersion: Number(simulationBeforeLink.version),
})
context.addStep(MODULE, '箱型不匹配拒绝关联真实订舱柜', wrongTypeLinkResponse,
failure(wrongTypeLinkResponse), `HTTP=${wrongTypeLinkResponse.http}, code=${codeOf(wrongTypeLinkResponse)}`, OPTIONS)
const requirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType === primaryRequirement.containerType)
if (!requirement?.id) return
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`, {
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulationBeforeLink.version),
oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo: realContainerNo,
expectedVersion: Number(simulationBeforeLink.version),
})
const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse,
const linkedContainer = context.apiSuccess(MODULE, '首次关联 RELEASED 订舱需求并补真实柜号', linkResponse,
responseEvidence(linkResponse), OPTIONS)
if (!linkedContainer) return
const afterLinkPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
@@ -426,24 +427,23 @@ export async function run(context) {
const activeContainersResponse = await context.request('GET',
`${CONTAINERS}?page=1&size=50&keyword=${encodeURIComponent(realContainerNo)}`)
const activeContainers = context.apiSuccess(MODULE, '合并后按真实柜号回查正常货柜列表', activeContainersResponse, '', OPTIONS)
const activeContainers = context.apiSuccess(MODULE, '关联后按真实柜号回查正常货柜列表', activeContainersResponse, '', OPTIONS)
const activeRealRows = records(activeContainers).filter((item) => item.containerNo === realContainerNo)
context.assert(MODULE, '合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份', activeContainersResponse,
activeRealRows.length === 1 && id(activeRealRows[0]?.id) === containerId
&& !records(activeContainers).some((item) => id(item?.id) === id(requirement.containerBookingId)),
`activeIds=${activeRealRows.map((item) => id(item?.id)).join('|') || 'none'}, mergedSourceId=${id(requirement.containerBookingId)}`, OPTIONS)
context.assert(MODULE, '关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份', activeContainersResponse,
activeRealRows.length === 1 && id(activeRealRows[0]?.id) === containerId,
`activeIds=${activeRealRows.map((item) => id(item?.id)).join('|') || 'none'}`, OPTIONS)
const containerGaps = containerFieldGaps(activeRealRows[0], { requireContainerNo: true })
context.assert(MODULE, '货柜列表关键展示字段来自正式柜次快照且格式完整', activeContainersResponse,
activeRealRows.length === 1 && containerGaps.length === 0,
`missing=${containerGaps.join(',') || 'none'}`, OPTIONS)
const mergedBookingResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${bookingId}`)
const mergedBooking = context.apiSuccess(MODULE, '合并后回查订舱真实柜当前操作身份', mergedBookingResponse, '', OPTIONS)
const mergedBooking = context.apiSuccess(MODULE, '关联后回查订舱 allocation 当前操作身份', mergedBookingResponse, '', OPTIONS)
const mergedContainer = mergedBooking?.containers?.find?.((item) => item.containerNo === realContainerNo)
context.assert(MODULE, '订舱真实柜 allocation 已原子改指稳定模拟柜身份', mergedBookingResponse,
context.assert(MODULE, '订舱 allocation 原子创建并指向稳定模拟柜身份', mergedBookingResponse,
id(mergedContainer?.containerBookingId) === containerId
&& id(mergedContainer?.requirementId) === id(requirement.requirementId)
&& id(mergedContainer?.allocationId) === id(requirement.allocationId),
&& id(mergedContainer?.requirementId) === id(requirement.id)
&& Boolean(mergedContainer?.allocationId),
`containerBookingId=${id(mergedContainer?.containerBookingId) || 'missing'}, requirementId=${id(mergedContainer?.requirementId) || 'missing'}, allocationId=${id(mergedContainer?.allocationId) || 'missing'}`, OPTIONS)
const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS, {
@@ -452,9 +452,10 @@ export async function run(context) {
})
const duplicateSimulation = context.apiSuccess(MODULE, '新增重复容量占用负向模拟柜', duplicateSimulationResponse, '', OPTIONS)
const duplicateLinkResponse = await request(context, 'POST', `${CONTAINERS}/${id(duplicateSimulation?.id)}/ocean-booking-link`, {
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(duplicateSimulation?.version),
oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo: realContainerNo,
expectedVersion: Number(duplicateSimulation?.version),
})
context.addStep(MODULE, '已消费 MERGED 真实柜拒绝再次关联', duplicateLinkResponse,
context.addStep(MODULE, '已消费订舱需求拒绝再次关联', duplicateLinkResponse,
failure(duplicateLinkResponse), `HTTP=${duplicateLinkResponse.http}, code=${codeOf(duplicateLinkResponse)}`, OPTIONS)
const unlinkAfterLoadingResponse = await request(context, 'DELETE', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
@@ -136,7 +136,7 @@ export async function run(context) {
parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `P3-NOTIFY-${suffix}` }],
containers: [{ containerNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76,
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76,
maxWeightKg: 28000 }],
}
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
@@ -287,49 +287,41 @@ export async function run(context) {
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) })
const bookingConfirmed = context.apiSuccess(MODULE, '创建带渠道仓库快照的三期 CONFIRMED 订舱', bookingCreateResponse, '', OPTIONS)
const bookingId = id(bookingConfirmed?.oceanBooking?.id)
const bookingDraftResponse = context.apiSuccess(MODULE, '创建三期 DRAFT 订舱(不生成真实柜)', bookingCreateResponse, '', OPTIONS)
const bookingId = id(bookingDraftResponse?.oceanBooking?.id)
const draftVersion = Number(bookingDraftResponse?.oceanBooking?.version)
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`,
{ expectedVersion: draftVersion, reason: context.marker },
{ 'Idempotency-Key': `${context.marker}-p3-booking-release`.slice(0, 128) })
const bookingConfirmed = context.apiSuccess(MODULE, '三期订舱 RELEASED 冻结运输快照', releaseResponse, '', OPTIONS)
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, bookingConfirmed?.oceanBooking?.[field]]))
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field]))
const requirement = bookingConfirmed?.containers?.find?.((item) => item.containerNo === containerNo)
context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse,
Boolean(bookingId) && frozenTransportMatches && Boolean(requirement?.containerBookingId)
&& bookingConfirmed?.containers?.length === 1,
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
context.assert(MODULE, 'RELEASED 订舱回显完整冻结运输快照且无真实柜号', releaseResponse,
Boolean(bookingId) && bookingConfirmed?.oceanBooking?.status === 'RELEASED' && frozenTransportMatches
&& Boolean(requirement?.id) && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
`bookingId=${bookingId || 'missing'}, status=${bookingConfirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
if (!bookingId || !requirement?.containerBookingId || !frozenTransportMatches) return
if (!bookingId || !requirement?.id || !frozenTransportMatches) return
const latestContainerResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}`)
const latestContainer = context.apiSuccess(MODULE, '合并前读取模拟柜最新版本', latestContainerResponse, '', OPTIONS)
const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, {
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(latestContainer?.version),
oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo,
expectedVersion: Number(latestContainer?.version),
})
const linked = context.apiSuccess(MODULE, '模拟柜原地合并三期 CONFIRMED 真实订舱柜', linkResponse, '', OPTIONS)
const linked = context.apiSuccess(MODULE, '模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号', linkResponse, '', OPTIONS)
context.assert(MODULE, '订舱关联保持柜次计划与真实柜号身份', linkResponse,
id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo
&& linked?.containerNo === containerNo && id(linked?.oceanBookingId) === bookingId,
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS)
if (id(linked?.id) !== containerBookingId) return
const confirmedBookingTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '订舱已关联但未 RELEASED 仍无 PDA 任务', confirmedBookingTaskResponse,
confirmedBookingTaskResponse.http === 200 && codeOf(confirmedBookingTaskResponse) === 'A0404',
`HTTP=${confirmedBookingTaskResponse.http}, code=${codeOf(confirmedBookingTaskResponse)}`, OPTIONS)
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(bookingConfirmed?.oceanBooking?.version),
...frozenTransport, reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-p3-booking-release`.slice(0, 128) })
const bookingReleased = context.apiSuccess(MODULE, '三期订舱携带已冻结运输快照并放舱', releaseResponse, '', OPTIONS)
context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份', releaseResponse,
bookingReleased?.oceanBooking?.status === 'RELEASED'
&& bookingReleased?.containers?.some?.((item) => id(item.containerBookingId) === containerBookingId
&& item.containerNo === containerNo)
&& transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field])),
`status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS)
if (bookingReleased?.oceanBooking?.status !== 'RELEASED') return
context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份和运输快照', releaseResponse,
bookingConfirmed?.oceanBooking?.status === 'RELEASED'
&& id(linked?.oceanBookingId) === bookingId && linked?.containerNo === containerNo,
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS)
const unconfirmedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)