refactor(firstleg): keep trucking payable only

This commit is contained in:
2026-09-01 10:38:15 +08:00
parent 6a0f51f136
commit 179a20fc38
17 changed files with 74 additions and 25 deletions
@@ -122,6 +122,8 @@ export function truckAppointmentFieldGaps(record) {
status: isNonBlank,
carrierId: isPositiveId,
carrierName: isNonBlank,
payableAmount: isNonNegativeNumber,
currency: (value) => value === 'RMB' || value === 'USD',
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', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachmentCount'],
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', '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: '宁波仓', 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: '拖车商', payableAmount: 0, currency: 'RMB', 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'],
]
@@ -95,7 +95,7 @@ function truckPayload(context, oceanBooking, carrier, index) {
storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000',
packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, palletCount: 1, poNo: `PO-${value}`,
plannedLoadingAt: '2026-09-16T08:00:00', transitMinutes: 120, payableAmount: 100,
receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 }
currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 }
}
async function createTruck(context, oceanBooking, index) {
@@ -98,7 +98,7 @@ export async function run(context) {
driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人',
storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000',
packageCount: 1, grossWeightKg: 1, volumeCbm: 0.1, palletCount: 0, poNo: `FLOW-PO-${suffix}`,
plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, receivableAmount: 2,
plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1,
currency: 'RMB', remark: context.marker, expectedVersion: 0,
}
if (linkContainer) {
@@ -58,6 +58,7 @@ test('payload requires booking container and carrier but never emits customer fi
{ oceanBookingId: '91', containerBookingId: '81', carrierId: '501', carrierName: '正式拖车商' })
assert.equal(Object.hasOwn(payload, 'customerId'), false)
assert.equal(Object.hasOwn(payload, 'customerName'), false)
assert.equal(Object.hasOwn(payload, 'receivableAmount'), false)
})
test('backend DTO entity mapper service and OpenAPI have no truck customer protocol', () => {
@@ -91,13 +92,15 @@ test('HTTP regression is self-contained and covers no-customer and idempotency m
for (const label of ['自包含创建 DRAFT 订舱', '上传当前有效 BILL_OF_LADING 文件', '放舱自包含订舱',
'绑定本轮真实 ISO 柜号', '拖车请求不含客户字段', '同柜第二条非终态预约拒绝且零漂移',
'同键同载荷重放', '同键重放版本零漂移', '同键异载荷拒绝', '错误订舱拒绝创建',
'错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建',
'错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建', '非法拖车币种拒绝创建',
'RMB应付费用响应无客户及应收字段', '编辑为USD应付费用', 'USD应付费用保存且无应收字段',
'派车命令返回状态身份与版本正确', '派车后回查拖车完整详情',
'详情字段完整且订舱柜号身份无客户字段漂移']) assert.match(source, new RegExp(label))
assert.match(source, /const detailResponse = await request\(context, 'GET', path\); const detail = data\(detailResponse\)/)
assert.match(source, /const detailGaps = truckAppointmentFieldGaps\(detail\)/)
assert.doesNotMatch(source, /truckAppointmentFieldGaps\(dispatched\)/)
assert.doesNotMatch(source, /phase3ContainerLoading|phase3Loading/)
assert.doesNotMatch(source, /receivableAmount\s*:/)
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
assert.match(entry, /\['truck-appointments', \['merchant-qualification-fixture'\]\]/)
assert.doesNotMatch(entry, /\['truck-appointments', \[[^\]]*phase3-loading/)
@@ -55,7 +55,7 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0,
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,
plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0,
currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides }
}
@@ -172,6 +172,7 @@ export async function run(context) {
['错误柜号归属拒绝创建', { ...base, containerBookingId: (BigInt(identity.containerBookingId) + 1n).toString() }],
['错误提单拒绝创建', { ...base, billOfLadingNo: `WRONG-${identity.suffix}` }],
['错误承运商拒绝创建', { ...base, carrierId: (BigInt(identity.truckSupplier.supplierId) + 1n).toString() }],
['非法拖车币种拒绝创建', { ...base, currency: 'CNY' }],
]) {
const response = await request(context, 'POST', TRUCKS, payload)
context.addStep(MODULE, label, response, failure(response), `code=${codeOf(response)}`, OPTIONS)
@@ -179,12 +180,21 @@ export async function run(context) {
const createdResponse = await request(context, 'POST', TRUCKS, base)
const appointment = context.apiSuccess(MODULE, '创建合法拖车预约', createdResponse, '', OPTIONS)
const appointmentId = id(appointment?.id); if (!appointmentId) return
context.assert(MODULE, '拖车响应不含客户字段', createdResponse,
!Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS)
context.assert(MODULE, 'RMB应付费用响应无客户及应收字段', createdResponse,
appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0
&& !Object.hasOwn(appointment, 'receivableAmount')
&& !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS)
const path = `${TRUCKS}/${appointmentId}`
const usdResponse = await request(context, 'PUT', path,
{ ...base, currency: 'USD', payableAmount: 12.34, expectedVersion: Number(appointment.version) })
const usdAppointment = context.apiSuccess(MODULE, '编辑为USD应付费用', usdResponse, '', OPTIONS)
context.assert(MODULE, 'USD应付费用保存且无应收字段', usdResponse,
usdAppointment?.currency === 'USD' && Number(usdAppointment?.payableAmount) === 12.34
&& !Object.hasOwn(usdAppointment || {}, 'receivableAmount'), '', OPTIONS)
const duplicate = await request(context, 'POST', TRUCKS, base)
context.addStep(MODULE, '同柜第二条非终态预约拒绝且零漂移', duplicate, failure(duplicate), `code=${codeOf(duplicate)}`, OPTIONS)
const path = `${TRUCKS}/${appointmentId}`; const submitKey = `${context.marker}-truck-submit`.slice(0, 128)
const submitBody = { expectedVersion: Number(appointment.version), reason: '幂等提交' }
const submitKey = `${context.marker}-truck-submit`.slice(0, 128)
const submitBody = { expectedVersion: Number(usdAppointment?.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 })
@@ -206,9 +216,11 @@ export async function run(context) {
context.assert(MODULE, '详情字段完整且订舱柜号身份无客户字段漂移', detailResponse,
detail?.status === 'DISPATCHED' && detailGaps.length === 0
&& !Object.hasOwn(detail || {}, 'customerId') && !Object.hasOwn(detail || {}, 'customerName')
&& !Object.hasOwn(detail || {}, 'receivableAmount')
&& detail?.currency === 'USD' && Number(detail?.payableAmount) === 12.34
&& id(detail?.oceanBookingId) === identity.oceanBookingId
&& id(detail?.containerBookingId) === identity.containerBookingId,
`missing=${detailGaps.join(',') || 'none'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS)
`missing=${detailGaps.join(',') || 'none'}, currency=${detail?.currency || 'missing'}, payableAmount=${detail?.payableAmount ?? 'missing'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS)
context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED',
oceanBookingId: identity.oceanBookingId, containerBookingId: identity.containerBookingId }
}