This commit is contained in:
2026-08-17 19:51:54 +08:00
parent cddd23e4c7
commit 45b51415cf
84 changed files with 1278 additions and 158 deletions
+10
View File
@@ -121,6 +121,16 @@ node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs
node --test scripts/admin-test-env-modules/domestic-container-contract.test.mjs
```
## 货柜履约列表字段合同
`fulfillment-list-field-contract.mjs` 统一校验货柜、预装/装柜清单、订舱、船期、拖车、报关协同和国内出仓正式响应中的稳定 ID、非空展示字段、非负数量/重量/体积及 ISO 时间格式。各生产模块在已取得的本轮 marker 响应上执行断言,不额外借用历史数据;未冻结的原型字段只登记在 `docs/三期/货柜履约原型字段验收矩阵-2026-08-17.md` 的 `UNKNOWN`,不得由 fixture 伪造。
离线合同:
```powershell
node --test scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs
```
## 清库后的商户下单前置
`merchant-qualification-fixture.mjs` 是默认全量验收的首个模块。它通过平台正式接口创建本轮隔离客户,经 File 上传真实营业执照和法人身份证文件,再走 IAM 提交审核和平台审核通过;审核完成后把本轮动态加载模块使用的商户账号切换到该客户。
@@ -0,0 +1,165 @@
function valueAt(record, path) {
return path.split('.').reduce((value, key) => value?.[key], record)
}
export function isPositiveId(value) {
return /^\d+$/.test(String(value ?? '')) && BigInt(String(value)) > 0n
}
export function isNonBlank(value) {
return typeof value === 'string' && value.trim().length > 0
}
export function isIsoDateTime(value) {
return isNonBlank(value) && Number.isFinite(Date.parse(value))
}
export function isNonNegativeNumber(value) {
return value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0
}
function gaps(record, rules) {
return Object.entries(rules)
.filter(([path, predicate]) => !predicate(valueAt(record, path), record))
.map(([path]) => path)
}
export function containerFieldGaps(record, { requireContainerNo = false } = {}) {
return gaps(record, {
id: isPositiveId,
cabinetNo: isNonBlank,
warehouseId: isPositiveId,
warehouseName: isNonBlank,
channelCode: isNonBlank,
containerType: isNonBlank,
capacityCbm: (value) => Number(value) > 0,
maxWeightKg: (value) => Number(value) > 0,
planStatus: isNonBlank,
...(requireContainerNo ? { containerNo: isNonBlank } : {}),
})
}
export function preloadFieldGaps(record) {
return gaps(record, {
planId: isPositiveId,
containerBookingId: isPositiveId,
preloadNo: isNonBlank,
cabinetNo: isNonBlank,
customers: (value) => Array.isArray(value) && value.length > 0
&& value.every((customer) => isPositiveId(customer?.accountId) && isNonBlank(customer?.customerName)),
warehouseName: isNonBlank,
containerType: isNonBlank,
status: isNonBlank,
skuCount: isNonNegativeNumber,
totalPackageCount: isNonNegativeNumber,
totalVolumeCbm: isNonNegativeNumber,
totalGrossWeightKg: isNonNegativeNumber,
createdAt: isIsoDateTime,
})
}
export function loadingListFieldGaps(record) {
return gaps(record, {
planId: isPositiveId,
containerBookingId: isPositiveId,
loadingListNo: isNonBlank,
containerNo: isNonBlank,
cabinetNo: isNonBlank,
warehouseName: isNonBlank,
status: (value) => value === 'COMPLETED',
totalPackageCount: isNonNegativeNumber,
totalGrossWeightKg: isNonNegativeNumber,
totalNetWeightKg: isNonNegativeNumber,
totalVolumeCbm: isNonNegativeNumber,
totalQuantity: isNonNegativeNumber,
createdAt: isIsoDateTime,
})
}
export function oceanBookingFieldGaps(record) {
return gaps(record, {
id: isPositiveId,
bookingNo: isNonBlank,
status: (value) => ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'].includes(value),
vesselName: isNonBlank,
voyageNo: isNonBlank,
estimatedSailingAt: isIsoDateTime,
estimatedArrivalAt: isIsoDateTime,
customerName: (value, item) => item.customerId == null ? value == null : isNonBlank(value),
shippingCompanyName: (value, item) => item.shippingCompanyId == null ? value == null : isNonBlank(value),
bookingAgentName: (value, item) => item.bookingAgentId == null ? value == null : isNonBlank(value),
})
}
export function portScheduleFieldGaps(record) {
const view = record?.portSchedule ?? record
const schedule = view?.schedule ?? view
return gaps(schedule, {
id: isPositiveId,
scheduleNo: isNonBlank,
sourceName: isNonBlank,
vesselName: isNonBlank,
voyageNo: isNonBlank,
departurePortCode: isNonBlank,
destinationPortCode: isNonBlank,
etdAt: isIsoDateTime,
etaAt: isIsoDateTime,
observedAt: isIsoDateTime,
updatedAt: isIsoDateTime,
expiresAt: isIsoDateTime,
}).concat(typeof view?.expired === 'boolean' ? [] : ['expired'])
}
export function truckAppointmentFieldGaps(record) {
return gaps(record, {
id: isPositiveId,
appointmentNo: isNonBlank,
oceanBookingId: isPositiveId,
containerBookingId: isPositiveId,
loadingPlanId: isPositiveId,
warehouseId: isPositiveId,
warehouseName: isNonBlank,
plannedLoadingAt: isIsoDateTime,
status: isNonBlank,
customerName: (value, item) => item.customerId == null ? value == null : isNonBlank(value),
carrierName: (value, item) => item.carrierId == null ? value == null : isNonBlank(value),
})
}
export function customsCaseFieldGaps(record) {
const customsCase = record?.customsCase ?? record?.case ?? record
return gaps(customsCase, {
id: isPositiveId,
customsNo: isNonBlank,
orderId: isPositiveId,
inboundId: isPositiveId,
containerBookingId: isPositiveId,
checklistVersionId: isPositiveId,
checklistCode: isNonBlank,
checklistVersionNo: isNonBlank,
status: isNonBlank,
updatedAt: isIsoDateTime,
})
}
export function outboundAggregateFieldGaps(record) {
const aggregate = record?.aggregate ?? record
return gaps(aggregate, {
orderId: isPositiveId,
inboundId: isPositiveId,
inboundCode: isNonBlank,
customerId: isPositiveId,
customerName: isNonBlank,
containerBookingId: isPositiveId,
containerNo: isNonBlank,
loadingPlanId: isPositiveId,
loadingPlanStatus: isNonBlank,
pickTaskId: isPositiveId,
pickTaskStatus: isNonBlank,
oceanBookingId: isPositiveId,
oceanBookingStatus: isNonBlank,
dispatchStatus: isNonBlank,
version: isNonNegativeNumber,
'gate.items': (value) => Array.isArray(value),
})
}
@@ -0,0 +1,132 @@
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 {
containerFieldGaps,
customsCaseFieldGaps,
loadingListFieldGaps,
oceanBookingFieldGaps,
outboundAggregateFieldGaps,
portScheduleFieldGaps,
preloadFieldGaps,
truckAppointmentFieldGaps,
} from './fulfillment-list-field-contract.mjs'
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const workspace = path.resolve(repo, '..')
const read = (...parts) => fs.readFileSync(path.join(...parts), 'utf8')
const ids = {
id: '2089000000000000001', plan: '2089000000000000002', container: '2089000000000000003',
order: '2089000000000000004', inbound: '2089000000000000005', task: '2089000000000000006',
}
const at = '2026-08-17T08:00:00'
test('原型锁定货柜、预装、装柜、订舱与拖车关键列表字段', () => {
const source = read(workspace, 'docs', '悦', '货代SAAS平台后台', 'prototype-src', 'modules', '06-containers.html')
for (const label of ['集装箱号', '装柜清单号', '货柜状态', '柜号', '预装单号', '客户', '仓库',
'船名航次', '箱型', '总毛重', '总体积', '订舱代理', '派车单号', '运输公司', '预计装箱日期']) {
assert.match(source, new RegExp(label))
}
})
test('八类 Admin 列表均以左固定操作列开头', () => {
const sources = [
['container-loading/pages/ContainersView.vue', 1],
['loading-lists/components/LoadingListsView.vue', 2],
['ocean-bookings/pages/OceanBookingsView.vue', 1],
['port-schedules/pages/PortSchedulesView.vue', 1],
['trucking/pages/TruckAppointmentsView.vue', 1],
['customs/pages/CustomsCasesView.vue', 1],
['outbound-dispatch/pages/OutboundDispatchesView.vue', 1],
]
for (const [relative, count] of sources) {
const source = read(workspace, 'apps', 'admin-web', 'src', 'modules', relative)
assert.equal([...source.matchAll(/\{ key: 'actions', label: '操作',[^\n}]*fixed: 'left'|\{ key: 'actions', label: '操作', fixed: 'left'/g)].length, count,
`${relative} operation column count`)
}
})
test('Admin 当前列表逐字段列与验收矩阵一致', () => {
const expectations = {
'container-loading/pages/ContainersView.vue': ['cabinetNo', 'oceanBooking', 'containerNo', 'planStatus', 'warehouseName', 'terminalTruckingCompany', 'billOfLadingNo', 'containerType', 'shippingCompanyName', 'estimatedSailingAt', 'actualPortArrivalAt'],
'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'],
'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'],
'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'],
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'customerName', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachments'],
'customs/pages/CustomsCasesView.vue': ['customsNo', 'orderLink', 'inboundLink', 'checklistCode', 'checklistVersionNo', 'status', 'updatedAt'],
'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'],
}
for (const [relative, fields] of Object.entries(expectations)) {
const source = read(workspace, 'apps', 'admin-web', 'src', 'modules', relative)
for (const field of fields) assert.match(source, new RegExp(field), `${relative}: ${field}`)
}
})
test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => {
const sources = [
['firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java', ['cabinetNo', 'containerNo', 'channelCode', 'warehouseName', 'capacityCbm', 'maxWeightKg', 'planStatus']],
['firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java', ['record PreloadSummary', 'record ConfirmedSummary', 'totalGrossWeightKg', 'totalVolumeCbm', 'customers']],
['firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', ['record Detail', 'record RealContainer', 'bookingAgentName', 'estimatedSailingAt', 'estimatedArrivalAt']],
['firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java', ['record ScheduleView', 'sourceName', 'etdAt', 'etaAt', 'expiresAt']],
['firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java', ['appointmentNo', 'customerName', 'carrierName', 'plannedLoadingAt', 'containerNo']],
['firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java', ['customsNo', 'checklistCode', 'checklistVersionNo', 'updatedAt']],
['firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java', ['record Aggregate', 'customerName', 'pickTaskStatus', 'dispatchStatus', 'GateResult']],
]
for (const [relative, fields] of sources) {
const source = read(repo, relative)
for (const field of fields) assert.match(source, new RegExp(field), `${relative}: ${field}`)
}
})
test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
const samples = [
[containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'],
[preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HC', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'],
[loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'],
[oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'],
[portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'],
[truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', customerId: ids.id, customerName: '测试客户', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT' }, undefined, 'appointmentNo'],
[customsCaseFieldGaps, { customsCase: { id: ids.id, customsNo: 'CUS-1', orderId: ids.order, inboundId: ids.inbound, containerBookingId: ids.container, checklistVersionId: ids.id, checklistCode: 'CHECK', checklistVersionNo: 'V1', status: 'DOCUMENTS_PENDING', updatedAt: at } }, undefined, 'checklistCode'],
[outboundAggregateFieldGaps, { orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', customerId: ids.id, customerName: '测试客户', containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { items: [] } }, undefined, 'inboundCode'],
]
for (const [validator, sample, options, missing] of samples) {
assert.deepEqual(validator(sample, options), [])
const invalid = structuredClone(sample)
const target = invalid.portSchedule?.schedule ?? invalid.customsCase ?? invalid
target[missing] = ''
assert.ok(validator(invalid, options).includes(missing))
}
})
test('字段断言接入正式生产模块和默认全量入口', () => {
const consumers = {
'phase3-container-loading.mjs': ['containerFieldGaps', 'preloadFieldGaps', 'loadingListFieldGaps'],
'ocean-bookings.mjs': ['oceanBookingFieldGaps'],
'phase3-port-schedules.mjs': ['portScheduleFieldGaps'],
'truck-appointments.mjs': ['truckAppointmentFieldGaps'],
'phase3-customs.mjs': ['customsCaseFieldGaps'],
'phase3-outbound-dispatch.mjs': ['outboundAggregateFieldGaps'],
}
for (const [file, tokens] of Object.entries(consumers)) {
const source = read(repo, 'scripts', 'admin-test-env-modules', file)
for (const token of tokens) assert.match(source, new RegExp(token), `${file}: ${token}`)
}
const entry = read(repo, 'scripts', 'admin-test-env-acceptance.mjs')
for (const module of Object.keys(consumers).map((file) => file.replace(/\.mjs$/, ''))) {
assert.match(entry, new RegExp(`['"]${module}['"]`), `default entry: ${module}`)
}
})
test('矩阵登记 LANDED 与 UNKNOWN 且禁止 fixture 伪造未知字段', () => {
const matrix = read(workspace, 'docs', '三期', '货柜履约原型字段验收矩阵-2026-08-17.md')
assert.match(matrix, /LANDED/)
assert.match(matrix, /UNKNOWN/)
assert.match(matrix, /不得由 fixture 伪造|不生成假值/)
for (const label of ['货柜', '预装清单', '装柜清单', '订舱', '船期', '拖车', '报关协同', '国内出仓']) {
assert.match(matrix, new RegExp(label))
}
})
@@ -2,6 +2,7 @@ const MODULE = 'ocean-bookings'
const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/ocean-bookings'
import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
import { oceanBookingFieldGaps } from './fulfillment-list-field-contract.mjs'
function id(value) { return value == null ? '' : String(value) }
function codeOf(r) { return r?.body?.code == null ? 'missing' : String(r.body.code) }
@@ -247,6 +248,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)
const bookingFieldGaps = oceanBookingFieldGaps(fd?.oceanBooking)
context.assert(MODULE, '订舱列表所需客户、船公司、代理、航次与关键时间字段来源完整', final,
bookingFieldGaps.length === 0, `missing=${bookingFieldGaps.join(',') || 'none'}`, OPTIONS)
context.assert(MODULE, '独立订舱状态历史、审计操作者和终态完整', final,
fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 4 && fd.histories.every((h) => h.operatorName)
&& fd?.containers?.length === 2,
@@ -1,5 +1,6 @@
import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs'
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'phase3-container-loading'
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
@@ -431,6 +432,10 @@ export async function run(context) {
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)
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)
@@ -481,9 +486,13 @@ export async function run(context) {
const preloadPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(realContainerNo)}&page=1&size=20`)
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
const preloadRow = records(preloadPage).find((item) => id(item.planId) === planId)
context.assert(MODULE, '预装清单包含本轮已推送 EDITING 计划', preloadPageResponse,
records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'EDITING'),
`records=${records(preloadPage).length}`, OPTIONS)
const preloadGaps = preloadFieldGaps(preloadRow)
context.assert(MODULE, '预装清单客户、仓库、箱型、数量重量体积与时间字段完整', preloadPageResponse,
preloadGaps.length === 0, `missing=${preloadGaps.join(',') || 'none'}`, OPTIONS)
const unconfirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
@@ -784,9 +793,13 @@ export async function run(context) {
const confirmedPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-lists?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
const confirmedPage = context.apiSuccess(MODULE, '查询完成装柜清单', confirmedPageResponse, '', OPTIONS)
const confirmedRow = records(confirmedPage).find((item) => id(item.planId) === planId)
context.assert(MODULE, '完成清单分页包含本轮计划', confirmedPageResponse,
records(confirmedPage).some((item) => id(item.planId) === planId && item.status === 'COMPLETED'),
`records=${records(confirmedPage).length}`, OPTIONS)
const loadingListGaps = loadingListFieldGaps(confirmedRow)
context.assert(MODULE, '装柜清单柜号、仓库、数量重量体积与时间字段完整', confirmedPageResponse,
loadingListGaps.length === 0, `missing=${loadingListGaps.join(',') || 'none'}`, OPTIONS)
const confirmedDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-lists/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
@@ -1,3 +1,5 @@
import { customsCaseFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'phase3-customs'
const OPTIONS = { scenario: 'phase3-customs-collaboration', deploymentBlocker: true }
const PLATFORM = '/api/platform/v1/customs'
@@ -183,12 +185,15 @@ export async function run(context) {
{ 'Idempotency-Key': key(context, 'case') })
const created = context.apiSuccess(MODULE, '创建客户订单柜次关联报关协同单', caseResponse, '', OPTIONS)
const caseId = id(created?.customsCase?.id)
const customsFieldGaps = customsCaseFieldGaps(created)
context.assert(MODULE, '报关单绑定本轮实际承载来源分片、入库单、柜次和清单版本', caseResponse,
Boolean(caseId) && id(created?.customsCase?.tenantId) === tenantId && id(created?.customsCase?.orderId) === actualOrderId
&& id(created?.customsCase?.inboundId) === inboundId && id(created?.customsCase?.containerBookingId) === containerBookingId
&& id(created?.customsCase?.checklistVersionId) === checklistVersionId
&& created?.customsCase?.status === 'DOCUMENTS_PENDING' && Number(created?.customsCase?.version) === 0,
`caseId=${caseId || 'missing'}, parentOrderId=${parentOrderId}, sourceOrderId=${sourceOrderId}, actualOrderId=${actualOrderId}, status=${created?.customsCase?.status || 'missing'}`, OPTIONS)
context.assert(MODULE, '报关列表单号、订单入库柜次、清单版本、状态与更新时间字段完整', caseResponse,
customsFieldGaps.length === 0, `missing=${customsFieldGaps.join(',') || 'none'}`, OPTIONS)
if (!caseId) return
const caseReplay = await request(context, 'POST', `${PLATFORM}/cases`, casePayload, context.token,
@@ -1,3 +1,5 @@
import { outboundAggregateFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'phase3-outbound-dispatch'
const OPTIONS = { scenario: 'phase3-domestic-outbound-gate', deploymentBlocker: true }
const BASE = '/api/platform/v1/first-leg/outbound-dispatches'
@@ -43,10 +45,13 @@ export async function run(context) {
const aggregates = context.apiSuccess(MODULE, '按入库单和柜次查询出仓主聚合', listResponse, selector, OPTIONS)
const aggregate = Array.isArray(aggregates) ? aggregates.find((item) => id(item.inboundId) === inboundId
&& id(item.containerBookingId) === containerBookingId) : null
const outboundFieldGaps = outboundAggregateFieldGaps(aggregate)
context.assert(MODULE, '主聚合串联订单、入库单、柜次、计划与客户隔离键', listResponse,
Boolean(aggregate?.orderId) && Boolean(aggregate?.tenantId) && Boolean(aggregate?.loadingPlanId)
&& id(aggregate?.inboundId) === inboundId && id(aggregate?.containerBookingId) === containerBookingId,
`orderId=${id(aggregate?.orderId) || 'missing'}, tenantId=${id(aggregate?.tenantId) || 'missing'}, planId=${id(aggregate?.loadingPlanId) || 'missing'}`, OPTIONS)
context.assert(MODULE, '国内出仓列表客户、订单入库、柜次计划、PDA订舱与门禁摘要完整', listResponse,
outboundFieldGaps.length === 0, `missing=${outboundFieldGaps.join(',') || 'none'}`, OPTIONS)
if (!aggregate) return
const orderResponse = await request(context, 'GET', `${BASE}?orderId=${encodeURIComponent(id(aggregate.orderId))}`)
@@ -1,3 +1,5 @@
import { portScheduleFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'phase3-port-schedules'
const OPTIONS = { scenario: 'port-schedule-source-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/port-schedules'
@@ -135,6 +137,7 @@ export async function run(context) {
const createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
const created = context.apiSuccess(MODULE, '新增独立港务船期观测', createResponse, '', OPTIONS)
const scheduleId = id(created?.portSchedule?.schedule?.id)
const scheduleFieldGaps = portScheduleFieldGaps(created)
context.assert(MODULE, '船期来源、时效、截单截关截港和关联字段完整', createResponse,
Boolean(scheduleId) && created?.portSchedule?.expired === false
&& created?.portSchedule?.schedule?.sourceType === 'CARRIER'
@@ -146,6 +149,8 @@ export async function run(context) {
&& Boolean(created?.portSchedule?.schedule?.customsCutoffAt)
&& Boolean(created?.portSchedule?.schedule?.portCutoffAt),
`scheduleId=${scheduleId || 'missing'}, expired=${created?.portSchedule?.expired ?? 'missing'}`, OPTIONS)
context.assert(MODULE, '船期列表来源、船名航次、港口与关键时间字段格式完整', createResponse,
scheduleFieldGaps.length === 0, `missing=${scheduleFieldGaps.join(',') || 'none'}`, OPTIONS)
if (!scheduleId) return
const replay = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
@@ -1,3 +1,5 @@
import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'truck-appointments'
const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/truck-appointments'
@@ -189,6 +191,9 @@ export async function run(context) {
const action = async (name, endpoint, expectedStatus) => { const response = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: version, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const result = context.apiSuccess(MODULE, name, response, '', OPTIONS); context.assert(MODULE, `${name} 状态返回`, response, result?.status === expectedStatus, `status=${result?.status || 'missing'}`, OPTIONS); version = Number(result?.version) }
for (const [name, endpoint, status] of [['提交预约','submit','PENDING_DISPATCH'],['派车','dispatch','DISPATCHED'],['开始提货','pickup','PICKUP_IN_PROGRESS'],['到仓','arrive','ARRIVED_WAREHOUSE'],['开始装货','load','LOADING'],['离场','depart','DEPARTED'],['完成','complete','COMPLETED']]) await action(name, endpoint, status)
const final = await request(context, 'GET', path); const finalData = data(final); context.apiSuccess(MODULE, '回查 COMPLETED 拖车预约', final, '', OPTIONS)
const appointmentFieldGaps = truckAppointmentFieldGaps(finalData)
context.assert(MODULE, '拖车列表客户、仓库、订舱柜次、供应商与关键时间字段完整', final,
appointmentFieldGaps.length === 0, `missing=${appointmentFieldGaps.join(',') || 'none'}`, OPTIONS)
context.assert(MODULE, '拖车预约状态终态和费用快照正确', final, finalData?.status === 'COMPLETED' && finalData?.payableAmount != null && finalData?.receivableAmount != null, `status=${finalData?.status || 'missing'}`, OPTIONS)
const history = await request(context, 'GET', `${path}/history`); const histories = context.apiSuccess(MODULE, '查询拖车预约状态历史', history, '', OPTIONS)
const actions = Array.isArray(histories) ? histories.map((item) => item.action) : []