This commit is contained in:
2026-08-15 11:36:53 +08:00
parent 282eb73371
commit 46cb95db15
6 changed files with 119 additions and 26 deletions
@@ -23,7 +23,7 @@ test('IT 测试客户初始化后具备已审核的必填资质和下单资格',
test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => {
for (const marker of [
"'TP-001'", "'TP-002'", "'A1 普通库位'", "'A0 共享溢出位'",
"'CUS-001'", "'CUSTOMER'", "'SHIPPER'", "'EXP-TEST'", "'IT 测试快递'",
"'CAR-001'", "'CARRIER'", "'SHIPPER'", "'EXP-TEST'", "'IT 测试快递'",
]) assert.ok(sql.includes(marker), `missing initialization marker: ${marker}`)
assert.match(sql, /'EXP-TEST', 'IT 测试快递'[\s\S]*?'ACTIVE'/)
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./master-data.mjs', import.meta.url), 'utf8')
test('master-data uses only current partner types for API and import fixtures', () => {
assert.match(source, /partnerType: 'CARRIER', types: \['CARRIER'\]/)
assert.match(source, /types: \['CARRIER'\]/)
assert.match(source, /`IMP\$\{suffix\}`, `导入船公司-\$\{suffix\}`, 'CARRIER'/)
assert.doesNotMatch(source, /partnerType: 'SUPPLIER'|types: \['SUPPLIER'|SHIPPING_COMPANY/)
})
@@ -158,7 +158,7 @@ export async function run(context) {
expressPage?.records?.some((item) => id(item.id) === expressId && item.status === 'INACTIVE'), `records=${expressPage?.records?.length ?? 'n/a'}`)
const partnerPayload = {
partnerCode: `PT${suffix}`, partnerName: `Admin验收往来单位-${suffix}`, partnerType: 'SUPPLIER', types: ['SUPPLIER'],
partnerCode: `PT${suffix}`, partnerName: `Admin验收船公司-${suffix}`, partnerType: 'CARRIER', types: ['CARRIER'],
contactName: '往来单位验收员', contactPhone: '13800000002', contactEmail: `partner-${suffix}@example.com`,
countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', addressLine: 'Admin API 往来单位地址',
postalCode: '518000', status: 'ACTIVE', remark: context.marker,
@@ -171,8 +171,8 @@ export async function run(context) {
const partnerDetailResponse = await context.request('GET', partnerPath)
const partner = context.apiSuccess(module, '查询往来单位详情', partnerDetailResponse)
context.assert(module, '往来单位详情正确', partnerDetailResponse,
id(partner?.id) === partnerId && partner?.types?.includes('SUPPLIER'), `partnerId=${partnerId}`)
const updatePartnerResponse = await context.request('PUT', partnerPath, { ...partnerPayload, partnerName: `${partnerPayload.partnerName}-已编辑`, types: ['SUPPLIER', 'CARRIER'], remark: `${context.marker}-EDITED` })
id(partner?.id) === partnerId && partner?.types?.includes('CARRIER'), `partnerId=${partnerId}`)
const updatePartnerResponse = await context.request('PUT', partnerPath, { ...partnerPayload, partnerName: `${partnerPayload.partnerName}-已编辑`, types: ['CARRIER'], remark: `${context.marker}-EDITED` })
context.apiSuccess(module, '编辑往来单位', updatePartnerResponse)
const disablePartnerResponse = await context.request('PATCH', `/api/platform/v1/partners/${partnerId}/status?tenantId=0`, { status: 'INACTIVE' })
context.apiSuccess(module, '停用往来单位', disablePartnerResponse)
@@ -190,7 +190,7 @@ export async function run(context) {
const importFixtures = {
仓库: { headers: ['仓库编码*', '仓库名称', '仓库类型', '国家二字码', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '时区', '状态', '联系人', '联系电话', '联系邮箱', '备注'], values: [`IMW${suffix}`, `导入验收仓-${suffix}`, 'DOMESTIC', 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'Asia/Shanghai', 'ACTIVE', '导入联系人', '13800000011', `im-${suffix}@example.com`, context.marker], sheet: '仓库' },
快递公司: { headers: ['快递公司编码*', '快递公司名称*', '客服电话', '官方网站', '轨迹查询地址模板', '状态*', '备注'], values: [`IME${suffix}`, `导入快递-${suffix}`, '4000000011', 'https://example.com', 'https://example.com/{trackingNo}', 'ACTIVE', context.marker], sheet: '快递公司' },
往来单位: { headers: ['单位编码*', '单位名称*', '单位类型*', '联系人*', '联系电话*', '联系邮箱', '国家二字码*', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '状态*', '备注'], values: [`IMP${suffix}`, `导入单位-${suffix}`, 'SUPPLIER', '导入联系人', '13800000012', `ip-${suffix}@example.com`, 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'ACTIVE', context.marker], sheet: '往来单位' },
往来单位: { headers: ['单位编码*', '单位名称*', '单位类型*', '联系人*', '联系电话*', '联系邮箱', '国家二字码*', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '状态*', '备注'], values: [`IMP${suffix}`, `导入船公司-${suffix}`, 'CARRIER', '导入联系人', '13800000012', `ip-${suffix}@example.com`, 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'ACTIVE', context.marker], sheet: '往来单位' },
}
for (const [name, path] of templates) {
const response = await context.requestBinary('GET', path)
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { findPricedOrderRoute } from './orders.mjs'
import { findPricedOrderRoute, findPricedOrderRouteAcrossOrigins } from './orders.mjs'
const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8')
@@ -13,7 +13,30 @@ test('orders fixture verifies the complete pricing tuple before selecting an FBA
assert.match(source, new RegExp(`${field}:`))
}
assert.match(source, /estimateMatches\(response, candidate\)/)
assert.doesNotMatch(source, /const destination = records\(fbaPage\)\[0\]/)
assert.doesNotMatch(source, /records\(domesticPage\)\[0\]/)
assert.match(source, /findPricedOrderRouteAcrossOrigins/)
assert.match(source, /warehouse\?\.status === 'ACTIVE'/)
})
test('pricing selection skips an ACTIVE domestic warehouse with an empty channel catalog', async () => {
const channelCalls = []
const result = await findPricedOrderRouteAcrossOrigins({
receivings: [{ id: 'unpriced', status: 'ACTIVE' }, { id: 'priced', status: 'ACTIVE' }],
destinations: [{ id: 'abe3', warehouseCode: 'ABE3', warehouseName: 'ABE3' }],
loadChannels: async (receiving) => {
channelCalls.push(receiving.id)
return receiving.id === 'unpriced'
? { http: 200, body: { code: '200', data: [] }, requestId: 'channels-empty', traceId: 'trace-empty' }
: { http: 200, body: { code: '200', data: [{ priceBookVersionId: '19', channelCode: 'EXX_RAIL_NY', pricingTaxModes: ['TAX_INCLUDED'] }] } }
},
estimate: async (request) => ({ http: 200, body: { code: '200', data: {
priceBookVersionId: '19', channelCode: request.channelCode, pricingTaxMode: request.pricingTaxMode,
} } }),
})
assert.deepEqual(channelCalls, ['unpriced', 'priced'])
assert.equal(result.receiving.id, 'priced')
assert.equal(result.priced.destination.warehouseCode, 'ABE3')
assert.equal(result.originFailures[0].requestId, 'channels-empty')
})
test('order payload reuses the tax mode from the successful estimate', () => {
+47 -18
View File
@@ -97,6 +97,34 @@ export async function findPricedOrderRoute({ receiving, destinations, channels,
return { priced: null, response: lastResponse, attempts, failures }
}
export async function findPricedOrderRouteAcrossOrigins({ receivings, destinations, loadChannels, estimate,
concurrency = PRICING_PROBE_CONCURRENCY }) {
const originFailures = []
let attempts = 0
let response = null
for (const receiving of receivings) {
if (!receiving?.id) continue
const channelResponse = await loadChannels(receiving)
response = channelResponse || response
const channels = success(channelResponse) && Array.isArray(channelResponse.body?.data)
? channelResponse.body.data : []
const priceableChannels = channels.filter((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0)
if (priceableChannels.length === 0) {
originFailures.push({ originWarehouseId: id(receiving.id), stage: 'channels', http: channelResponse?.http ?? 'missing',
code: channelResponse?.body?.code ?? 'missing', requestId: channelResponse?.requestId ?? 'missing',
traceId: channelResponse?.traceId ?? 'missing', count: channels.length })
continue
}
const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels, estimate, concurrency })
attempts += result.attempts
response = result.response || response
if (result.priced) return { receiving, ...result, originFailures }
originFailures.push({ originWarehouseId: id(receiving.id), stage: 'estimate', attempts: result.attempts,
evidence: pricingFailureEvidence(result) })
}
return { receiving: null, priced: null, response, attempts, originFailures }
}
function pricingFailureEvidence(result) {
const describe = (failure) => failure
? `${failure.tuple}[HTTP=${failure.http},code=${failure.code},requestId=${failure.requestId},traceId=${failure.traceId}${failure.mismatch ? `,response=${failure.mismatch}` : ''}]`
@@ -104,6 +132,14 @@ function pricingFailureEvidence(result) {
return `没有可计价组合;attempts=${result.attempts}, first=${describe(result.failures[0])}, last=${describe(result.failures.at(-1))}`
}
function originPricingFailureEvidence(result) {
const describe = (failure) => !failure ? 'missing'
: failure.stage === 'channels'
? `origin=${failure.originWarehouseId},channels[HTTP=${failure.http},code=${failure.code},count=${failure.count},requestId=${failure.requestId},traceId=${failure.traceId}]`
: `origin=${failure.originWarehouseId},estimate[attempts=${failure.attempts},${failure.evidence}]`
return `没有 ACTIVE 国内仓可计价组合;origins=${result.originFailures?.length ?? 0}, attempts=${result.attempts}, first=${describe(result.originFailures?.[0])}, last=${describe(result.originFailures?.at(-1))}`
}
async function request(context, method, path, body, token, extraHeaders = {}) {
const headers = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...extraHeaders }
let payload = body
@@ -171,32 +207,25 @@ async function loadMasterData(context, merchantToken) {
const fbaPage = context.apiSuccess(MODULE, 'Client 查询 FBA 目的仓', fbaResponse)
context.assert(MODULE, '存在可用 FBA 目的仓', fbaResponse, records(fbaPage).length > 0,
`count=${records(fbaPage).length}`)
const receiving = records(domesticPage)[0]
const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
const destinations = records(fbaPage)
if (!receiving || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓')
if (receivings.length === 0 || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓')
const channelResponse = await request(context, 'GET',
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
undefined, merchantToken)
const channels = context.apiSuccess(MODULE, 'Client 查询可用计费渠道', channelResponse)
const priceableChannels = Array.isArray(channels)
? channels.filter((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0)
: []
context.assert(MODULE, '存在带计价税制的可用渠道', channelResponse, priceableChannels.length > 0,
`count=${Array.isArray(channels) ? channels.length : 'n/a'}`)
if (priceableChannels.length === 0) throw new Error('缺少创建测试订单所需的可用渠道')
const pricingResult = await findPricedOrderRoute({
receiving, destinations, channels: priceableChannels,
const pricingResult = await findPricedOrderRouteAcrossOrigins({
receivings, destinations,
loadChannels: (receiving) => request(context, 'GET',
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
undefined, merchantToken),
estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken),
})
const priced = pricingResult.priced
context.addStep(MODULE, 'Client 选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced),
const receiving = pricingResult.receiving
context.addStep(MODULE, 'Client 从 ACTIVE 国内仓选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced),
priced
? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination.warehouseCode}, attempts=${pricingResult.attempts}`
: pricingFailureEvidence(pricingResult),
: originPricingFailureEvidence(pricingResult),
CONTRACT_OPTIONS)
if (!priced) throw new Error('当前起运仓、渠道、报价税制和FBA目的仓没有可计价组合')
if (!priced) throw new Error('当前 ACTIVE 国内收货仓、渠道、报价税制和FBA目的仓没有可计价组合')
const partnerResponse = await request(context, 'GET',
'/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken)
@@ -2,6 +2,7 @@ const MODULE = 'truck-appointments'
const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/truck-appointments'
const OCEAN_BOOKING_BASE = '/api/platform/v1/ocean-bookings'
const SUPPLIER_BASE = '/api/platform/v1/suppliers?tenantId=0'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
@@ -67,10 +68,11 @@ export function inspectOceanBookingIdentity(marker, resource, detail = null) {
}
export function truckAppointmentPayload(context, suffix, identity, version = 0) {
const supplier = context.createdResources?.truckSupplier || {}
return {
outboundOrderId: null, oceanBookingId: identity.oceanBookingId, containerBookingId: null, loadingPlanId: null,
warehouseId: identity.warehouseId, customerId: identity.customerId, customerName: identity.customerName,
carrierId: null, carrierName: `承运商-${suffix}`, fleetContact: '车队联系人', fleetPhone: '13800000004',
carrierId: supplier.supplierId || null, carrierName: supplier.supplierName || `承运商-${suffix}`, 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,
@@ -79,6 +81,30 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0)
}
}
async function ensureTruckSupplier(context, suffix) {
const response = await request(context, 'POST', SUPPLIER_BASE, {
name: `拖车供应商-${suffix}`.slice(0, 128), category: 'TRUCKING', scope: null,
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, '创建本轮有效拖车供应商', response,
Boolean(supplierId) && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE',
`supplierId=${supplierId || 'missing'}, category=${supplier?.category || 'missing'}, status=${supplier?.status || 'missing'}`, OPTIONS)
if (supplierId) context.createdResources.truckSupplier = {
marker: context.marker, supplierId, supplierName: supplier.name, finalStatus: 'ACTIVE', retained: true,
}
return context.createdResources.truckSupplier || 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)}`)
@@ -122,6 +148,9 @@ export async function run(context) {
}
const bookingIdentity = Object.freeze({ ...identityResult.identity })
const truckSupplier = await ensureTruckSupplier(context, suffix)
if (!truckSupplier) return
const createResponse = await request(context, 'POST', BASE, truckAppointmentPayload(context, suffix, bookingIdentity)); const appointment = context.apiSuccess(MODULE, '创建隔离拖车预约', createResponse, '', OPTIONS)
const appointmentId = id(appointment?.id)
context.assert(MODULE, '拖车预约费用、司机车辆和地址字段完整', createResponse, Boolean(appointmentId) && appointment?.status === 'DRAFT' && appointment?.payableAmount != null && appointment?.receivableAmount != null, `id=${appointmentId || 'missing'}, status=${appointment?.status || 'missing'}`, OPTIONS)