This commit is contained in:
2026-08-15 11:05:58 +08:00
parent c4282020b5
commit 557ab75319
50 changed files with 591 additions and 394 deletions
@@ -46,7 +46,7 @@ test('writes carry marker idempotency and include replay and stale-version confl
assert.match(source, /错误 sourceVersion 调整产生并发版本冲突/)
})
test('uses existing ACTIVE partner and never creates one', () => {
assert.match(source, /\/api\/platform\/v1\/partners\?page=1&size=200&status=ACTIVE/)
assert.doesNotMatch(source, /request\(context, 'POST', '\/api\/platform\/v1\/partners'/)
test('uses existing ACTIVE Warehouse supplier and never creates one', () => {
assert.match(source, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE&category=BOOKING/)
assert.doesNotMatch(source, /\/api\/platform\/v1\/partners/)
})
@@ -34,13 +34,14 @@ async function loadFeeItem(context, direction) {
const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const data = context.apiSuccess(MODULE, `读取 ${direction} ACTIVE 费用项目`, response, '', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
? item.warehouseReceivable === true : item.warehousePayable === true)) || null
? item.warehouseReceivable === true
: item.warehousePayable === true && /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`))) || null
}
async function loadActivePartner(context) {
const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE')
const data = context.apiSuccess(MODULE, '读取现有 ACTIVE 往来单位', response, '', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE') || null
const response = await context.request('GET', '/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=BOOKING')
const data = context.apiSuccess(MODULE, '读取 Warehouse ACTIVE 订舱供应商', response, 'category=BOOKING', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE' && item?.category === 'BOOKING') || null
}
async function createFee(context, resource, item, spec, partner) {
@@ -50,7 +51,7 @@ async function createFee(context, resource, item, spec, partner) {
feeItemId: Number(item.id), direction: spec.direction, businessDate: spec.businessDate,
unitPrice: spec.amount, currency: 'CNY', amount: spec.amount, settlementIncluded: true,
partnerId: spec.direction === 'PAYABLE' ? String(partner.id) : null,
partnerName: spec.direction === 'PAYABLE' ? (partner.partnerName || partner.name || null) : null,
partnerName: spec.direction === 'PAYABLE' ? (partner.name || null) : null,
remark: `${FIXED_MARKER}-${spec.name}`,
}
const response = await request(context, 'POST', path, body, `create-${spec.name}`)
@@ -46,7 +46,8 @@ test('扩展财务验收输出可回传资源与柜次部署阻断', () => {
assert.match(source, /containerPayableDeploymentBlocker/)
})
test('按柜 PAYABLE 费用携带有效往来单位', () => {
test('按柜 PAYABLE 费用携带 Warehouse 供应商', () => {
assert.match(fixtureSource, /partnerId: id\(supplier\.id\)/)
assert.match(fixtureSource, /partnerName: supplier\.partnerName \|\| null/)
assert.match(fixtureSource, /partnerName: supplier\.name \|\| null/)
assert.match(fixtureSource, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE&category=BOOKING/)
})
@@ -49,16 +49,17 @@ async function runOwnedFixture(context) {
}
async function loadPayableInputs(context) {
const [feeItemsResponse, partnersResponse] = await Promise.all([
const [feeItemsResponse, suppliersResponse] = await Promise.all([
context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200'),
context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE'),
context.request('GET', '/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=BOOKING'),
])
const feeItems = rows(data(feeItemsResponse))
const partners = rows(data(partnersResponse))
const suppliers = rows(data(suppliersResponse))
return {
feeItem: feeItems.find((item) => item?.status === 'ACTIVE' && item?.warehousePayable === true) || null,
supplier: partners.find((item) => item?.status === 'ACTIVE' && item?.id) || null,
response: !ok(feeItemsResponse) ? feeItemsResponse : partnersResponse,
feeItem: feeItems.find((item) => item?.status === 'ACTIVE' && item?.warehousePayable === true
&& /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`)) || null,
supplier: suppliers.find((item) => item?.status === 'ACTIVE' && item?.category === 'BOOKING' && item?.id) || null,
response: !ok(feeItemsResponse) ? feeItemsResponse : suppliersResponse,
}
}
@@ -68,7 +69,7 @@ async function createWaybillPayable(context, fixture, order, input, index) {
const path = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(fixture.customerId)}`
const body = { feeItemId: Number(input.feeItem.id), direction: 'PAYABLE', businessDate: dateOffset(-3 - index),
unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true,
partnerId: id(input.supplier.id), partnerName: input.supplier.partnerName || null,
partnerId: id(input.supplier.id), partnerName: input.supplier.name || null,
remark: `${context.marker}-WAYBILL-PAYABLE-${index + 1}` }
const response = await write(context, 'POST', path, body, `waybill-payable-${index + 1}`)
const detail = context.apiSuccess(MODULE, `创建第 ${index + 1} 条独占运单 PAYABLE 来源`, response,
@@ -82,7 +83,7 @@ async function createWaybillPayable(context, fixture, order, input, index) {
return { sourceFeeId, inboundId, orderId: id(order.orderId),
waybillId: id(order.waybillId ?? order.shipmentId ?? order.orderNo),
businessDate: body.businessDate, amount: body.amount,
supplierId: id(input.supplier.id), supplierName: input.supplier.partnerName || null }
supplierId: id(input.supplier.id), supplierName: input.supplier.name || null }
}
async function resolvePoolSources(context, fixture, created) {
@@ -71,7 +71,7 @@ function hookSet({ missing = false } = {}) {
orderId: `O-${suffix}`, orderNo: `NO-${suffix}`, waybillId: suffix === 'AUTO1' ? 'WB-1' : null, inboundId: `I-${suffix}` }),
measureOrder: async (context, resource) => ({ ...resource, measurementId: `M-${resource.orderId}`, actualWeightKg: 12.5, totalVolumeCbm: 0.072 }),
loadFeeItem: async (context, direction) => ({ id: direction === 'PAYABLE' ? '902' : '901', status: 'ACTIVE' }),
loadSupplier: async () => missing ? null : ({ id: 'PARTNER-1', partnerName: '往来单位一', status: 'ACTIVE' }),
loadSupplier: async () => missing ? null : ({ id: 'SUPPLIER-1', name: '订舱供应商一', category: 'BOOKING', status: 'ACTIVE' }),
uploadVoucher: async () => missing ? null : 'VOUCHER-1',
verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }),
loadPublishedAutomaticFee: async (context, resource) => {
@@ -104,8 +104,8 @@ test('run behavior emits consumer shape, both billing bases, distinct dates, sup
assert.deepEqual(new Set(fixture.feeSources.map((item) => item.billingBasis).filter((value) => ['WEIGHT_KG', 'VOLUME_CBM'].includes(value))),
new Set(['WEIGHT_KG', 'VOLUME_CBM']))
assert.ok(new Set(fixture.feeSources.map((item) => item.businessDate)).size >= 2)
assert.equal(fixture.supplier.supplierId, 'PARTNER-1')
assert.equal(fixture.supplier.supplierName, '往来单位一')
assert.equal(fixture.supplier.supplierId, 'SUPPLIER-1')
assert.equal(fixture.supplier.supplierName, '订舱供应商一')
assert.equal(fixture.voucherFileId, 'VOUCHER-1')
assert.ok(Array.isArray(fixture.allocations))
assert.ok(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').length >= 4)
@@ -143,7 +143,7 @@ test('run behavior preserves null IDs and deployment blockers when supplier, vou
assert.deepEqual(fixture.container, { containerBookingId: null, containerNo: null })
assert.deepEqual(fixture.allocations, [])
assert.ok(fixture.deploymentBlocker.length >= 3)
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('往来单位')))
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('供应商')))
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('voucherFileId')))
})
@@ -105,7 +105,8 @@ async function loadFeeItem(context, direction) {
const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const list = context.apiSuccess(MODULE, `查询${direction}人工费用项目`, response, '', OPTIONS)
return rows(list).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
? item.warehouseReceivable === true : item.warehousePayable === true)) || null
? item.warehouseReceivable === true
: item.warehousePayable === true && /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`))) || null
}
async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate, partner = null) {
@@ -114,7 +115,7 @@ async function createManualFee(context, resource, feeItem, direction, amount, ba
const listPath = `${basePath}?accountId=${encodeURIComponent(resource.tenantId)}`
const body = { feeItemId: Number(feeItem.id), direction, businessDate,
unitPrice: amount, currency: 'CNY', amount, settlementIncluded: true,
partnerId: partner?.id ? String(partner.id) : null, partnerName: partner?.partnerName || null,
partnerId: partner?.id ? String(partner.id) : null, partnerName: partner?.name || null,
remark: `${context.marker}-${basis}` }
const response = await request(context, 'POST', listPath, body)
const detail = context.apiSuccess(MODULE, `${basis}人工费用明细`, response, `inboundId=${resource.inboundId}`, OPTIONS)
@@ -201,9 +202,9 @@ async function loadPublishedAutomaticFee(context, resource) {
async function loadSupplier(context) {
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context)
const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE')
const page = context.apiSuccess(MODULE, '查询真实供应商往来单位', response, '', OPTIONS)
return rows(page).find((item) => item?.status === 'ACTIVE') || null
const response = await context.request('GET', '/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=BOOKING')
const page = context.apiSuccess(MODULE, '查询 Warehouse ACTIVE 订舱供应商', response, 'category=BOOKING', OPTIONS)
return rows(page).find((item) => item?.status === 'ACTIVE' && item?.category === 'BOOKING') || null
}
async function uploadVoucher(context, customerId) {
@@ -352,7 +353,7 @@ export async function run(context) {
PAYABLE: await loadFeeItem(context, 'PAYABLE'),
}
const supplier = await loadSupplier(context)
if (!supplier?.id) blocker(context, '取得真实 PAYABLE 往来单位', '未找到 ACTIVE 往来单位;不能伪造 supplierId/partnerId')
if (!supplier?.id) blocker(context, '取得 Warehouse PAYABLE 供应商', '未找到 ACTIVE BOOKING 供应商;不能伪造 supplierId/partnerId')
for (const direction of ['RECEIVABLE', 'PAYABLE']) {
if (!feeItems[direction]) context.addCoverageGap(MODULE, `missing-${direction.toLowerCase()}-fee-item`, `${direction} 人工费用 fixture`,
`没有找到带 warehouse${direction === 'RECEIVABLE' ? 'Receivable' : 'Payable'}=true 的 ACTIVE 费用项目,禁止猜测费用项目 ID`, 'missing-deployed-fee-item')
@@ -381,7 +382,7 @@ export async function run(context) {
for (const [index, manualResource] of manualResources.entries()) {
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10,
'MANUAL_ORDER_FEE', dateOffset(-3 - index), supplier)
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null })
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.name || null })
}
}
@@ -395,7 +396,7 @@ export async function run(context) {
const path = `${basePath}?accountId=${encodeURIComponent(container.tenantId)}`
const body = { feeItemId: Number(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2),
amount: 300, currency: 'CNY', settlementIncluded: true, partnerId: id(supplier.id),
partnerName: supplier.partnerName || null, remark: `${context.marker}-CONTAINER_SHARED` }
partnerName: supplier.name || null, remark: `${context.marker}-CONTAINER_SHARED` }
const response = await request(context, 'POST', path, body)
if (response?.http === 404) {
blocker(context, '按柜费用来源接口已部署并可路由',
@@ -455,7 +456,7 @@ export async function run(context) {
feeSources: fixtures,
container: containerFixture ? { containerBookingId: containerFixture.containerBookingId,
containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null },
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null } : null,
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.name || null } : null,
voucherFileId, pricingEvidence,
deploymentBlocker: context.steps.slice(start).filter((step) => step.deploymentBlocker).map((step) => step.evidence),
}
@@ -22,6 +22,9 @@ test('retains all clickable container, loading-list and PDA workflow states thro
const transportIndex = source.indexOf('`${CONTAINERS}/${containerId}/transport`')
assert.ok(transportIndex >= 0 && pushIndex > transportIndex)
assert.match(source, /transported\?\.cabinetNo !== container\?\.cabinetNo/)
assert.match(source, /shippingCompanyId: null, shippingCompanyName: null/)
assert.match(source, /bookingAgentId: null, bookingAgentName: null/)
assert.match(source, /billOfLadingNo: confirmed\?\.oceanBooking\?\.billOfLadingNo/)
const batch = source.slice(source.indexOf('async function pickAll'), source.indexOf('async function complete'))
assert.doesNotMatch(batch, /containerNo:/)
})
@@ -102,8 +102,8 @@ async function createConfirmedContainer(context, source, label) {
customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户',
channelCode: summary.channelCode, warehouseId: source.warehouseId,
warehouseName: summary.warehouseName, billOfLadingNo: `BL-${value}`.slice(0, 128),
shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`,
bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX',
shippingCompanyId: null, shippingCompanyName: null,
bookingAgentId: null, bookingAgentName: null, departurePortCode: 'CNSZX',
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
@@ -140,9 +140,11 @@ async function createConfirmedContainer(context, source, label) {
throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`)
}
const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(confirmed?.oceanBooking?.version), billOfLadingNo: `BL-${value}`.slice(0, 128),
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
expectedVersion: Number(confirmed?.oceanBooking?.version),
billOfLadingNo: confirmed?.oceanBooking?.billOfLadingNo,
vesselName: confirmed?.oceanBooking?.vesselName, voyageNo: confirmed?.oceanBooking?.voyageNo,
estimatedSailingAt: confirmed?.oceanBooking?.estimatedSailingAt,
estimatedArrivalAt: confirmed?.oceanBooking?.estimatedArrivalAt,
reason: context.marker,
}, `${label}-booking-release`)
context.apiSuccess(MODULE, `${label} 放舱已关联真实订舱`, releasedResponse, '', OPTIONS)
@@ -28,5 +28,8 @@ test('retains only operational ocean booking states from direct CONFIRMED regist
assert.match(source, /warehouseId: oceanBooking\.warehouseId/)
assert.doesNotMatch(source, /customerId: ['"]10001['"]/)
assert.doesNotMatch(source, /warehouseId: ['"]193000000000200001['"]/)
assert.match(source, /carrierId: null, carrierName: null/)
assert.doesNotMatch(source, /人工验收车队-/)
assert.match(source, /billOfLadingNo: value\.oceanBooking\.billOfLadingNo/)
assert.doesNotMatch(source, /DELETE/)
})
@@ -26,8 +26,9 @@ function oceanPayload(context, template, index) {
billOfLadingNo: `BL-${suffix(context, index)}`, vesselName: `人工轮-${suffix(context, index)}`,
voyageNo: `V${suffix(context, index)}`,
shippingCompanyId: source.shippingCompanyId,
shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId,
bookingAgentName: source.bookingAgentName,
shippingCompanyName: source.shippingCompanyId ? source.shippingCompanyName : null,
bookingAgentId: source.bookingAgentId,
bookingAgentName: source.bookingAgentId ? source.bookingAgentName : null,
departurePortCode: source.departurePortCode, departurePortName: source.departurePortName,
destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName,
placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm,
@@ -68,9 +69,9 @@ async function buildOcean(context, template, status, index) {
vesselName: `变更轮-${suffix(context, index)}`, voyageNo: `CV${suffix(context, index)}`,
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' })
if (status === 'RELEASED') return oceanAction(context, value, 'release', {
billOfLadingNo: `REL-BL-${suffix(context, index)}`, vesselName: `放舱轮-${suffix(context, index)}`,
voyageNo: `RV${suffix(context, index)}`, estimatedSailingAt: '2026-09-16T08:00:00',
estimatedArrivalAt: '2026-10-16T08:00:00' })
billOfLadingNo: value.oceanBooking.billOfLadingNo, vesselName: value.oceanBooking.vesselName,
voyageNo: value.oceanBooking.voyageNo, estimatedSailingAt: value.oceanBooking.estimatedSailingAt,
estimatedArrivalAt: value.oceanBooking.estimatedArrivalAt })
throw new Error(`unsupported ocean status ${status}`)
}
@@ -80,7 +81,7 @@ function truckPayload(context, oceanBooking, index) {
containerBookingId: null, loadingPlanId: null,
warehouseId: oceanBooking.warehouseId, customerId: oceanBooking.customerId,
customerName: oceanBooking.customerName,
carrierName: `人工验收车队-${value}`, fleetContact: '人工验收联系人', fleetPhone: '13800000004',
carrierId: null, carrierName: null, fleetContact: '人工验收联系人', fleetPhone: '13800000004',
driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`,
storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006',
storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000',
@@ -40,6 +40,10 @@ test('shared booking data omits server-owned channelName and includes offline-co
assert.ok(draft[key], `${key} is required`)
}
assert.equal(draft.channelName, undefined)
assert.equal(draft.shippingCompanyId, null)
assert.equal(draft.shippingCompanyName, null)
assert.equal(draft.bookingAgentId, null)
assert.equal(draft.bookingAgentName, null)
assert.equal(draft.serviceTerm, 'CY-CY')
assert.equal(draft.freightTerm, 'NINGBO')
assert.equal(draft.containerRequirements[0].capacityCbm, 68)
@@ -56,6 +60,15 @@ test('shared booking data omits server-owned channelName and includes offline-co
}
})
test('shared booking data never sends supplier names without valid supplier ids', () => {
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-SUPPLIER' }, 'supplier', {
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
}, { shippingCompanyName: '无来源船公司', bookingAgentName: '无来源代理' })
assert.equal(draft.shippingCompanyName, null)
assert.equal(draft.bookingAgentName, null)
})
test('shared booking draft can omit customer identity without inventing placeholders', () => {
const draft = oceanBookingDraftWithoutCustomer({ marker: 'ADMIN-RUN-OPTIONAL' }, 'optional', {
customerId: '21', customerName: '不应写入', channelCode: 'SEA-US', channelName: '美西海运',
@@ -57,8 +57,10 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
return {
...writableMaster,
billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`,
shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`,
bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`,
shippingCompanyId: labels.shippingCompanyId ?? null,
shippingCompanyName: labels.shippingCompanyId ? labels.shippingCompanyName ?? null : null,
bookingAgentId: labels.bookingAgentId ?? null,
bookingAgentName: labels.bookingAgentId ? labels.bookingAgentName ?? null : null,
vesselName: labels.vesselName || `验收船-${suffix}`, voyageNo: labels.voyageNo || `V-${suffix}`,
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
@@ -103,10 +103,12 @@ test('HTTP fixtures cover unassigned CONFIRMED persistence and assigned customer
})
test('independent ocean booking release reuses the server-frozen transport snapshot', () => {
assert.match(bookingModule, /estimatedSailingAt: detail\.oceanBooking\.estimatedSailingAt/)
assert.match(bookingModule, /estimatedArrivalAt: detail\.oceanBooking\.estimatedArrivalAt/)
assert.match(bookingModule, /const releaseSnapshotResponse = await request\(context, 'GET', path\)/)
assert.match(bookingModule, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(bookingModule, /const frozenTransportValid = transportFields\.every/)
assert.match(bookingModule, /if \(!frozenTransportValid\) return/)
assert.match(bookingModule, /const frozenTransportMatches = frozenTransportValid/)
assert.match(bookingModule, /if \(!frozenTransportMatches\) return/)
assert.match(bookingModule, /expectedVersion: version, \.\.\.frozenTransport, reason: context\.marker/)
assert.doesNotMatch(bookingModule, /expectedVersion: version, billOfLadingNo: `BL-\$\{suffix\}`/)
assert.doesNotMatch(bookingModule, /estimatedSailingAt: '2026-09-01T08:00:00'/)
@@ -141,7 +141,12 @@ export async function run(context) {
const path = `${BASE}/${bookingId}`
let version = Number(detail.oceanBooking.version)
const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-ocean-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d }
const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` })
const changed = await request(context, 'POST', `${path}/change-request`, {
expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`,
estimatedSailingAt: detail.oceanBooking.estimatedSailingAt,
estimatedArrivalAt: detail.oceanBooking.estimatedArrivalAt,
reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-change` })
context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version)
const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change')
const releaseSnapshotResponse = await request(context, 'GET', path)
@@ -149,10 +154,11 @@ export async function run(context) {
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, releaseSnapshot?.oceanBooking?.[field]]))
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
context.assert(MODULE, '改船后回读完整冻结运输快照供放舱', releaseSnapshot, frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(changeConfirmed?.oceanBooking?.[field])),
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(changeConfirmed?.oceanBooking?.[field]))
context.assert(MODULE, '改船后回读完整冻结运输快照供放舱', releaseSnapshotResponse, frozenTransportMatches,
`bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS)
if (!frozenTransportValid) return
if (!frozenTransportMatches) return
const allocatedContainerBookingId = ''
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
@@ -116,8 +116,8 @@ export async function run(context) {
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
channelCode, warehouseId, warehouseName,
billOfLadingNo: `BL-${suffix}`.slice(0, 128),
shippingCompanyId: null, shippingCompanyName: `验收船公司-${suffix}`,
bookingAgentId: null, bookingAgentName: `验收代理-${suffix}`,
shippingCompanyId: null, shippingCompanyName: null,
bookingAgentId: null, bookingAgentName: null,
vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳',
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
@@ -240,10 +240,19 @@ export async function run(context) {
const bookingNo = id(bookingCreated?.oceanBooking?.bookingNo)
if (!bookingId) return
const confirmedBooking = bookingCreated
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, confirmedBooking?.oceanBooking?.[field]]))
const frozenTransportMatches = transportFields.every((field) => frozenTransport[field] != null
&& String(frozenTransport[field]).trim() !== ''
&& String(frozenTransport[field]) === String(bookingDraft[field]))
context.assert(MODULE, '一次登记直接返回 CONFIRMED 且尚未占用模拟柜', bookingCreate,
confirmedBooking?.oceanBooking?.status === 'CONFIRMED'
&& Array.isArray(confirmedBooking?.containerAllocations) && confirmedBooking.containerAllocations.length === 0,
`status=${confirmedBooking?.oceanBooking?.status || 'missing'}, allocations=${confirmedBooking?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
context.assert(MODULE, '已确认订舱回显完整且一致的冻结运输快照', bookingCreate,
frozenTransportMatches,
transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', '), OPTIONS)
if (!frozenTransportMatches) return
const confirmedListResponse = await context.request('GET',
`/api/platform/v1/ocean-bookings?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`)
const confirmedList = context.apiSuccess(MODULE, '按真实订舱号查询正式列表', confirmedListResponse, '', OPTIONS)
@@ -362,19 +371,16 @@ export async function run(context) {
`HTTP=${confirmedTaskResponse.http}, code=${codeOf(confirmedTaskResponse)}, task=${id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
if (!confirmedTaskAbsent) return
const sailingAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
const arrivalAt = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString()
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(confirmedBooking?.oceanBooking?.version),
billOfLadingNo: `BL-${suffix}`.slice(0, 128), vesselName: `VESSEL-${suffix}`.slice(0, 128),
voyageNo: `V-${suffix}`.slice(0, 64), estimatedSailingAt: sailingAt,
estimatedArrivalAt: arrivalAt, reason: context.marker,
...frozenTransport, reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-loading-booking-release`.slice(0, 128) })
const releasedBooking = context.apiSuccess(MODULE, '订舱携带已冻结运输快照放舱并满足 PDA 门禁', releaseResponse, '', OPTIONS)
context.assert(MODULE, '放舱后订舱进入 RELEASED 且柜次分配保持稳定', releaseResponse,
releasedBooking?.oceanBooking?.status === 'RELEASED'
&& releasedBooking?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerId),
`status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}`, OPTIONS)
&& releasedBooking?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerId)
&& transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field])),
`status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}, transportFrozen=${transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS)
if (releasedBooking?.oceanBooking?.status !== 'RELEASED') return
const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
@@ -276,7 +276,28 @@ export async function run(context) {
approved?.customsCase?.status === 'READY_TO_SUBMIT' && Number(approved?.customsCase?.version) === 4,
`status=${approved?.customsCase?.status || 'missing'}, version=${approved?.customsCase?.version ?? 'missing'}`, OPTIONS)
const batchPayload = { customsAgentName: `代理-${suffix}`, externalReference: `EXT-${suffix}`,
const supplierCreateResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', {
name: `报关代理-${context.marker}`.slice(0, 128), category: 'CUSTOMS', scope: null, region: '深圳',
legalRepresentative: '验收法人', contactName: '报关联系人', phone: '13800000000',
email: 'customs-acceptance@example.com', address: '深圳市验收地址',
businessLicenseNo: `CUSTOMS${context.marker.replace(/\D/g, '')}`.slice(0, 64), licenseFileId: null,
status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker,
})
const customsAgentId = id(context.apiSuccess(MODULE, '创建本轮正式 ACTIVE 报关代理供应商',
supplierCreateResponse, `marker=${context.marker}`, OPTIONS))
if (!customsAgentId) return
const supplierDetailResponse = await context.request('GET',
`/api/platform/v1/suppliers/${encodeURIComponent(customsAgentId)}?tenantId=0`)
const customsAgent = context.apiSuccess(MODULE, '回查本轮报关代理供应商', supplierDetailResponse, '', OPTIONS)
context.assert(MODULE, '代理提交使用本轮正式 CUSTOMS 供应商身份', supplierDetailResponse,
id(customsAgent?.id) === customsAgentId && customsAgent?.status === 'ACTIVE'
&& customsAgent?.category === 'CUSTOMS' && customsAgent?.remark === context.marker,
`customsAgentId=${id(customsAgent?.id) || 'missing'}, customsAgentName=${customsAgent?.name || 'missing'}`, OPTIONS)
if (id(customsAgent?.id) !== customsAgentId || customsAgent?.status !== 'ACTIVE'
|| customsAgent?.category !== 'CUSTOMS') return
context.createdResources.phase3CustomsSupplier = { marker: context.marker, supplierId: customsAgentId,
supplierName: customsAgent.name, status: customsAgent.status }
const batchPayload = { customsAgentId, externalReference: `EXT-${suffix}`,
note: context.marker, expectedVersion: 4 }
const batchResponse = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/submission-batches`, batchPayload,
context.token, { 'Idempotency-Key': key(context, 'batch') })
@@ -298,7 +319,7 @@ export async function run(context) {
`HTTP=${batchReplay.http}, code=${codeOf(batchReplay)}, batchId=${id(batchReplay.body?.data?.id) || 'missing'}`, OPTIONS)
const staleSubmit = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/submission-batches`, {
...batchPayload, customsAgentName: '不同请求',
...batchPayload, externalReference: `EXT-DIFFERENT-${suffix}`,
}, context.token, { 'Idempotency-Key': key(context, 'batch') })
context.addStep(MODULE, '同一幂等键不同代理提交请求被拒绝', staleSubmit,
staleSubmit.http === 200 && codeOf(staleSubmit) !== '200',
@@ -39,11 +39,11 @@ export async function run(context) {
const inboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`)
const inbound = successful(inboundResponse) ? inboundResponse.body?.data : null
const serverLoading = inbound?.summary?.processStatus === 'LOADING'
context.assert(MODULE, '服务端入库进入 LOADING 后才开放装柜证据', inboundResponse,
serverLoading,
const evidenceReadable = ['LOADING', 'LOADED'].includes(inbound?.summary?.processStatus)
context.assert(MODULE, '服务端入库进入 LOADING 或 LOADED 后开放装柜证据只读回查', inboundResponse,
evidenceReadable,
`processStatus=${inbound?.summary?.processStatus || 'missing'}, taskWorkflow=${task?.workflowStatus || 'missing'}`, OPTIONS)
if (!serverLoading) {
if (!evidenceReadable) {
context.addCoverageGap(MODULE, OPTIONS.scenario, '服务端装柜阶段前不请求证据',
`taskId=${taskId}, inboundId=${inboundId}, loadingPlanId=${loadingPlanId}, containerBookingId=${containerBookingId}, processStatus=${inbound?.summary?.processStatus || 'missing'};AWAITING_REVIEW 不作为证据开放条件`, 'deployment-blocker')
return
@@ -14,8 +14,8 @@ test('evidence gate requires stable PDA identity and server LOADING before any e
assert.match(source, /task\?\.loadingPlanId\) === loadingPlanId/)
assert.match(source, /task\?\.containerBookingId\) === containerBookingId/)
assert.match(source, /taskItem\?\.inboundId\) === inboundId/)
assert.match(source, /processStatus === 'LOADING'/)
assert.match(source, /if \(!serverLoading\)[\s\S]*return/)
assert.match(source, /\['LOADING', 'LOADED'\]\.includes\(inbound\?\.summary\?\.processStatus\)/)
assert.match(source, /if \(!evidenceReadable\)[\s\S]*return/)
assert.match(source, /AWAITING_REVIEW 不作为证据开放条件/)
const gate = source.slice(source.indexOf('const taskResponse'), source.indexOf('const path ='))
assert.doesNotMatch(gate, /loading\/evidence/)
@@ -49,7 +49,8 @@ async function request(context, method, path, body, headers = {}) {
export async function run(context) {
const fixture = context.createdResources?.phase3Loading
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
if (!tenantId || !inboundId || fixture?.marker !== context.marker
const orderId = id(fixture?.orderId)
if (!tenantId || !orderId || !inboundId || fixture?.marker !== context.marker
|| fixture?.loadingPlanStatus !== 'COMPLETED') {
context.addStep(MODULE, '取得下游门禁待完成装柜 fixture', null, false,
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, marker=${fixture?.marker || 'missing'}, plan=${fixture?.loadingPlanStatus || 'missing'}`, OPTIONS)
@@ -93,6 +94,23 @@ export async function run(context) {
&& item.operatorName && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'),
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
const unifiedDetailPath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
const unifiedDetailResponse = await context.request('GET', unifiedDetailPath)
const unifiedDetail = context.apiSuccess(MODULE, 'LOADED 后回查同一统一订单详情', unifiedDetailResponse, '', OPTIONS)
const orderNo = String(unifiedDetail?.summary?.orderNo || '').trim()
context.assert(MODULE, '统一订单详情投影同一订单为 LOADED', unifiedDetailResponse,
id(unifiedDetail?.summary?.orderId) === orderId && unifiedDetail?.summary?.mainStatus === 'LOADED'
&& Boolean(orderNo),
`orderId=${id(unifiedDetail?.summary?.orderId) || 'missing'}, mainStatus=${unifiedDetail?.summary?.mainStatus || 'missing'}, orderNo=${orderNo || 'missing'}`, OPTIONS)
if (id(unifiedDetail?.summary?.orderId) !== orderId || unifiedDetail?.summary?.mainStatus !== 'LOADED' || !orderNo) return
const unifiedListPath = `/api/platform/v1/orders?page=1&size=100&tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}`
const unifiedListResponse = await context.request('GET', unifiedListPath)
const unifiedPage = context.apiSuccess(MODULE, 'LOADED 后回查同一统一订单列表', unifiedListResponse, '', OPTIONS)
const unifiedRow = unifiedPage?.records?.find?.((row) => id(row?.orderId) === orderId)
context.assert(MODULE, '统一订单列表投影同一订单为 LOADED', unifiedListResponse,
Boolean(unifiedRow) && unifiedRow?.mainStatus === 'LOADED',
`orderId=${orderId}, found=${Boolean(unifiedRow)}, mainStatus=${unifiedRow?.mainStatus || 'missing'}`, OPTIONS)
const evidencePath = `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`
const immutableEvidenceResponse = await context.request('GET', evidencePath)
const immutableEvidence = context.apiSuccess(MODULE, '读取 LOADED 后不可变证据快照', immutableEvidenceResponse, '', OPTIONS)
@@ -122,7 +122,6 @@ export async function run(context) {
warehouseId, warehouseName: initial?.summary?.warehouseName,
channelCode: initial?.summary?.channelCode,
billOfLadingNo: `P3BL-${suffix}`.slice(0, 128),
shippingCompanyName: `P3-SHIPPING-${suffix}`, bookingAgentName: `P3-AGENT-${suffix}`,
vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
@@ -880,7 +879,8 @@ export async function run(context) {
`status=${blockedDetail?.summary?.processStatus || 'missing'}, version=${blockedDetail?.summary?.version ?? 'missing'}`, OPTIONS)
context.createdResources.phase3Loading = {
tenantId, inboundId, preloadPlanId: planId, evidenceIds: evidenceItems.map((item) => id(item.id)),
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
evidenceIds: evidenceItems.map((item) => id(item.id)),
voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`,
missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId,
immutableEvidenceId: id(firstPhoto?.id), immutableEvidenceVersion: Number(firstPhoto?.version),
@@ -111,6 +111,7 @@ test('bridge loading starts through a real PDA batch before preload confirmation
assert.match(value, /version: transportVersion/)
assert.match(value, /Number\.isInteger\(transportVersion\)/)
assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/)
assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /const frozenTransportMatches = frozenTransportValid[\s\S]*transportFields\.every/)
assertOrder(value, [
@@ -123,6 +124,20 @@ test('bridge loading starts through a real PDA batch before preload confirmation
assert.match(value, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
})
test('finalize rechecks the same unified order detail and list as LOADED', () => {
const value = source('./phase3-loading-finalize.mjs')
assertOrder(value, [
'入库单终态 LOADED、版本递增且审计历史存在',
'LOADED 后回查同一统一订单详情',
'统一订单详情投影同一订单为 LOADED',
'LOADED 后回查同一统一订单列表',
'统一订单列表投影同一订单为 LOADED',
])
assert.match(value, /summary\?\.mainStatus === 'LOADED'/)
assert.match(value, /unifiedRow\?\.mainStatus === 'LOADED'/)
assert.match(source('./phase3-loading.mjs'), /tenantId, orderId: id\(source\?\.orderId\), inboundId/)
})
test('PDA inventory snapshot gates batch and records the only safe mutation gap', () => {
const loading = source('./phase3-loading.mjs')
assertOrder(loading, [
@@ -285,6 +300,14 @@ test('container loading enforces channel isolation and stable PDA identity acros
assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
assert.match(value, /pdaIdentityStable: true/)
assert.match(value, /shippingCompanyId: null, shippingCompanyName: null/)
assert.match(value, /bookingAgentId: null, bookingAgentName: null/)
assert.doesNotMatch(value, /shippingCompanyName: `验收船公司-/)
assert.doesNotMatch(value, /bookingAgentName: `验收代理-/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /if \(!frozenTransportMatches\) return/)
assert.match(value, /expectedVersion: Number\(confirmedBooking\?\.oceanBooking\?\.version\),\s*\.\.\.frozenTransport, reason: context\.marker/s)
assert.doesNotMatch(value, /const sailingAt = new Date\(Date\.now\(\)/)
const batch = value.slice(value.indexOf('const staleBatchResponse'), value.indexOf('const completePath'))
assert.doesNotMatch(batch, /containerNo:/)
})
@@ -355,6 +378,11 @@ test('customs provisions released booking and completed truck for the selected l
])
assert.match(value, /oceanBookings\.run\(context\)/)
assert.match(value, /serviceCenter\.run\(context\)/)
assert.match(value, /request\('POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/)
assert.match(value, /category: 'CUSTOMS'/)
assert.match(value, /context\.createdResources\.phase3CustomsSupplier/)
assert.match(value, /const batchPayload = \{ customsAgentId,/)
assert.doesNotMatch(value, /customsAgentName: `代理-/)
})
test('downstream producers prefer the phase3Loading container fixture', () => {
@@ -376,6 +404,8 @@ test('downstream producers prefer the phase3Loading container fixture', () => {
assert.match(serviceCenter, /if \(!allocationMatches\) return/)
assert.match(serviceCenter, /const customerName = String\(oceanDetail\?\.oceanBooking\?\.customerName \|\| ''\)\.trim\(\)/)
assert.match(serviceCenter, /customerId, customerName, carrierId: null/)
assert.match(serviceCenter, /carrierId: null, carrierName: null/)
assert.doesNotMatch(serviceCenter, /链路承运商-/)
assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/)
const schedules = source('./phase3-port-schedules.mjs')
assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containerAllocations \|\| \[\]\)/)
@@ -78,7 +78,7 @@ export async function run(context) {
warehouseId: linkContainer
? id(containerFlow.warehouseId)
: id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'),
customerId, customerName, carrierId: null, carrierName: `链路承运商-${suffix}`,
customerId, customerName, carrierId: null, carrierName: null,
fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008',
driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人',
storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000',