diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 255c546c5..9d12972a5 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -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'/) diff --git a/scripts/admin-test-env-modules/master-data-partner-contract.test.mjs b/scripts/admin-test-env-modules/master-data-partner-contract.test.mjs new file mode 100644 index 000000000..9b21e02ca --- /dev/null +++ b/scripts/admin-test-env-modules/master-data-partner-contract.test.mjs @@ -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/) +}) diff --git a/scripts/admin-test-env-modules/master-data.mjs b/scripts/admin-test-env-modules/master-data.mjs index 85a505861..c8c8160fe 100644 --- a/scripts/admin-test-env-modules/master-data.mjs +++ b/scripts/admin-test-env-modules/master-data.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs index 5e3eda40c..1cdcc0f66 100644 --- a/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs @@ -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', () => { diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index ad3b8f3a6..0f8275260 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 6990a6ba8..7b18a4433 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -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)