Files
baiyue-firstleg/scripts/prod-release-order-stage.mjs
T
2026-09-18 18:50:40 +08:00

167 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' }
}