refactor(firstleg): remove customer scope from trucking

This commit is contained in:
2026-09-01 09:51:59 +08:00
parent 936412f351
commit 249b182e7f
17 changed files with 357 additions and 463 deletions
@@ -114,14 +114,12 @@ export function truckAppointmentFieldGaps(record) {
return gaps(record, {
id: isPositiveId,
appointmentNo: isNonBlank,
oceanBookingId: (value) => value == null || isPositiveId(value),
containerBookingId: (value) => value == null || isPositiveId(value),
oceanBookingId: isPositiveId,
containerBookingId: isPositiveId,
loadingPlanId: (value) => value == null || isPositiveId(value),
warehouseId: (value) => value == null || isPositiveId(value),
plannedLoadingAt: isIsoDateTime,
status: isNonBlank,
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', 'attachmentCount'],
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', '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'],
}
@@ -72,7 +72,7 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => {
['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/entity/TruckAppointmentEntity.java', ['appointmentNo', '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']],
]
@@ -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', attachmentCount: 0 }, undefined, 'appointmentNo'],
[truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', 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, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'],
]
@@ -88,8 +88,7 @@ function truckPayload(context, oceanBooking, carrier, index) {
const value = suffix(context, index)
return { outboundOrderId: null, oceanBookingId: oceanBooking.id,
containerBookingId: null, loadingPlanId: null,
warehouseId: oceanBooking.warehouseId, customerId: oceanBooking.customerId,
customerName: null,
warehouseId: oceanBooking.warehouseId,
carrierId: id(carrier.id), carrierName: carrier.name, fleetContact: '人工验收联系人', fleetPhone: '13800000004',
driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`,
storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006',
@@ -177,8 +176,8 @@ export async function run(context, matrix = context.createdResources.manualState
}
const releasedOcean = matrix.pages.oceanBookings.fixtures.RELEASED
if (!releasedOcean.id || !releasedOcean.customerId || !releasedOcean.warehouseId) {
throw new Error('RELEASED 订舱缺少拖车所需的客户或仓库归属')
if (!releasedOcean.id || !releasedOcean.warehouseId) {
throw new Error('RELEASED 订舱缺少拖车所需的仓库归属')
}
matrix.pages.truckAppointments = { requiredStatuses: TRUCK_STATUSES, fixtures: {} }
for (const [index, status] of TRUCK_STATUSES.entries()) {
@@ -64,29 +64,20 @@ export async function run(context) {
const suffix = context.marker.replace(/\D/g, '').slice(-10)
const oceanResponse = await context.request('GET', `${OCEAN}/${ocean.id}`)
const oceanDetail = context.apiSuccess(MODULE, '读取跨服务链路订舱真实客户归属', oceanResponse, `oceanBookingId=${ocean.id}`, OPTIONS)
const customerId = id(oceanDetail?.oceanBooking?.customerId)
const customerName = String(oceanDetail?.oceanBooking?.customerName || '').trim()
context.assert(MODULE, '跨服务链路订舱客户归属有效', oceanResponse,
Boolean(customerId) && Boolean(customerName),
`customerId=${customerId || 'missing'}, customerName=${customerName || 'missing'}`, OPTIONS)
if (!customerId || !customerName) return
const oceanDetail = context.apiSuccess(MODULE, '读取跨服务链路已放舱订舱', oceanResponse, `oceanBookingId=${ocean.id}`, OPTIONS)
const linkContainer = ['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus)
&& id(containerFlow?.tenantId) === customerId
&& Boolean(containerFlow?.containerBookingId)
&& Boolean(containerFlow?.loadingPlanId)
if (containerFlow && !linkContainer) {
context.addStep(MODULE, '本轮柜次与订舱客户归属可串联', oceanResponse, false,
`oceanCustomerId=${customerId}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}, containerStatus=${containerFlow?.finalStatus || 'missing'}`, OPTIONS)
if (!linkContainer) {
context.addStep(MODULE, '本轮已放舱订舱必须关联真实柜和装柜计划', oceanResponse, false,
`containerStatus=${containerFlow?.finalStatus || 'missing'}, containerBookingId=${id(containerFlow?.containerBookingId) || 'missing'}`, OPTIONS)
return
}
if (linkContainer) {
const allocationMatches = oceanDetail?.containers?.some?.((item) =>
id(item?.containerBookingId) === id(containerFlow.containerBookingId))
context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches,
`containerBookingId=${id(containerFlow.containerBookingId)}, containers=${oceanDetail?.containers?.length ?? 'n/a'}`, OPTIONS)
if (!allocationMatches) return
}
const allocationMatches = oceanDetail?.containers?.some?.((item) =>
id(item?.containerBookingId) === id(containerFlow.containerBookingId))
context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches,
`containerBookingId=${id(containerFlow.containerBookingId)}, containers=${oceanDetail?.containers?.length ?? 'n/a'}`, OPTIONS)
if (!allocationMatches) return
const carrierResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20')
const carrierPage = context.apiSuccess(MODULE, '读取 ACTIVE TRUCKING 承运商事实源', carrierResponse, '', OPTIONS)
@@ -102,7 +93,7 @@ export async function run(context) {
warehouseId: linkContainer
? id(containerFlow.warehouseId)
: id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'),
customerId, customerName: null, carrierId: id(carrier.id), carrierName: carrier.name,
carrierId: id(carrier.id), carrierName: carrier.name,
fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008',
driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人',
storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000',
@@ -2,167 +2,71 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { inspectOceanBookingIdentity, selectIndependentTruckCustomer, truckAppointmentPayload } from './truck-appointments.mjs'
import { inspectOceanBookingIdentity, truckAppointmentPayload } from './truck-appointments.mjs'
test('truck appointment accepts only the current marker ocean booking identity', () => {
const current = inspectOceanBookingIdentity('RUN-2', {
id: '91', marker: 'RUN-2', customerId: '21', warehouseId: '41', customerName: '本轮客户',
})
assert.deepEqual(current.identity, {
oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户',
})
const stale = inspectOceanBookingIdentity('RUN-2', {
id: '90', marker: 'RUN-1', customerId: '20', warehouseId: '40', customerName: '历史客户',
})
assert.equal(stale.identity, null)
assert.match(stale.reason, /marker 不属于本轮/)
test('booking identity is marker scoped and contains no customer snapshot', () => {
const result = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2', warehouseId: '41' })
assert.deepEqual(result.identity, { oceanBookingId: '91', warehouseId: '41' })
assert.equal(Object.hasOwn(result.identity, 'customerId'), false)
assert.equal(inspectOceanBookingIdentity('RUN-2', { id: '90', marker: 'RUN-1', warehouseId: '41' }).identity, null)
})
test('truck appointment fills missing identity only from the current booking detail', () => {
const partial = { id: '91', marker: 'RUN-2', customerId: '21' }
const beforeDetail = inspectOceanBookingIdentity('RUN-2', partial)
assert.equal(beforeDetail.identity, null)
assert.deepEqual(beforeDetail.missingFields, ['warehouseId', 'customerName'])
const resolved = inspectOceanBookingIdentity('RUN-2', partial, { oceanBooking: {
id: 91, customerId: 21, warehouseId: 41, customerName: '本轮客户',
} })
assert.deepEqual(resolved.identity, {
oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户',
test('payload requires booking container and carrier but never emits customer fields', () => {
const context = { createdResources: { truckSupplier: { supplierId: '501', supplierName: '正式拖车商' } } }
const payload = truckAppointmentPayload(context, '2', {
oceanBookingId: '91', containerBookingId: '81', loadingPlanId: '71', warehouseId: '41', billOfLadingNo: 'BL-1',
})
assert.deepEqual({ oceanBookingId: payload.oceanBookingId, containerBookingId: payload.containerBookingId,
carrierId: payload.carrierId, carrierName: payload.carrierName },
{ oceanBookingId: '91', containerBookingId: '81', carrierId: '501', carrierName: '正式拖车商' })
assert.equal(Object.hasOwn(payload, 'customerId'), false)
assert.equal(Object.hasOwn(payload, 'customerName'), false)
})
test('truck appointment rejects mismatched booking detail instead of mixing identities', () => {
const wrongResource = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2' }, {
oceanBooking: { id: '92', customerId: '21', warehouseId: '41', customerName: '其他订舱' },
})
assert.equal(wrongResource.identity, null)
assert.match(wrongResource.reason, /资源归属不一致/)
const conflictingCustomer = inspectOceanBookingIdentity('RUN-2', {
id: '91', marker: 'RUN-2', customerId: '21',
}, { oceanBooking: { id: '91', customerId: '22', warehouseId: '41', customerName: '本轮客户' } })
assert.equal(conflictingCustomer.identity, null)
assert.match(conflictingCustomer.reason, /customerId/)
})
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),
truckAppointmentPayload(context, '2', identity, 0),
test('backend DTO entity mapper service and OpenAPI have no truck customer protocol', () => {
const paths = [
'../../firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java',
'../../firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java',
'../../firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java',
'../../firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java',
'../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml',
]
for (const payload of payloads) {
assert.deepEqual({
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' })
for (const path of paths) {
const source = readFileSync(new URL(path, import.meta.url), 'utf8')
assert.doesNotMatch(source, /customerId|customerName|customer_id|customer_name/)
}
assert.deepEqual(payloads.map((payload) => payload.expectedVersion), [0, 3, 0])
})
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(missing.carrierId, null)
assert.equal(missing.carrierName, null)
const valid = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {
truckSupplier: { supplierId: '501', supplierName: '正式拖车商' },
} }, '2', identity)
assert.deepEqual({ carrierId: valid.carrierId, carrierName: valid.carrierName }, { carrierId: '501', carrierName: '正式拖车商' })
const incomplete = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {
truckSupplier: { supplierId: '501', supplierName: null },
} }, '2', identity)
assert.equal(incomplete.carrierId, '501')
assert.equal(incomplete.carrierName, null)
})
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.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', () => {
test('backend freezes released booking real container bill file and carrier rules', () => {
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 oceanBookingId/)
assert.match(dto, /@NotNull @Positive Long containerBookingId/)
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/)
for (const token of ['"RELEASED"', 'containerBookingId未分配至该订舱', 'Iso6346ContainerNumber.require',
'oceanBookingDocuments.requireActiveValidDocument', 'suppliers.trucking', 'uk_truck_active_container']) {
assert.ok(service.includes(token), `missing ${token}`)
}
})
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', () => {
test('HTTP regression covers no-customer response and submit idempotency matrix', () => {
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'/)
for (const label of ['响应不含客户字段且冻结订舱柜号关系', '同键同载荷重放', '提交幂等重放零漂移',
'同键异载荷拒绝', '提单号与订舱不一致拒绝创建']) assert.match(source, new RegExp(label))
assert.doesNotMatch(source, /customerId\s*:|customerName\s*:|独立拖车/)
})
test('dispatch requires bill of lading number and active BILL_OF_LADING attachment', () => {
const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8')
assert.match(source, /attachmentType=BILL_OF_LADING/)
assert.match(source, /billOfLadingNo:/)
assert.match(source, /DISPATCH 缺提单号与文件明确拒绝/)
assert.match(source, /派车前保留有效 BILL_OF_LADING 提单附件/)
const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', import.meta.url), 'utf8')
assert.match(openapi, /DISPATCH requires a nonblank billOfLadingNo/)
assert.match(openapi, /BILL_OF_LADING attachment/)
test('schema owns platform attachments and one active appointment per real container', () => {
const sql = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8')
assert.doesNotMatch(sql, /customer_id|customer_name|idx_truck_appointment_customer/)
assert.match(sql, /UNIQUE KEY uk_truck_active_container\(active_container_booking_id\)/)
assert.match(sql, /visibility VARCHAR\(24\) NOT NULL DEFAULT 'PLATFORM'/)
})
test('method permissions remain independently enforced after removing customer scope', () => {
const controller = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java', import.meta.url), 'utf8')
for (const permission of ['platform:firstleg:truck-appointment:list', 'platform:firstleg:truck-appointment:create',
'platform:firstleg:truck-appointment:edit', 'platform:firstleg:truck-appointment:operate']) {
assert.match(controller, new RegExp(`hasAuthority\\('${permission}'\\)`))
}
})
@@ -3,19 +3,17 @@ 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 SUPPLIER_BASE = '/api/platform/v1/suppliers?tenantId=0'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function missing(response) { return response?.http === 404 || response?.http === 405 || response?.http === 503 }
function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') }
function data(response) { return codeOf(response) === '200' ? response.body.data : null }
function failure(response) { return response?.http === 200 && codeOf(response) !== '200' }
async function request(context, method, path, body, headers = {}) {
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method,
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
})
const raw = await response.text(); let payload = null
@@ -25,228 +23,91 @@ async function request(context, method, path, body, headers = {}) {
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
}
async function uploadAttachment(context, appointmentId) {
const path = `${BASE}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING`
const form = new FormData()
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-truck.png`)
try {
const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, body: form, signal: AbortSignal.timeout(60_000) })
const raw = await response.text(); let payload = null
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
return { method: 'POST', path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') }
} catch (error) { return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
}
export function inspectOceanBookingIdentity(marker, resource, detail = null) {
if (!resource?.id) return { identity: null, reason: '本轮 oceanBooking.id 缺失' }
if (resource.marker !== marker) {
return { identity: null, reason: `oceanBooking marker 不属于本轮:expected=${marker}, actual=${resource.marker || 'missing'}` }
}
if (resource.marker !== marker) return { identity: null, reason: `oceanBooking marker 不属于本轮:expected=${marker}, actual=${resource.marker || 'missing'}` }
const booking = detail?.oceanBooking || null
if (booking && id(booking.id) !== id(resource.id)) {
return { identity: null, reason: `订舱详情资源归属不一致:expected=${id(resource.id)}, actual=${id(booking.id) || 'missing'}` }
}
const fields = ['customerId', 'warehouseId', 'customerName']
for (const field of fields) {
const published = id(resource[field])
const queried = id(booking?.[field])
if (published && queried && published !== queried) {
return { identity: null, reason: `订舱身份字段冲突:field=${field}, published=${published}, detail=${queried}` }
}
}
const identity = {
oceanBookingId: id(resource.id),
customerId: id(resource.customerId || booking?.customerId),
warehouseId: id(resource.warehouseId || booking?.warehouseId),
customerName: id(resource.customerName || booking?.customerName),
}
const missingFields = fields.filter((field) => !identity[field])
return missingFields.length
? { identity: null, reason: `本轮订舱身份字段缺失:${missingFields.join(',')}`, missingFields }
if (booking && id(booking.id) !== id(resource.id)) return { identity: null, reason: '订舱详情资源归属不一致' }
const identity = { oceanBookingId: id(resource.id), warehouseId: id(resource.warehouseId || booking?.warehouseId) }
const missingFields = Object.entries(identity).filter(([, value]) => !value).map(([key]) => key)
return missingFields.length ? { identity: null, reason: `本轮订舱身份字段缺失:${missingFields.join(',')}`, missingFields }
: { identity, reason: null, missingFields: [] }
}
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 = {}) {
export function truckAppointmentPayload(context, suffix, identity, version = 0, overrides = {}) {
const supplier = context.createdResources?.truckSupplier || {}
const supplierId = id(supplier.supplierId)
const supplierName = id(supplier.supplierName)
return {
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,
billOfLadingNo: Object.hasOwn(associations, 'billOfLadingNo')
? associations.billOfLadingNo : `BL-TRUCK-${suffix}`.slice(0, 128),
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,
palletCount: 1, poNo: `PO-${suffix}`, plannedLoadingAt: '2026-09-02T08:00:00', transitMinutes: 120,
payableAmount: 100, receivableAmount: 150, currency: 'RMB', remark: context.marker, expectedVersion: version,
outboundOrderId: null, oceanBookingId: identity.oceanBookingId,
containerBookingId: identity.containerBookingId, loadingPlanId: identity.loadingPlanId || null,
warehouseId: identity.warehouseId || null, carrierId: id(supplier.supplierId) || null,
carrierName: supplier.supplierName || null, billOfLadingNo: identity.billOfLadingNo || null,
fleetContact: '车队联系人', fleetPhone: '13800000004', driverName: '司机验收', driverPhone: '13800000005',
vehicleNo: `粤B-${suffix.slice(-6)}`, storeName: `装柜点-${suffix}`, storeContact: '仓库联系人',
storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000',
packageCount: 0, grossWeightKg: 0, volumeCbm: 0, palletCount: 0, poNo: `PO-${suffix}`,
plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0, receivableAmount: 0,
currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides,
}
}
async function ensureTruckSupplier(context, suffix) {
const response = await request(context, 'POST', SUPPLIER_BASE, {
name: `拖车供应商-${suffix}`.slice(0, 128), category: 'TRUCKING',
region: '深圳', legalRepresentative: '验收法人', contactName: '拖车联系人',
phone: '13800000000', email: 'trucking-acceptance@example.com', address: '深圳市验收地址',
businessLicenseNo: `TRUCK${suffix}`.slice(0, 64), licenseFileId: null,
status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker,
})
const supplierValue = data(response)
const supplierId = id(typeof supplierValue === 'object' ? supplierValue?.id : supplierValue)
let supplier = typeof supplierValue === 'object' ? supplierValue : null
if (supplierId) {
const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`)
supplier = data(detailResponse)
}
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 supplierId && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE'
? context.createdResources.truckSupplier : null
function currentIdentity(context) {
const fixture = [context.createdResources?.phase3ContainerLoading, context.createdResources?.phase3Loading]
.find((item) => item?.marker === context.marker && id(item.oceanBookingId) && id(item.containerBookingId))
if (!fixture) return null
return { oceanBookingId: id(fixture.oceanBookingId), containerBookingId: id(fixture.containerBookingId),
loadingPlanId: id(fixture.loadingPlanId) || null, warehouseId: id(fixture.warehouseId) || null,
billOfLadingNo: fixture.billOfLadingNo || null }
}
export async function run(context) {
const suffix = context.marker.replace(/\D/g, '').slice(-10)
const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`)
if (missing(list) || list.http === 0) { context.addStep(MODULE, '拖车预约服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`, OPTIONS); return }
const page = context.apiSuccess(MODULE, '拖车预约分页 keyword 查询', list, '', OPTIONS)
context.assert(MODULE, '拖车预约分页结构正确', list, Array.isArray(page?.records), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS)
for (const status of ['DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED']) {
const response = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const result = data(response)
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)
}
const identity = currentIdentity(context)
const supplier = context.createdResources?.truckSupplier
context.assert(MODULE, '本轮拖车前置仅依赖已放舱订舱、真实柜和拖车供应商', null,
Boolean(identity?.oceanBookingId && identity?.containerBookingId && supplier?.supplierId),
`oceanBookingId=${identity?.oceanBookingId || 'missing'}, containerBookingId=${identity?.containerBookingId || 'missing'}, carrierId=${id(supplier?.supplierId) || 'missing'}`, OPTIONS)
if (!identity?.oceanBookingId || !identity?.containerBookingId || !supplier?.supplierId) return
const customerIdentity = selectIndependentTruckCustomer(context)
if (!customerIdentity) {
context.addStep(MODULE, '取得本轮独立拖车客户身份', null, false,
'缺少同 marker 的 customerId;未借用历史客户或订舱', 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 suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-10)
const base = truckAppointmentPayload(context, suffix, identity)
for (const [label, payload] of [
['缺少 oceanBookingId 拒绝创建', { ...base, oceanBookingId: null }],
['缺少 containerBookingId 拒绝创建', { ...base, containerBookingId: null }],
['缺少拖车供应商拒绝创建', { ...base, carrierId: null }],
['提单号与订舱不一致拒绝创建', { ...base, billOfLadingNo: `WRONG-${suffix}` }],
]) {
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 response = await request(context, 'POST', BASE, payload)
context.addStep(MODULE, label, response, failure(response), `HTTP=${response.http}, code=${codeOf(response)}`, OPTIONS)
}
const missingBillCreate = await request(context, 'POST', BASE,
truckAppointmentPayload(context, `${suffix}B`, customerIdentity, 0, { billOfLadingNo: null }))
const missingBill = data(missingBillCreate)
if (missingBill?.id) {
const missingBillSubmit = await request(context, 'POST', `${BASE}/${id(missingBill.id)}/submit`,
{ expectedVersion: Number(missingBill.version), reason: context.marker },
{ 'Idempotency-Key': `${context.marker}-truck-no-bill-submit`.slice(0, 128) })
const submitted = data(missingBillSubmit)
const rejectedDispatch = await request(context, 'POST', `${BASE}/${id(missingBill.id)}/dispatch`,
{ expectedVersion: Number(submitted?.version), reason: context.marker },
{ 'Idempotency-Key': `${context.marker}-truck-no-bill-dispatch`.slice(0, 128) })
context.addStep(MODULE, '拖车 DRAFT 可无提单但 DISPATCH 缺提单号与文件明确拒绝', rejectedDispatch,
failure(rejectedDispatch), `HTTP=${rejectedDispatch.http}, code=${codeOf(rejectedDispatch)}`, 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)
}
}
const createResponse = await request(context, 'POST', BASE, basePayload); const appointment = context.apiSuccess(MODULE, '创建无订舱柜次计划关联的独立拖车预约', createResponse, '', OPTIONS)
const createdResponse = await request(context, 'POST', BASE, base)
const appointment = context.apiSuccess(MODULE, '创建订舱真实柜拖车预约', createdResponse, '', OPTIONS)
const appointmentId = id(appointment?.id)
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)
context.assert(MODULE, '响应不含客户字段且冻结订舱柜号关系', createdResponse,
Boolean(appointmentId) && appointment?.status === 'DRAFT' && !Object.hasOwn(appointment, 'customerId')
&& !Object.hasOwn(appointment, 'customerName') && id(appointment?.oceanBookingId) === identity.oceanBookingId
&& id(appointment?.containerBookingId) === identity.containerBookingId,
`appointmentId=${appointmentId || 'missing'}`, OPTIONS)
if (!appointmentId) return
context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'COMPLETED' }
context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED' }
const path = `${BASE}/${appointmentId}`
const upload = await uploadAttachment(context, appointmentId); const attachment = context.apiSuccess(MODULE, '上传本轮拖车预约图片附件', upload, `fileName=${context.marker}-truck.png`, OPTIONS)
const attachmentId = id(attachment?.id)
context.assert(MODULE, '提单附件归属、类型和哈希元数据完整', upload, Boolean(attachmentId) && id(attachment?.appointmentId) === appointmentId && attachment?.attachmentType === 'BILL_OF_LADING' && Boolean(attachment?.fileId) && Boolean(attachment?.fileHash), `attachmentId=${attachmentId || 'missing'}, fileId=${attachment?.fileId || 'missing'}`, OPTIONS)
const attachments = await request(context, 'GET', `${path}/attachments`); const attachmentList = context.apiSuccess(MODULE, '查询拖车预约附件列表', attachments, '', OPTIONS)
context.assert(MODULE, '附件列表包含本轮上传引用', attachments, Array.isArray(attachmentList) && attachmentList.some((item) => id(item.id) === attachmentId), `attachments=${attachmentList?.length ?? 'n/a'}`, OPTIONS)
const forged = await request(context, 'POST', `${path}/attachments`, { fileId: '999999999999999999', attachmentType: 'BILL_OF_LADING', fileName: `${context.marker}-forged.txt`, fileHash: 'forged' })
context.addStep(MODULE, '伪造附件 fileId 安全拒绝', forged, failure(forged), `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
context.assert(MODULE, '派车前保留有效 BILL_OF_LADING 提单附件', attachments,
Boolean(attachmentId) && attachmentList.some((item) => id(item.id) === attachmentId && item.attachmentType === 'BILL_OF_LADING'),
`attachmentId=${attachmentId || 'missing'}`, OPTIONS)
let version = Number(appointment.version)
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)
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)
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)
const submitKey = `${context.marker}-submit`
const submitBody = { expectedVersion: version, reason: '并发幂等提交' }
const first = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey })
const firstData = context.apiSuccess(MODULE, '提交拖车预约', first, '', OPTIONS)
const replay = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey })
const replayData = context.apiSuccess(MODULE, '同键同载荷重放', replay, '', OPTIONS)
context.assert(MODULE, '提交幂等重放零漂移', replay,
id(firstData?.id) === id(replayData?.id) && Number(firstData?.version) === Number(replayData?.version),
`firstVersion=${firstData?.version}, replayVersion=${replayData?.version}`, OPTIONS)
version = Number(firstData?.version)
const conflict = await request(context, 'POST', `${path}/submit`, { expectedVersion: version, reason: '异载荷' },
{ 'Idempotency-Key': submitKey })
context.addStep(MODULE, '同键异载荷拒绝', conflict, failure(conflict), `code=${codeOf(conflict)}`, OPTIONS)
const dispatch = await request(context, 'POST', `${path}/dispatch`, { expectedVersion: version, reason: '派车' },
{ 'Idempotency-Key': `${context.marker}-dispatch` })
const dispatched = context.apiSuccess(MODULE, '有效提单后派车', dispatch, '', OPTIONS)
context.assert(MODULE, '派车状态与稳定字段完整', dispatch,
dispatched?.status === 'DISPATCHED' && truckAppointmentFieldGaps(dispatched).length === 0,
`status=${dispatched?.status || 'missing'}`, OPTIONS)
}