Files
baiyue-firstleg/scripts/prod-release-domestic-stage.mjs
T

232 lines
20 KiB
JavaScript

import { fingerprint } from './prod-release-order-stage.mjs'
import { oceanBookingDraft } from './admin-test-env-modules/ocean-booking-fixture.mjs'
import { iso6346ContainerNumber } from './admin-test-env-modules/iso6346-container-number.mjs'
import { channelGroup } from './admin-test-env-modules/smart-loading-channel-group.mjs'
const assert = (value, message) => { if (!value) throw Error(message) }
const id = value => value == null ? '' : String(value)
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64')
const NODES = ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY']
const localTime = () => new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 19)
function fileForm(name, content = PNG, type = 'image/png', fields = {}) {
const form = new FormData()
for (const [key, value] of Object.entries(fields)) form.append(key, String(value))
form.append('file', new Blob([content], { type }), name)
return form
}
export function domesticSuccess(response) {
if (response?.http !== 200 || String(response?.body?.code) !== '200') {
const error = Error(`国内履约请求失败 HTTP=${response?.http} code=${response?.body?.code}`)
error.evidence = { http: response?.http, code: response?.body?.code, message: response?.body?.message,
requestId: response?.requestId, traceId: response?.traceId }
throw error
}
return response.body.data
}
// Persist intent before invoking transport. Never retry a partially observed write.
export function domesticCommands(checkpoint, binding) {
const { state, save } = checkpoint
assert(!state.binding || state.binding === binding, '国内检查点绑定发生变化')
return async (name, command, execute) => {
const previous = state.commands?.[name]
if (previous?.status === 'DONE') return previous.result
assert(!previous, `国内写入 ${name} 结果未知,必须人工核对原请求,禁止自动重放`)
const intent = { status: 'INTENT', command, fingerprint: fingerprint(command) }
const persist = async value => {
const patch = { binding, commands: { ...state.commands, [name]: value } }
await save(patch); Object.assign(state, patch)
}
await persist(intent)
const result = await execute()
await persist({ ...intent, status: 'DONE', result })
return result
}
}
export async function runDomesticStage({ input, order, context, checkpoint }) {
const config = input.domestic
const cargo = input.payload.cargoes[0]
assert(order?.status === 'ORDER_APPROVED' && order.orderId && order.inboundCode, '必须使用本票已审核订单')
assert(cargo.packageCount >= 1 && cargo.packageCount <= 2 && input.payload.cargoes.length === 1, '国内阶段仅支持本票一个 SKU、1~2 箱')
assert(input.payload.taxMode === 'BUY_DECLARATION', '当前专票须为买单报关,不自动改写报关方式')
assert(config?.palletId && config.assigneeId && config.bookingAgent?.id && config.carrier?.id
&& config.carrier.carrierCode && config.estimatedSailingAt && config.estimatedArrivalAt
&& config.terminalFleet?.id && config.terminalFleet.name, '缺少已有国内资源、目的港车队或明确船期')
assert(context.token && context.pdaToken && context.merchantToken, '缺少已登录平台、国内 PDA 或客户会话')
const binding = fingerprint({ input, orderId: order.orderId })
const command = domesticCommands(checkpoint, binding)
const tenantId = input.customer.id, warehouseId = input.origin.id
const q = `tenantId=${encodeURIComponent(tenantId)}`
const key = name => `release-${fingerprint({ binding, name }).slice(0, 48)}`
const get = async (path, token = context.token) => domesticSuccess(await context.request('GET', path, undefined, token))
const write = (name, method, path, body, token = context.token) => command(name, { method, path, body, idempotencyKey: key(name) },
async () => domesticSuccess(await context.request(method, path, body, token, { 'Idempotency-Key': key(name) })))
const upload = (name, path, form, token = context.token) => command(name, { method: 'POST', path, idempotencyKey: key(name), testEvidence: name },
async () => domesticSuccess(await context.requestMultipart('POST', path, form, token, { 'Idempotency-Key': key(name) })))
const poll = async (read, condition, description) => {
for (let i = 0; i < (context.pollAttempts || 45); i++) {
const data = await read()
if (condition(data)) return data
await (context.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000)
}
throw Error(`国内异步事实未就绪:${description}`)
}
const unified = await poll(() => get(`/api/platform/v1/orders/${order.orderId}?${q}`), data => data.summary?.inboundId, '订单入库投影')
const inboundId = id(unified.summary.inboundId)
const inboundPath = `/api/platform/inbounds/${inboundId}?${q}`
const base = `/api/platform/v1/first-leg/inbounds/${inboundId}`
const measurementPath = `${base}/measurement?${q}`
const inbound = await get(inboundPath)
assert(id(inbound.summary?.tenantId) === tenantId && inbound.summary.inboundCode === order.inboundCode
&& id(inbound.summary?.orderId) === order.orderId, '入库单客户、订单或入仓码不属于本票')
await write('accept', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/accept?${q}`)
await write('start', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/start?${q}`)
await command('carton-labels', { method: 'POST', path: `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}` }, async () => {
const result = await context.requestBinary('POST', `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}`)
assert(result.http === 200 && String(result.contentType).includes('pdf') && Buffer.from(result.bytes || []).subarray(0, 4).toString() === '%PDF', '本票箱唛未成功生成')
return { generated: true }
})
const scanForm = new FormData()
scanForm.append('data', new Blob([JSON.stringify({ boxCode: `${order.inboundCode}#${cargo.packageCount}-1`,
length: cargo.lengthCm, width: cargo.widthCm, height: cargo.heightCm,
machineNo: 'TEST-RELEASE-SIMULATED', measuredAt: localTime() })], { type: 'application/json' }))
scanForm.append('image', new Blob([PNG], { type: 'image/png' }), 'TEST-simulated-carton.png')
const scan = await upload('carton-scan', `${base}/measurement/carton-scans?${q}`, scanForm)
assert(scan?.itemId && scan.measurementId, '测量扫描未生成本票明细')
await write('carton-count', 'PUT', `${base}/measurement/items/${scan.itemId}/confirmed-package-count?${q}`,
{ measurementVersion: Number(scan.measurementVersion), confirmedPackageCount: cargo.packageCount })
const measured = await get(measurementPath)
assert(measured.items?.length === 1 && id(measured.items[0].id) === id(scan.itemId), '本票测量明细数量不符')
const item = measured.items[0]
await write('measurement-draft', 'PUT', `${base}/measurement/draft?${q}`, {
measurementId: measured.measurementId, version: Number(measured.version), actualGoodsName: cargo.goodsName || cargo.skuNo,
items: [{ inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
packageCount: cargo.packageCount, lengthCm: cargo.lengthCm, widthCm: cargo.widthCm, heightCm: cargo.heightCm,
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null,
outerLengthCm: cargo.lengthCm, outerWidthCm: cargo.widthCm, outerHeightCm: cargo.heightCm,
coefficientMode: 'ADD', remark: `${input.batch} TEST模拟测量,非实测证据` }] })
const weightFile = await upload('weight-file', `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, fileForm('TEST-weight.png'))
const weight = await write('weight-create', 'POST', `${base}/weight-results?${q}`, { fileId: weightFile.fileId, palletId: config.palletId,
ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null })
assert(weight.palletTareWeightKg != null && Number(weight.palletTareWeightKg) >= 0, '缺少已有托盘皮重快照')
await write('weight-confirm', 'POST', `${base}/weight-results/${weight.id}/confirm?${q}`, {
version: Number(weight.version), adjustedWeightValue: Number(cargo.grossWeightKg) + Number(weight.palletTareWeightKg),
adjustedWeightUnit: 'KG', adjustmentReason: `${input.batch} TEST模拟称重,申报货重加托盘皮重` })
const ready = await get(measurementPath)
await write('measurement-submit', 'POST', `${base}/measurement/submit?${q}`, { measurementId: ready.measurementId, version: Number(ready.version) })
const pending = await get(inboundPath)
await write('measurement-approve', 'POST', `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${q}`,
{ measurementId: ready.measurementId, expectedVersion: Number(pending.summary.version), reason: `${input.batch} TEST专票流程` })
const approved = await poll(() => get(measurementPath), value => value.processStatus === 'WAIT_SPLIT'
|| checkpoint.state.commands?.['plan-assign']?.status === 'DONE', '测量审核待分柜')
assert(approved.measurementStatus === 'APPROVED' && approved.measureReviewStatus === 'APPROVED'
&& Number(approved.actualPackageCount) === cargo.packageCount
&& Math.abs(Number(approved.actualNetWeightKg) - cargo.grossWeightKg) <= 0.001, '测量审核、箱数或净重不符')
const latestFee = await context.request('GET', `/api/platform/v1/inbounds/${inboundId}/fees/latest?${q}`, undefined, context.token)
if (latestFee.http === 200 && String(latestFee.body?.code) === 'A0404') {
await write('fee-calculate', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/calculate?${q}`)
} else domesticSuccess(latestFee)
await write('fee-confirm', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/confirm?${q}`)
const sent = await write('fee-send', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/send?${q}`)
assert(sent.feeStatus === 'SENT', '头程费用未发送客户')
const billing = `/api/merchant/v1/first-leg/orders/${order.orderId}/inbound-billing`
const customerFee = await write('customer-fee-confirm', 'POST', `${billing}/confirm`, { expectedFeeVersion: Number(sent.feeVersion) }, context.merchantToken)
assert(customerFee.customerConfirmationStatus === 'CONFIRMED', '客户费用未确认')
const containers = '/api/platform/v1/containers', smart = '/api/platform/v1/first-leg/smart-loading', pda = '/api/platform/v1/first-leg/pda-loading'
const simulation = await write('container-create', 'POST', containers, { channelGroupCode: channelGroup(input.payload.channelCode), warehouseId,
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
const containerId = id(simulation.id)
const booking = await write('booking-create', 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0,
data: oceanBookingDraft({ marker: input.batch }, input.batch, { customerId: tenantId, customerName: input.customer.name,
channelCode: input.payload.channelCode, warehouseId }, { shippingCompanyId: config.carrier.id, shippingCompanyName: config.carrier.name,
carrierCode: config.carrier.carrierCode, bookingAgentId: config.bookingAgent.id, bookingAgentName: config.bookingAgent.name,
estimatedSailingAt: config.estimatedSailingAt, estimatedArrivalAt: config.estimatedArrivalAt }) })
const oceanBookingId = id(booking.oceanBooking.id)
const document = await upload('booking-document', `/api/platform/v1/ocean-bookings/${oceanBookingId}/documents/upload`,
fileForm('TEST-booking-confirmation.txt', `${input.batch} TEST模拟订舱确认文件,非真实单证`, 'text/plain',
{ expectedVersion: booking.oceanBooking.version, documentType: 'BOOKING_CONFIRMATION' }))
const released = await write('booking-release', 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`,
{ expectedVersion: Number(document.bookingVersion), reason: `${input.batch} TEST` })
assert(released.oceanBooking?.status === 'RELEASED', '订舱未放舱')
const requirement = released.containerRequirements?.find(value => value.containerType === '40HQ')
assert(requirement?.id, '订舱缺少40HQ需求')
const containerNo = iso6346ContainerNumber(input.batch)
await write('booking-link', 'POST', `${containers}/${containerId}/ocean-booking-link`, { oceanBookingId,
requirementId: id(requirement.id), containerNo, expectedVersion: Number(simulation.version) })
const candidates = await get(`${smart}/candidates?bookingId=${containerId}&warehouseId=${warehouseId}`)
const candidate = candidates.find(value => id(value.orderId) === order.orderId && id(value.accountId) === tenantId)
// After assignment a resumed run uses the captured candidate, never another order.
const assignedBefore = checkpoint.state.commands?.['plan-assign']
assert(candidate || assignedBefore?.status === 'DONE', '本票不在可分柜候选中')
const plan = await get(`${smart}/plans/by-booking/${containerId}`), planId = id(plan.id)
const assigned = await write('plan-assign', 'POST', `${smart}/plans/${planId}/assignments`, {
inboundId, inboundDetailId: id(candidate?.inboundDetailId), packageCount: cargo.packageCount,
expectedVersion: Number(plan.version), operationId: key('plan-assign') })
assert(assigned.items?.length === 1 && Number(assigned.items[0].packageCount) === cargo.packageCount, '装柜计划混入其他货物或数量不符')
const confirmable = await get(`/api/platform/v1/first-leg/loading-plans/${planId}?warehouseId=${warehouseId}`)
const confirmed = await write('plan-confirm', 'POST', `/api/platform/v1/first-leg/loading-plans/${planId}/confirm`,
{ expectedVersion: Number(confirmable.summary.version), assigneeId: config.assigneeId })
const taskId = id(confirmed.readiness?.pdaTaskId)
assert(taskId && confirmed.confirmationStatus === 'CONFIRMED', '国内 PDA 任务未生成')
const claimed = await write('pda-claim', 'POST', `${pda}/tasks/${taskId}/claim`,
{ expectedAssignmentVersion: 0, operationId: key('pda-claim') }, context.pdaToken)
assert(claimed.items?.length === 1, '国内 PDA 任务货物不唯一')
const snapshot = await get(`${pda}/tasks/${taskId}/inventory-snapshot?warehouseId=${warehouseId}`, context.pdaToken)
const picked = await write('pda-pick', 'POST', `${pda}/tasks/${taskId}/batch`, { warehouseId, loadingPlanId: planId,
containerBookingId: containerId, expectedVersion: Number(snapshot.taskVersion), inventorySnapshotToken: snapshot.snapshotToken,
items: [{ itemId: id(claimed.items[0].id), action: 'PICK', pickedPackageCount: cargo.packageCount, version: Number(claimed.items[0].version) }] }, context.pdaToken)
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
const params = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType, remark: `${input.batch} TEST模拟凭证` })
if (evidenceType === 'SEAL') params.set('sealNo', `TEST-${input.batch}`.slice(0, 64))
await upload(`loading-${evidenceType}`, `${pda}/tasks/${taskId}/evidence?${params}`, fileForm(`TEST-${evidenceType}.png`), context.pdaToken)
}
await write('pda-complete', 'POST', `${pda}/tasks/${taskId}/complete`, { expectedTaskVersion: Number(picked.version),
operationId: key('pda-complete'), remark: `${input.batch} TEST装柜` }, context.pdaToken)
await poll(() => get(`${pda}/tasks/${taskId}?warehouseId=${warehouseId}`, context.pdaToken),
value => value.taskStatus === 'COMPLETED' && value.workflowStatus === 'COMPLETED', '国内装柜完成')
const customsPath = `/api/platform/v1/customs/loading-plans/${planId}`
let customs = await get(customsPath)
assert(customs.inbounds?.length === 1 && id(customs.inbounds[0].inboundId) === inboundId
&& customs.inbounds.every(value => value.taxMode === 'BUY_DECLARATION'), '报关聚合不是本票买单货物')
if (customs.status === 'READY_TO_START') customs = await write('customs-start', 'POST', `${customsPath}/start`,
{ expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` })
if (customs.status === 'DECLARING') customs = await write('customs-complete', 'POST', `${customsPath}/complete-all-buy`,
{ expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` })
assert(customs.status === 'COMPLETED', '国内报关未完成')
const outboundPath = `/api/platform/v1/first-leg/outbound-dispatches/${containerId}`
const outbound = await poll(() => get(outboundPath), value => value.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING', '报关自动国内离仓')
assert(outbound.aggregate.inbounds?.length === 1 && id(outbound.aggregate.inbounds[0].inboundId) === inboundId, '离仓货物归属不符')
const queries = await poll(() => get(`/api/platform/v1/carrier-schedule-queries?containerBookingId=${containerId}&page=1&size=20`),
value => value.records?.some(row => row.query?.triggerSource === 'OUTBOUND_CONFIRM'), '离仓船节点任务')
const owned = queries.records.filter(row => id(row.query?.containerBookingId) === containerId && row.query.triggerSource === 'OUTBOUND_CONFIRM')
assert(owned.length === 1, '本柜船节点任务不唯一')
const carrierQueryId = id(owned[0].query.id), carrierPath = `/api/platform/v1/carrier-schedule-queries/${carrierQueryId}`
let carrierDetail = await get(carrierPath)
let start = NODES.indexOf(carrierDetail.query?.query?.latestActualNode)
assert(start >= 0, '缺少自动离仓起点')
for (let index = start + 1; index < NODES.length; index++) {
const latest = carrierDetail.query.query
// Current observed wall clock; actual timestamps advance from the authoritative last node.
const actualAt = new Date(Date.parse(`${latest.latestActualAt}Z`) + 1000).toISOString().slice(0, 19)
carrierDetail = await write(`carrier-${NODES[index]}`, 'POST', `${carrierPath}/manual-nodes`, {
expectedVersion: Number(latest.version), standardNode: NODES[index], observedAt: localTime(), actualAt, estimatedAt: null,
locationCode: 'USLAX', locationName: 'Los Angeles', reason: `${input.batch} TEST模拟船节点 ${NODES[index]}` })
}
assert(carrierDetail.query?.query?.latestActualNode === 'PICKUP_READY', '船节点未到可提柜')
const terminalPath = `/api/platform/v1/terminal-drayages/${containerId}`
const initialTerminal = await get(terminalPath)
assert(id(initialTerminal.containerBookingId) === containerId && initialTerminal.containerNo === containerNo, '目的港拖车货柜不符')
assert(!initialTerminal.fleetSupplierId || checkpoint.state.commands?.['terminal-fleet']?.status === 'DONE', '禁止更换既有车队触发邮件')
const selected = await write('terminal-fleet', 'POST', `${terminalPath}/fleet`, { expectedVersion: Number(initialTerminal.version),
fleetSupplierId: config.terminalFleet.id, fleetSupplierName: config.terminalFleet.name, reason: `${input.batch} TEST` })
const appointmentAt = new Date(Date.parse(`${carrierDetail.query.query.latestActualAt}Z`) + 3600000).toISOString().slice(0, 19)
const scheduled = await write('terminal-appointment', 'POST', `${terminalPath}/pickup-appointment`, {
expectedVersion: Number(selected.version), pickupAppointmentAt: appointmentAt, reason: `${input.batch} TEST` })
assert(scheduled.pickupAppointmentAt === appointmentAt, '目的港提柜预约未持久化')
return { status: 'DOMESTIC_PICKUP_SCHEDULED', orderId: order.orderId, orderNo: order.orderNo, tenantId, warehouseId,
inboundId, inboundCode: order.inboundCode, containerId, containerBookingId: containerId, containerNo,
oceanBookingId, planId, loadingPlanId: planId, taskId, carrierQueryId, packageCount: cargo.packageCount,
terminalDrayageVersion: Number(scheduled.version), pickupAppointmentAt: appointmentAt }
}