167 lines
10 KiB
JavaScript
167 lines
10 KiB
JavaScript
import { createHash } from 'node:crypto'
|
||
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
|
||
import { dirname } from 'node:path'
|
||
|
||
// Internal stage only. No credentials, fixture generation, CLI or environment fallback.
|
||
const ROOT = '/merchant/v1/orders/first-leg'
|
||
const assert = (condition, message) => { if (!condition) throw new Error(message) }
|
||
const longId = value => typeof value === 'string' && /^[1-9]\d*$/.test(value)
|
||
const canonical = value => Array.isArray(value) ? value.map(canonical)
|
||
: value && typeof value === 'object'
|
||
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
|
||
export const fingerprint = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
|
||
|
||
export function validateOrderInput(input) {
|
||
const { batch, customer, origin, destination, payload, operator } = input
|
||
assert(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(batch || ''), '批次须为 8~48 位字母数字或连字符')
|
||
assert(longId(customer?.id) && customer.name && customer.code, '缺少固定客户身份')
|
||
assert(operator?.merchant && operator?.admin, '缺少幂等操作者身份')
|
||
assert(origin?.code === 'CN-YIWU-01' && longId(origin.id), '仅允许义乌始发')
|
||
assert(destination?.type === 'FBA' && longId(destination.id) && destination.code, '当前订单阶段仅支持显式 FBA 目的仓')
|
||
assert(payload?.customerName === customer.name && (payload.customerId == null || payload.customerId === customer.id), '下单客户与固定客户不符')
|
||
assert(payload.receivingWarehouseId === origin.id && payload.destinationWarehouseId === destination.id
|
||
&& payload.amazonWarehouseCode === destination.code, '下单仓库与已核对主档不符')
|
||
assert(payload.transportMode === 'SEA' && payload.destinationType === 'FBA'
|
||
&& payload.fulfillmentType === 'STANDARD' && payload.containerMode === 'LCL'
|
||
&& payload.pickupMethod === 'SELF_EXPRESS', '仅支持标准海运 FBA 自送散货链路')
|
||
assert(payload.customerRemark === `RELEASE-ACCEPTANCE:${batch}`, '批次标记必须精确匹配')
|
||
assert(typeof payload.channelCode === 'string' && payload.channelCode.length > 0, '缺少明确渠道')
|
||
assert(payload.inboundCode == null && payload.senderAddressId == null && payload.privateDestinationAddressId == null, '不允许手工入仓码或地址副作用')
|
||
assert(Array.isArray(payload.cargoes) && payload.cargoes.length === 1, '专属冒烟固定一个 SKU')
|
||
const cargo = payload.cargoes[0]
|
||
assert(Number.isInteger(cargo.packageCount) && cargo.packageCount >= 1 && cargo.packageCount <= 2, '冒烟仅允许 1~2 箱')
|
||
assert(cargo.woodenProduct === false && cargo.dangerousGoods === false && !cargo.packingGroupNo, '仅支持非木制非危险单 SKU 货物')
|
||
assert(longId(cargo.productImageFileId), '需要客户已上传产品图片,不生成虚假证明文件')
|
||
assert(cargo.skuNo && Number.isInteger(cargo.unitsPerPackage) && cargo.unitsPerPackage > 0
|
||
&& cargo.totalQuantity === cargo.packageCount * cargo.unitsPerPackage, 'SKU 或件数不守恒')
|
||
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm']) {
|
||
assert(typeof cargo[field] === 'number' && Number.isFinite(cargo[field]) && cargo[field] > 0, `货物 ${field} 无效`)
|
||
}
|
||
const volume = cargo.lengthCm * cargo.widthCm * cargo.heightCm * cargo.packageCount / 1_000_000
|
||
assert(Math.abs(volume - cargo.volumeCbm) < 0.000001, '箱规与总体积不守恒')
|
||
return input
|
||
}
|
||
|
||
function binding(input) {
|
||
return fingerprint({ batch: input.batch, customer: input.customer, origin: input.origin,
|
||
destination: input.destination, payload: input.payload, operator: input.operator, environment: input.environment })
|
||
}
|
||
|
||
// Atomic snapshots plus an exclusive process lock. A crashed process leaves the lock for manual review.
|
||
// The caller must retain this file between invocations. Never generate a new batch to retry a failed one.
|
||
export async function withOrderJournal(path, input, action) {
|
||
validateOrderInput(input)
|
||
assert(typeof input.environment === 'string' && input.environment.startsWith('https://'), '缺少环境绑定')
|
||
await mkdir(dirname(path), { recursive: true })
|
||
const lock = await open(`${path}.lock`, 'wx', 0o600)
|
||
const temp = `${path}.next`
|
||
let tempOwned = false
|
||
try {
|
||
let state
|
||
try { state = JSON.parse(await readFile(path, 'utf8')) }
|
||
catch (error) {
|
||
if (error.code !== 'ENOENT') throw new Error('检查点损坏,禁止重新创建订单')
|
||
state = { version: 1, binding: binding(input), phase: 'NEW', orderId: null }
|
||
}
|
||
assert(state.version === 1 && state.binding === binding(input), '批次配置、环境、客户、账号或载荷已变化')
|
||
assert(['NEW', 'CREATE_INTENT', 'CREATED', 'SUBMIT_INTENT', 'SUBMITTED', 'APPROVE_INTENT', 'APPROVED'].includes(state.phase), '检查点阶段无效')
|
||
assert(['NEW', 'CREATE_INTENT'].includes(state.phase) ? state.orderId === null : longId(state.orderId), '检查点订单身份无效')
|
||
const save = async patch => {
|
||
const next = { ...state, ...patch }
|
||
const file = await open(temp, 'wx', 0o600)
|
||
tempOwned = true
|
||
try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync() } finally { await file.close() }
|
||
await rename(temp, path)
|
||
tempOwned = false
|
||
Object.assign(state, next)
|
||
}
|
||
if (state.phase === 'NEW') await save({})
|
||
return await action(state, save)
|
||
} finally {
|
||
// Only delete files created by this invocation; never remove an existing crash checkpoint.
|
||
if (tempOwned) await unlink(temp).catch(() => {})
|
||
await lock.close()
|
||
await unlink(`${path}.lock`)
|
||
}
|
||
}
|
||
|
||
export function assertOrderDetail(detail, input, orderId) {
|
||
const order = detail?.order
|
||
assert(order?.id === orderId && order.tenantId === input.customer.id && order.customerId === input.customer.id,
|
||
'订单归属与本批次不符')
|
||
for (const field of ['receivingWarehouseId', 'destinationWarehouseId', 'channelCode', 'destinationType',
|
||
'fulfillmentType', 'customerRemark', 'pickupMethod', 'containerMode', 'pricingTaxMode']) {
|
||
assert(order[field] === input.payload[field], `订单 ${field} 与批次不符`)
|
||
}
|
||
assert(typeof order.orderNo === 'string' && order.orderNo.length > 0, '缺少业务订单号')
|
||
assert(['DRAFT', 'SUBMITTED', 'APPROVED'].includes(order.status), '订单已偏离当前验收阶段,停止自动推进')
|
||
assert(Array.isArray(detail.cargoes) && detail.cargoes.length === 1, '订单货物行发生变化')
|
||
const cargo = detail.cargoes[0]
|
||
for (const key of ['skuNo', 'packageCount', 'totalQuantity', 'unitsPerPackage', 'productImageFileId']) {
|
||
assert(cargo[key] === input.payload.cargoes[0][key], `订单货物 ${key} 与批次不符`)
|
||
}
|
||
for (const key of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm']) {
|
||
assert(Number(cargo[key]) === input.payload.cargoes[0][key], `订单货物 ${key} 与批次不符`)
|
||
}
|
||
return order
|
||
}
|
||
|
||
/**
|
||
* transport is intentionally scoped to this module's five fixed endpoints, not the old test context.
|
||
* authorize performs fresh client/admin identity and route/quote checks, including on explicit resume.
|
||
* An uncertain non-idempotent approval is never replayed based only on SUBMITTED being observed.
|
||
*/
|
||
export async function runOrderStage({ input, state, save, transport, authorize, resume = false }) {
|
||
validateOrderInput(input)
|
||
assert(state.binding === binding(input), '检查点绑定不符')
|
||
await authorize(input)
|
||
const key = op => `prod-release-${fingerprint({ binding: state.binding, op }).slice(0, 48)}`
|
||
const request = (role, method, path, body, idempotencyKey) => transport({ role, method, path, body, idempotencyKey })
|
||
if (state.phase === 'CREATE_INTENT') assert(resume, '创建结果待核对;需要显式续跑')
|
||
if (['NEW', 'CREATE_INTENT'].includes(state.phase)) {
|
||
const policy = await request('MERCHANT', 'GET', `${ROOT}/inbound-code-policy`)
|
||
assert(policy?.mode === 'AUTO' && policy.manualEntryRequired === false, '当前专属链只支持自动入仓码')
|
||
await save({ phase: 'CREATE_INTENT' })
|
||
const orderId = await request('MERCHANT', 'POST', ROOT, input.payload, key('create'))
|
||
assert(longId(orderId), '创建返回订单 ID 无效,保留创建意图待核对')
|
||
await save({ phase: 'CREATED', orderId })
|
||
}
|
||
assert(longId(state.orderId), '缺少精确订单 ID')
|
||
const path = `${ROOT}/${state.orderId}`
|
||
const read = async () => assertOrderDetail(await request('MERCHANT', 'GET', path), input, state.orderId)
|
||
let order = await read()
|
||
if (state.phase === 'CREATED') {
|
||
assert(order.status === 'DRAFT', '创建后状态被外部修改')
|
||
await save({ phase: 'SUBMIT_INTENT' })
|
||
await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit'))
|
||
order = await read()
|
||
assert(order.status === 'SUBMITTED', '提交后未进入待审核')
|
||
await save({ phase: 'SUBMITTED', orderNo: order.orderNo, inboundCode: order.inboundCode })
|
||
} else if (state.phase === 'SUBMIT_INTENT') {
|
||
assert(resume, '提交结果待核对;需要显式续跑')
|
||
if (order.status === 'DRAFT') {
|
||
await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit'))
|
||
order = await read()
|
||
}
|
||
assert(order.status === 'SUBMITTED', '提交结果无法安全核对')
|
||
await save({ phase: 'SUBMITTED', orderNo: order.orderNo, inboundCode: order.inboundCode })
|
||
}
|
||
if (state.phase === 'SUBMITTED') {
|
||
order = await read()
|
||
assert(order.status === 'SUBMITTED', '审核前状态被外部修改')
|
||
await save({ phase: 'APPROVE_INTENT' })
|
||
await request('ADMIN', 'POST', `/platform/v1/orders/first-leg/${state.orderId}/approve?tenantId=${input.customer.id}`)
|
||
order = await read()
|
||
assert(order.status === 'APPROVED', '审核后未进入已审核,保留意图待核对')
|
||
await save({ phase: 'APPROVED' })
|
||
} else if (state.phase === 'APPROVE_INTENT') {
|
||
assert(resume && order.status === 'APPROVED', '审核结果不确定;禁止重发审核,需人工核对')
|
||
await save({ phase: 'APPROVED' })
|
||
}
|
||
order = await read()
|
||
assert(state.phase === 'APPROVED' && order.status === 'APPROVED', '订单阶段没有完成')
|
||
assert(typeof order.inboundCode === 'string' && order.inboundCode.length > 0, '审核订单缺少入仓码')
|
||
return { status: 'ORDER_APPROVED', orderId: state.orderId, orderNo: order.orderNo,
|
||
inboundCode: order.inboundCode, fullFlowVerified: false, next: 'YIWU_MEASUREMENT' }
|
||
}
|