优化
This commit is contained in:
@@ -50,7 +50,7 @@ export async function run(context) {
|
||||
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
|
||||
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `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` |
|
||||
| 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空) | `truck-appointments-contract.test.mjs` |
|
||||
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
|
||||
| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` |
|
||||
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
|
||||
@@ -63,11 +63,11 @@ export async function run(context) {
|
||||
|
||||
默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为:
|
||||
|
||||
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> PDA task complete 联动同柜有效拖车(如有)并同事务完成 task/plan/inbound -> LOADED`。
|
||||
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> PDA task complete 先完成 task/plan/inbound -> LOADED -> 提交后独立尝试同步唯一同柜有效拖车`。
|
||||
|
||||
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
|
||||
|
||||
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态不是前置门禁,无有效拖车时继续,恰有一条有效拖车时同一事务推进为 `COMPLETED`,多条或并发冲突返回 `A0409` 且本地状态回滚。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
|
||||
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
|
||||
|
||||
`phase3-pda-convergence` 同时回查 `/tasks/pending` 与稳定 task detail:两者的 `workflowStatus`、`itemCount`、`pendingItemCount` 必须来自同一任务明细事实。全拣任务必须在两处均为 `AWAITING_REVIEW`,可执行证据 GET,但派生 `actions=[]`,不得重复发送 batch;操作历史必须恰有一次有效 `BATCH`。若 pending 仍按 assignment 返回 `IN_PROGRESS`,该轮以真实 HTTP 失败阻断,不由脚本或前端改写状态。
|
||||
|
||||
|
||||
@@ -114,15 +114,17 @@ export function truckAppointmentFieldGaps(record) {
|
||||
return gaps(record, {
|
||||
id: isPositiveId,
|
||||
appointmentNo: isNonBlank,
|
||||
oceanBookingId: isPositiveId,
|
||||
containerBookingId: isPositiveId,
|
||||
loadingPlanId: isPositiveId,
|
||||
warehouseId: isPositiveId,
|
||||
warehouseName: isNonBlank,
|
||||
oceanBookingId: (value) => value == null || isPositiveId(value),
|
||||
containerBookingId: (value) => value == null || isPositiveId(value),
|
||||
loadingPlanId: (value) => value == null || isPositiveId(value),
|
||||
warehouseId: (value) => value == null || isPositiveId(value),
|
||||
plannedLoadingAt: isIsoDateTime,
|
||||
status: isNonBlank,
|
||||
customerName: (value, item) => item.customerId == null ? value == null : isNonBlank(value),
|
||||
carrierName: (value, item) => item.carrierId == null ? value == null : isNonBlank(value),
|
||||
customerId: isPositiveId,
|
||||
customerName: isNonBlank,
|
||||
carrierId: isPositiveId,
|
||||
carrierName: isNonBlank,
|
||||
attachmentCount: isNonNegativeNumber,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => {
|
||||
'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'],
|
||||
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'customerName', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachmentCount'],
|
||||
'customs/pages/CustomsCasesView.vue': ['customsNo', 'orderLink', 'inboundLink', 'checklistCode', 'checklistVersionNo', 'status', 'updatedAt'],
|
||||
'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'],
|
||||
}
|
||||
@@ -89,7 +89,7 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
|
||||
[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'],
|
||||
[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', attachmentCount: 0 }, 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'],
|
||||
]
|
||||
|
||||
@@ -132,7 +132,7 @@ export async function run(context) {
|
||||
`taskUnchanged=${taskAfterSignature === taskBeforeSignature}, planUnchanged=${planAfterSignature === planBeforeSignature}, inboundStatus=${inboundAfterFailure?.summary?.processStatus || 'missing'}, inboundVersion=${inboundAfterFailure?.summary?.version ?? 'missing'}, operationsUnchanged=${JSON.stringify(operationsAfterFailure || []) === operationsBeforeSignature}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
const completed = context.apiSuccess(MODULE, '订舱拖车报关齐备后完成装柜', completeResponse, '', OPTIONS)
|
||||
const completed = context.apiSuccess(MODULE, '订舱与五类证据等保留门禁齐备后完成装柜', completeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移', completeResponse,
|
||||
completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED'
|
||||
&& id(completed?.id) === taskId && id(completed?.loadingPlanId) === loadingPlanId
|
||||
|
||||
@@ -1083,7 +1083,7 @@ export async function run(context) {
|
||||
{ expectedTaskVersion: Number(beforeBlockedTask?.version),
|
||||
operationId: `${context.marker}-complete-before-downstream`.slice(0, 128), remark: `${context.marker}-before-downstream` },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) })
|
||||
context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream,
|
||||
context.addStep(MODULE, '订舱或五类证据等保留门禁未齐备时 LOADED 拒绝', blockedBeforeDownstream,
|
||||
blockedBeforeDownstream.http === 200 && codeOf(blockedBeforeDownstream) === 'A0409',
|
||||
responseEvidence(blockedBeforeDownstream), OPTIONS)
|
||||
const blockedDetailResponse = await context.request('GET', detailPath)
|
||||
|
||||
@@ -450,7 +450,7 @@ test('phase3 loading proves stale inventory zero-write and defers final completi
|
||||
'PDA 原子批次同参同键重放保持待人工确认',
|
||||
'回查下游门禁前原子 Batch 审计',
|
||||
'最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING',
|
||||
'订舱拖车报关未齐备时 LOADED 硬门禁拒绝',
|
||||
'订舱或五类证据等保留门禁未齐备时 LOADED 拒绝',
|
||||
'LOADED 硬门禁拒绝后 task/plan/inbound 零写入',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { inspectOceanBookingIdentity, truckAppointmentPayload } from './truck-appointments.mjs'
|
||||
import { inspectOceanBookingIdentity, selectIndependentTruckCustomer, truckAppointmentPayload } from './truck-appointments.mjs'
|
||||
|
||||
test('truck appointment accepts only the current marker ocean booking identity', () => {
|
||||
const current = inspectOceanBookingIdentity('RUN-2', {
|
||||
@@ -45,9 +45,11 @@ test('truck appointment rejects mismatched booking detail instead of mixing iden
|
||||
assert.match(conflictingCustomer.reason, /customerId/)
|
||||
})
|
||||
|
||||
test('create edit and stale replay payloads keep the resolved booking identity stable', () => {
|
||||
const context = { marker: 'RUN-2' }
|
||||
const identity = { oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户' }
|
||||
test('create edit and stale replay payloads keep the independent customer and empty links stable', () => {
|
||||
const context = { marker: 'RUN-2', createdResources: {
|
||||
truckSupplier: { supplierId: '501', supplierName: '正式拖车商' },
|
||||
} }
|
||||
const identity = { customerId: '21', customerName: '本轮客户' }
|
||||
const payloads = [
|
||||
truckAppointmentPayload(context, '2', identity),
|
||||
truckAppointmentPayload(context, '2', identity, 3),
|
||||
@@ -55,18 +57,20 @@ test('create edit and stale replay payloads keep the resolved booking identity s
|
||||
]
|
||||
for (const payload of payloads) {
|
||||
assert.deepEqual({
|
||||
oceanBookingId: payload.oceanBookingId, customerId: payload.customerId,
|
||||
warehouseId: payload.warehouseId, customerName: payload.customerName,
|
||||
}, identity)
|
||||
oceanBookingId: payload.oceanBookingId, containerBookingId: payload.containerBookingId,
|
||||
loadingPlanId: payload.loadingPlanId, customerId: payload.customerId,
|
||||
customerName: payload.customerName, carrierId: payload.carrierId,
|
||||
}, { oceanBookingId: null, containerBookingId: null, loadingPlanId: null,
|
||||
customerId: '21', customerName: '本轮客户', carrierId: '501' })
|
||||
}
|
||||
assert.deepEqual(payloads.map((payload) => payload.expectedVersion), [0, 3, 0])
|
||||
})
|
||||
|
||||
test('truck payload sends carrier fields only as a verified ID/name pair', () => {
|
||||
const identity = { oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户' }
|
||||
test('truck payload requires a verified carrier ID/name pair', () => {
|
||||
const identity = { customerId: '21', customerName: '本轮客户' }
|
||||
const missing = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {} }, '2', identity)
|
||||
assert.equal(Object.hasOwn(missing, 'carrierId'), false)
|
||||
assert.equal(Object.hasOwn(missing, 'carrierName'), false)
|
||||
assert.equal(missing.carrierId, null)
|
||||
assert.equal(missing.carrierName, null)
|
||||
const valid = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {
|
||||
truckSupplier: { supplierId: '501', supplierName: '正式拖车商' },
|
||||
} }, '2', identity)
|
||||
@@ -74,13 +78,80 @@ test('truck payload sends carrier fields only as a verified ID/name pair', () =>
|
||||
const incomplete = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {
|
||||
truckSupplier: { supplierId: '501', supplierName: null },
|
||||
} }, '2', identity)
|
||||
assert.equal(Object.hasOwn(incomplete, 'carrierId'), false)
|
||||
assert.equal(Object.hasOwn(incomplete, 'carrierName'), false)
|
||||
assert.equal(incomplete.carrierId, '501')
|
||||
assert.equal(incomplete.carrierName, null)
|
||||
})
|
||||
|
||||
test('truck appointment source has no historical list or hardcoded identity fallback', () => {
|
||||
test('independent customer comes only from the current marker fixture', () => {
|
||||
const current = selectIndependentTruckCustomer({ marker: 'RUN-2', createdResources: {
|
||||
orders: { marker: 'RUN-2', tenantId: '21', customerName: '本轮客户' },
|
||||
} })
|
||||
assert.deepEqual(current, { customerId: '21', customerName: '本轮客户' })
|
||||
const stale = selectIndependentTruckCustomer({ marker: 'RUN-2', createdResources: {
|
||||
orders: { marker: 'RUN-1', tenantId: '20', customerName: '历史客户' },
|
||||
} })
|
||||
assert.equal(stale, null)
|
||||
})
|
||||
|
||||
test('truck appointment source has no booking prerequisite historical list or hardcoded identity fallback', () => {
|
||||
const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(source, /customerId:\s*['"]10001['"]|warehouseId:\s*['"]193000000000000001['"]/)
|
||||
assert.match(source, /\/api\/platform\/v1\/ocean-bookings/)
|
||||
assert.match(source, /未使用历史订舱或硬编码客户\/仓库/)
|
||||
assert.doesNotMatch(source, /import\(new URL\('\.\/ocean-bookings\.mjs'/)
|
||||
assert.match(source, /创建无订舱柜次计划关联的独立拖车预约/)
|
||||
assert.match(source, /缺少同 marker 的 customerId;未借用历史客户或订舱/)
|
||||
})
|
||||
|
||||
test('backend freezes independent creation association validation and PDA failure isolation', () => {
|
||||
const dto = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java', import.meta.url), 'utf8')
|
||||
assert.match(dto, /@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId/)
|
||||
assert.match(dto, /@NotNull @Positive Long customerId/)
|
||||
assert.match(dto, /@NotNull @Positive Long carrierId/)
|
||||
assert.match(dto, /@NotNull LocalDateTime plannedLoadingAt/)
|
||||
assert.doesNotMatch(dto, /@NotNull @Positive Long oceanBookingId|@NotNull @Positive Long containerBookingId/)
|
||||
|
||||
const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', import.meta.url), 'utf8')
|
||||
assert.match(openapi, /required: \[customerId, carrierId, plannedLoadingAt, expectedVersion\]/)
|
||||
assert.match(openapi, /Ocean booking, container booking and loading plan are optional links/)
|
||||
assert.match(openapi, /absence, duplicates or concurrent truck edits never roll back loading completion/)
|
||||
|
||||
const service = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java', import.meta.url), 'utf8')
|
||||
for (const token of ['customers.active(r.customerId(),r.customerName())', 'suppliers.trucking(r.carrierId(),r.carrierName())',
|
||||
'customerId与订舱客户不一致', 'customerId与柜次客户不一致', 'containerBookingId未分配至该订舱',
|
||||
'loadingPlanId与柜次当前装柜计划不一致', 'Propagation.REQUIRES_NEW', 'active.isEmpty()',
|
||||
'active.size()!=1', 'repo.completeLoadingFromPda']) assert.match(service, new RegExp(token.replace(/[().]/g, '\\$&')))
|
||||
|
||||
const loading = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java', import.meta.url), 'utf8')
|
||||
assert.match(loading, /completeTruckAfterCommit/)
|
||||
assert.match(loading, /afterCommit\(\) \{ completion\.run\(\); \}/)
|
||||
assert.match(loading, /catch \(RuntimeException exception\)/)
|
||||
assert.ok(loading.indexOf('loadingListService.publishCompletedEvent') < loading.indexOf('completeTruckAfterCommit'),
|
||||
'PDA task/plan/inbound completion must precede best-effort truck synchronization')
|
||||
|
||||
const truckTests = readFileSync(new URL('../../firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java', import.meta.url), 'utf8')
|
||||
for (const scenario of ['pdaCompletionDoesNotRequireTruckAppointment',
|
||||
'pdaCompletionLocksAndCompletesTheOnlyActiveTruckWithHistory',
|
||||
'pdaCompletionRejectsMultipleActiveTruckAppointmentsWithoutMutation',
|
||||
'pdaCompletionRejectsConcurrentTruckModificationWithoutHistory']) assert.match(truckTests, new RegExp(scenario))
|
||||
const loadingTests = readFileSync(new URL('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java', import.meta.url), 'utf8')
|
||||
assert.match(loadingTests, /conflictingTruckAppointmentsDoNotBlockPdaCompletion/)
|
||||
})
|
||||
|
||||
test('Admin requires customer carrier and planned time but keeps booking and container optional', () => {
|
||||
const source = readFileSync(new URL('../../../apps/admin-web/src/modules/trucking/components/TruckAppointmentForm.vue', import.meta.url), 'utf8')
|
||||
assert.match(source, /customerId: \[\{ required: true/)
|
||||
assert.match(source, /carrierId: \[\{ required: true/)
|
||||
assert.match(source, /plannedLoadingAt: \[\{ required: true/)
|
||||
assert.match(source, /label="关联柜次(可选)"/)
|
||||
assert.match(source, /label="关联订舱(可选)"/)
|
||||
assert.doesNotMatch(source, /containerBookingId: \[\{ required: true|oceanBookingId: \[\{ required: true/)
|
||||
})
|
||||
|
||||
test('default HTTP entry carries independent truck positives and association negatives after current-marker producers', () => {
|
||||
const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8')
|
||||
for (const label of ['缺少 customerId 拒绝创建独立拖车', '缺少 ACTIVE TRUCKING carrier 拒绝创建独立拖车',
|
||||
'缺少 plannedLoadingAt 拒绝创建独立拖车', 'loadingPlanId 与关联柜次当前计划不一致时零写入拒绝',
|
||||
'warehouseId 与可选关联柜次不一致时零写入拒绝', '订舱与柜次无 allocation 关系时零写入拒绝',
|
||||
'创建无订舱柜次计划关联的独立拖车预约']) assert.match(source, new RegExp(label))
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
assert.match(entry, /'orders'[\s\S]*'phase3-loading'[\s\S]*'ocean-bookings'[\s\S]*'truck-appointments'/)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
const OCEAN_BOOKING_BASE = '/api/platform/v1/ocean-bookings'
|
||||
const SUPPLIER_BASE = '/api/platform/v1/suppliers?tenantId=0'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
@@ -69,17 +68,28 @@ export function inspectOceanBookingIdentity(marker, resource, detail = null) {
|
||||
: { identity, reason: null, missingFields: [] }
|
||||
}
|
||||
|
||||
export function truckAppointmentPayload(context, suffix, identity, version = 0) {
|
||||
export function selectIndependentTruckCustomer(context) {
|
||||
const candidates = [
|
||||
context.createdResources?.orders,
|
||||
context.createdResources?.merchantQualificationFixture,
|
||||
context.createdResources?.phase3Loading,
|
||||
context.createdResources?.phase3ContainerLoading,
|
||||
].filter(Boolean)
|
||||
const source = candidates.find((item) => item.marker === context.marker && id(item.tenantId))
|
||||
return source ? { customerId: id(source.tenantId), customerName: source.customerName || null } : null
|
||||
}
|
||||
|
||||
export function truckAppointmentPayload(context, suffix, identity, version = 0, associations = {}) {
|
||||
const supplier = context.createdResources?.truckSupplier || {}
|
||||
const supplierId = id(supplier.supplierId)
|
||||
const supplierName = id(supplier.supplierName)
|
||||
const carrierSnapshot = supplierId && supplierName
|
||||
? { carrierId: supplierId, carrierName: supplierName }
|
||||
: {}
|
||||
return {
|
||||
outboundOrderId: null, oceanBookingId: identity.oceanBookingId, containerBookingId: null, loadingPlanId: null,
|
||||
warehouseId: identity.warehouseId, customerId: identity.customerId, customerName: identity.customerName,
|
||||
...carrierSnapshot, fleetContact: '车队联系人', fleetPhone: '13800000004',
|
||||
outboundOrderId: null, oceanBookingId: associations.oceanBookingId || null,
|
||||
containerBookingId: associations.containerBookingId || null,
|
||||
loadingPlanId: associations.loadingPlanId || null, warehouseId: associations.warehouseId || null,
|
||||
customerId: identity.customerId, customerName: identity.customerName || null,
|
||||
carrierId: supplierId || null, carrierName: supplierName || null,
|
||||
fleetContact: '车队联系人', fleetPhone: '13800000004',
|
||||
driverName: '司机验收', driverPhone: '13800000005', driverIdCard: null, vehicleNo: `粤B-${suffix.slice(-6)}`,
|
||||
storeName: `门店-${suffix}`, storeContact: '门店联系人', storePhone: '13800000006', storeAddress: '深圳市南山区验收地址',
|
||||
storeCity: '深圳市', storeZipcode: '518000', packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5,
|
||||
@@ -103,14 +113,15 @@ async function ensureTruckSupplier(context, suffix) {
|
||||
const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`)
|
||||
supplier = data(detailResponse)
|
||||
}
|
||||
context.addStep(MODULE, '创建本轮有效拖车供应商(可选)', response,
|
||||
!supplierId || (supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE' && Boolean(supplier?.name)),
|
||||
context.addStep(MODULE, '创建本轮有效 ACTIVE TRUCKING 拖车供应商', response,
|
||||
Boolean(supplierId) && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE' && Boolean(supplier?.name),
|
||||
`supplierId=${supplierId || 'missing'}, category=${supplier?.category || 'missing'}, status=${supplier?.status || 'missing'}`, OPTIONS)
|
||||
if (supplierId) context.createdResources.truckSupplier = {
|
||||
marker: context.marker, supplierId, supplierName: supplier.name || supplier.supplierName || null,
|
||||
category: supplier.category, status: supplier.status, finalStatus: 'ACTIVE', retained: true,
|
||||
}
|
||||
return context.createdResources.truckSupplier || null
|
||||
return supplierId && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE'
|
||||
? context.createdResources.truckSupplier : null
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
@@ -124,43 +135,69 @@ export async function run(context) {
|
||||
context.addStep(MODULE, `拖车预约 ${status} 筛选`, response, response.http === 200 && codeOf(response) === '200' && Array.isArray(result?.records) && result.records.every((item) => item.status === status), `HTTP=${response.http}, code=${codeOf(response)}, records=${result?.records?.length ?? 'n/a'}`, OPTIONS)
|
||||
}
|
||||
|
||||
if (!context.createdResources?.oceanBooking?.id) {
|
||||
const oceanBookings = await import(new URL('./ocean-bookings.mjs', import.meta.url))
|
||||
await oceanBookings.run(context)
|
||||
}
|
||||
const oceanBooking = context.createdResources?.oceanBooking
|
||||
if (!oceanBooking?.id) {
|
||||
context.addStep(MODULE, '创建拖车预约所需隔离订舱 fixture', null, false,
|
||||
'本轮 ocean-bookings 未返回可关联的隔离订舱,未使用历史订舱', OPTIONS)
|
||||
const customerIdentity = selectIndependentTruckCustomer(context)
|
||||
if (!customerIdentity) {
|
||||
context.addStep(MODULE, '取得本轮独立拖车客户身份', null, false,
|
||||
'缺少同 marker 的 customerId;未借用历史客户或订舱', OPTIONS)
|
||||
return
|
||||
}
|
||||
let identityResult = inspectOceanBookingIdentity(context.marker, oceanBooking)
|
||||
if (!identityResult.identity && identityResult.missingFields?.length) {
|
||||
const bookingPath = `${OCEAN_BOOKING_BASE}/${encodeURIComponent(id(oceanBooking.id))}`
|
||||
const bookingResponse = await request(context, 'GET', bookingPath)
|
||||
if (bookingResponse.http !== 200 || codeOf(bookingResponse) !== '200') {
|
||||
context.addStep(MODULE, '读取本轮订舱身份快照', bookingResponse, false,
|
||||
`path=${bookingPath}, HTTP=${bookingResponse.http}, code=${codeOf(bookingResponse)}, requestId=${bookingResponse.requestId || 'missing'}, traceId=${bookingResponse.traceId || 'missing'}`, OPTIONS)
|
||||
return
|
||||
const supplier = await ensureTruckSupplier(context, suffix)
|
||||
if (!supplier?.supplierId || !supplier?.supplierName) return
|
||||
|
||||
const basePayload = truckAppointmentPayload(context, suffix, customerIdentity)
|
||||
for (const [name, payload] of [
|
||||
['缺少 customerId 拒绝创建独立拖车', { ...basePayload, customerId: null, customerName: null }],
|
||||
['缺少 ACTIVE TRUCKING carrier 拒绝创建独立拖车', { ...basePayload, carrierId: null, carrierName: null }],
|
||||
['缺少 plannedLoadingAt 拒绝创建独立拖车', { ...basePayload, plannedLoadingAt: null }],
|
||||
]) {
|
||||
const rejected = await request(context, 'POST', BASE, payload)
|
||||
context.addStep(MODULE, name, rejected, failure(rejected),
|
||||
`HTTP=${rejected.http}, code=${codeOf(rejected)}, requestId=${rejected.requestId || 'missing'}, traceId=${rejected.traceId || 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
const linkedFlow = [context.createdResources?.phase3Loading, context.createdResources?.phase3ContainerLoading]
|
||||
.find((item) => item?.marker === context.marker && id(item.containerBookingId) && id(item.loadingPlanId))
|
||||
if (linkedFlow) {
|
||||
const invalidPlanId = (BigInt(id(linkedFlow.loadingPlanId)) + 1n).toString()
|
||||
const mismatchedPlan = await request(context, 'POST', BASE,
|
||||
truckAppointmentPayload(context, `${suffix}P`, customerIdentity, 0, {
|
||||
containerBookingId: id(linkedFlow.containerBookingId), loadingPlanId: invalidPlanId,
|
||||
warehouseId: id(linkedFlow.warehouseId), oceanBookingId: id(linkedFlow.oceanBookingId) || null,
|
||||
}))
|
||||
context.addStep(MODULE, 'loadingPlanId 与关联柜次当前计划不一致时零写入拒绝', mismatchedPlan,
|
||||
failure(mismatchedPlan),
|
||||
`containerBookingId=${id(linkedFlow.containerBookingId)}, loadingPlanId=${invalidPlanId}, HTTP=${mismatchedPlan.http}, code=${codeOf(mismatchedPlan)}`, OPTIONS)
|
||||
if (id(linkedFlow.warehouseId)) {
|
||||
const mismatchedWarehouseId = (BigInt(id(linkedFlow.warehouseId)) + 1n).toString()
|
||||
const mismatchedWarehouse = await request(context, 'POST', BASE,
|
||||
truckAppointmentPayload(context, `${suffix}W`, customerIdentity, 0, {
|
||||
containerBookingId: id(linkedFlow.containerBookingId), warehouseId: mismatchedWarehouseId,
|
||||
}))
|
||||
context.addStep(MODULE, 'warehouseId 与可选关联柜次不一致时零写入拒绝', mismatchedWarehouse,
|
||||
failure(mismatchedWarehouse),
|
||||
`containerBookingId=${id(linkedFlow.containerBookingId)}, warehouseId=${mismatchedWarehouseId}, HTTP=${mismatchedWarehouse.http}, code=${codeOf(mismatchedWarehouse)}`, OPTIONS)
|
||||
}
|
||||
const unrelatedBooking = context.createdResources?.oceanBookingUnassignedCustomer
|
||||
if (unrelatedBooking?.marker === context.marker && id(unrelatedBooking.id)) {
|
||||
const mismatchedAllocation = await request(context, 'POST', BASE,
|
||||
truckAppointmentPayload(context, `${suffix}A`, customerIdentity, 0, {
|
||||
oceanBookingId: id(unrelatedBooking.id), containerBookingId: id(linkedFlow.containerBookingId),
|
||||
}))
|
||||
context.addStep(MODULE, '订舱与柜次无 allocation 关系时零写入拒绝', mismatchedAllocation,
|
||||
failure(mismatchedAllocation),
|
||||
`oceanBookingId=${id(unrelatedBooking.id)}, containerBookingId=${id(linkedFlow.containerBookingId)}, HTTP=${mismatchedAllocation.http}, code=${codeOf(mismatchedAllocation)}`, OPTIONS)
|
||||
}
|
||||
identityResult = inspectOceanBookingIdentity(context.marker, oceanBooking, data(bookingResponse))
|
||||
context.addStep(MODULE, '读取本轮订舱身份快照', bookingResponse, Boolean(identityResult.identity),
|
||||
identityResult.identity
|
||||
? `oceanBookingId=${identityResult.identity.oceanBookingId}, customerId=${identityResult.identity.customerId}, warehouseId=${identityResult.identity.warehouseId}`
|
||||
: `${identityResult.reason}, path=${bookingPath}, requestId=${bookingResponse.requestId || 'missing'}, traceId=${bookingResponse.traceId || 'missing'}`, OPTIONS)
|
||||
}
|
||||
if (!identityResult.identity) {
|
||||
context.addStep(MODULE, '创建拖车预约所需本轮订舱身份', null, false,
|
||||
`${identityResult.reason};未使用历史订舱或硬编码客户/仓库`, OPTIONS)
|
||||
return
|
||||
}
|
||||
const bookingIdentity = Object.freeze({ ...identityResult.identity })
|
||||
|
||||
await ensureTruckSupplier(context, suffix)
|
||||
|
||||
const createResponse = await request(context, 'POST', BASE, truckAppointmentPayload(context, suffix, bookingIdentity)); const appointment = context.apiSuccess(MODULE, '创建隔离拖车预约', createResponse, '', OPTIONS)
|
||||
const createResponse = await request(context, 'POST', BASE, basePayload); const appointment = context.apiSuccess(MODULE, '创建无订舱柜次计划关联的独立拖车预约', createResponse, '', OPTIONS)
|
||||
const appointmentId = id(appointment?.id)
|
||||
context.assert(MODULE, '拖车预约费用、司机车辆和地址字段完整', createResponse, Boolean(appointmentId) && appointment?.status === 'DRAFT' && appointment?.payableAmount != null && appointment?.receivableAmount != null, `id=${appointmentId || 'missing'}, status=${appointment?.status || 'missing'}`, OPTIONS)
|
||||
context.assert(MODULE, '独立拖车仅冻结客户承运商与自身业务字段', createResponse,
|
||||
Boolean(appointmentId) && appointment?.status === 'DRAFT'
|
||||
&& appointment?.oceanBookingId == null && appointment?.containerBookingId == null
|
||||
&& appointment?.loadingPlanId == null && id(appointment?.customerId) === customerIdentity.customerId
|
||||
&& id(appointment?.carrierId) === id(supplier.supplierId)
|
||||
&& appointment?.payableAmount != null && appointment?.receivableAmount != null,
|
||||
`id=${appointmentId || 'missing'}, status=${appointment?.status || 'missing'}, oceanBookingId=${id(appointment?.oceanBookingId) || 'null'}, containerBookingId=${id(appointment?.containerBookingId) || 'null'}`, OPTIONS)
|
||||
if (!appointmentId) return
|
||||
context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'COMPLETED' }
|
||||
const path = `${BASE}/${appointmentId}`
|
||||
@@ -183,8 +220,8 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
let version = Number(appointment.version)
|
||||
const edit = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, bookingIdentity, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑拖车预约', edit, `version=${version}`, OPTIONS); version = Number(edited?.version)
|
||||
const stale = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, bookingIdentity, 0), vehicleNo: `STALE-${suffix}` })
|
||||
const edit = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, customerIdentity, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑独立拖车预约且关联身份保持为空', edit, `version=${version}`, OPTIONS); version = Number(edited?.version)
|
||||
const stale = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, customerIdentity, 0), vehicleNo: `STALE-${suffix}` })
|
||||
context.addStep(MODULE, '拖车预约旧版本编辑返回冲突', stale, stale.http === 200 && ['A0409','409'].includes(codeOf(stale)), `HTTP=${stale.http}, code=${codeOf(stale)}`, OPTIONS)
|
||||
const after = await request(context, 'GET', path); const afterData = data(after); context.apiSuccess(MODULE, '冲突后回查拖车预约', after, '', OPTIONS)
|
||||
context.assert(MODULE, '拖车预约冲突未部分更新', after, afterData?.vehicleNo === edited?.vehicleNo, `vehicleNo=${afterData?.vehicleNo || 'missing'}`, OPTIONS); version = Number(afterData?.version)
|
||||
@@ -195,6 +232,9 @@ export async function run(context) {
|
||||
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)
|
||||
context.assert(MODULE, '独立拖车完成自身状态机后仍不产生履约关联', final,
|
||||
finalData?.oceanBookingId == null && finalData?.containerBookingId == null && finalData?.loadingPlanId == null,
|
||||
`oceanBookingId=${id(finalData?.oceanBookingId) || 'null'}, containerBookingId=${id(finalData?.containerBookingId) || 'null'}, loadingPlanId=${id(finalData?.loadingPlanId) || 'null'}`, 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) : []
|
||||
context.assert(MODULE, '拖车状态历史动作、版本和审计操作者完整', history, ['SUBMIT','DISPATCH','PICKUP','ARRIVE','LOAD','DEPART','COMPLETE'].every((item) => actions.includes(item)) && histories.every((item) => Number.isFinite(Number(item.revision)) && Boolean(item.operatorName) && Boolean(item.createdAt)), `actions=${actions.join(',')}`, OPTIONS)
|
||||
|
||||
Reference in New Issue
Block a user