1214 lines
81 KiB
JavaScript
1214 lines
81 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
|
|
const MODULE = 'orders'
|
|
const CONTRACT_OPTIONS = { scenario: 'isolated-order-chain', deploymentBlocker: true }
|
|
const BOXES = [
|
|
{ boxType: 'SMALL', lengthCm: 30, widthCm: 20, heightCm: 20 },
|
|
{ boxType: 'MEDIUM', lengthCm: 40, widthCm: 30, heightCm: 30 },
|
|
{ boxType: 'LARGE', lengthCm: 50, widthCm: 40, heightCm: 40 },
|
|
{ boxType: 'EXTRA_LARGE', lengthCm: 60, widthCm: 50, heightCm: 50 },
|
|
]
|
|
const MEASURABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
|
const PRICING_PROBE_CONCURRENCY = 6
|
|
const ORDER_ESTIMATE_WEIGHT_KG = BOXES.reduce((total, _box, index) => total + 10 + index, 0)
|
|
const ORDER_ESTIMATE_VOLUME_CBM = BOXES.reduce((total, box) =>
|
|
total + box.lengthCm * box.widthCm * box.heightCm / 1_000_000, 0)
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
|
|
function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite(Number(value?.total)) }
|
|
function success(response) { return Boolean(response) && response.http === 200 && String(response.body?.code) === '200' }
|
|
function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
|
|
async function requestTransportRetry(context, method, path, body, token, extraHeaders = {}) {
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
const response = await request(context, method, path, body, token, extraHeaders)
|
|
if (response?.http !== 0 || attempt === 3) return response
|
|
await sleep(500 * attempt)
|
|
}
|
|
}
|
|
function rotate(values, offset) {
|
|
if (!Array.isArray(values) || values.length < 2) return Array.isArray(values) ? [...values] : []
|
|
const shift = Math.abs(Number(offset) || 0) % values.length
|
|
return [...values.slice(shift), ...values.slice(0, shift)]
|
|
}
|
|
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
|
|
export function freezeOrderCommand(detail, idempotencyKey, payload = {}) {
|
|
const version = detail?.order?.version
|
|
if (!Number.isSafeInteger(version) || version < 0 || !idempotencyKey) throw new Error('订单首次详情版本或幂等键缺失')
|
|
const command = structuredClone({ body: { ...payload, expectedVersion: version }, headers: { 'Idempotency-Key': idempotencyKey } })
|
|
const freeze = value => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) } }
|
|
freeze(command)
|
|
return command
|
|
}
|
|
export async function verifyPlatformDraftEdit(context, tenantId, orderId, merchantToken, payload) {
|
|
const path = `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
|
|
const read = async label => context.apiSuccess(MODULE, label, await context.request('GET', path))
|
|
const before = await read('Admin 编辑前冻结客户订单版本')
|
|
if (id(before?.order?.tenantId) !== id(tenantId) || id(before?.order?.id) !== id(orderId) || before.order.status !== 'DRAFT') throw new Error('平台编辑客户/草稿身份不符')
|
|
const editedPayload = structuredClone(payload)
|
|
editedPayload.customerRemark = `${payload.customerRemark} 平台编辑验收`
|
|
const command = freezeOrderCommand(before, key(context, 'platform-draft-edit'), { draft: editedPayload })
|
|
context.createdResources.orders.platformEditCommand = command
|
|
const send = (body, headers, token = context.token) => context.request('PUT', path, body, token, headers)
|
|
const unauthorized = await send(command.body, command.headers, merchantToken)
|
|
context.assert(MODULE, 'Client不得调用Platform编辑接口', unauthorized,
|
|
[200,403].includes(unauthorized.http) && String(unauthorized.body?.code) === 'A0403', 'merchant-token/platform-route', CONTRACT_OPTIONS)
|
|
const afterDenied = await read('平台编辑越权拒绝后回查')
|
|
context.assert(MODULE, '越权平台编辑后完整事实不变', null, orderPersistenceFacts(before) === orderPersistenceFacts(afterDenied), 'no-drift', CONTRACT_OPTIONS)
|
|
context.apiSuccess(MODULE, 'Admin按客户范围版本化编辑草稿', await send(command.body, command.headers))
|
|
const edited = await read('平台编辑后权威详情')
|
|
context.assert(MODULE, '平台编辑保持身份且版本与UPDATE历史递增', null,
|
|
id(edited?.order?.id) === id(orderId) && id(edited.order.tenantId) === id(tenantId) && edited.order.status === 'DRAFT'
|
|
&& edited.order.version > before.order.version && edited.order.customerRemark === editedPayload.customerRemark
|
|
&& edited.statusHistory.length === before.statusHistory.length + 1, 'versioned-edit', CONTRACT_OPTIONS)
|
|
context.apiSuccess(MODULE, 'Platform编辑原键原版本重放成功', await send(command.body, command.headers))
|
|
for (const [label, body, headers] of [
|
|
['Platform编辑原键异载荷冲突', {...command.body, draft:{...editedPayload,customerRemark:`${editedPayload.customerRemark}-conflict`}},command.headers],
|
|
['Platform编辑旧页面版本冲突', command.body, {'Idempotency-Key':key(context,'platform-edit-stale')}],
|
|
]) {
|
|
const rejected = await send(body, headers)
|
|
context.assert(MODULE, label, rejected, rejected.http === 200 && String(rejected.body?.code) === 'A0409', 'exact-conflict', CONTRACT_OPTIONS)
|
|
}
|
|
const stable = await read('Platform重放及冲突后完整事实')
|
|
context.assert(MODULE, 'Platform重放与拒绝不重复写入', null, orderPersistenceFacts(stable) === orderPersistenceFacts(edited), 'order/cargo/parties/history', CONTRACT_OPTIONS)
|
|
return stable
|
|
}
|
|
export async function verifyPlatformSubmitCancel(context, tenantId, payload, policy) {
|
|
const base = '/api/platform/v1/orders/first-leg'
|
|
const scope = `tenantId=${encodeURIComponent(tenantId)}`
|
|
const proofs = context.createdResources.orders.platformLifecycle = []
|
|
for (const action of ['submit', 'cancel']) {
|
|
const draftPayload = { ...structuredClone(payload), ...fixtureInboundCode(policy, `${context.marker}-PLATFORM-${action}`),
|
|
customerRemark: `${context.marker} Platform ${action} independent draft` }
|
|
const created = await context.request('POST', `${base}?${scope}`, draftPayload, context.token,
|
|
{ 'Idempotency-Key': key(context, `platform-${action}-create`) })
|
|
const orderId = id(context.apiSuccess(MODULE, `Platform ${action} 创建本轮独立草稿`, created))
|
|
if (!orderId) throw new Error(`Platform ${action} fixture order missing`)
|
|
const path = `${base}/${encodeURIComponent(orderId)}?${scope}`
|
|
const mutationPath = `${base}/${encodeURIComponent(orderId)}/${action}?${scope}`
|
|
const proof = { action, tenantId, orderId, commands: [], checks: [] }
|
|
proofs.push(proof)
|
|
const assertFact = (label, response, passed, evidence) => {
|
|
context.assert(MODULE, `Platform ${action} ${label}`, response, passed, evidence, CONTRACT_OPTIONS)
|
|
if (!passed) throw new Error(`Platform ${action} ${label}`)
|
|
}
|
|
const read = async label => {
|
|
const response = await context.request('GET', path)
|
|
const detail = context.apiSuccess(MODULE, `Platform ${action} ${label}`, response)
|
|
assertFact('详情身份与完整事实集合', response, id(detail?.order?.id) === orderId && id(detail?.order?.tenantId) === id(tenantId)
|
|
&& Number.isSafeInteger(detail.order.version) && detail.order.version >= 0
|
|
&& ['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(detail[field])), 'exact-customer/order/version/aggregate')
|
|
return detail
|
|
}
|
|
const same = (label, before, after) => assertFact(label, null, orderPersistenceFacts(before) === orderPersistenceFacts(after), 'state/version/cargo/parties/notes/history unchanged')
|
|
const original = await read('冻结首次草稿版本')
|
|
assertFact('取消与提交前置仅为未履约草稿', created, original.order.status === 'DRAFT', 'DRAFT-only')
|
|
// Advance through an actual edit, so stale version 0 remains valid input rather than Min(0) validation failure.
|
|
const editCommand = freezeOrderCommand(original, key(context, `platform-${action}-prepare-edit`), { draft: draftPayload })
|
|
context.apiSuccess(MODULE, `Platform ${action} 形成真实旧页面版本`,
|
|
await context.request('PUT', path, editCommand.body, context.token, editCommand.headers))
|
|
const current = await read('冻结动作首次有效请求版本')
|
|
assertFact('编辑后仍为草稿且版本增加', null, current.order.status === 'DRAFT' && current.order.version > original.order.version, 'old-page-version-created')
|
|
const extra = action === 'cancel' ? { reason: `${context.marker} independent draft cancellation` } : {}
|
|
const command = freezeOrderCommand(current, key(context, `platform-${action}-execute`), extra)
|
|
proof.originalVersion = original.order.version
|
|
proof.command = command
|
|
const send = async (label, body, headers) => {
|
|
proof.commands.push({ label, path: mutationPath, body: structuredClone(body), headers: structuredClone(headers) })
|
|
return context.request('POST', mutationPath, body, context.token, headers)
|
|
}
|
|
const reject = async (label, body, headers, code, expected, statuses = [200]) => {
|
|
const response = await send(label, body, headers)
|
|
assertFact(label, response, statuses.includes(response?.http) && String(response.body?.code) === code, `expected=${code}`)
|
|
same(`${label}后零漂移`, expected, await read(`${label}后回查`))
|
|
proof.checks.push({ label, code })
|
|
}
|
|
await reject('首次旧页面版本拒绝', { ...extra, expectedVersion: original.order.version },
|
|
{ 'Idempotency-Key': key(context, `platform-${action}-stale-first`) }, 'A0409', current)
|
|
await reject('首次缺版本拒绝', extra,
|
|
{ 'Idempotency-Key': key(context, `platform-${action}-missing-version`) }, 'A0422', current)
|
|
await reject('首次缺幂等键拒绝', command.body, {}, 'A0400', current, [200,400])
|
|
context.apiSuccess(MODULE, `Platform ${action} 原版本原键成功`, await send('success', command.body, command.headers))
|
|
const completed = await read('成功后的事实快照')
|
|
const expectedStatus = action === 'submit' ? 'SUBMITTED' : 'CANCELLED'
|
|
const oldHistoryIds = new Set(current.statusHistory.map(row => id(row.id)))
|
|
const added = completed.statusHistory.filter(row => !oldHistoryIds.has(id(row.id)))
|
|
assertFact('成功状态版本与唯一动作历史', null, completed.order.status === expectedStatus && completed.order.version > current.order.version
|
|
&& added.length === 1 && added[0].action === expectedStatus && added[0].fromStatus === 'DRAFT' && added[0].toStatus === expectedStatus
|
|
&& Boolean(added[0].operatorId && added[0].occurredAt)
|
|
&& (action !== 'cancel' || added[0].reason === extra.reason), 'one attributed lifecycle history')
|
|
assertFact('成功后货物旧历史及业务身份保留', null,
|
|
['cargoes','parties','internalNotes'].every(field => JSON.stringify(current[field]) === JSON.stringify(completed[field]))
|
|
&& JSON.stringify(current.statusHistory) === JSON.stringify(completed.statusHistory.filter(row => oldHistoryIds.has(id(row.id))))
|
|
&& ['id','tenantId','customerId','orderNo'].every(field => current.order[field] === completed.order[field]), 'no unrelated mutations')
|
|
context.apiSuccess(MODULE, `Platform ${action} 原键原版本精确重放成功`, await send('replay', command.body, command.headers))
|
|
same('成功重放后零漂移', completed, await read('原键重放后回查'))
|
|
const changed = action === 'submit' ? { expectedVersion: completed.order.version } : { ...command.body, reason: `${extra.reason}-changed` }
|
|
await reject('原键异载荷拒绝', changed, command.headers, 'A0409', completed)
|
|
await reject('新键重复动作拒绝', { ...extra, expectedVersion: completed.order.version },
|
|
{ 'Idempotency-Key': key(context, `platform-${action}-repeat-new-key`) }, 'A0409', completed)
|
|
proof.finalStatus = expectedStatus
|
|
proof.finalFactsSha256 = createHash('sha256').update(orderPersistenceFacts(completed)).digest('hex')
|
|
}
|
|
return proofs
|
|
}
|
|
export function fixtureInboundCode(policy, marker) {
|
|
if (policy?.mode === 'AUTO' && policy.manualEntryRequired === false) return {}
|
|
if (policy?.mode !== 'LEGACY_MANUAL' || policy.manualEntryRequired !== true || !marker) {
|
|
throw new Error('入仓码策略缺失或不一致,停止创建测试订单')
|
|
}
|
|
// Short enough for the 34-character carton QR limit; stable within this fresh fixture.
|
|
return { inboundCode: `AT${createHash('sha256').update(marker).digest('hex').slice(0, 20).toUpperCase()}` }
|
|
}
|
|
|
|
export function cargoRequirementNegativeCases(payload) {
|
|
const cases = [
|
|
{ name: '木制品未选择商检', at: 'CREATE', mutate: value => { value.customsInspection = false; value.cargoes[0].woodenProduct = true } },
|
|
{ name: '货物零箱数', at: 'CREATE', mutate: value => { value.cargoes[0].packageCount = 0 } },
|
|
{ name: '货物总件数与每箱件数不守恒', at: 'CREATE', mutate: value => { value.cargoes[0].totalQuantity++ } },
|
|
]
|
|
if (payload.destinationType === 'FBA') {
|
|
cases.push({ name: 'FBA缺少订单货件编号', at: 'SUBMIT', mutate: value => { value.shipmentId = null } },
|
|
{ name: 'FBA缺少逐行跟踪编号', at: 'SUBMIT', mutate: value => { value.cargoes[0].referenceId = null } })
|
|
}
|
|
if (payload.fulfillmentType === 'ONE_CLICK_DROPSHIP') {
|
|
cases.push({ name: '一件代发缺少SKU', at: 'CREATE_OR_SUBMIT', mutate: value => { value.cargoes[0].skuNo = null } },
|
|
{ name: '一件代发混箱组箱规冲突', at: 'SUBMIT', mutate: value => {
|
|
const extra = structuredClone(value.cargoes[0]); extra.skuNo = `${extra.skuNo}-B`.slice(0, 64); extra.lengthCm++
|
|
value.cargoes.push(extra)
|
|
} })
|
|
}
|
|
return cases.map(({ mutate, ...item }) => { const body = structuredClone(payload); mutate(body); return { ...item, body } })
|
|
}
|
|
|
|
async function verifyCargoRequirementNegatives(context, payload, policy, merchantToken) {
|
|
const base = '/api/merchant/v1/orders/first-leg'
|
|
const cases = cargoRequirementNegativeCases(payload)
|
|
const evidence = []
|
|
for (const [index, item] of cases.entries()) {
|
|
const body = { ...item.body, ...fixtureInboundCode(policy, `${context.marker}-NEG-${index}`) }
|
|
const created = await request(context, 'POST', base, body, merchantToken,
|
|
{ 'Idempotency-Key': key(context, `cargo-negative-${index}-create`) })
|
|
if (item.at === 'CREATE' || String(created?.body?.code) !== '200') {
|
|
context.assert(MODULE, `${payload.destinationType}/${payload.fulfillmentType} ${item.name}创建拒绝`, created,
|
|
created.http === 200 && String(created.body?.code) === 'A0422',
|
|
`HTTP=${created.http}, code=${created.body?.code || 'missing'}`, CONTRACT_OPTIONS)
|
|
evidence.push({ name: item.name, stage: 'CREATE', code: created.body?.code,
|
|
orderId: String(created.body?.code) === '200' ? id(created.body?.data) : null })
|
|
continue
|
|
}
|
|
const orderId = id(created.body?.data)
|
|
if (!orderId) throw new Error('negative draft missing order id')
|
|
const before = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken)
|
|
const submitCommand = freezeOrderCommand(context.apiSuccess(MODULE, '负向提交冻结草稿版本', before), key(context, `cargo-negative-${index}-submit`))
|
|
const submitted = await request(context, 'POST', `${base}/${orderId}/submit`, submitCommand.body, merchantToken, submitCommand.headers)
|
|
const after = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken)
|
|
context.assert(MODULE, `${payload.destinationType}/${payload.fulfillmentType} ${item.name}提交拒绝且草稿零漂移`, submitted,
|
|
submitted.http === 200 && String(submitted.body?.code) === 'A0422'
|
|
&& String(before.body?.code) === '200' && String(after.body?.code) === '200'
|
|
&& after.body.data?.order?.status === 'DRAFT'
|
|
&& JSON.stringify(before.body.data) === JSON.stringify(after.body.data),
|
|
`orderId=${orderId}, code=${submitted.body?.code || 'missing'}`, CONTRACT_OPTIONS)
|
|
evidence.push({ name: item.name, stage: 'SUBMIT', code: submitted.body?.code, orderId })
|
|
}
|
|
context.createdResources.orderCargoNegatives ??= []
|
|
context.createdResources.orderCargoNegatives.push({ marker: context.marker,
|
|
destinationType: payload.destinationType, fulfillmentType: payload.fulfillmentType, cases: evidence })
|
|
}
|
|
function declarationCargo(source, index) {
|
|
const number = (value) => Number.isFinite(Number(value)) ? Number(value) : 0
|
|
const optionalNumber = (value) => value === null || value === undefined || value === '' ? null : number(value)
|
|
return {
|
|
itemNo: number(source?.itemNo) || index + 1,
|
|
cargoName: String(source?.cargoName || ''), cargoNameEn: source?.cargoNameEn || null,
|
|
skuNo: source?.skuNo || null, model: source?.model || null,
|
|
packageCount: number(source?.packageCount), totalQuantity: number(source?.totalQuantity),
|
|
lengthCm: number(source?.lengthCm), widthCm: number(source?.widthCm), heightCm: number(source?.heightCm),
|
|
grossWeightKg: number(source?.grossWeightKg), volumeCbm: number(source?.volumeCbm),
|
|
netWeightKg: optionalNumber(source?.netWeightKg), hsCode: source?.hsCode || null,
|
|
customsHsCode: source?.customsHsCode || null, unitsPerPackage: optionalNumber(source?.unitsPerPackage),
|
|
unitDeclaredValue: optionalNumber(source?.unitDeclaredValue), declaredValue: optionalNumber(source?.declaredValue),
|
|
currency: source?.currency || null, material: source?.material || null, usage: source?.usage || null,
|
|
brand: source?.brand || null, cargoRemark: source?.cargoRemark || source?.remark || null,
|
|
}
|
|
}
|
|
export function orderPersistenceFacts(detail) {
|
|
return JSON.stringify({
|
|
order: detail?.order ?? null,
|
|
cargoes: Array.isArray(detail?.cargoes) ? detail.cargoes : [],
|
|
parties: Array.isArray(detail?.parties) ? detail.parties : [],
|
|
internalNotes: Array.isArray(detail?.internalNotes) ? detail.internalNotes : [],
|
|
statusHistory: Array.isArray(detail?.statusHistory) ? detail.statusHistory : [],
|
|
})
|
|
}
|
|
// Read-only planning and evidence validation only. These exports never configure services or send requests.
|
|
export function buildOrderDependencyFaultPlan(fixture) {
|
|
const customerId = id(fixture?.customerId)
|
|
if (!/^[1-9]\d*$/.test(customerId) || customerId !== id(fixture?.registeredCustomerId)
|
|
|| !/^[A-Za-z0-9_-]{1,80}$/.test(fixture?.marker || '')) throw new Error('Dependency fault fixture must belong to this run registered customer')
|
|
const commands = {}
|
|
for (const role of ['merchant','platform']) {
|
|
const detail = fixture[role]
|
|
if (id(detail?.order?.tenantId) !== customerId || !/^[1-9]\d*$/.test(id(detail?.order?.id))
|
|
|| detail.order.status !== 'DRAFT' || !detail.order.orderNo
|
|
|| !['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(detail[field]))) {
|
|
throw new Error(`Incomplete owned ${role} DRAFT evidence`)
|
|
}
|
|
const command = freezeOrderCommand(detail, `dep-${role}-${fixture.marker}`)
|
|
commands[role] = { method:'POST', path:`/api/${role}/v1/orders/first-leg/${detail.order.id}/submit${role==='platform'?`?tenantId=${customerId}`:''}`,
|
|
...command, orderId:id(detail.order.id), orderNo:detail.order.orderNo,
|
|
initialOrderSha256:createHash('sha256').update(orderPersistenceFacts(detail)).digest('hex') }
|
|
}
|
|
if (commands.merchant.orderId === commands.platform.orderId) throw new Error('Use two independent drafts for same-key recovery')
|
|
const receivingWarehouseId = id(fixture.merchant.order.receivingWarehouseId)
|
|
if (!/^[1-9]\d*$/.test(receivingWarehouseId) || receivingWarehouseId !== id(fixture.platform.order.receivingWarehouseId)) {
|
|
throw new Error('Dependency drafts must reference the same valid public receiving warehouse')
|
|
}
|
|
return { marker:fixture.marker, customerId, receivingWarehouseId, runtimeChangesExecuted:false, commands,
|
|
phases:[
|
|
{ dependency:'IAM', urlProperty:'baiyue.clients.iam.url', targetMethod:'GET', targetPath:`/internal/iam/tenants/${customerId}/order-eligibility`,
|
|
faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['IAM eligibility service is unavailable','IAM 下单资格响应无效'] },
|
|
{ dependency:'FINANCE', urlProperty:'baiyue.clients.finance.url', targetMethod:'GET', targetPath:`/internal/finance/v1/wallet/accounts/${customerId}`,
|
|
faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['Finance account service is unavailable','Finance account response is invalid'] },
|
|
{ dependency:'WAREHOUSE', urlProperty:'baiyue.clients.warehouse.url', targetMethod:'GET', targetPath:`/internal/warehouses/0/${receivingWarehouseId}/active`,
|
|
faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['主数据服务暂时不可用'] },
|
|
],
|
|
recovery:'Restore original Order image/config/network identity; submit each original frozen command once, then replay it; require one submission history and no duplicate Firstleg facts.' }
|
|
}
|
|
|
|
export function assertOrderDependencyFaultObservation(plan, dependency, role, observation) {
|
|
const phase = plan?.phases?.find(item => item.dependency === dependency), command = plan?.commands?.[role]
|
|
const require = (ok, message) => { if (!ok) throw new Error(`Dependency fault evidence: ${message}`) }
|
|
require(Boolean(phase && command), 'unknown dependency or role')
|
|
const request = observation?.request, response = observation?.response
|
|
require(request?.method === command.method && request.path === command.path
|
|
&& JSON.stringify(request.body) === JSON.stringify(command.body)
|
|
&& request.headers?.['Idempotency-Key'] === command.headers['Idempotency-Key'], 'original frozen request/version/key changed')
|
|
const requestId = request.headers?.['X-Request-Id']
|
|
require(typeof requestId === 'string' && requestId.length > 0, 'missing correlation ID')
|
|
require(response?.http === phase.expectedHttp && String(response.body?.code) === phase.expectedCode
|
|
&& phase.expectedMessages.includes(response.body.message), 'must be the real Order dependency failure, not auth/version/business validation')
|
|
require(Array.isArray(observation.proxyRecords) && observation.proxyRecords.some(row =>
|
|
row.requestId === requestId && row.method === phase.targetMethod && row.path === phase.targetPath
|
|
&& row.matched === true && row.responseStatus === phase.faultHttp && row.forwarded === false
|
|
&& row.internalTokenPresent === true), 'missing exact authenticated upstream fault hit')
|
|
require(observation.proxyRecords.filter(row => row.matched === true).every(row => row.method === phase.targetMethod
|
|
&& row.path === phase.targetPath && row.responseStatus === phase.faultHttp && row.forwarded === false), 'fault escaped selected customer/path')
|
|
for (const label of ['before','after']) {
|
|
const snapshot = observation[label], detail = snapshot?.detail
|
|
require(snapshot?.inboundPageComplete === true && Array.isArray(snapshot.inbounds)
|
|
&& ['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(detail?.[field]))
|
|
&& id(detail?.order?.id) === command.orderId && id(detail.order.tenantId) === plan.customerId
|
|
&& detail.order.status === 'DRAFT', `${label} incomplete/foreign order or Firstleg evidence`)
|
|
require(snapshot.task === null || snapshot.task && id(snapshot.task.orderId) === command.orderId
|
|
&& id(snapshot.task.tenantId) === plan.customerId, `${label} task source mismatch`)
|
|
require(snapshot.inbounds.every(row => id(row.tenantId) === plan.customerId && row.orderNo === command.orderNo), `${label} inbound source mismatch`)
|
|
}
|
|
require(createHash('sha256').update(orderPersistenceFacts(observation.before.detail)).digest('hex') === command.initialOrderSha256,
|
|
'baseline differs from prepared draft')
|
|
require(JSON.stringify(observation.before) === JSON.stringify(observation.after), 'order/version/history/Firstleg facts changed')
|
|
return { dependency, role, orderId:command.orderId, verified:true, requestId,
|
|
snapshotSha256:createHash('sha256').update(JSON.stringify(observation.after)).digest('hex') }
|
|
}
|
|
export function requestedOrderDestinationType(context) {
|
|
const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase()
|
|
return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA'
|
|
}
|
|
export function eligibleOrderDestinations(values, destinationType) {
|
|
const active = (Array.isArray(values) ? values : []).filter((warehouse) => warehouse?.status === 'ACTIVE')
|
|
return destinationType === 'FBA'
|
|
? active.filter((warehouse) => String(warehouse?.internalCode || '').trim() !== '')
|
|
: active
|
|
}
|
|
export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) {
|
|
const summary = orderDetail?.summary
|
|
const task = success(taskResponse) ? taskResponse.body?.data : null
|
|
return Boolean(summary?.inboundId)
|
|
&& MEASURABLE_STATUSES.has(summary?.mainStatus)
|
|
&& id(task?.orderId) === id(orderId)
|
|
&& id(task?.tenantId) === id(tenantId)
|
|
}
|
|
|
|
export function approvalReplayDigest(snapshot) {
|
|
return createHash('sha256').update(JSON.stringify(snapshot)).digest('hex')
|
|
}
|
|
|
|
export async function verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId, inboundCode }) {
|
|
const scope = `tenantId=${encodeURIComponent(tenantId)}`
|
|
const orderPath = `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?${scope}`
|
|
const taskPath = `/api/platform/v1/first-leg/tasks/by-order/${encodeURIComponent(orderId)}?${scope}`
|
|
const inboundPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}?${scope}`
|
|
async function read(name) {
|
|
const orderResponse = await context.request('GET', orderPath)
|
|
const detail = context.apiSuccess(MODULE, `${name}订单及状态历史`, orderResponse, '', CONTRACT_OPTIONS)
|
|
const taskResponse = await context.request('GET', taskPath)
|
|
const task = context.apiSuccess(MODULE, `${name}原履约任务`, taskResponse, '', CONTRACT_OPTIONS)
|
|
const inboundResponse = await context.request('GET', inboundPath)
|
|
const inbound = context.apiSuccess(MODULE, `${name}原入库单及历史`, inboundResponse, '', CONTRACT_OPTIONS)
|
|
const rows = []; let total = null; let page = 1
|
|
while (total == null || rows.length < total) {
|
|
const response = await context.request('GET', `/api/platform/inbounds?${scope}`
|
|
+ `&inboundCode=${encodeURIComponent(inboundCode)}&page=${page}&size=100`)
|
|
const data = context.apiSuccess(MODULE, `${name}入仓码来源入库列表第${page}页`, response, '', CONTRACT_OPTIONS)
|
|
const valid = pageOk(data) && Number.isInteger(Number(data.total)) && Number(data.total) >= 0
|
|
&& (total == null || total === Number(data.total))
|
|
&& (data.records.length > 0 || Number(data.total) === 0)
|
|
context.assert(MODULE, `${name}入库分页计数完整`, response, valid,
|
|
`page=${page}, total=${data?.total ?? 'missing'}`, CONTRACT_OPTIONS)
|
|
if (!valid) throw new Error('审核重放核验缺少完整入库分页事实')
|
|
total = Number(data.total); rows.push(...data.records); page += 1
|
|
}
|
|
const exact = rows.filter(row => id(row.inboundCode) === inboundCode)
|
|
const valid = detail?.order?.status === 'APPROVED' && id(detail.order.id) === orderId
|
|
&& id(detail.order.inboundCode) === inboundCode && Array.isArray(detail.statusHistory)
|
|
&& id(task?.orderId) === orderId && id(task?.tenantId) === tenantId && Boolean(id(task?.id))
|
|
&& id(inbound?.summary?.id) === inboundId && id(inbound?.summary?.tenantId) === tenantId
|
|
&& id(inbound?.summary?.inboundCode) === inboundCode && Array.isArray(inbound?.statusHistory)
|
|
&& rows.length === total && new Set(rows.map(row => id(row.id))).size === total
|
|
&& exact.length === 1 && id(exact[0].id) === inboundId
|
|
context.assert(MODULE, `${name}审核态原身份与唯一入库事实有效`, inboundResponse, valid,
|
|
`exactInbounds=${exact.length}, taskId=${id(task?.id)}, inboundId=${id(inbound?.summary?.id)}`, CONTRACT_OPTIONS)
|
|
if (!valid) throw new Error('审核重放核验的订单任务或唯一入库身份不一致')
|
|
return { response: inboundResponse, facts: {
|
|
order: JSON.parse(orderPersistenceFacts(detail)), task, inbound, exactInbounds: exact,
|
|
} }
|
|
}
|
|
const before = await read('审核重放前')
|
|
const response = await context.request('POST',
|
|
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/approve?${scope}`)
|
|
const rejected = response?.http === 200 && String(response.body?.code) === 'A0409'
|
|
context.assert(MODULE, '已审核订单再次审核明确A0409', response, rejected,
|
|
`HTTP=${response?.http}, code=${response?.body?.code ?? 'missing'}`, CONTRACT_OPTIONS)
|
|
const after = await read('审核重放后')
|
|
const beforeDigest = approvalReplayDigest(before.facts)
|
|
const afterDigest = approvalReplayDigest(after.facts)
|
|
const unchanged = beforeDigest === afterDigest
|
|
context.assert(MODULE, '审核重复拒绝后订单入仓码历史任务及唯一入库事实零漂移', after.response,
|
|
unchanged, `inboundCount=1, orderHistories=${after.facts.order.statusHistory.length}`
|
|
+ `, inboundHistories=${after.facts.inbound.statusHistory.length}`, CONTRACT_OPTIONS)
|
|
const evidence = { marker: context.marker, tenantId, orderId, inboundId, inboundCode,
|
|
taskId: id(after.facts.task.id), inboundCount: after.facts.exactInbounds.length,
|
|
orderHistoryCount: after.facts.order.statusHistory.length,
|
|
inboundHistoryCount: after.facts.inbound.statusHistory.length,
|
|
beforeDigest, afterDigest, rejected, unchanged }
|
|
context.createdResources.orders.approvalReplay = evidence
|
|
return evidence
|
|
}
|
|
|
|
export async function finishOrderLifecycle(context, { tenantId, orderId, firstLegDetailPath }) {
|
|
const stopAtApproved = context.context?.orderStopAtApproved === true
|
|
if (!stopAtApproved) {
|
|
const acceptResponse = await context.request('POST',
|
|
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/accept?tenantId=${encodeURIComponent(tenantId)}`)
|
|
context.apiSuccess(MODULE, 'Admin 受理本轮订单', acceptResponse)
|
|
const startResponse = await context.request('POST',
|
|
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/start?tenantId=${encodeURIComponent(tenantId)}`)
|
|
context.apiSuccess(MODULE, 'Admin 启动本轮订单履约', startResponse)
|
|
}
|
|
const finalDetailResponse = await context.request('GET', firstLegDetailPath)
|
|
const finalDetail = context.apiSuccess(MODULE, 'Admin 回查本轮订单履约状态', finalDetailResponse)
|
|
const history = finalDetail?.statusHistory
|
|
const valid = finalDetail?.order?.status === (stopAtApproved ? 'APPROVED' : 'PROCESSING')
|
|
&& history?.some?.((item) => item.toStatus === 'APPROVED')
|
|
&& (stopAtApproved
|
|
? !history.some((item) => ['ACCEPTED', 'PROCESSING'].includes(item.toStatus))
|
|
: history.some((item) => item.toStatus === 'ACCEPTED')
|
|
&& history.some((item) => item.toStatus === 'PROCESSING'))
|
|
context.assert(MODULE, stopAtApproved ? '显式审批停点保留原始APPROVED且未受理启动' : '本轮订单已进入处理中且保留状态历史',
|
|
finalDetailResponse, valid,
|
|
`status=${finalDetail?.order?.status ?? 'missing'}, history=${history?.length ?? 'n/a'}`, CONTRACT_OPTIONS)
|
|
if (stopAtApproved && !valid) throw new Error('显式审批停点未保留原始APPROVED,不能继续准备该专项')
|
|
context.createdResources.orders.rawOrderStatus = finalDetail?.order?.status || null
|
|
context.createdResources.orders.stoppedAtApproved = stopAtApproved
|
|
}
|
|
function tomorrow(days = 30) {
|
|
const date = new Date(); date.setUTCDate(date.getUTCDate() + days)
|
|
return date.toISOString().slice(0, 10)
|
|
}
|
|
|
|
function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType = 'FBA') {
|
|
return {
|
|
originWarehouseId: receiving.id,
|
|
channelCode: channel.channelCode,
|
|
destinationCode: destination.warehouseCode,
|
|
destinationType,
|
|
destinationWarehouseId: destination.id,
|
|
destinationName: destination.warehouseName,
|
|
pricingTaxMode,
|
|
actualWeightKg: ORDER_ESTIMATE_WEIGHT_KG,
|
|
actualVolumeCbm: ORDER_ESTIMATE_VOLUME_CBM,
|
|
}
|
|
}
|
|
|
|
function estimateMatches(response, candidate) {
|
|
const estimate = success(response) ? response.body?.data : null
|
|
return Boolean(estimate)
|
|
&& id(estimate.priceBookVersionId) === id(candidate.channel.priceBookVersionId)
|
|
&& id(estimate.channelCode) === id(candidate.channel.channelCode)
|
|
&& id(estimate.pricingTaxMode) === id(candidate.pricingTaxMode)
|
|
}
|
|
|
|
export async function findPricedOrderRoute({ receiving, destinations, channels, estimate,
|
|
concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) {
|
|
const candidates = []
|
|
for (const channel of channels) {
|
|
if (!channel?.priceBookVersionId || !channel?.channelCode || !Array.isArray(channel.pricingTaxModes)) continue
|
|
for (const pricingTaxMode of channel.pricingTaxModes) {
|
|
if (!pricingTaxMode) continue
|
|
for (const destination of destinations) {
|
|
if (!destination?.id || !destination?.warehouseCode) continue
|
|
candidates.push({ channel, pricingTaxMode, destination,
|
|
request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType) })
|
|
}
|
|
}
|
|
}
|
|
const failures = []
|
|
let lastResponse = null
|
|
const batchSize = Math.max(1, Math.min(Number(concurrency) || 1, PRICING_PROBE_CONCURRENCY))
|
|
let attempts = 0
|
|
for (let offset = 0; offset < candidates.length; offset += batchSize) {
|
|
const batch = candidates.slice(offset, offset + batchSize)
|
|
const results = await Promise.all(batch.map(async (candidate) => ({
|
|
candidate, response: await estimate(candidate.request),
|
|
})))
|
|
attempts += results.length
|
|
lastResponse = results.at(-1)?.response || lastResponse
|
|
const match = results.find(({ candidate, response }) => estimateMatches(response, candidate))
|
|
if (match) return { priced: match.candidate, response: match.response, attempts, failures }
|
|
for (const { candidate, response } of results) {
|
|
const returned = success(response) ? response.body?.data : null
|
|
failures.push({
|
|
tuple: `${candidate.channel.channelCode}/${candidate.pricingTaxMode}/${candidate.destination.warehouseCode}`,
|
|
http: response?.http ?? 'missing', code: response?.body?.code ?? 'missing',
|
|
requestId: response?.requestId ?? 'missing', traceId: response?.traceId ?? 'missing',
|
|
mismatch: returned
|
|
? `version=${id(returned.priceBookVersionId) || 'missing'},channel=${id(returned.channelCode) || 'missing'},tax=${id(returned.pricingTaxMode) || 'missing'}`
|
|
: null,
|
|
})
|
|
}
|
|
}
|
|
return { priced: null, response: lastResponse, attempts, failures }
|
|
}
|
|
|
|
export async function findPricedOrderRouteAcrossOrigins({ receivings, destinations, loadChannels, estimate,
|
|
concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) {
|
|
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
|
|
}
|
|
if (destinationType === 'PRIVATE') {
|
|
// Private destinations use customer-owned addresses, not warehouse-rate
|
|
// destination rows. Match the same authoritative channel path already
|
|
// used by fixed-origin private orders.
|
|
const channel = priceableChannels[0]
|
|
return { receiving, priced: { channel, pricingTaxMode: channel.pricingTaxModes[0], destination: null },
|
|
response: channelResponse, attempts: 0, failures: [], originFailures }
|
|
}
|
|
const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels,
|
|
estimate, concurrency, destinationType })
|
|
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}` : ''}]`
|
|
: 'missing'
|
|
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
|
|
if (body !== undefined && !(body instanceof FormData)) {
|
|
headers['Content-Type'] = 'application/json; charset=utf-8'
|
|
payload = JSON.stringify(body)
|
|
}
|
|
try {
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
method, headers, body: payload, signal: AbortSignal.timeout(30_000),
|
|
})
|
|
const content = await response.text()
|
|
let parsed = null
|
|
try { parsed = content ? JSON.parse(content) : null } catch { /* Recorded as a bounded raw response. */ }
|
|
return {
|
|
method, path, http: response.status, body: parsed,
|
|
raw: parsed === null ? content.slice(0, 500) : null,
|
|
requestId: parsed?.requestId || response.headers.get('x-request-id'),
|
|
traceId: parsed?.traceId || response.headers.get('x-trace-id'),
|
|
}
|
|
} catch (error) {
|
|
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
|
}
|
|
}
|
|
|
|
async function uploadProductImage(context, merchantToken) {
|
|
const form = new FormData()
|
|
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
|
|
form.append('file', new Blob([png], { type: 'image/png' }), `product-${context.marker}.png`)
|
|
const response = await request(context, 'POST', '/api/merchant/v1/files/product-images', form, merchantToken)
|
|
const data = context.apiSuccess(MODULE, 'Client 上传产品图片', response, 'contentType=image/png', CONTRACT_OPTIONS)
|
|
context.assert(MODULE, '产品图片上传返回字符串文件 ID', response,
|
|
Boolean(data?.fileId) && typeof data.fileId === 'string' && data.contentType === 'image/png',
|
|
`fileIdType=${typeof data?.fileId}, contentType=${data?.contentType ?? 'missing'}`, CONTRACT_OPTIONS)
|
|
return id(data?.fileId)
|
|
}
|
|
|
|
async function assertProductImageDownload(context, merchantToken, fileId) {
|
|
const path = `/api/merchant/v1/files/product-images/${encodeURIComponent(fileId)}`
|
|
try {
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
headers: { Accept: 'image/*', Authorization: `Bearer ${merchantToken}` },
|
|
signal: AbortSignal.timeout(30_000),
|
|
})
|
|
const bytes = await response.arrayBuffer()
|
|
const evidence = { method: 'GET', path, http: response.status, body: null, raw: null,
|
|
requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }
|
|
context.assert(MODULE, 'Client 可下载本轮产品图片', evidence,
|
|
response.status === 200 && bytes.byteLength > 0 && (response.headers.get('content-type') || '').startsWith('image/'),
|
|
`bytes=${bytes.byteLength}, contentType=${response.headers.get('content-type') || 'missing'}`, CONTRACT_OPTIONS)
|
|
} catch (error) {
|
|
context.assert(MODULE, 'Client 可下载本轮产品图片', null, false, String(error), CONTRACT_OPTIONS)
|
|
}
|
|
}
|
|
|
|
async function loadMasterData(context, merchantToken) {
|
|
const destinationType = requestedOrderDestinationType(context)
|
|
const fixedRoute = context.context?.waitSplitRoute || {}
|
|
const domesticResponse = await requestTransportRetry(context, 'GET',
|
|
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', undefined, merchantToken)
|
|
const domesticPage = context.apiSuccess(MODULE, 'Client 查询国内收货仓', domesticResponse)
|
|
context.assert(MODULE, '存在可用国内收货仓', domesticResponse, records(domesticPage).length > 0,
|
|
`count=${records(domesticPage).length}`)
|
|
|
|
let destinationResponse = null
|
|
let destinationPage = null
|
|
if (destinationType !== 'PRIVATE') {
|
|
destinationResponse = await requestTransportRetry(context, 'GET',
|
|
`/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
|
|
undefined, merchantToken)
|
|
destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse)
|
|
context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse,
|
|
records(destinationPage).length > 0, `count=${records(destinationPage).length}`)
|
|
}
|
|
const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
|
|
let destinations = eligibleOrderDestinations(records(destinationPage), destinationType)
|
|
if (Number(destinationPage?.total) > destinations.length) {
|
|
const nextResponse = await requestTransportRetry(context, 'GET',
|
|
`/api/merchant/v1/warehouses?page=2&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
|
|
undefined, merchantToken)
|
|
const nextPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓第二页`, nextResponse)
|
|
destinations = [...destinations, ...eligibleOrderDestinations(records(nextPage), destinationType)]
|
|
}
|
|
if (receivings.length === 0 || (destinationType !== 'PRIVATE' && destinations.length === 0)) {
|
|
throw new Error('缺少创建测试订单所需的收货仓或目的仓')
|
|
}
|
|
// The warehouse page can contain repeated rows for the same business code.
|
|
// Collapse those rows before applying a deterministic route offset; otherwise
|
|
// a producer intended for the second FBA destination can silently reuse ABE4.
|
|
if (destinationType !== 'PRIVATE') {
|
|
destinations = [...new Map(destinations.map(value => [String(value.warehouseCode).trim().toUpperCase(), value])).values()]
|
|
const excluded = new Set((fixedRoute.excludeDestinationCodes || []).map(value => String(value).trim().toUpperCase()).filter(Boolean))
|
|
if (excluded.size) destinations = destinations.filter(value => !excluded.has(String(value.warehouseCode).trim().toUpperCase()))
|
|
}
|
|
|
|
let pricingResult
|
|
if (fixedRoute.originWarehouseName && (fixedRoute.channelCode || fixedRoute.channelName)) {
|
|
const receiving = receivings.find((warehouse) => warehouse.warehouseName === fixedRoute.originWarehouseName)
|
|
if (!receiving) throw new Error(`未找到固定起运仓: ${fixedRoute.originWarehouseName}`)
|
|
const channelsResponse = await request(context, 'GET',
|
|
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
|
|
undefined, merchantToken)
|
|
const channels = context.apiSuccess(MODULE, 'Client 查询固定起运仓物流渠道', channelsResponse)
|
|
const channel = (Array.isArray(channels) ? channels : []).find((item) =>
|
|
(!fixedRoute.channelCode || item.channelCode === fixedRoute.channelCode)
|
|
&& (!fixedRoute.channelName || item.channelName === fixedRoute.channelName))
|
|
if (!channel) throw new Error(`未找到固定物流渠道: ${fixedRoute.channelCode || fixedRoute.channelName}`)
|
|
const modes = Array.isArray(channel.pricingTaxModes) ? [...channel.pricingTaxModes] : []
|
|
if (modes.length === 0) throw new Error('固定物流渠道没有可用报价税制')
|
|
const shift = modes.length ? Number(fixedRoute.taxModeIndex || 0) % modes.length : 0
|
|
const selectedChannel = { ...channel, pricingTaxModes: [...modes.slice(shift), ...modes.slice(0, shift)] }
|
|
if (destinationType === 'PRIVATE') {
|
|
pricingResult = { receiving, priced: { channel: selectedChannel,
|
|
pricingTaxMode: selectedChannel.pricingTaxModes[0], destination: null },
|
|
response: channelsResponse, attempts: 0, failures: [], originFailures: [] }
|
|
} else {
|
|
const detailResponse = await context.request('GET',
|
|
`/api/platform/v1/fba-pricing/carrier-rates/price-books/${encodeURIComponent(channel.priceBookVersionId)}`)
|
|
const detail = context.apiSuccess(MODULE, 'Admin 查询固定渠道当前价格明细', detailResponse)
|
|
const pricedCodes = new Set((detail?.rates || [])
|
|
.filter((rate) => rate.channelCode === channel.channelCode && rate.destinationType === destinationType)
|
|
.map((rate) => String(rate.destinationCode || '').trim()).filter(Boolean))
|
|
const eligible = destinations.filter((destination) => pricedCodes.has(String(destination.warehouseCode)))
|
|
if (eligible.length < 1) throw new Error(`固定渠道没有可计价目的仓: ${destinationType}`)
|
|
const distinctEligible = [...new Map(eligible.map(value => [String(value.warehouseCode).trim().toUpperCase(), value])).values()]
|
|
const destination = distinctEligible[Number(fixedRoute.destinationIndex || 0) % distinctEligible.length]
|
|
const result = await findPricedOrderRoute({
|
|
receiving, destinations: [destination], channels: [selectedChannel],
|
|
estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken),
|
|
destinationType,
|
|
})
|
|
pricingResult = { receiving, ...result, originFailures: [] }
|
|
}
|
|
} else if (fixedRoute.originWarehouseName) {
|
|
const receiving = receivings.find((warehouse) => warehouse.warehouseName === fixedRoute.originWarehouseName)
|
|
if (!receiving) throw new Error(`未找到固定起运仓: ${fixedRoute.originWarehouseName}`)
|
|
const channelsResponse = await request(context, 'GET',
|
|
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
|
|
undefined, merchantToken)
|
|
const availableChannels = context.apiSuccess(MODULE, 'Client 查询固定起运仓随机渠道池', channelsResponse)
|
|
const channels = rotate((Array.isArray(availableChannels) ? availableChannels : [])
|
|
.filter((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0),
|
|
fixedRoute.randomIndex)
|
|
.map((channel, index) => ({ ...channel,
|
|
pricingTaxModes: rotate(channel.pricingTaxModes, Number(fixedRoute.randomIndex) + index) }))
|
|
if (channels.length === 0) throw new Error('固定起运仓没有客户可选择的可计价渠道')
|
|
if (destinationType === 'PRIVATE') {
|
|
const channel = channels[0]
|
|
pricingResult = { receiving, priced: { channel,
|
|
pricingTaxMode: channel.pricingTaxModes[0], destination: null },
|
|
response: channelsResponse, attempts: 0, failures: [], originFailures: [] }
|
|
} else {
|
|
const result = await findPricedOrderRoute({
|
|
receiving,
|
|
destinations: rotate(destinations, Number.isFinite(Number(fixedRoute.destinationIndex))
|
|
? Number(fixedRoute.destinationIndex) : Math.floor(Number(fixedRoute.randomIndex) / 17)),
|
|
channels,
|
|
estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken),
|
|
destinationType,
|
|
})
|
|
pricingResult = { receiving, ...result, originFailures: [] }
|
|
}
|
|
} else {
|
|
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),
|
|
destinationType,
|
|
})
|
|
}
|
|
const priced = pricingResult.priced
|
|
const receiving = pricingResult.receiving
|
|
context.addStep(MODULE, destinationType === 'PRIVATE'
|
|
? 'Client 私人地址订单选择有效国内仓渠道与报价税制'
|
|
: '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 || 'PRIVATE_ADDRESS'}, attempts=${pricingResult.attempts}`
|
|
: originPricingFailureEvidence(pricingResult),
|
|
CONTRACT_OPTIONS)
|
|
if (!priced) throw new Error(`当前 ACTIVE 国内收货仓、渠道、报价税制和 ${destinationType} 目的仓没有可计价组合`)
|
|
|
|
const partnerResponse = await request(context, 'GET',
|
|
'/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken)
|
|
const partners = context.apiSuccess(MODULE, 'Client 查询发货客户候选', partnerResponse, '', CONTRACT_OPTIONS)
|
|
const partner = Array.isArray(partners)
|
|
? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER'))
|
|
: null
|
|
return { receiving, destination: priced.destination, channel: priced.channel,
|
|
pricingTaxMode: priced.pricingTaxMode, destinationType, partner }
|
|
}
|
|
|
|
async function ensureCustomerAddress(context, merchantToken, profile, addressType) {
|
|
const path = `/api/merchant/v1/orders/first-leg/addresses?addressType=${encodeURIComponent(addressType)}`
|
|
const listResponse = await request(context, 'GET', path, undefined, merchantToken)
|
|
const existing = context.apiSuccess(MODULE, `Client 查询${addressType}地址簿`, listResponse)
|
|
const existingId = id(Array.isArray(existing) ? existing[0]?.id : null)
|
|
if (existingId) return existingId
|
|
|
|
const isPickup = addressType === 'PICKUP'
|
|
const createResponse = await request(context, 'POST', '/api/merchant/v1/orders/first-leg/addresses', {
|
|
addressType,
|
|
addressName: isPickup ? '开发联调取货地址' : '开发联调私人目的仓',
|
|
companyName: profile.tenantName || `订单验收客户-${context.marker}`,
|
|
contactName: isPickup ? '订单验收发货人' : '订单验收收货人',
|
|
phone: isPickup ? '13800138000' : '12025550188',
|
|
email: isPickup ? 'order-acceptance@example.com' : 'private-receiver@example.com',
|
|
countryCode: isPickup ? 'CN' : 'US',
|
|
province: isPickup ? '广东省' : 'New York',
|
|
city: isPickup ? '深圳市' : 'New York',
|
|
district: isPickup ? '南山区' : 'Queens',
|
|
addressLine: `${context.marker} ${isPickup ? '开发联调取货地址' : '88 Randomized Test Ave'}`,
|
|
postalCode: isPickup ? '518000' : '11354',
|
|
defaultAddress: true,
|
|
}, merchantToken)
|
|
return id(context.apiSuccess(MODULE, `Client 创建${addressType}地址`, createResponse,
|
|
`addressType=${addressType}`, CONTRACT_OPTIONS))
|
|
}
|
|
|
|
function buildPayload(context, profile, master, productImageFileId, customerAddresses) {
|
|
const shipmentId = `SHIP-${context.marker}`.slice(0, 64)
|
|
const referenceId = `REF-${context.marker}`.slice(0, 64)
|
|
const companyName = profile.tenantName || `订单验收客户-${context.marker}`
|
|
const requestedSkuCount = Number(context.context?.waitSplitVariant?.skuCount || BOXES.length)
|
|
const skuCount = Math.max(1, Math.min(BOXES.length, requestedSkuCount))
|
|
const packageCountPerSku = Math.max(1,
|
|
Number(context.context?.waitSplitVariant?.packageCountPerSku
|
|
|| context.context?.phase3LoadingIncrementalPackageCount || 1))
|
|
const packageCounts = Array.isArray(context.context?.waitSplitVariant?.packageCounts)
|
|
? context.context.waitSplitVariant.packageCounts : []
|
|
const selectedBoxes = BOXES.slice(0, skuCount)
|
|
const orderProfile = context.context?.orderProfile || {}
|
|
const dropship = String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase()
|
|
=== 'ONE_CLICK_DROPSHIP'
|
|
const mixedPackageCount = Math.max(1,
|
|
Number(context.context?.waitSplitVariant?.totalPackageCount || packageCountPerSku))
|
|
const mixedPackingGroupNo = `MIX-${context.marker}`.slice(0, 32)
|
|
const isPrivate = master.destinationType === 'PRIVATE'
|
|
const pickupMethod = orderProfile.pickupMethod || 'SELF_EXPRESS'
|
|
const receiver = isPrivate ? {
|
|
companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64),
|
|
phone: '12025550188', email: 'private-receiver@example.com', countryCode: 'US',
|
|
province: 'New York', city: 'New York', district: 'Queens',
|
|
addressLine: `${context.marker} 88 Randomized Test Ave`, postalCode: '11354',
|
|
} : null
|
|
return {
|
|
fulfillmentType: dropship ? 'ONE_CLICK_DROPSHIP' : 'STANDARD',
|
|
customerId: profile.id,
|
|
customerName: companyName,
|
|
transportMode: 'SEA', tradeTerm: 'FOB',
|
|
destinationCountryCode: master.destination?.countryCode || receiver?.countryCode || 'US',
|
|
destinationWarehouseId: master.destination?.id || null,
|
|
destinationWarehouseName: master.destination?.warehouseName || (isPrivate ? '私人地址' : null),
|
|
expectedShipDate: tomorrow(), customerRemark: `${context.marker} Client 创建隔离订单`,
|
|
pickupMethod, requestedPickupAt: pickupMethod === 'PICKUP'
|
|
? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null,
|
|
senderAddressId: pickupMethod === 'PICKUP' ? customerAddresses.pickupAddressId : null,
|
|
privateDestinationAddressId: isPrivate ? customerAddresses.privateDestinationAddressId : null,
|
|
receivingWarehouseId: master.receiving.id,
|
|
receivingWarehouseName: master.receiving.warehouseName,
|
|
channelCode: master.channel.channelCode, channelName: master.channel.channelName,
|
|
carrierId: null, carrierName: null, taxMode: orderProfile.taxMode || 'BUY_DECLARATION',
|
|
pricingTaxMode: master.pricingTaxMode, containerMode: orderProfile.containerMode || 'LCL',
|
|
customsInspection: orderProfile.customsInspection ?? false, destinationType: master.destinationType,
|
|
amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination?.warehouseCode : null,
|
|
shipmentId: master.destinationType === 'FBA' ? shipmentId : null,
|
|
referenceId: master.destinationType === 'FBA' ? referenceId : null,
|
|
sender: {
|
|
companyName, contactName: '订单验收联系人', phone: '13800138000',
|
|
email: 'order-acceptance@example.com', countryCode: 'CN', province: '广东省',
|
|
city: '深圳市', district: '南山区', addressLine: `${context.marker} 测试地址`, postalCode: '518000',
|
|
},
|
|
receiver,
|
|
cargoes: selectedBoxes.map((box, index) => {
|
|
const unitsPerPackage = 10 + index
|
|
const packageCount = dropship ? mixedPackageCount : Number(packageCounts[index] || packageCountPerSku)
|
|
const carton = dropship ? selectedBoxes[0] : box
|
|
const cartonVolumeCbm = carton.lengthCm * carton.widthCm * carton.heightCm / 1_000_000
|
|
return {
|
|
skuNo: context.context?.waitSplitVariant?.skuCodes?.[index]
|
|
|| `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
|
|
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
|
|
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
|
|
packingGroupNo: dropship ? mixedPackingGroupNo : null,
|
|
lengthCm: carton.lengthCm, widthCm: carton.widthCm, heightCm: carton.heightCm,
|
|
unitsPerPackage, packageCount, grossWeightKg: (dropship ? 10 : 10 + index) * packageCount,
|
|
volumeCbm: cartonVolumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
|
|
netWeightKg: (dropship ? 9 : 9 + index) * packageCount,
|
|
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * packageCount * 8.5, currency: 'USD',
|
|
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
|
|
model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO',
|
|
productLengthCm: 12 + index, productWidthCm: 8 + index, productHeightCm: 6 + index,
|
|
shipmentId: master.destinationType === 'FBA' ? shipmentId : null,
|
|
referenceId: master.destinationType === 'FBA' ? referenceId : null,
|
|
upcNo: `${Date.now()}${index}`.slice(-12),
|
|
productImageFileId, dangerousGoods: orderProfile.dangerousGoods === true,
|
|
woodenProduct: orderProfile.woodenProduct === true,
|
|
cargoRemark: `${context.marker} boxType=${box.boxType}`,
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
function assertCargoContract(context, response, cargoes, payload) {
|
|
const expectedCargoes = payload.cargoes || []
|
|
const complete = Array.isArray(cargoes) && cargoes.length === expectedCargoes.length
|
|
&& expectedCargoes.every((expected, index) => {
|
|
const actual = cargoes[index]
|
|
return Number(actual?.lengthCm) === expected.lengthCm && Number(actual?.widthCm) === expected.widthCm
|
|
&& Number(actual?.heightCm) === expected.heightCm && id(actual?.productImageFileId) === id(expected.productImageFileId)
|
|
&& actual?.skuNo === expected.skuNo && actual?.cargoName === expected.cargoName
|
|
&& actual?.cargoNameEn === expected.cargoNameEn && actual?.customsHsCode === expected.customsHsCode
|
|
&& Number(actual?.unitsPerPackage) === expected.unitsPerPackage
|
|
&& Number(actual?.packageCount) === expected.packageCount && Number(actual?.totalQuantity) === expected.totalQuantity
|
|
&& Number(actual?.netWeightKg) === expected.netWeightKg
|
|
&& Number(actual?.unitDeclaredValue) === expected.unitDeclaredValue
|
|
&& actual?.material === expected.material && actual?.productUsage === expected.usage
|
|
&& actual?.model === expected.model && actual?.brand === expected.brand
|
|
&& Number(actual?.productLengthCm) === expected.productLengthCm
|
|
&& Number(actual?.productWidthCm) === expected.productWidthCm
|
|
&& Number(actual?.productHeightCm) === expected.productHeightCm
|
|
&& (actual?.shipmentId ?? null) === (expected.shipmentId ?? null)
|
|
&& (actual?.referenceId ?? null) === (expected.referenceId ?? null)
|
|
&& actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker)
|
|
})
|
|
context.assert(MODULE, '动态SKU产品字段及图片契约完整回显', response, complete,
|
|
`cargoes=${Array.isArray(cargoes) ? cargoes.length : 'n/a'}, imageFileId=${payload.cargoes[0].productImageFileId}`,
|
|
CONTRACT_OPTIONS)
|
|
}
|
|
|
|
async function assertDimensionConfig(context, tenantId) {
|
|
const path = `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${encodeURIComponent(tenantId)}`
|
|
const response = await context.request('GET', path)
|
|
const data = context.apiSuccess(MODULE, 'Admin 查询客户固定箱型', response, `tenantId=${tenantId}`, CONTRACT_OPTIONS)
|
|
const rules = Array.isArray(data?.rules) ? data.rules : []
|
|
const exact = BOXES.every((box, index) => {
|
|
const rule = rules[index]
|
|
return rule?.boxType === box.boxType && Number(rule.innerLengthCm) === box.lengthCm
|
|
&& Number(rule.innerWidthCm) === box.widthCm && Number(rule.innerHeightCm) === box.heightCm
|
|
&& Number(rule.edgeAdditionCm) === 2
|
|
})
|
|
context.assert(MODULE, '固定四箱型顺序、内径及各尺寸增加2cm正确', response,
|
|
rules.length === BOXES.length && exact,
|
|
`types=${rules.map((rule) => rule.boxType).join(',') || 'missing'}`, CONTRACT_OPTIONS)
|
|
}
|
|
|
|
async function waitForInbound(context, tenantId, orderId) {
|
|
const path = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
|
|
const taskPath = `/api/platform/v1/first-leg/tasks/by-order/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
|
|
let response = null; let detail = null; let taskResponse = null; let task = null
|
|
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
|
response = await context.request('GET', path)
|
|
const candidate = success(response) ? response.body?.data : null
|
|
if (candidate?.summary?.inboundId) {
|
|
taskResponse = await context.request('GET', taskPath)
|
|
}
|
|
if (approvedInboundReady(candidate, taskResponse, tenantId, orderId)) {
|
|
detail = candidate
|
|
task = taskResponse.body.data
|
|
break
|
|
}
|
|
if (attempt < 30) await sleep(1_000)
|
|
}
|
|
context.addStep(MODULE, '审核后入库单与履约任务异步收敛', response, Boolean(detail && task),
|
|
detail
|
|
? `inboundId=${detail.summary.inboundId}, status=${detail.summary.mainStatus}, taskId=${id(task?.id)}`
|
|
: `30秒内未收敛;status=${response?.body?.data?.summary?.mainStatus ?? 'missing'}, taskCode=${taskResponse?.body?.code ?? 'not-requested'}`,
|
|
CONTRACT_OPTIONS)
|
|
return { response, detail, taskResponse, task }
|
|
}
|
|
|
|
export async function run(context) {
|
|
// Resolve per invocation: this module is cached across serial fresh-customer runs.
|
|
const fixture = context.context?.merchantQualificationFixture
|
|
const configured = context.options?.merchantUsername || context.options?.merchantPassword
|
|
const credentials = fixture ? { username: fixture.username, password: fixture.password }
|
|
: configured ? { username: context.options.merchantUsername, password: context.options.merchantPassword }
|
|
: { username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }
|
|
if (!credentials.username || !credentials.password) throw new Error('Client 当前运行缺少完整登录凭据')
|
|
const loginResponse = await context.request('POST', '/api/auth/login', {
|
|
loginId: credentials.username, password: credentials.password,
|
|
}, '')
|
|
const login = context.apiSuccess(MODULE, 'Client 测试账号登录', loginResponse,
|
|
'current-run customer credentials')
|
|
const merchantToken = login?.accessToken
|
|
if (!merchantToken) throw new Error('Client 登录未返回 accessToken')
|
|
|
|
const profileResponse = await request(context, 'GET', '/api/merchant/v1/iam/tenant/profile',
|
|
undefined, merchantToken)
|
|
const profile = context.apiSuccess(MODULE, 'Client 查询当前企业资料', profileResponse)
|
|
const tenantId = id(profile?.id)
|
|
context.assert(MODULE, 'Client 测试企业已审核且客户 ID 有效', profileResponse,
|
|
Boolean(tenantId) && profile?.status === 'ACTIVE', `tenantId=${tenantId || 'missing'}, status=${profile?.status ?? 'missing'}`)
|
|
if (!tenantId) throw new Error('Client 企业资料未返回客户 ID')
|
|
if (context.options?.customerName) {
|
|
context.assert(MODULE, 'Client 登录企业与指定客户名称一致', profileResponse,
|
|
profile?.tenantName === context.options.customerName,
|
|
`expected=${context.options.customerName}, actual=${profile?.tenantName || 'missing'}`, CONTRACT_OPTIONS)
|
|
if (profile?.tenantName !== context.options.customerName) throw new Error('Client 登录企业不是指定客户')
|
|
}
|
|
|
|
await assertDimensionConfig(context, tenantId)
|
|
const master = await loadMasterData(context, merchantToken)
|
|
const productImageFileId = await uploadProductImage(context, merchantToken)
|
|
if (!productImageFileId) throw new Error('产品图片新契约未部署,无法创建带图片的隔离订单')
|
|
await assertProductImageDownload(context, merchantToken, productImageFileId)
|
|
|
|
const customerAddresses = {
|
|
pickupAddressId: await ensureCustomerAddress(context, merchantToken, profile, 'PICKUP'),
|
|
privateDestinationAddressId: master.destinationType === 'PRIVATE'
|
|
? await ensureCustomerAddress(context, merchantToken, profile, 'PRIVATE_DESTINATION') : null,
|
|
}
|
|
const createPath = '/api/merchant/v1/orders/first-leg'
|
|
const policyResponse = await request(context, 'GET', `${createPath}/inbound-code-policy`, undefined, merchantToken)
|
|
const policy = context.apiSuccess(MODULE, 'Client 读取入仓码录入策略', policyResponse, '', CONTRACT_OPTIONS)
|
|
const payload = { ...buildPayload(context, profile, master, productImageFileId, customerAddresses),
|
|
...fixtureInboundCode(policy, context.marker) }
|
|
if (context.context.verifyOrderCargoNegatives === true) {
|
|
await verifyCargoRequirementNegatives(context, payload, policy, merchantToken)
|
|
}
|
|
const createResponse = await request(context, 'POST', createPath, payload, merchantToken,
|
|
{ 'Idempotency-Key': key(context, 'client-create') })
|
|
const orderId = id(context.apiSuccess(MODULE, 'Client 创建四箱型隔离订单', createResponse,
|
|
`marker=${context.marker}`, CONTRACT_OPTIONS))
|
|
if (!orderId) throw new Error('Client 订单创建失败,后续 Admin 链路停止')
|
|
context.createdResources.orders = {
|
|
marker: context.marker, tenantId, orderId, productImageFileId,
|
|
fulfillmentType: payload.fulfillmentType,
|
|
skuCodes: payload.cargoes.map((cargo) => cargo.skuNo),
|
|
skuLines: payload.cargoes.map((cargo) => ({
|
|
skuCode: cargo.skuNo, packageCount: cargo.packageCount,
|
|
})),
|
|
boxTypes: BOXES.map((box) => box.boxType), inboundId: null,
|
|
receivingWarehouseId: id(master.receiving.id),
|
|
receivingWarehouseName: master.receiving.warehouseName,
|
|
destinationType: master.destinationType,
|
|
destinationWarehouseId: id(master.destination?.id),
|
|
destinationWarehouseCode: master.destination?.warehouseCode || null,
|
|
destinationWarehouseName: master.destination?.warehouseName || (master.destinationType === 'PRIVATE' ? '私人地址' : null),
|
|
channelCode: master.channel.channelCode,
|
|
channelName: master.channel.channelName,
|
|
pricingTaxMode: master.pricingTaxMode,
|
|
pickupMethod: payload.pickupMethod,
|
|
taxMode: payload.taxMode,
|
|
containerMode: payload.containerMode,
|
|
customsInspection: payload.customsInspection,
|
|
}
|
|
|
|
const replayResponse = await request(context, 'POST', createPath, payload, merchantToken,
|
|
{ 'Idempotency-Key': key(context, 'client-create') })
|
|
const replayedId = id(context.apiSuccess(MODULE, 'Client 创建订单幂等重放', replayResponse))
|
|
context.assert(MODULE, 'Client 创建幂等结果一致', replayResponse, replayedId === orderId,
|
|
`orderId=${orderId}, replayedId=${replayedId}`)
|
|
|
|
const clientDetailPath = `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}`
|
|
const draftResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
|
|
let draft = context.apiSuccess(MODULE, 'Client 查询隔离订单草稿', draftResponse)
|
|
const orderNo = id(draft?.order?.orderNo)
|
|
context.assert(MODULE, 'Client 草稿身份、marker 和状态正确', draftResponse,
|
|
id(draft?.order?.id) === orderId && draft?.order?.status === 'DRAFT'
|
|
&& String(draft?.order?.customerRemark || '').includes(context.marker),
|
|
`orderNo=${orderNo || 'missing'}, status=${draft?.order?.status ?? 'missing'}`)
|
|
assertCargoContract(context, draftResponse, draft?.cargoes, payload)
|
|
context.createdResources.orders.orderNo = orderNo
|
|
draft = await verifyPlatformDraftEdit(context, tenantId, orderId, merchantToken, payload)
|
|
await verifyPlatformSubmitCancel(context, tenantId, payload, policy)
|
|
|
|
const createConflictPayload = {
|
|
...payload,
|
|
customerRemark: `${payload.customerRemark} 同键异载荷`,
|
|
}
|
|
const createConflictResponse = await request(context, 'POST', createPath, createConflictPayload, merchantToken,
|
|
{ 'Idempotency-Key': key(context, 'client-create') })
|
|
context.assert(MODULE, 'Client 创建订单同键异载荷返回A0409', createConflictResponse,
|
|
createConflictResponse.http === 200 && String(createConflictResponse.body?.code) === 'A0409',
|
|
`http=${createConflictResponse.http}, code=${createConflictResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS)
|
|
const draftAfterConflictResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
|
|
const draftAfterConflict = context.apiSuccess(MODULE, 'Client 回查创建冲突后的订单草稿', draftAfterConflictResponse)
|
|
context.assert(MODULE, '创建同键异载荷失败后订单全事实零漂移', draftAfterConflictResponse,
|
|
orderPersistenceFacts(draftAfterConflict) === orderPersistenceFacts(draft),
|
|
`orderId=${orderId}, status=${draftAfterConflict?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS)
|
|
|
|
const submitCommand = freezeOrderCommand(draft, key(context, 'client-submit'))
|
|
context.createdResources.orders.submitCommand = submitCommand
|
|
const submitResponse = await requestTransportRetry(context, 'POST', `${clientDetailPath}/submit`, submitCommand.body,
|
|
merchantToken, submitCommand.headers)
|
|
context.apiSuccess(MODULE, 'Client 提交隔离订单', submitResponse, `orderId=${orderId}`)
|
|
const submitReplayResponse = await request(context, 'POST', `${clientDetailPath}/submit`, submitCommand.body,
|
|
merchantToken, submitCommand.headers)
|
|
context.apiSuccess(MODULE, 'Client 提交订单同键同载荷精确重放', submitReplayResponse, `orderId=${orderId}`)
|
|
const submittedResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
|
|
const submitted = context.apiSuccess(MODULE, 'Client 回查已提交订单', submittedResponse)
|
|
context.assert(MODULE, 'Client 订单已进入待审核状态', submittedResponse,
|
|
submitted?.order?.status === 'SUBMITTED', `status=${submitted?.order?.status ?? 'missing'}`)
|
|
const conflictSubmitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, { expectedVersion: submitted.order.version },
|
|
merchantToken, submitCommand.headers)
|
|
context.assert(MODULE, '提交同键异版本返回A0409', conflictSubmitResponse,
|
|
conflictSubmitResponse.http === 200 && String(conflictSubmitResponse.body?.code) === 'A0409', 'frozen-key/different-payload', CONTRACT_OPTIONS)
|
|
const duplicateCommand = freezeOrderCommand(submitted, key(context, 'client-submit-new-key'))
|
|
const duplicateSubmitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, duplicateCommand.body,
|
|
merchantToken, duplicateCommand.headers)
|
|
context.assert(MODULE, '已提交订单使用新键重复提交返回A0409', duplicateSubmitResponse,
|
|
duplicateSubmitResponse.http === 200 && String(duplicateSubmitResponse.body?.code) === 'A0409',
|
|
`http=${duplicateSubmitResponse.http}, code=${duplicateSubmitResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS)
|
|
const submittedAfterFailureResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
|
|
const submittedAfterFailure = context.apiSuccess(MODULE, 'Client 回查重复提交失败后的订单', submittedAfterFailureResponse)
|
|
context.assert(MODULE, '重复提交失败后订单状态货物与历史零漂移', submittedAfterFailureResponse,
|
|
orderPersistenceFacts(submittedAfterFailure) === orderPersistenceFacts(submitted),
|
|
`orderId=${orderId}, status=${submittedAfterFailure?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS)
|
|
|
|
const unifiedListResponse = await context.request('GET',
|
|
`/api/platform/v1/orders?page=1&size=100&tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}`)
|
|
const unifiedPage = context.apiSuccess(MODULE, 'Admin 查询统一订单列表', unifiedListResponse)
|
|
const unifiedRow = records(unifiedPage).find((row) => id(row.orderId) === orderId)
|
|
context.assert(MODULE, 'Admin 统一订单列表只核对本轮订单', unifiedListResponse,
|
|
pageOk(unifiedPage) && Boolean(unifiedRow), `orderId=${orderId}, found=${Boolean(unifiedRow)}`)
|
|
|
|
const unifiedDetailPath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
|
|
const unifiedDetailResponse = await context.request('GET', unifiedDetailPath)
|
|
const unifiedDetail = context.apiSuccess(MODULE, 'Admin 查询统一订单详情', unifiedDetailResponse)
|
|
context.assert(MODULE, 'Admin 统一订单详情身份与待审核状态正确', unifiedDetailResponse,
|
|
id(unifiedDetail?.summary?.orderId) === orderId && unifiedDetail?.summary?.mainStatus === 'PENDING_REVIEW'
|
|
&& Array.isArray(unifiedDetail?.timeline),
|
|
`mainStatus=${unifiedDetail?.summary?.mainStatus ?? 'missing'}, timeline=${unifiedDetail?.timeline?.length ?? 'n/a'}`)
|
|
|
|
const firstLegListResponse = await context.request('GET',
|
|
`/api/platform/v1/orders/first-leg?tenantId=${encodeURIComponent(tenantId)}&keyword=${encodeURIComponent(orderNo)}&page=1&size=100`)
|
|
const firstLegPage = context.apiSuccess(MODULE, 'Admin 查询头程订单列表', firstLegListResponse)
|
|
const firstLegRow = records(firstLegPage).find((row) => id(row.id) === orderId)
|
|
context.assert(MODULE, 'Admin 头程订单列表只核对本轮订单', firstLegListResponse,
|
|
pageOk(firstLegPage) && Boolean(firstLegRow), `orderId=${orderId}, found=${Boolean(firstLegRow)}`)
|
|
|
|
const firstLegDetailPath = `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
|
|
const firstLegDetailResponse = await context.request('GET', firstLegDetailPath)
|
|
const firstLegDetail = context.apiSuccess(MODULE, 'Admin 查询头程订单详情', firstLegDetailResponse)
|
|
context.assert(MODULE, 'Admin 头程订单详情聚合结构完整', firstLegDetailResponse,
|
|
id(firstLegDetail?.order?.id) === orderId && Array.isArray(firstLegDetail?.cargoes)
|
|
&& Array.isArray(firstLegDetail?.parties) && Array.isArray(firstLegDetail?.internalNotes)
|
|
&& Array.isArray(firstLegDetail?.statusHistory),
|
|
`cargoes=${firstLegDetail?.cargoes?.length ?? 'n/a'}, history=${firstLegDetail?.statusHistory?.length ?? 'n/a'}`)
|
|
assertCargoContract(context, firstLegDetailResponse, firstLegDetail?.cargoes, payload)
|
|
|
|
const noteResponse = await context.request('PUT',
|
|
`/api/platform/v1/orders/${encodeURIComponent(orderId)}/remarks?tenantId=${encodeURIComponent(tenantId)}`,
|
|
{
|
|
expectedVersion: Number(firstLegDetail?.order?.version),
|
|
internalRemark: `${context.marker} Admin 订单链路验收`,
|
|
warehouseRemark: firstLegDetail?.order?.warehouseRemark || null,
|
|
customerServiceRemark: firstLegDetail?.order?.customerServiceRemark || null,
|
|
})
|
|
context.apiSuccess(MODULE, 'Admin 为本轮订单添加内部备注', noteResponse)
|
|
|
|
const approveResponse = await context.request('POST',
|
|
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/approve?tenantId=${encodeURIComponent(tenantId)}`)
|
|
context.apiSuccess(MODULE, 'Admin 审核通过本轮订单', approveResponse, `orderId=${orderId}`)
|
|
|
|
// APPROVED declaration edits fail closed against the authoritative Firstleg projection.
|
|
// Wait for the approval outbox to create that projection before exercising the edit API.
|
|
const inboundResult = await waitForInbound(context, tenantId, orderId)
|
|
const inboundId = id(inboundResult.detail?.summary?.inboundId)
|
|
if (!inboundId || !inboundResult.task) {
|
|
throw new Error('订单审核后的入库单与履约任务未在30秒内收敛,无法修改申报并向 OCR/墨家模块交接')
|
|
}
|
|
|
|
await verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId,
|
|
inboundCode: id(inboundResult.detail?.summary?.inboundCode) })
|
|
|
|
const editableDetailResponse = await context.request('GET', firstLegDetailPath)
|
|
const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse)
|
|
const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail
|
|
const declarationOrder = declarationDetail?.order || {}
|
|
const declarationCargoes = Array.isArray(declarationDetail?.cargoes) ? declarationDetail.cargoes : []
|
|
const declarationVersion = Number(declarationOrder?.version)
|
|
const declarationRemark = `${context.marker} Admin 修改客户申报资料`
|
|
const declarationPayload = {
|
|
expectedVersion: declarationVersion,
|
|
customerRemark: declarationRemark,
|
|
expectedArrivalAt: declarationOrder?.expectedArrivalAt || editableDetail?.summary?.expectedArrivalAt || null,
|
|
channelCode: declarationOrder?.channelCode || editableDetail?.summary?.channelCode,
|
|
channelName: declarationOrder?.channelName || editableDetail?.summary?.channelName,
|
|
cargoes: declarationCargoes.map(declarationCargo),
|
|
}
|
|
const declarationUpdateResponse = await context.request('PUT',
|
|
`/api/platform/v1/orders/${encodeURIComponent(orderId)}/declaration?tenantId=${encodeURIComponent(tenantId)}`,
|
|
declarationPayload)
|
|
const declarationUpdate = context.apiSuccess(MODULE, 'Admin 修改审核后客户申报资料', declarationUpdateResponse)
|
|
context.assert(MODULE, '申报修改保持订单号入仓码和渠道且版本递增', declarationUpdateResponse,
|
|
Number.isFinite(declarationVersion) && Number(declarationUpdate?.version) === declarationVersion + 1
|
|
&& id(declarationUpdate?.inboundCode) === id(firstLegDetail?.order?.inboundCode)
|
|
&& declarationUpdate?.channelChanged === false,
|
|
`version=${declarationVersion}->${declarationUpdate?.version ?? 'missing'}, inboundCode=${declarationUpdate?.inboundCode ?? 'missing'}`,
|
|
CONTRACT_OPTIONS)
|
|
|
|
const staleDeclarationResponse = await context.request('PUT',
|
|
`/api/platform/v1/orders/${encodeURIComponent(orderId)}/declaration?tenantId=${encodeURIComponent(tenantId)}`,
|
|
{ ...declarationPayload, customerRemark: `${declarationRemark} stale` })
|
|
context.assert(MODULE, '申报资料旧版本并发修改返回A0409', staleDeclarationResponse,
|
|
staleDeclarationResponse.http === 200 && String(staleDeclarationResponse.body?.code) === 'A0409',
|
|
`code=${staleDeclarationResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS)
|
|
|
|
const inboundPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`
|
|
const inboundResponse = await context.request('GET', inboundPath)
|
|
const inbound = context.apiSuccess(MODULE, 'Admin 查询本轮入库单详情', inboundResponse,
|
|
`inboundId=${inboundId}`, CONTRACT_OPTIONS)
|
|
const markerValues = [
|
|
inbound?.publicRemark, inbound?.summary?.orderNo, inbound?.summary?.inboundCode,
|
|
...(Array.isArray(inbound?.internalNotes) ? inbound.internalNotes.map((note) => note.content) : []),
|
|
]
|
|
const markerFound = markerValues.some((value) => String(value || '').includes(context.marker))
|
|
const measurable = MEASURABLE_STATUSES.has(inbound?.summary?.processStatus)
|
|
context.assert(MODULE, '隔离入库单 marker 来源与可测量状态正确', inboundResponse,
|
|
id(inbound?.summary?.id) === inboundId && id(inbound?.summary?.tenantId) === tenantId
|
|
&& markerFound && measurable,
|
|
`markerFound=${markerFound}, status=${inbound?.summary?.processStatus ?? 'missing'}`, CONTRACT_OPTIONS)
|
|
context.assert(MODULE, '入库单详情包含托盘库位、文件、备注和状态历史', inboundResponse,
|
|
Array.isArray(inbound?.palletLocations) && Array.isArray(inbound?.files)
|
|
&& Array.isArray(inbound?.internalNotes) && Array.isArray(inbound?.statusHistory),
|
|
`inboundId=${inboundId}, history=${inbound?.statusHistory?.length ?? 'n/a'}`)
|
|
|
|
const inboundListResponse = await context.request('GET',
|
|
`/api/platform/inbounds?tenantId=${encodeURIComponent(tenantId)}&inboundCode=${encodeURIComponent(inbound.summary.inboundCode)}&page=1&size=100`)
|
|
const inboundPage = context.apiSuccess(MODULE, 'Admin 查询入库单列表', inboundListResponse)
|
|
context.assert(MODULE, 'Admin 入库单列表只核对本轮入库单', inboundListResponse,
|
|
pageOk(inboundPage) && records(inboundPage).some((row) => id(row.id) === inboundId),
|
|
`inboundId=${inboundId}, total=${inboundPage?.total ?? 'n/a'}`)
|
|
|
|
let declarationConverged = false
|
|
let convergedInboundResponse = inboundResponse
|
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
convergedInboundResponse = await context.request('GET', inboundPath)
|
|
const currentInbound = success(convergedInboundResponse) ? convergedInboundResponse.body?.data : null
|
|
if (currentInbound?.publicRemark === declarationRemark) { declarationConverged = true; break }
|
|
if (attempt < 29) await sleep(1_000)
|
|
}
|
|
context.assert(MODULE, '申报修改异步同步到Firstleg入库事实', convergedInboundResponse,
|
|
declarationConverged, `expectedRemark=${declarationRemark}`, CONTRACT_OPTIONS)
|
|
|
|
context.createdResources.orders.inboundId = inboundId
|
|
context.createdResources.orders.inboundCode = inbound.summary.inboundCode
|
|
context.createdResources.orders.customerRemark = declarationRemark
|
|
context.createdResources.orders.internalRemark = `${context.marker} Admin 订单链路验收`
|
|
context.createdResources.orders.cargoRemark = payload.cargoes?.[0]?.cargoRemark || null
|
|
context.createdResources.orders.processStatus = inbound.summary.processStatus
|
|
context.createdResources.ocrInbound = { tenantId, inboundId, marker: context.marker }
|
|
context.createdResources.testInbound = { tenantId, inboundId, marker: context.marker }
|
|
context.context.inboundWeightOcr = { tenantId, inboundId, marker: context.marker }
|
|
context.context.ocrInbound = context.context.inboundWeightOcr
|
|
context.context.testInbound = context.context.inboundWeightOcr
|
|
context.context.createdInbound = context.context.inboundWeightOcr
|
|
|
|
const taskResponse = inboundResult.taskResponse
|
|
const task = inboundResult.task
|
|
context.apiSuccess(MODULE, 'Admin 查询本轮订单履约任务', taskResponse,
|
|
'审批 Outbox 收敛后应生成可查询的履约任务', CONTRACT_OPTIONS)
|
|
context.assert(MODULE, '履约任务与本轮订单和客户一致', taskResponse,
|
|
id(task?.orderId) === orderId && id(task?.tenantId) === tenantId,
|
|
`taskId=${id(task?.id) || 'missing'}, status=${task?.status ?? 'missing'}`, CONTRACT_OPTIONS)
|
|
if (task?.id) {
|
|
context.createdResources.orders.taskId = id(task.id)
|
|
const historyResponse = await context.request('GET',
|
|
`/api/platform/v1/first-leg/tasks/${encodeURIComponent(task.id)}/assignment-history?tenantId=${encodeURIComponent(tenantId)}&page=1&size=20`)
|
|
const history = context.apiSuccess(MODULE, 'Admin 查询本轮履约任务分配历史', historyResponse)
|
|
context.assert(MODULE, '履约分配历史分页结构正确', historyResponse, pageOk(history),
|
|
`taskId=${task.id}, total=${history?.total ?? 'n/a'}`)
|
|
}
|
|
|
|
await finishOrderLifecycle(context, { tenantId, orderId, firstLegDetailPath })
|
|
}
|