merge: sync test and stabilize full acceptance
This commit is contained in:
+1
-1
@@ -15,7 +15,7 @@
|
||||
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
|
||||
| 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 |
|
||||
| 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` |
|
||||
| 源 Excel 订单造数 | `real-order-xlsx-test-env.mjs`、`real-order-xlsx-import.md` | 按源入仓码归并及严格主数据匹配,分别支持预检、真实字段草稿和显式模拟测量到待分柜;仅单个已配置客户,私人地址及一件代发工作托盘未获确认前阻断;批次检查点防重复,清库后用新批次重跑 |
|
||||
| 源 Excel 订单造数 | `real-order-xlsx-test-env.mjs`、`real-order-xlsx-import.md` | 按源入仓码归并及严格主数据匹配,支持预检、真实字段草稿、批量提交到待审核和少量完整测量到待分柜;待审核阶段不审核且不使用托盘/库位;仅单个已配置客户,私人地址及一件代发工作托盘未获确认前阻断;批次检查点防重复,清库后用新批次重跑 |
|
||||
| Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 |
|
||||
| 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写目标环境,必须有对应环境授权 |
|
||||
| Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 |
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
|
||||
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 |
|
||||
| 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 |
|
||||
| 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` |
|
||||
| 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建缺失字段留空的草稿,`wait-split` 显式允许测试占位后走正式审核测量。只复用两个原有托盘,不新增主档或库位。参数、限制及重跑说明见 `real-order-xlsx-import.md` |
|
||||
| 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建草稿,`pending-review` 批量提交后停在待审核且不使用托盘/库位,`wait-split` 只供少量完整测量验收。参数、限制及重跑说明见 `real-order-xlsx-import.md` |
|
||||
| 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 |
|
||||
| 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 |
|
||||
| 测试环境 HTTP | `phase2-test-env-acceptance.ps1` | 入库测量与计费测试环境 API 验收,会写业务数据 |
|
||||
|
||||
@@ -47,6 +47,7 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['orders', ['merchant-qualification-fixture']],
|
||||
['inbound-hold', ['orders']],
|
||||
['inbound-measurement', ['orders']],
|
||||
['inbound-fees', ['inbound-measurement']],
|
||||
['manual-order-splitting-fixture', ['inbound-measurement']],
|
||||
['coload-groups', ['inbound-measurement']],
|
||||
['phase3-container-loading', ['inbound-measurement']],
|
||||
@@ -1571,7 +1572,11 @@ export class AcceptanceRun {
|
||||
if (Number(readiness().dropshipContainerCount || 0) < 1) {
|
||||
await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true)
|
||||
}
|
||||
if (Number(readiness().fbaContainerCount || 0) < 2) {
|
||||
const hasStandardFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
|
||||
.some((item) => item?.destinationType === 'FBA'
|
||||
&& item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP'
|
||||
&& String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:'))
|
||||
if (!hasStandardFbaProducer()) {
|
||||
await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', true)
|
||||
}
|
||||
if (Number(readiness().overseasContainerCount || 0) < 1) {
|
||||
@@ -1783,7 +1788,7 @@ export class AcceptanceRun {
|
||||
continue
|
||||
}
|
||||
let restoreFixture = null
|
||||
if (module === 'phase4-overseas-pda') {
|
||||
if (module === 'phase4-tail-order-controls' || module === 'phase4-overseas-pda') {
|
||||
try {
|
||||
await this.ensurePhase4PdaProducerMatrix()
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ test('initial fee preparation is reusable without confirming or publishing the f
|
||||
assert.match(helper, /\/fees\/latest/)
|
||||
assert.match(helper, /\/fees\/calculate/)
|
||||
assert.match(helper, /feeStatus === 'CALCULATED'/)
|
||||
assert.match(helper, /\$\{inboundId\}-inbound-fee-initial-calculate/)
|
||||
assert.doesNotMatch(helper, /\/fees\/confirm|\/fees\/send/)
|
||||
})
|
||||
|
||||
@@ -46,6 +47,9 @@ test('automatic fee adjustment confirms the ten percent tolerance boundary', asy
|
||||
test('customer confirms chargeable quantity and manual unit price in one locked version', async () => {
|
||||
const source = await readFile(moduleFile, 'utf8')
|
||||
assert.match(source, /const orderId = id\(fixture\?\.orderId\)/)
|
||||
assert.match(source, /context\.context\?\.merchantQualificationFixture/)
|
||||
assert.match(source, /merchantFixture\?\.username/)
|
||||
assert.match(source, /merchantFixture\?\.password/)
|
||||
assert.doesNotMatch(source, /const orderId = id\(context\.createdResources\.orders\?\.orderId\)/)
|
||||
assert.match(source, /Client 读取已推送计费量、对客单价和费用合计/)
|
||||
assert.match(source, /Client 一次确认计费量、对客单价和费用/)
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function ensureInitialCalculated(context, fixture,
|
||||
if (!success(initialResponse) && String(initialResponse?.body?.code) === 'A0404') {
|
||||
initialResponse = await context.request('POST',
|
||||
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/calculate?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
undefined, undefined, requestHeaders(`${context.marker}-inbound-fee-initial-calculate`))
|
||||
undefined, undefined, requestHeaders(`${context.marker}-${inboundId}-inbound-fee-initial-calculate`))
|
||||
}
|
||||
const initial = context.apiSuccess(MODULE, '读取或补齐审核后初始费用', initialResponse, '', options)
|
||||
context.assert(MODULE, '初始费用保持 WAIT_SPLIT 且尚未发布', initialResponse,
|
||||
@@ -132,8 +132,9 @@ export async function run(context) {
|
||||
`status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const orderId = id(fixture?.orderId)
|
||||
const merchantFixture = context.context?.merchantQualificationFixture
|
||||
const merchantLoginResponse = await context.request('POST', '/api/auth/login', {
|
||||
loginId: context.options.merchantUsername, password: context.options.merchantPassword,
|
||||
loginId: merchantFixture?.username, password: merchantFixture?.password,
|
||||
}, '')
|
||||
const merchantLogin = context.apiSuccess(MODULE, 'Client 登录并准备确认计费量和费用', merchantLoginResponse,
|
||||
`orderId=${orderId || 'missing'}`, OPTIONS)
|
||||
|
||||
@@ -19,6 +19,11 @@ function operationId(context, label) {
|
||||
return `${context.marker}-manual-loading-${label}-${crypto.randomUUID()}`.slice(0, 128)
|
||||
}
|
||||
|
||||
async function post(context, path, body, label) {
|
||||
return context.request('POST', path, body, context.token,
|
||||
{ 'Idempotency-Key': operationId(context, label) })
|
||||
}
|
||||
|
||||
async function ensureCompletionEvidence(context, chain, label) {
|
||||
const taskId = id(chain.task?.id)
|
||||
const warehouseId = id(chain.source?.warehouseId)
|
||||
|
||||
@@ -47,7 +47,7 @@ function context(resources = {}) {
|
||||
if (token === this.overseasToken) return response(path, null, 403, 'A0403')
|
||||
if (path.includes('/overview')) return response(path, { container: {
|
||||
id: '3001', containerNo: 'MSCU1234567', unloadingCompletedAt: '2026-08-24T10:00:00Z',
|
||||
unloadingPhotoUploadedAt: '2026-08-24T09:00:00Z', estimatedEmptyReportedAt: '2026-08-26T09:00:00Z',
|
||||
unloadingPhotoUploadedAt: '2026-08-24T09:00:00Z', estimatedEmptyReportedAt: '2026-08-27T10:00:00Z',
|
||||
actualEmptyReportedAt: null, emptyReportSourceVersion: 7,
|
||||
} })
|
||||
return response(path, { records: [{ container: { id: '3001', containerNo: 'MSCU1234567' } }] })
|
||||
@@ -103,6 +103,13 @@ test('cross-region producers do not repeat the destructive A0 snapshot mutation'
|
||||
assert.match(fixtureCall, /preparePhase3LoadingFixture\('', false\)/)
|
||||
})
|
||||
|
||||
test('tail controls receive their own completed producer matrix before they consume it', async () => {
|
||||
const entry = await import('node:fs/promises').then((fs) => fs.readFile(
|
||||
new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8'))
|
||||
assert.match(entry, /module === 'phase4-tail-order-controls' \|\| module === 'phase4-overseas-pda'/)
|
||||
assert.match(entry, /await this\.ensurePhase4PdaProducerMatrix\(\)/)
|
||||
})
|
||||
|
||||
test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => {
|
||||
for (const token of [
|
||||
'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function run(context) {
|
||||
const estimatedAt = Date.parse(snapshot.estimatedEmptyReportedAt)
|
||||
const validTimeline = Number.isFinite(completedAt) && Number.isFinite(uploadedAt)
|
||||
&& Number.isFinite(estimatedAt) && completedAt >= uploadedAt
|
||||
&& estimatedAt - uploadedAt === 2 * 24 * 60 * 60 * 1_000
|
||||
&& estimatedAt - completedAt === 72 * 60 * 60 * 1_000
|
||||
context.assert(MODULE, '海外柜完成经Bridge回写Firstleg时间事实', writtenBack.response,
|
||||
validTimeline && traced(writtenBack.response),
|
||||
evidence(writtenBack.response,
|
||||
|
||||
@@ -33,6 +33,11 @@ test('relabel transfer and discard cover create replay conflict list detail and
|
||||
assert.match(source, /chargeAmount: 0\.01/)
|
||||
assert.match(source, /cartonMark:/)
|
||||
assert.match(source, /fbaCartonMark:/)
|
||||
for (const field of [
|
||||
'operationWarehouseId:', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:', 'sourceInboundOrderId:',
|
||||
'destinationFbaWarehouseCode:',
|
||||
]) assert.match(source, new RegExp(field))
|
||||
assert.match(source, /query\(`\$\{RELABEL\}\/inventory-candidates`, \{/)
|
||||
for (const token of [
|
||||
'客户创建贴标申请', '客户贴标申请幂等重放', '贴标同幂等键异载荷返回A0409',
|
||||
'客户查询自身贴标申请列表', '客户查询自身贴标申请详情', 'PENDING_PROCESSING',
|
||||
|
||||
@@ -89,13 +89,30 @@ async function verifyRelabel(context, token, order) {
|
||||
`orderId=${id(order.row?.id) || 'missing'}, sku=${cargo?.skuCode || 'missing'}, warehouse=${order.row?.overseasWarehouseId || 'missing'}`)
|
||||
return false
|
||||
}
|
||||
const inventory = await merchant(context, token, 'GET', '客户查询贴标权威库存候选',
|
||||
query(`${RELABEL}/inventory-candidates`, {
|
||||
operationWarehouseId: order.row.overseasWarehouseId,
|
||||
inboundCode: order.row.inboundCode,
|
||||
}))
|
||||
const source = (Array.isArray(inventory.data) ? inventory.data : []).find((item) =>
|
||||
id(item?.inventoryBalanceId) && id(item?.inboundOrderId)
|
||||
&& item?.skuCode === cargo.skuCode
|
||||
&& item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0))
|
||||
const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0)
|
||||
context.assert(MODULE, '贴标申请使用当前客户真实库存余额、位置和来源入库单', inventory.response,
|
||||
Boolean(source && position),
|
||||
`balanceId=${id(source?.inventoryBalanceId) || 'missing'}, positionId=${id(position?.positionId) || 'missing'}, inboundOrderId=${id(source?.inboundOrderId) || 'missing'}`, OPTIONS)
|
||||
if (!source || !position) return false
|
||||
const body = {
|
||||
customerOrderNo: order.row.orderNo,
|
||||
overseasWarehouseId: order.row.overseasWarehouseId,
|
||||
operationWarehouseId: order.row.overseasWarehouseId,
|
||||
labelDeadline: future(7), contactName: '客户贴标验收联系人', contactPhone: '13800000006',
|
||||
chargeAmount: 0.01,
|
||||
remark: context.marker, idempotencyKey: `${context.marker}-CLIENT-RELABEL`.slice(0, 255),
|
||||
items: [{
|
||||
sourceInventoryBalanceId: id(source.inventoryBalanceId),
|
||||
sourceInventoryPositionId: id(position.positionId),
|
||||
sourceInboundOrderId: id(source.inboundOrderId),
|
||||
skuCode: cargo.skuCode, productImageFileId: null, productImageFileName: null, productImageUrl: null,
|
||||
productNameZh: cargo.productNameZh, totalPackageCount: 1, notifiedPackageCount: 1,
|
||||
shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null,
|
||||
@@ -103,6 +120,7 @@ async function verifyRelabel(context, token, order) {
|
||||
relabelFileId: null, relabelFileName: null, relabelFileUrl: null,
|
||||
cartonMark: `CM-${String(context.marker).slice(-32)}`,
|
||||
fbaCartonMark: `FBA-${String(context.marker).slice(-32)}`,
|
||||
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
|
||||
}],
|
||||
}
|
||||
const created = await merchant(context, token, 'POST', '客户创建贴标申请', RELABEL, body)
|
||||
|
||||
@@ -36,6 +36,7 @@ test('module covers the public tail-order write surface before PDA receiving', (
|
||||
assert.match(source, /feeItemCode/)
|
||||
assert.match(source, /feeItemName/)
|
||||
assert.match(source, /direction: 'RECEIVABLE'/)
|
||||
assert.match(source, /allIds\.length === 1 \|\| nonBlank\(groupRead\.data\?\.order\?\.palletMergeBatchId\)/)
|
||||
assert.match(source, /businessDate:/)
|
||||
assert.match(source, /originalCurrency === 'USD'/)
|
||||
assert.match(source, /currency === 'CNY'/)
|
||||
|
||||
@@ -183,7 +183,9 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) {
|
||||
const groupRead = await detail(context, representative, '回查批量合托关系')
|
||||
context.assert(MODULE, '批量合托关系包含本轮订单', groupRead.response,
|
||||
groupRead.data?.order?.palletMergeFlag === 'REQUIRED'
|
||||
&& nonBlank(groupRead.data?.order?.palletMergeBatchId), trace(groupRead.response), OPTIONS)
|
||||
&& (allIds.length === 1 || nonBlank(groupRead.data?.order?.palletMergeBatchId)),
|
||||
`orders=${allIds.length}, batchId=${groupRead.data?.order?.palletMergeBatchId || 'single-order'}; ${trace(groupRead.response)}`,
|
||||
OPTIONS)
|
||||
const batchCleared = await api(context, 'PUT', '批量取消PDA合托分组', batchMergePath,
|
||||
{ orderIds: allIds, palletMergeRequired: false })
|
||||
context.assert(MODULE, '批量合托分组取消成功', batchCleared.response, batchCleared.data == null, trace(batchCleared.response), OPTIONS)
|
||||
|
||||
@@ -43,7 +43,7 @@ test('phase-five module proves the client delivery create, list and nested detai
|
||||
assert.match(source, /id\(profile\?\.id\) === customerId/)
|
||||
assert.match(source, /客户创建派送并原子转出库单/)
|
||||
assert.match(source, /deliveryMethod: 'TRUCK_PALLET'/)
|
||||
assert.match(source, /fees: \[\{ feeType: 'DELIVERY_FEE', currency: 'USD', amount: 10/)
|
||||
assert.match(source, /fees: \[\{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',\s*currency: 'USD', amount: 10/)
|
||||
assert.match(source, /客户分页回查自身派送单/)
|
||||
assert.match(source, /客户读取自身派送详情/)
|
||||
assert.match(source, /merchantDetail\.data\?\.order\?\.deliveryNo/)
|
||||
|
||||
@@ -179,7 +179,8 @@ export async function run(context) {
|
||||
vehicleType: 'TRUCK', vehicleSize: 'MEDIUM', driverName: updateBody.driverName,
|
||||
driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate,
|
||||
appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`,
|
||||
fees: [{ feeType: 'DELIVERY_FEE', currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }],
|
||||
fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',
|
||||
currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }],
|
||||
}
|
||||
const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单',
|
||||
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
|
||||
|
||||
@@ -44,6 +44,8 @@ test('four real command payloads mutate contract fields and preserve remarks and
|
||||
const forbidden = index < 2 ? 'remark' : 'reason'
|
||||
assert.equal(Object.hasOwn(body, forbidden), false, `${actionPath} must not send ${forbidden}`)
|
||||
const key = headers['Idempotency-Key']
|
||||
assert.match(key, /^[A-Za-z0-9:_-]+$/)
|
||||
assert.ok(key.endsWith(`terminal-${actionPath}`))
|
||||
const saved = commands.get(key)
|
||||
if (saved) {
|
||||
if (JSON.stringify(saved.body) === JSON.stringify(body)) { replays++; return response(saved.state) }
|
||||
@@ -98,6 +100,8 @@ test('all four commands cover idempotent replay hash conflict and lifecycle sema
|
||||
'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED',
|
||||
]) assert.ok(source.includes(token), `missing ${token}`)
|
||||
assert.match(source, /JSON\.stringify\(replay\.data\) === JSON\.stringify\(first\.data\)/)
|
||||
assert.match(source, /terminal-\$\{path\}.*replace\(\/\[\^A-Za-z0-9:_-\]\//)
|
||||
assert.doesNotMatch(source, /terminal-\$\{action\}/)
|
||||
})
|
||||
|
||||
test('target is strictly current-marker pickup and completed-PDA container', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ function fixture(context) {
|
||||
return null
|
||||
}
|
||||
async function command(context, target, action, path, body) {
|
||||
const key = `${context.marker}-terminal-${action}`.slice(0, 128)
|
||||
const key = `${context.marker}-terminal-${path}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 128)
|
||||
const first = await api(context, 'POST', `目的港码头拖车${action}`, `${BASE}/${target.containerBookingId}/${path}`,
|
||||
body, { 'Idempotency-Key': key })
|
||||
if (!success(first.response)) return first
|
||||
|
||||
@@ -806,7 +806,9 @@ Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{group
|
||||
Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验'
|
||||
Assert-Contains $containerFeeService '调整必须包含分摊组全部费用行' '柜费分摊完整调整校验'
|
||||
Assert-Contains $lastlegAgents '海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务' 'Lastleg 五类海外费用归属'
|
||||
Assert-Contains $overseasFinanceOpenApi 'OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE' '海外五类费用 OpenAPI'
|
||||
foreach ($businessType in @('OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE')) {
|
||||
Assert-Contains $overseasFinanceOpenApi $businessType "海外费用 OpenAPI: $businessType"
|
||||
}
|
||||
Assert-Contains $overseasFinanceOpenApi '审核通过与作废均事务性写入Outbox' '海外费用可靠发布契约'
|
||||
Assert-Contains $commonEventAgents '海外业务费用来源变更 V1' '公共海外费用事件入口'
|
||||
Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"' '海外费用事件类型'
|
||||
|
||||
@@ -159,7 +159,8 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV
|
||||
assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/)
|
||||
assert.match(source, /includes\('-P4-FBA-STANDARD:'\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/)
|
||||
assert.match(source, /runUntilPickupReady\(this\)/)
|
||||
const variant = source.slice(source.indexOf('async runPhase4ProducerVariant'), source.indexOf('async ensurePhase4PdaProducerMatrix'))
|
||||
|
||||
@@ -17,13 +17,17 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env"
|
||||
- `--expected-host` 必须与配置完全相同且以 `test-` 或 `test.` 开头,禁止生产环境。
|
||||
- `--stage preflight` 登录并查询目录、托盘和编号策略,不创建业务数据。
|
||||
- `--stage create --limit 1` 按源实际箱数、重量、体积尝试创建一票草稿,不伪造图片、尺寸或报关字段,不提交订单。按用户确认规则,源表未注明木制品时 `woodenProduct=false`;其他缺失必填项保留服务端错误,不保证所有源行能创建。
|
||||
- `--stage wait-split --allow-test-placeholders true --limit 1` 创建完整测试订单并提交、审核、测量,回查 `WAIT_SPLIT`、审核状态、箱数和货重。只有明确接受下述模拟规则时才能使用。
|
||||
- `--limit` 是本次新增尝试数,默认 1,串行执行。前三票连续失败且没有成功时停止扩大写入。
|
||||
- `--stage pending-review --allow-test-placeholders true --limit 1` 创建订单并由客户提交,回查客户状态 `SUBMITTED` 和统一状态 `PENDING_REVIEW` 后停止;不审核、不生成入库投影,不调用箱码、测量、称重、托盘或库位接口。这是源订单批量造数的默认推荐阶段。试价返回“无匹配报价”或“未达到最低重量阶梯”时只记录 `pricingWarning`,仍创建并提交到待审核,交由人工审核处理。
|
||||
- `--stage wait-split --allow-test-placeholders true --limit 1` 创建完整测试订单并提交、审核、测量,回查 `WAIT_SPLIT`、审核状态、箱数和货重。只用于少量完整链验收,不用于大批量造数。
|
||||
- `--limit` 是本次新增尝试数,默认 1;实际处理方式由 `--concurrency` 决定。前三票连续失败且没有成功时停止扩大写入。
|
||||
- `--concurrency` 是单进程并发票数,默认 1、最大 10。所有并发票共享一个带串行落盘队列的 `state.json` 和一个批次锁,不得再启动多个进程。测试环境实测 3 路可稳定运行;提高并发前必须重新小批压测,出现服务端 `S0500` 时降低并发,不能用失败率换速度。
|
||||
|
||||
## 源数据与模拟规则
|
||||
|
||||
同入仓码多行按实际箱数/毛重/体积求和,分柜明细还须与唯一预报箱数相符;同码客户、渠道、目的仓或报关方式冲突则阻断。缺失或零实际值不以预报替换。无有效入仓码的行排除,源文件保持原样。
|
||||
|
||||
订单表持久精度为毛重 3 位小数、体积 4 位小数。拆分行先按源实际值求和,再统一按该精度四舍五入后创建和回查;不逐行先取整,避免分摊尾差导致总量漂移。
|
||||
|
||||
报关方式“买单改退税”取最终退税,“退税改买单”取最终买单;空白或未识别文本阻断,不默认买单。
|
||||
|
||||
试价计费方式取“计费数据”后的第一个“单位”列:KGS/KG 映射 KG,CBM 保留。包税按服务端契约固定 KG;不包税单位缺失或分柜行冲突则阻断。
|
||||
@@ -38,20 +42,24 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env"
|
||||
|
||||
源体积可用三位小数表达时,优先以 0.001 m³ 分组,使各明细体积重和汇总体积重避免逐行向上取整差异;否则按四位精度分组。提交前同时检查明细体积重合计与批次一致,不一致记录服务端取整阻断,不提交审核或伪造总量。
|
||||
|
||||
照片、缺失产品数量/单价/HS/材质/危险品属性等为明确标记的测试占位,不能用于真实报关、计费准确性或实物测量验收。木制品未注明时按用户确认默认非木制品。多行或超长货件标识不截取为另一个真实 ID,模拟模式用 TEST 标识、草稿模式留空,完整原值仍在源文件中。`create` 模式不添加这些占位值。
|
||||
产品照片、缺失产品数量/单价/HS/材质/危险品属性等为明确标记的测试占位,不能用于真实报关、计费准确性或实物测量验收。木制品未注明时按用户确认默认非木制品。同一批次只上传一次产品占位图并在订单中复用;箱级测量图和每托称重图仍逐条上传作为正式流程要求的证据。客户箱规每次进程启动只查询一次。多行或超长货件标识不截取为另一个真实 ID,模拟模式用 TEST 标识、草稿模式留空,完整原值仍在源文件中。`create` 模式不添加这些占位值。
|
||||
|
||||
托盘只允许 `TP-001` 和 `YW-TP-002`,任一缺失则停止;不调用托盘/库位新增接口。普通位与公共位 A0 由正式测量链分配。一件代发后续需要新建工作托盘,与当前禁止新增约束有待确认,因此先记录阻断,不降为普通单或擅自创建工作托盘。
|
||||
测量草稿是带版本的幂等更新。仅当该接口返回服务端 `S0500` 时,脚本最多重读两次最新测量版本后重试,并把每次 requestId/traceId 记入订单检查点;业务校验错误、其他接口错误和第三次失败均原样终止该票,不无限重试。
|
||||
|
||||
只有 `wait-split` 阶段会查询并复用 `TP-001` 和 `YW-TP-002`,任一缺失则停止;不调用托盘/库位新增接口。普通位与公共位 A0 由正式测量链分配。`pending-review` 完全不查询或占用托盘、库位。一件代发后续需要新建工作托盘,与当前禁止新增约束有待确认,因此先记录阻断,不降为普通单或擅自创建工作托盘。
|
||||
|
||||
## 报告与重跑
|
||||
|
||||
必须保留批次目录:`preflight.json` 为全部解析/主数据匹配结果;`state.json` 为源文件哈希、批次身份、源行号到新订单/入仓码的对应、每次写入前后的检查点、错误码和 requestId/traceId。该目录包含业务数据,仅在本机仓库外保存,不提交或公开分享。原始 Excel 自身是完整源数据依据。
|
||||
|
||||
同目录重新运行会跳过所有已经尝试过的票(包括成功、失败和中断票),不会自动重新创建。`limit` 每次从尚未尝试的票继续;不是完整失败恢复器。特别注意:`create` 成功的草稿不会因切换 `wait-split` 自动补齐和推进;需单独选择尚未尝试票做完整链试跑。失败/中断保留服务端部分数据,先核对检查点,不删单或自动补偿。
|
||||
同目录重新运行会跳过所有已经尝试过的票(包括成功、失败和中断票),不会自动重新创建。`pending-review` 仅安全恢复停在试价阶段、尚未创建订单的 `A0404/A0422`,旧错误保留在 `priorAttempts`。载荷规则发生变化后必须使用新的 batch-id 和输出目录,不能用旧创建幂等键发送不同载荷。`limit` 每次从尚未尝试或上述可恢复票继续;不是通用失败恢复器。特别注意:不同阶段不能用同一批状态自动推进已有订单;需使用全新批次选择尚未尝试票。失败/中断保留服务端部分数据,先核对检查点,不删单或自动补偿。任一并发票返回 `A0401` 时,本批最多完成当前并发组便立即熔断,防止令牌失效污染后续订单。批次锁记录进程 PID;异常终止后,下次启动仅在该 PID 已不存在时清理精确的陈旧锁。
|
||||
|
||||
源文件、客户、网关或批次 ID 改变时拒绝复用目录。并发进程通过 `run.lock` 拒绝;异常终止残留锁,人工确认旧进程已结束后才可移除精确锁文件。
|
||||
|
||||
用户清库并重新初始化客户、报价、仓库和原有托盘后,指定**新的 batch-id 和全新输出目录**重新运行同一脚本。不能复用旧状态,否则已尝试票会跳过。清库不由本脚本执行。
|
||||
|
||||
清库后的批量待审核运行固定使用 `--stage pending-review --allow-test-placeholders true --concurrency 10 --limit 10000`。先确认 IT测试客户、义乌仓、可选渠道与目的仓主档已经初始化;报价缺失不阻断待审核创建。运行完成以 `preflight.matched = state.orders`、全部检查点无 `STARTED`、成功项均为 `PENDING_REVIEW` 为完成口径。
|
||||
|
||||
## 本地检查
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Explicitly authorized non-production HTTP import. Never resets a database.
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, readFile, writeFile, rename, open } from 'node:fs/promises'
|
||||
import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
|
||||
@@ -45,7 +45,7 @@ export function normalizeRows(rows) {
|
||||
for (const name of ['实际件数', '实际毛重', '实际立方']) {
|
||||
if (entries.some(({ row }) => !positive(get(row, name)))) problems.push(`MISSING_${name}`)
|
||||
}
|
||||
const sum = (name) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0))
|
||||
const sum = (name, digits = 6) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0), digits)
|
||||
const packageCount = sum('实际件数')
|
||||
if (!Number.isInteger(packageCount) || packageCount < 1) problems.push('INVALID_PACKAGE_COUNT')
|
||||
if (entries.length > 1) {
|
||||
@@ -68,7 +68,10 @@ export function normalizeRows(rows) {
|
||||
sourceCustomer: text(get(first, '客户')), sourceChannel, channelName: channelName(sourceChannel),
|
||||
destination, destinationType, pricingTaxMode, taxMode, chargeMode,
|
||||
fulfillmentType: destination.includes('一件代发') ? 'ONE_CLICK_DROPSHIP' : 'STANDARD',
|
||||
packageCount, weightKg: sum('实际毛重'), volumeCbm: sum('实际立方'),
|
||||
// Match the persisted order schema: kg has 3 decimals and CBM has 4.
|
||||
// Split source rows can contain longer allocation decimals whose exact sum
|
||||
// cannot be represented by the order table.
|
||||
packageCount, weightKg: sum('实际毛重', 3), volumeCbm: sum('实际立方', 4),
|
||||
cargoName: distinct('品名').filter(Boolean).join(' / '),
|
||||
sku: distinct('SKU').filter(Boolean).join('\n'), shipmentId: distinct('FBA货件编号').filter(Boolean).join('\n'),
|
||||
referenceId: distinct('跟踪编号').filter(Boolean).join('\n'), problems }
|
||||
@@ -199,7 +202,7 @@ export function requireSuccess(response) {
|
||||
|
||||
async function main() {
|
||||
const args = new Map()
|
||||
const allowed = new Set(['input', 'sheet', 'python', 'output-dir', 'batch-id', 'stage', 'limit', 'customer-name', 'origin-warehouse-name', 'allow-test-placeholders', 'expected-host'])
|
||||
const allowed = new Set(['input', 'sheet', 'python', 'output-dir', 'batch-id', 'stage', 'limit', 'concurrency', 'customer-name', 'origin-warehouse-name', 'allow-test-placeholders', 'expected-host'])
|
||||
for (let i = 2; i < process.argv.length; i += 2) {
|
||||
const name = process.argv[i].replace(/^--/, ''), value = process.argv[i + 1]
|
||||
if (!allowed.has(name) || !value || value.startsWith('--')) throw Error(`无效参数:${name}`)
|
||||
@@ -207,10 +210,14 @@ async function main() {
|
||||
}
|
||||
for (const key of ['input', 'python', 'output-dir', 'batch-id', 'expected-host']) if (!args.get(key)) throw Error(`缺少 --${key}`)
|
||||
const stage = args.get('stage') || 'preflight'
|
||||
if (!['preflight', 'create', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/wait-split')
|
||||
if (stage === 'wait-split' && args.get('allow-test-placeholders') !== 'true') throw Error('此源表缺少必填字段,模拟测量须显式允许测试占位')
|
||||
if (!['preflight', 'create', 'pending-review', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/pending-review/wait-split')
|
||||
if (['pending-review', 'wait-split'].includes(stage) && args.get('allow-test-placeholders') !== 'true') {
|
||||
throw Error('此源表缺少提交必填字段,须显式允许测试占位')
|
||||
}
|
||||
const limit = Number(args.get('limit') || 1)
|
||||
if (!Number.isInteger(limit) || limit < 1) throw Error('limit 必须为正整数')
|
||||
const concurrency = Number(args.get('concurrency') || 1)
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 10) throw Error('concurrency 必须是1到10的整数')
|
||||
const options = validateExecutionOptions(parseArguments(['--modules', 'orders,inbound-measurement', '--quiet', 'true']))
|
||||
if (new URL(options.gateway).hostname !== args.get('expected-host') || !/^test[-.]/.test(args.get('expected-host'))) throw Error('只允许明确匹配的测试域名')
|
||||
const input = resolve(args.get('input'))
|
||||
@@ -229,16 +236,22 @@ async function main() {
|
||||
try { state = JSON.parse(await readFile(statePath, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
if (state && JSON.stringify(state.identity) !== JSON.stringify(identity)) throw Error('批次/源文件/目标发生变化,请使用独立输出目录')
|
||||
state ||= { identity, orders: {}, startedAt: new Date().toISOString() }
|
||||
const lock = await open(resolve(directory, 'run.lock'), 'wx')
|
||||
const save = async () => {
|
||||
await writeFile(`${statePath}.tmp`, JSON.stringify(state, null, 2), 'utf8')
|
||||
const lockPath = resolve(directory, 'run.lock')
|
||||
const lock = await acquireRunLock(lockPath)
|
||||
let saveQueue = Promise.resolve()
|
||||
const save = () => {
|
||||
saveQueue = saveQueue.then(async () => {
|
||||
const temporaryPath = `${statePath}.${process.pid}.tmp`
|
||||
await writeFile(temporaryPath, JSON.stringify(state, null, 2), 'utf8')
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { await rename(`${statePath}.tmp`, statePath); break }
|
||||
try { await rename(temporaryPath, statePath); break }
|
||||
catch (error) {
|
||||
if (!['EPERM', 'EBUSY', 'EACCES'].includes(error.code) || attempt >= 5) throw error
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
})
|
||||
return saveQueue
|
||||
}
|
||||
const ctx = new AcceptanceRun(options)
|
||||
const api = async (method, path, body, token = ctx.token, headers = {}) => requireSuccess(await ctx.request(method, path, body, token, headers))
|
||||
@@ -264,14 +277,35 @@ async function main() {
|
||||
const channels = await api('GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${receiving.id}`, undefined, merchant)
|
||||
const catalog = { receiving, channels, destinations: [...await list('FBA'), ...await list('OVERSEAS')] }
|
||||
const pallets = []
|
||||
for (const code of PALLETS) {
|
||||
const page = await api('GET', `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${receiving.id}&palletCode=${encodeURIComponent(code)}`)
|
||||
const found = page.records?.filter((item) => item.palletCode === code && String(item.warehouseId) === String(receiving.id))
|
||||
if (found?.length !== 1) throw Error(`REQUIRED_PALLET_MISSING_${code}`)
|
||||
pallets.push(found[0])
|
||||
if (stage === 'wait-split') {
|
||||
for (const code of PALLETS) {
|
||||
const page = await api('GET', `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${receiving.id}&palletCode=${encodeURIComponent(code)}`)
|
||||
const found = page.records?.filter((item) => item.palletCode === code && String(item.warehouseId) === String(receiving.id))
|
||||
if (found?.length !== 1) throw Error(`REQUIRED_PALLET_MISSING_${code}`)
|
||||
pallets.push(found[0])
|
||||
}
|
||||
}
|
||||
const policy = await api('GET', '/api/merchant/v1/orders/first-leg/inbound-code-policy', undefined, merchant)
|
||||
if (policy.mode !== 'AUTO') throw Error('INBOUND_CODE_POLICY_NOT_AUTO')
|
||||
let sharedResources = null
|
||||
if (['pending-review', 'wait-split'].includes(stage)) {
|
||||
let dimensionRules = null
|
||||
if (stage === 'wait-split') {
|
||||
const dimensionConfig = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`)
|
||||
totalPreservingShapes(normalized.orders.find((order) => order.problems.length === 0), dimensionConfig.rules)
|
||||
dimensionRules = dimensionConfig.rules
|
||||
}
|
||||
let productImageFileId = state.sharedResources?.productImageFileId
|
||||
if (!productImageFileId) {
|
||||
const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images',
|
||||
{ content: PNG, type: 'image/png', name: `TEST-XLSX-${state.identity.batchId}.png` }, merchant))
|
||||
productImageFileId = image?.fileId
|
||||
if (!productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_ID_MISSING')
|
||||
}
|
||||
state.sharedResources = { productImageFileId, ...(dimensionRules ? { dimensionRules } : {}) }
|
||||
sharedResources = state.sharedResources
|
||||
await save()
|
||||
}
|
||||
const candidates = [], rejected = [...normalized.excluded]
|
||||
for (const order of normalized.orders) {
|
||||
try { candidates.push({ order, route: matchRoute(order, catalog) }) }
|
||||
@@ -284,47 +318,88 @@ async function main() {
|
||||
console.log(JSON.stringify({ stage, sourceRows: preflight.sourceRows, orders: preflight.orders, matched: candidates.length, rejected: rejected.length }))
|
||||
await save()
|
||||
if (stage === 'preflight') return
|
||||
await executeCandidates({ candidates, limit, state, save, api, ctx, merchant, profile, pallets, stage })
|
||||
await executeCandidates({ candidates, limit, concurrency, state, save, api, ctx, merchant, profile, pallets,
|
||||
sharedResources, stage })
|
||||
} finally {
|
||||
await saveQueue.catch(() => {})
|
||||
await lock.close()
|
||||
const { unlink } = await import('node:fs/promises')
|
||||
await unlink(resolve(directory, 'run.lock'))
|
||||
await unlink(lockPath)
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCandidates({ candidates, limit, state, save, api, ctx, merchant, profile, pallets, stage }) {
|
||||
let attempted = 0, succeeded = 0, failed = 0, skipped = 0
|
||||
async function acquireRunLock(lockPath) {
|
||||
try {
|
||||
const handle = await open(lockPath, 'wx')
|
||||
await handle.writeFile(String(process.pid), 'utf8')
|
||||
return handle
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error
|
||||
const existingPid = Number(await readFile(lockPath, 'utf8').catch(() => ''))
|
||||
let alive = false
|
||||
if (Number.isInteger(existingPid) && existingPid > 0) {
|
||||
try { process.kill(existingPid, 0); alive = true } catch { /* Stale PID. */ }
|
||||
}
|
||||
if (alive) throw Error(`批次正在运行,pid=${existingPid}`)
|
||||
await unlink(lockPath)
|
||||
const handle = await open(lockPath, 'wx')
|
||||
await handle.writeFile(String(process.pid), 'utf8')
|
||||
return handle
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCandidates({ candidates, limit, concurrency = 1, state, save, api, ctx, merchant, profile, pallets,
|
||||
sharedResources, stage }) {
|
||||
let attempted = 0, completed = 0, succeeded = 0, failed = 0, skipped = 0, stopReason = null
|
||||
const pending = []
|
||||
for (const { order, route } of candidates) {
|
||||
// An interrupted write is not permission to create another order. Keep its last
|
||||
// checkpoint for inspection. A reset database is always a NEW batch/directory.
|
||||
if (state.orders[order.key]) { skipped++; continue }
|
||||
if (attempted >= limit) break
|
||||
const previous = state.orders[order.key]
|
||||
const retryablePricingWarning = stage === 'pending-review' && previous?.status === 'FAILED'
|
||||
&& previous.stage === 'estimate' && !previous.orderId && ['A0404', 'A0422'].includes(previous.evidence?.code)
|
||||
if (previous && !retryablePricingWarning) { skipped++; continue }
|
||||
if (pending.length >= limit) break
|
||||
pending.push({ order, route, previous })
|
||||
}
|
||||
for (let offset = 0; offset < pending.length; offset += concurrency) {
|
||||
const batch = pending.slice(offset, offset + concurrency)
|
||||
await Promise.all(batch.map(async ({ order, route, previous }) => {
|
||||
const marker = `XLSX-${hash(`${state.identity.batchId}:${order.key}`).slice(0, 24)}`
|
||||
const entry = { rows: order.rows, marker, status: 'STARTED', stage: 'estimate', startedAt: new Date().toISOString() }
|
||||
const entry = { rows: order.rows, marker, status: 'STARTED', stage: 'estimate', startedAt: new Date().toISOString(),
|
||||
...(previous ? { priorAttempts: [...(previous.priorAttempts || []), { status: previous.status, stage: previous.stage,
|
||||
error: previous.error, evidence: previous.evidence, finishedAt: previous.finishedAt }] } : {}) }
|
||||
state.orders[order.key] = entry
|
||||
await save()
|
||||
attempted++
|
||||
try {
|
||||
const estimate = await api('POST', '/api/merchant/v1/first-leg/pricing/estimate', {
|
||||
originWarehouseId: route.receiving.id, channelCode: route.channel.channelCode,
|
||||
destinationCode: route.destination.warehouseCode, destinationType: order.destinationType,
|
||||
destinationWarehouseId: route.destination.id, destinationName: route.destination.warehouseName,
|
||||
pricingTaxMode: order.pricingTaxMode, chargeMode: order.chargeMode,
|
||||
actualWeightKg: order.weightKg, actualVolumeCbm: order.volumeCbm,
|
||||
}, merchant)
|
||||
if (estimate?.channelCode !== route.channel.channelCode || estimate?.pricingTaxMode !== order.pricingTaxMode) throw Error('ESTIMATE_ROUTE_MISMATCH')
|
||||
let estimate = null
|
||||
try {
|
||||
estimate = await api('POST', '/api/merchant/v1/first-leg/pricing/estimate', {
|
||||
originWarehouseId: route.receiving.id, channelCode: route.channel.channelCode,
|
||||
destinationCode: route.destination.warehouseCode, destinationType: order.destinationType,
|
||||
destinationWarehouseId: route.destination.id, destinationName: route.destination.warehouseName,
|
||||
pricingTaxMode: order.pricingTaxMode, chargeMode: order.chargeMode,
|
||||
actualWeightKg: order.weightKg, actualVolumeCbm: order.volumeCbm,
|
||||
}, merchant)
|
||||
} catch (error) {
|
||||
if (stage !== 'pending-review' || !['A0404', 'A0422'].includes(error.evidence?.code)) throw error
|
||||
entry.pricingWarning = { code: error.evidence.code, message: error.evidence.message,
|
||||
requestId: error.evidence.requestId, traceId: error.evidence.traceId }
|
||||
}
|
||||
if (estimate && (estimate.channelCode !== route.channel.channelCode || estimate.pricingTaxMode !== order.pricingTaxMode)) {
|
||||
throw Error('ESTIMATE_ROUTE_MISMATCH')
|
||||
}
|
||||
let payload
|
||||
if (stage === 'create') {
|
||||
payload = buildSourceDraft(order, route, profile.tenantName, marker)
|
||||
} else {
|
||||
const config = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`)
|
||||
entry.dimensionPlan = totalPreservingShapes(order, config.rules)
|
||||
entry.stage = 'product-image'; await save()
|
||||
const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images',
|
||||
{ content: PNG, type: 'image/png', name: `${marker}-TEST.png` }, merchant))
|
||||
if (!image?.fileId) throw Error('PRODUCT_IMAGE_ID_MISSING')
|
||||
entry.productImageFileId = image.fileId
|
||||
payload = buildPayload(order, route, profile.tenantName, image.fileId, marker)
|
||||
if (!sharedResources?.productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_MISSING')
|
||||
if (stage === 'wait-split') {
|
||||
if (!Array.isArray(sharedResources.dimensionRules)) throw Error('SHARED_DIMENSION_RULES_MISSING')
|
||||
entry.dimensionPlan = totalPreservingShapes(order, sharedResources.dimensionRules)
|
||||
}
|
||||
entry.productImageFileId = sharedResources.productImageFileId
|
||||
payload = buildPayload(order, route, profile.tenantName, sharedResources.productImageFileId, marker)
|
||||
}
|
||||
entry.stage = 'create'; entry.payloadHash = hash(JSON.stringify(payload)); await save()
|
||||
entry.orderId = String(await api('POST', '/api/merchant/v1/orders/first-leg', payload, merchant,
|
||||
@@ -340,9 +415,25 @@ export async function executeCandidates({ candidates, limit, state, save, api, c
|
||||
|| Math.abs(Number(cargo.volumeCbm) - order.volumeCbm) > 0.00001) throw Error('DRAFT_SOURCE_FACTS_MISMATCH')
|
||||
entry.orderNo = draft.order.orderNo
|
||||
entry.declarationVerified = true
|
||||
if (stage === 'wait-split') {
|
||||
if (['pending-review', 'wait-split'].includes(stage)) {
|
||||
entry.stage = 'submit'; await save()
|
||||
await api('POST', `${clientPath}/submit`, undefined, merchant, { 'Idempotency-Key': `${marker}-submit` })
|
||||
}
|
||||
if (stage === 'pending-review') {
|
||||
const submitted = await api('GET', clientPath, undefined, merchant)
|
||||
const unified = await api('GET', `/api/merchant/v1/orders/${entry.orderId}`, undefined, merchant)
|
||||
if (submitted.order?.status !== 'SUBMITTED' || unified.summary?.mainStatus !== 'PENDING_REVIEW') {
|
||||
const error = Error('PENDING_REVIEW_NOT_CONFIRMED')
|
||||
error.evidence = { clientStatus: submitted.order?.status, mainStatus: unified.summary?.mainStatus }
|
||||
throw error
|
||||
}
|
||||
entry.stage = 'PENDING_REVIEW'
|
||||
entry.verification = { clientStatus: submitted.order.status, mainStatus: unified.summary.mainStatus,
|
||||
expectedPackages: order.packageCount, actualPackages: Number(submitted.cargoes?.[0]?.packageCount),
|
||||
expectedCargoWeightKg: order.weightKg, actualCargoWeightKg: Number(submitted.cargoes?.[0]?.grossWeightKg),
|
||||
expectedVolumeCbm: order.volumeCbm, actualVolumeCbm: Number(submitted.cargoes?.[0]?.volumeCbm) }
|
||||
}
|
||||
if (stage === 'wait-split') {
|
||||
entry.stage = 'approve'; await save()
|
||||
const platformPath = `/api/platform/v1/orders/first-leg/${entry.orderId}`
|
||||
const q = `tenantId=${profile.id}`
|
||||
@@ -358,14 +449,22 @@ export async function executeCandidates({ candidates, limit, state, save, api, c
|
||||
entry.status = 'SUCCEEDED'; succeeded++
|
||||
} catch (error) {
|
||||
entry.status = 'FAILED'; entry.error = error.message; entry.evidence = error.evidence; failed++
|
||||
if (error.evidence?.code === 'A0401') stopReason ||= 'AUTHENTICATION_EXPIRED'
|
||||
}
|
||||
completed++
|
||||
entry.finishedAt = new Date().toISOString(); await save()
|
||||
console.log(JSON.stringify({ attempted, succeeded, failed, skipped, rows: entry.rows,
|
||||
stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode, error: entry.error, evidence: entry.evidence }))
|
||||
// A small pilot must expose a common deployment/data defect before multiplying it.
|
||||
if (entry.status === 'FAILED' || completed % 100 === 0 || completed === pending.length) {
|
||||
console.log(JSON.stringify({ attempted, completed, succeeded, failed, skipped, rows: entry.rows,
|
||||
stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode,
|
||||
error: entry.error, evidence: entry.evidence }))
|
||||
}
|
||||
}))
|
||||
if (stopReason) break
|
||||
// A small concurrent pilot must expose a common deployment/data defect before multiplying it.
|
||||
if (failed >= 3 && succeeded === 0) break
|
||||
}
|
||||
state.lastRun = { attempted, succeeded, failed, skipped, stage, finishedAt: new Date().toISOString() }
|
||||
state.lastRun = { attempted, completed, succeeded, failed, skipped, concurrency, stage, stopReason,
|
||||
finishedAt: new Date().toISOString() }
|
||||
await save()
|
||||
if (failed) process.exitCode = 1
|
||||
}
|
||||
@@ -383,6 +482,34 @@ async function poll(read, predicate) {
|
||||
throw error
|
||||
}
|
||||
|
||||
export async function saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker,
|
||||
attempts = 3, wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), onRetry }) {
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
const measured = await api('GET', measurementPath)
|
||||
if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS')
|
||||
const payload = {
|
||||
measurementId: measured.measurementId, version: Number(measured.version),
|
||||
actualGoodsName: oneValue(order.cargoName, '测试导入汇总货物', 128),
|
||||
items: entry.dimensionPlan.map((group) => {
|
||||
const item = measured.items.find((value) => String(value.id) === group.itemId)
|
||||
if (!item) throw Error('MEASUREMENT_GROUP_MISSING')
|
||||
return { inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
|
||||
packageCount: group.count, lengthCm: group.length, widthCm: group.width, heightCm: group.height,
|
||||
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null,
|
||||
outerLengthCm: group.length, outerWidthCm: group.width, outerHeightCm: group.height,
|
||||
coefficientMode: 'ADD', remark: `${marker} TEST按总体积分组反算箱规,非实测` }
|
||||
}),
|
||||
}
|
||||
try { return await api('PUT', `${base}/measurement/draft?${q}`, payload) }
|
||||
catch (error) {
|
||||
if (error.evidence?.code !== 'S0500' || attempt === attempts) throw error
|
||||
await onRetry?.({ attempt, code: error.evidence.code, requestId: error.evidence.requestId, traceId: error.evidence.traceId })
|
||||
await wait(250 * attempt)
|
||||
}
|
||||
}
|
||||
throw Error('MEASUREMENT_DRAFT_RETRY_EXHAUSTED')
|
||||
}
|
||||
|
||||
async function measureOrder({ order, entry, ctx, api, pallets, tenantId, save }) {
|
||||
const inboundId = entry.inboundId, q = `tenantId=${tenantId}`, marker = entry.marker
|
||||
const inboundPath = `/api/platform/inbounds/${inboundId}?${q}`
|
||||
@@ -411,21 +538,8 @@ async function measureOrder({ order, entry, ctx, api, pallets, tenantId, save })
|
||||
group.itemId = String(scan.itemId)
|
||||
cartonIndex += group.count
|
||||
}
|
||||
const measured = await api('GET', measurementPath)
|
||||
if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS')
|
||||
const draft = await api('PUT', `${base}/measurement/draft?${q}`, {
|
||||
measurementId: measured.measurementId, version: Number(measured.version),
|
||||
actualGoodsName: oneValue(order.cargoName, '测试导入汇总货物', 128),
|
||||
items: entry.dimensionPlan.map((group) => {
|
||||
const item = measured.items.find((value) => String(value.id) === group.itemId)
|
||||
if (!item) throw Error('MEASUREMENT_GROUP_MISSING')
|
||||
return { inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
|
||||
packageCount: group.count, lengthCm: group.length, widthCm: group.width, heightCm: group.height,
|
||||
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null,
|
||||
outerLengthCm: group.length, outerWidthCm: group.width, outerHeightCm: group.height,
|
||||
coefficientMode: 'ADD',
|
||||
remark: `${marker} TEST按总体积分组反算箱规,非实测` } }),
|
||||
})
|
||||
const draft = await saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker,
|
||||
onRetry: async (retry) => { entry.retries ||= []; entry.retries.push({ stage: 'measurement-draft', ...retry }); await save() } })
|
||||
const draftCheck = await api('GET', measurementPath)
|
||||
if (Math.abs(Number(draftCheck.actualVolumeCbm) - order.volumeCbm) > 0.0000001
|
||||
|| Number(draftCheck.actualPackageCount) !== order.packageCount) throw Error('DRAFT_VOLUME_OR_COUNT_MISMATCH')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { normalizeRows, channelName, matchRoute, measurementShape, buildSourceDraft, buildPayload,
|
||||
requireSuccess, executeCandidates, totalPreservingShapes } from './real-order-xlsx-test-env.mjs'
|
||||
requireSuccess, executeCandidates, totalPreservingShapes, saveMeasurementDraft } from './real-order-xlsx-test-env.mjs'
|
||||
|
||||
const header = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数', '买单/退税', '品名', 'SKU', 'FBA货件编号', '跟踪编号']
|
||||
const row = ['ABCD2606010001', 'Example', '合德海卡包税', 'ABC1', 31, 100, 2, 31, '买单', 'Goods', 'SKU1', 'SHIP1', 'REF1']
|
||||
@@ -39,6 +39,15 @@ test('split rows aggregate actual counts once and reconcile forecast', () => {
|
||||
b[4] = 15
|
||||
assert.ok(normalizeRows([header, a, b]).orders[0].problems.includes('SPLIT_COUNT_MISMATCH'))
|
||||
})
|
||||
test('split weight and volume totals use the persisted order precision', () => {
|
||||
const a = [...row], b = [...row]
|
||||
a[4] = 90; b[4] = 300; a[7] = 390; b[7] = 390
|
||||
a[5] = 1137.461538; b[5] = 3791.538
|
||||
a[6] = 7.256538462; b[6] = 24.188
|
||||
const result = normalizeRows([header, a, b]).orders[0]
|
||||
assert.equal(result.weightKg, 4929)
|
||||
assert.equal(result.volumeCbm, 31.4445)
|
||||
})
|
||||
test('conflicting channels on same inbound code block import', () => {
|
||||
const b = [...row]; b[2] = '其他包税'
|
||||
assert.ok(normalizeRows([header, row, b]).orders[0].problems.includes('CONFLICT_渠道'))
|
||||
@@ -105,9 +114,106 @@ test('HTTP business errors preserve diagnostic evidence', () => {
|
||||
assert.throws(() => requireSuccess({ http: 200, path: '/example', body: { code: 'A0409' }, requestId: 'req' }),
|
||||
(error) => error.evidence.code === 'A0409' && error.evidence.requestId === 'req')
|
||||
})
|
||||
test('measurement draft retries a transient server error after refreshing the version', async () => {
|
||||
let reads = 0, writes = 0
|
||||
const retries = []
|
||||
const entry = { dimensionPlan: [{ itemId: 'item-1', count: 31, length: 40, width: 30, height: 30 }] }
|
||||
const api = async (method) => {
|
||||
if (method === 'GET') return { measurementId: 'measurement-1', version: ++reads,
|
||||
items: [{ id: 'item-1', inboundDetailId: 'detail-1', itemNo: 1, boxSpecification: 'TEST' }] }
|
||||
writes++
|
||||
if (writes === 1) { const error = Error('temporary'); error.evidence = { code: 'S0500', requestId: 'req-1' }; throw error }
|
||||
return { measurementId: 'measurement-1', version: 3 }
|
||||
}
|
||||
const result = await saveMeasurementDraft({ api, measurementPath: '/measurement', base: '/inbound/1', q: 'tenantId=1',
|
||||
order: order(), entry, marker: 'MARKER', wait: async () => {}, onRetry: async (retry) => retries.push(retry) })
|
||||
assert.equal(result.version, 3)
|
||||
assert.equal(reads, 2)
|
||||
assert.equal(writes, 2)
|
||||
assert.deepEqual(retries.map((retry) => retry.code), ['S0500'])
|
||||
})
|
||||
test('same batch never recreates an attempted order, even when interrupted', async () => {
|
||||
const value = order(), state = { identity: { batchId: 'test' }, orders: { [value.key]: { status: 'STARTED' } } }
|
||||
await executeCandidates({ candidates: [{ order: value, route: catalog }], limit: 1, state, save: async () => {},
|
||||
api: async () => { throw Error('must not call HTTP') } })
|
||||
assert.equal(state.lastRun.attempted, 0); assert.equal(state.lastRun.skipped, 1)
|
||||
})
|
||||
test('pending-review stage submits but never calls approval or measurement APIs', async () => {
|
||||
const value = order(), route = matchRoute(value, catalog)
|
||||
const state = { identity: { batchId: 'pending-review' }, orders: {} }
|
||||
const calls = []
|
||||
let detailReads = 0
|
||||
const api = async (method, path) => {
|
||||
calls.push(`${method} ${path}`)
|
||||
if (path.endsWith('/pricing/estimate')) return { channelCode: route.channel.channelCode, pricingTaxMode: value.pricingTaxMode }
|
||||
if (method === 'POST' && path === '/api/merchant/v1/orders/first-leg') return 'order-1'
|
||||
if (path === '/api/merchant/v1/orders/first-leg/order-1' && method === 'GET') {
|
||||
detailReads++
|
||||
return { order: { id: 'order-1', orderNo: 'FL-1', customerRemark: Object.values(state.orders)[0].marker,
|
||||
status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' },
|
||||
cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] }
|
||||
}
|
||||
if (path === '/api/merchant/v1/orders/order-1') return { summary: { mainStatus: 'PENDING_REVIEW' } }
|
||||
if (method === 'POST' && path.endsWith('/submit')) return true
|
||||
throw Error(`unexpected call ${method} ${path}`)
|
||||
}
|
||||
await executeCandidates({ candidates: [{ order: value, route }], limit: 1, concurrency: 1, state,
|
||||
save: async () => {}, api, merchant: 'merchant', profile: { tenantName: 'Example' },
|
||||
sharedResources: { productImageFileId: 'image-1' }, stage: 'pending-review' })
|
||||
assert.equal(Object.values(state.orders)[0].stage, 'PENDING_REVIEW')
|
||||
assert.equal(Object.values(state.orders)[0].verification.mainStatus, 'PENDING_REVIEW')
|
||||
assert.equal(calls.some((call) => /approve|measurement|inbounds/.test(call)), false)
|
||||
})
|
||||
test('pending-review retries an estimate-only pricing failure and records it as a warning', async () => {
|
||||
const value = order(), route = matchRoute(value, catalog)
|
||||
const state = { identity: { batchId: 'pricing-warning' }, orders: { [value.key]: { status: 'FAILED', stage: 'estimate',
|
||||
error: 'HTTP_200_CODE_A0404', evidence: { code: 'A0404', requestId: 'old-request' }, finishedAt: '2026-01-01T00:00:00Z' } } }
|
||||
let detailReads = 0
|
||||
const api = async (method, path) => {
|
||||
if (path.endsWith('/pricing/estimate')) { const error = Error('no price'); error.evidence = { code: 'A0404', message: 'no price' }; throw error }
|
||||
if (method === 'POST' && path === '/api/merchant/v1/orders/first-leg') return 'order-2'
|
||||
if (path === '/api/merchant/v1/orders/first-leg/order-2' && method === 'GET') {
|
||||
detailReads++
|
||||
return { order: { id: 'order-2', orderNo: 'FL-2', customerRemark: Object.values(state.orders)[0].marker,
|
||||
status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' },
|
||||
cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] }
|
||||
}
|
||||
if (path === '/api/merchant/v1/orders/order-2') return { summary: { mainStatus: 'PENDING_REVIEW' } }
|
||||
if (method === 'POST' && path.endsWith('/submit')) return true
|
||||
throw Error(`unexpected call ${method} ${path}`)
|
||||
}
|
||||
await executeCandidates({ candidates: [{ order: value, route }], limit: 1, concurrency: 1, state,
|
||||
save: async () => {}, api, merchant: 'merchant', profile: { tenantName: 'Example' },
|
||||
sharedResources: { productImageFileId: 'image-1' }, stage: 'pending-review' })
|
||||
const entry = state.orders[value.key]
|
||||
assert.equal(entry.status, 'SUCCEEDED')
|
||||
assert.equal(entry.pricingWarning.code, 'A0404')
|
||||
assert.equal(entry.priorAttempts.length, 1)
|
||||
})
|
||||
test('single process respects bounded concurrency while keeping per-order checkpoints', async () => {
|
||||
const base = order()
|
||||
const route = matchRoute(base, catalog)
|
||||
const candidates = Array.from({ length: 7 }, (_, index) => ({
|
||||
order: { ...base, key: `key-${index}`, sourceCode: `SRC-${index}`, rows: [index + 2] }, route,
|
||||
}))
|
||||
const state = { identity: { batchId: 'concurrency' }, orders: {} }
|
||||
let active = 0, maximum = 0, id = 0
|
||||
const api = async (method, path, body) => {
|
||||
if (path.endsWith('/pricing/estimate')) {
|
||||
active++; maximum = Math.max(maximum, active)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
active--
|
||||
return { channelCode: route.channel.channelCode, pricingTaxMode: base.pricingTaxMode }
|
||||
}
|
||||
if (method === 'POST') return String(++id)
|
||||
const marker = path.split('/').at(-1)
|
||||
return { order: { id: marker, orderNo: `ORDER-${marker}`, customerRemark: Object.values(state.orders)
|
||||
.find((entry) => entry.orderId === marker)?.marker }, cargoes: [{ packageCount: 31, grossWeightKg: 100, volumeCbm: 2 }] }
|
||||
}
|
||||
await executeCandidates({ candidates, limit: 7, concurrency: 3, state, save: async () => {}, api,
|
||||
profile: { tenantName: 'Example' }, stage: 'create' })
|
||||
assert.equal(maximum, 3)
|
||||
assert.equal(state.lastRun.attempted, 7)
|
||||
assert.equal(state.lastRun.succeeded, 7)
|
||||
assert.equal(Object.values(state.orders).filter((entry) => entry.status === 'SUCCEEDED').length, 7)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user