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 } }