2026-08-10 12:23:43 +08:00
|
|
|
const MODULE = 'phase3-order-splitting'
|
2026-08-11 10:00:11 +08:00
|
|
|
const SCENARIO = 'source-loaded-single-remainder-chain'
|
2026-08-10 14:15:04 +08:00
|
|
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
2026-08-26 11:10:42 +08:00
|
|
|
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
|
|
|
|
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
2026-08-11 10:00:11 +08:00
|
|
|
const CONTAINERS = '/api/platform/v1/containers'
|
|
|
|
|
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
|
|
|
|
const PDA = '/api/platform/v1/first-leg/pda-loading'
|
2026-08-10 14:15:04 +08:00
|
|
|
|
2026-08-15 11:51:15 +08:00
|
|
|
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
2026-08-31 12:13:20 +08:00
|
|
|
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
|
2026-09-09 20:36:53 +08:00
|
|
|
import { ensureInitialCalculated } from './inbound-fees.mjs'
|
2026-09-10 20:47:15 +08:00
|
|
|
import { channelGroup } from './smart-loading-channel-group.mjs'
|
2026-08-15 11:51:15 +08:00
|
|
|
|
2026-08-10 14:15:04 +08:00
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
|
|
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
2026-08-11 10:00:11 +08:00
|
|
|
function isPositiveId(value) { return /^[1-9][0-9]*$/.test(id(value)) }
|
|
|
|
|
function isConflict(response) {
|
|
|
|
|
return response?.http === 409
|
|
|
|
|
|| (response?.http === 200 && ['A0409', '409'].includes(codeOf(response)))
|
|
|
|
|
}
|
|
|
|
|
function isForbidden(response) {
|
|
|
|
|
return response?.http === 403
|
|
|
|
|
|| (response?.http === 200 && ['A0403', '403'].includes(codeOf(response)))
|
|
|
|
|
}
|
|
|
|
|
function hasTrace(response) {
|
|
|
|
|
return typeof response?.requestId === 'string' && response.requestId.length > 0
|
|
|
|
|
&& typeof response?.traceId === 'string' && response.traceId.length > 0
|
2026-08-10 14:15:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-15 12:12:34 +08:00
|
|
|
const BOOKING_TRANSPORT_FIELDS = [
|
|
|
|
|
'billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt',
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
function bookingTransportSnapshot(value) {
|
|
|
|
|
return Object.fromEntries(BOOKING_TRANSPORT_FIELDS.map((field) => [field, value?.[field] ?? null]))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function completeBookingTransport(snapshot) {
|
|
|
|
|
return BOOKING_TRANSPORT_FIELDS.every((field) => typeof snapshot?.[field] === 'string'
|
|
|
|
|
&& snapshot[field].trim().length > 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sameBookingTransport(left, right) {
|
|
|
|
|
return BOOKING_TRANSPORT_FIELDS.every((field) => left?.[field] === right?.[field])
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 10:00:11 +08:00
|
|
|
async function request(context, method, path, body, headers = {}, token = context.token) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
|
|
|
method,
|
|
|
|
|
headers: {
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
|
|
|
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }),
|
|
|
|
|
...headers,
|
|
|
|
|
},
|
|
|
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
|
|
|
signal: AbortSignal.timeout(60_000),
|
|
|
|
|
})
|
|
|
|
|
const raw = await response.text()
|
|
|
|
|
let payload = null
|
|
|
|
|
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence below */ }
|
|
|
|
|
return {
|
|
|
|
|
method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
|
|
|
|
|
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
|
|
|
|
traceId: payload?.traceId || response.headers.get('x-trace-id'),
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-10 14:15:04 +08:00
|
|
|
|
2026-09-02 13:59:05 +08:00
|
|
|
async function ensureCompletionEvidence(context, taskId, warehouseId, keyBase) {
|
2026-08-24 13:53:10 +08:00
|
|
|
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
|
|
|
|
|
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
|
2026-09-02 13:59:05 +08:00
|
|
|
const query = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType,
|
|
|
|
|
remark: context.marker })
|
|
|
|
|
if (evidenceType === 'SEAL') query.set('sealNo', `SEAL-${keyBase}`.slice(0, 64))
|
|
|
|
|
const uploadPath = `${PDA}/tasks/${taskId}/evidence?${query}`
|
2026-08-24 13:53:10 +08:00
|
|
|
const form = new FormData()
|
|
|
|
|
form.append('file', new Blob([png], { type: 'image/png' }), `${keyBase}-${evidenceType}.png`)
|
|
|
|
|
const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, {
|
2026-09-02 13:59:05 +08:00
|
|
|
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
|
|
|
|
'Idempotency-Key': `${keyBase}-evidence-${evidenceType}`.slice(0, 128) },
|
2026-08-24 13:53:10 +08:00
|
|
|
body: form, signal: AbortSignal.timeout(60_000),
|
|
|
|
|
})
|
|
|
|
|
const uploadBody = await rawUpload.json()
|
|
|
|
|
const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody,
|
|
|
|
|
requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'),
|
|
|
|
|
traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') }
|
2026-09-02 13:59:05 +08:00
|
|
|
const evidence = context.apiSuccess(MODULE, `${keyBase} 上传并绑定 ${evidenceType} 装柜证据`, upload, '', OPTIONS)
|
|
|
|
|
const active = evidence?.evidences?.find?.((item) => item.evidenceType === evidenceType
|
|
|
|
|
&& item.status !== 'VOID')
|
|
|
|
|
if (!active?.evidenceId || id(evidence?.taskId) !== taskId) return false
|
2026-08-24 13:53:10 +08:00
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 10:00:11 +08:00
|
|
|
function treePath(orderId, tenantId) {
|
|
|
|
|
return `/api/platform/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(tenantId)}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function activeRemainders(tree) {
|
|
|
|
|
const rows = Array.isArray(tree?.children) ? tree.children : []
|
|
|
|
|
const consumedSources = new Set(rows.filter((row) => row.splitRole === 'LOADED').map((row) => id(row.sourceOrderId)))
|
|
|
|
|
return rows.filter((row) => row.splitRole === 'REMAINDER' && !consumedSources.has(id(row.orderId)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function treeShape(tree, rootOrderId) {
|
|
|
|
|
if (!tree || id(tree.parentOrderId) !== rootOrderId || !isPositiveId(tree.parentOrderId)
|
2026-08-10 14:15:04 +08:00
|
|
|
|| typeof tree.parentOrderNo !== 'string' || !Number.isInteger(Number(tree.parentVersion))
|
2026-08-11 10:00:11 +08:00
|
|
|
|| !Array.isArray(tree.children) || !Number.isInteger(Number(tree.originalPackageCount))
|
2026-08-11 09:17:39 +08:00
|
|
|
|| !Number.isInteger(Number(tree.cumulativeLoadedPackageCount))
|
|
|
|
|
|| !Number.isInteger(Number(tree.currentRemainderPackageCount))) return false
|
2026-08-11 10:00:11 +08:00
|
|
|
const rootNo = tree.parentOrderNo
|
2026-08-11 09:17:39 +08:00
|
|
|
const nestedCode = /-[0-9]+-[0-9]+$/
|
2026-08-11 10:00:11 +08:00
|
|
|
return tree.children.every((row) => {
|
|
|
|
|
const role = row?.splitRole
|
|
|
|
|
const sourceCarriesLoaded = role !== 'LOADED' || id(row.orderId) === id(row.sourceOrderId)
|
|
|
|
|
const remainderShape = role !== 'REMAINDER'
|
|
|
|
|
|| (id(row.orderId) !== id(row.sourceOrderId) && Number.isInteger(Number(row.splitSequence))
|
|
|
|
|
&& Number(row.splitSequence) > 0 && row.orderNo === `${rootNo}-${Number(row.splitSequence)}`)
|
|
|
|
|
return ['LOADED', 'REMAINDER'].includes(role)
|
|
|
|
|
&& isPositiveId(row.orderId) && isPositiveId(row.sourceOrderId)
|
|
|
|
|
&& typeof row.orderNo === 'string' && !nestedCode.test(row.orderNo)
|
|
|
|
|
&& Number.isInteger(Number(row.packageCount)) && Number(row.packageCount) > 0
|
|
|
|
|
&& isPositiveId(row.loadingPlanId) && isPositiveId(row.containerBookingId)
|
|
|
|
|
&& sourceCarriesLoaded && remainderShape
|
|
|
|
|
})
|
2026-08-11 09:17:39 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-11 10:00:11 +08:00
|
|
|
function quantityConserved(tree) {
|
|
|
|
|
const rows = Array.isArray(tree?.children) ? tree.children : []
|
|
|
|
|
const loadedRows = rows.filter((row) => row.splitRole === 'LOADED')
|
|
|
|
|
const remainders = rows.filter((row) => row.splitRole === 'REMAINDER')
|
|
|
|
|
const rootOriginal = Number(tree?.originalPackageCount)
|
|
|
|
|
const rootNo = tree?.parentOrderNo
|
|
|
|
|
return loadedRows.every((loaded) => {
|
|
|
|
|
const remainder = remainders.find((row) => id(row.sourceOrderId) === id(loaded.sourceOrderId)
|
|
|
|
|
&& id(row.loadingPlanId) === id(loaded.loadingPlanId)
|
|
|
|
|
&& id(row.containerBookingId) === id(loaded.containerBookingId))
|
|
|
|
|
const sourceCount = id(loaded.sourceOrderId) === id(tree.parentOrderId)
|
|
|
|
|
? rootOriginal
|
|
|
|
|
: Number(remainders.find((row) => id(row.orderId) === id(loaded.sourceOrderId))?.packageCount)
|
|
|
|
|
return Number.isInteger(sourceCount) && sourceCount > 0
|
|
|
|
|
&& sourceCount === Number(loaded.packageCount) + Number(remainder?.packageCount || 0)
|
|
|
|
|
&& (id(loaded.sourceOrderId) === id(tree.parentOrderId) || loaded.orderNo.startsWith(`${rootNo}-`))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function snapshotMatches(tree, original, loaded, remaining, activeSuffix) {
|
|
|
|
|
const active = activeRemainders(tree)
|
|
|
|
|
const activeMatches = activeSuffix == null
|
|
|
|
|
? active.length === 0
|
|
|
|
|
: active.length === 1 && active[0].orderNo === `${tree.parentOrderNo}-${activeSuffix}`
|
|
|
|
|
&& Number(active[0].packageCount) === remaining
|
|
|
|
|
return Number(tree.originalPackageCount) === original
|
|
|
|
|
&& Number(tree.cumulativeLoadedPackageCount) === loaded
|
|
|
|
|
&& Number(tree.currentRemainderPackageCount) === remaining
|
|
|
|
|
&& Number(tree.allocatedPackageCount) === loaded
|
|
|
|
|
&& Number(tree.remainingPackageCount) === remaining
|
|
|
|
|
&& original === loaded + remaining
|
|
|
|
|
&& activeMatches && quantityConserved(tree)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:34:53 +08:00
|
|
|
function bookingPayload(marker, sequence, source, agent, carrier) {
|
2026-08-11 10:00:11 +08:00
|
|
|
const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18)
|
2026-08-15 11:51:15 +08:00
|
|
|
return oceanBookingDraft({ marker }, suffix, {
|
|
|
|
|
customerId: source.tenantId, customerName: source.customerName, channelCode: source.channelCode,
|
2026-08-14 10:09:46 +08:00
|
|
|
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
|
2026-08-15 11:51:15 +08:00
|
|
|
}, { vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64),
|
2026-09-02 13:34:53 +08:00
|
|
|
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: carrier.carrierCode,
|
2026-08-24 13:48:16 +08:00
|
|
|
bookingAgentId: id(agent.id), bookingAgentName: agent.name,
|
2026-08-15 11:51:15 +08:00
|
|
|
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' })
|
2026-08-11 10:00:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadOneFragment(context, state) {
|
|
|
|
|
const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state
|
2026-08-14 10:09:46 +08:00
|
|
|
const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100)
|
2026-08-24 13:48:16 +08:00
|
|
|
const agentResponse = await context.request('GET',
|
2026-09-01 21:55:27 +08:00
|
|
|
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=20')
|
2026-08-24 13:48:16 +08:00
|
|
|
const agentPage = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS)
|
|
|
|
|
const agent = agentPage?.records?.find?.((item) => item?.id && item?.name)
|
|
|
|
|
if (!agent) return null
|
2026-09-02 13:34:53 +08:00
|
|
|
const { ensureMatsonCarrierPartner } = await import('./carrier-partner-fixture.mjs')
|
|
|
|
|
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
|
|
|
|
|
if (!carrier?.id) return null
|
2026-08-14 21:00:20 +08:00
|
|
|
const simulationResponse = await request(context, 'POST', CONTAINERS, {
|
2026-09-10 20:47:15 +08:00
|
|
|
channelGroupCode: channelGroup(state.channelCode), warehouseId,
|
|
|
|
|
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
|
2026-08-14 21:00:20 +08:00
|
|
|
})
|
|
|
|
|
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
|
|
|
|
|
const containerId = id(simulation?.id)
|
|
|
|
|
if (!containerId) return null
|
|
|
|
|
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId)
|
2026-08-14 10:09:46 +08:00
|
|
|
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', {
|
2026-09-02 13:34:53 +08:00
|
|
|
expectedVersion: 0, data: bookingPayload(context.marker, sequence, state, agent, carrier),
|
2026-08-14 10:09:46 +08:00
|
|
|
}, { 'Idempotency-Key': `${operationBase}-booking-create` })
|
|
|
|
|
const bookingCreated = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建渠道仓库一致的订舱`, bookingCreate, '', OPTIONS)
|
|
|
|
|
const oceanBookingId = id(bookingCreated?.oceanBooking?.id)
|
|
|
|
|
if (!oceanBookingId) return null
|
|
|
|
|
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(oceanBookingId)
|
2026-09-02 13:59:05 +08:00
|
|
|
const bookingDocumentForm = new FormData()
|
|
|
|
|
bookingDocumentForm.append('expectedVersion', String(Number(bookingCreated?.oceanBooking?.version)))
|
|
|
|
|
bookingDocumentForm.append('documentType', 'BOOKING_CONFIRMATION')
|
|
|
|
|
bookingDocumentForm.append('file', new Blob([`${context.marker}-${sourceOrderNo}-BOOKING_CONFIRMATION`],
|
|
|
|
|
{ type: 'text/plain' }), `booking-confirmation-${operationBase.replace(/[^A-Za-z0-9-]/g, '')}.txt`)
|
|
|
|
|
const bookingDocumentResponse = await context.requestMultipart('POST',
|
|
|
|
|
`/api/platform/v1/ocean-bookings/${oceanBookingId}/documents/upload`, bookingDocumentForm, context.token,
|
|
|
|
|
{ 'Idempotency-Key': `${operationBase}-booking-document`.slice(0, 128) })
|
|
|
|
|
const bookingDocument = context.apiSuccess(MODULE, `上传 ${sourceOrderNo} 的订舱确认文件`,
|
|
|
|
|
bookingDocumentResponse, '', OPTIONS)
|
|
|
|
|
const releaseVersion = Number(bookingDocument?.bookingVersion)
|
|
|
|
|
if (!Number.isInteger(releaseVersion)) return null
|
2026-08-18 16:39:39 +08:00
|
|
|
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, {
|
2026-09-02 13:59:05 +08:00
|
|
|
expectedVersion: releaseVersion, reason: context.marker,
|
2026-08-18 16:39:39 +08:00
|
|
|
}, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) })
|
|
|
|
|
const bookingConfirmed = context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的订舱`, releaseResponse, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 订舱为 RELEASED 且不预生成真实柜`, releaseResponse,
|
|
|
|
|
bookingConfirmed?.oceanBooking?.status === 'RELEASED'
|
|
|
|
|
&& (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
|
|
|
|
|
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS)
|
|
|
|
|
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
|
2026-08-31 12:13:20 +08:00
|
|
|
const realContainerNo = iso6346ContainerNumber(`${context.marker}-split-${sequence}`)
|
2026-08-18 16:39:39 +08:00
|
|
|
if (!requirement?.id) return null
|
2026-08-14 21:00:20 +08:00
|
|
|
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
|
2026-08-18 16:39:39 +08:00
|
|
|
oceanBookingId, requirementId: id(requirement.id), containerNo: realContainerNo,
|
|
|
|
|
expectedVersion: Number(simulation.version),
|
2026-08-14 21:00:20 +08:00
|
|
|
})
|
|
|
|
|
const linked = context.apiSuccess(MODULE, `关联 ${sourceOrderNo} 的模拟柜与真实订舱`, linkResponse, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 关联后保持稳定内部柜次`, linkResponse,
|
|
|
|
|
isPositiveId(containerId) && id(linked?.id) === containerId && linked?.cabinetNo === simulation.cabinetNo,
|
|
|
|
|
`oceanBookingId=${oceanBookingId}, containerBookingId=${containerId}`, OPTIONS)
|
2026-08-14 10:09:46 +08:00
|
|
|
|
|
|
|
|
const candidatesPath = `${SMART}/candidates?bookingId=${encodeURIComponent(containerId)}&warehouseId=${encodeURIComponent(warehouseId)}`
|
|
|
|
|
const candidatesResponse = await context.request('GET', candidatesPath)
|
2026-08-11 10:00:11 +08:00
|
|
|
const candidates = context.apiSuccess(MODULE, `查询余量 ${sourceOrderNo} 的真实装柜候选`, candidatesResponse, '', OPTIONS)
|
|
|
|
|
const candidate = Array.isArray(candidates)
|
|
|
|
|
? candidates.find((row) => id(row.orderId) === sourceOrderId && id(row.accountId) === tenantId)
|
|
|
|
|
: null
|
2026-09-10 21:29:40 +08:00
|
|
|
context.assert(MODULE, `余量 ${sourceOrderNo} 初始已拣为0且存在可用货物明细`, candidatesResponse,
|
2026-08-11 10:00:11 +08:00
|
|
|
Boolean(candidate?.inboundId) && Boolean(candidate?.inboundDetailId)
|
2026-09-10 21:29:40 +08:00
|
|
|
&& Number(candidate?.availablePackageCount) >= 1,
|
2026-08-11 10:00:11 +08:00
|
|
|
`orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
|
|
|
|
if (!candidate?.inboundId || !candidate?.inboundDetailId) return null
|
|
|
|
|
|
2026-09-09 20:36:53 +08:00
|
|
|
const initialFee = await ensureInitialCalculated(context, {
|
|
|
|
|
tenantId,
|
|
|
|
|
inboundId: id(candidate.inboundId),
|
|
|
|
|
finalStatus: 'APPROVED',
|
|
|
|
|
processStatus: 'WAIT_SPLIT',
|
|
|
|
|
}, OPTIONS)
|
|
|
|
|
if (!initialFee) return null
|
|
|
|
|
|
2026-09-02 13:03:03 +08:00
|
|
|
const createPlan = await request(context, 'GET', `${SMART}/plans/by-booking/${containerId}`)
|
|
|
|
|
const plan = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 模拟柜自动创建的装柜计划`, createPlan, '', OPTIONS)
|
2026-08-11 10:00:11 +08:00
|
|
|
const planId = id(plan?.id)
|
|
|
|
|
if (!planId) return null
|
|
|
|
|
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(planId)
|
|
|
|
|
|
|
|
|
|
const assignPayload = {
|
|
|
|
|
inboundId: id(candidate.inboundId), inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
|
|
|
|
|
expectedVersion: Number(plan.version), operationId: `${operationBase}-assign`,
|
|
|
|
|
}
|
|
|
|
|
const assign = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, assignPayload)
|
|
|
|
|
const assigned = context.apiSuccess(MODULE, `从 ${sourceOrderNo} 分配1箱`, assign, '', OPTIONS)
|
|
|
|
|
const planItem = assigned?.items?.find?.((row) => id(row.inboundId) === id(candidate.inboundId))
|
|
|
|
|
context.assert(MODULE, `来源 ${sourceOrderNo} 仅承接本次1箱`, assign,
|
|
|
|
|
Boolean(planItem?.id) && Number(planItem?.packageCount) === 1,
|
|
|
|
|
`planItemId=${id(planItem?.id) || 'missing'}, packages=${planItem?.packageCount ?? 'missing'}`, OPTIONS)
|
|
|
|
|
if (!planItem?.id) return null
|
|
|
|
|
|
|
|
|
|
const staleAssign = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, {
|
|
|
|
|
...assignPayload, expectedVersion: 0, operationId: `${operationBase}-stale`,
|
|
|
|
|
})
|
|
|
|
|
context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧计划版本被拒绝`, staleAssign,
|
|
|
|
|
isConflict(staleAssign) && hasTrace(staleAssign),
|
|
|
|
|
`HTTP=${staleAssign.http}, code=${codeOf(staleAssign)}, requestId=${staleAssign.requestId || 'missing'}, traceId=${staleAssign.traceId || 'missing'}`, OPTIONS)
|
|
|
|
|
|
2026-08-17 13:24:48 +08:00
|
|
|
const transportStable = linked?.containerNo === realContainerNo && id(linked?.id) === containerId
|
|
|
|
|
&& linked?.cabinetNo === simulation.cabinetNo
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 合并后真实柜号落在稳定模拟柜身份`, linkResponse,
|
2026-08-14 22:59:10 +08:00
|
|
|
transportStable,
|
2026-08-17 13:24:48 +08:00
|
|
|
`containerBookingId=${containerId}, containerNo=${linked?.containerNo || 'missing'}, cabinetNo=${linked?.cabinetNo || 'missing'}`, OPTIONS)
|
2026-08-14 22:59:10 +08:00
|
|
|
if (!transportStable) return null
|
|
|
|
|
|
2026-08-14 23:21:47 +08:00
|
|
|
const bookingPath = `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`
|
|
|
|
|
let bookingDetailResponse = await context.request('GET', bookingPath)
|
|
|
|
|
let bookingDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 柜次所属真实订舱`, bookingDetailResponse, '', OPTIONS)
|
2026-08-17 13:24:48 +08:00
|
|
|
const allocationMatches = bookingDetail?.containers?.some?.((item) =>
|
2026-08-14 23:21:47 +08:00
|
|
|
id(item?.containerBookingId) === containerId)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 订舱分配保持稳定柜次`, bookingDetailResponse,
|
|
|
|
|
id(bookingDetail?.oceanBooking?.id) === oceanBookingId && allocationMatches,
|
2026-08-17 13:24:48 +08:00
|
|
|
`oceanBookingId=${id(bookingDetail?.oceanBooking?.id) || 'missing'}, containerBookingId=${containerId}, containers=${bookingDetail?.containers?.length ?? 'n/a'}`, OPTIONS)
|
2026-08-14 23:21:47 +08:00
|
|
|
if (!allocationMatches || id(bookingDetail?.oceanBooking?.id) !== oceanBookingId) return null
|
|
|
|
|
|
2026-08-18 16:39:39 +08:00
|
|
|
const createdTransport = bookingTransportSnapshot(bookingConfirmed?.oceanBooking)
|
2026-08-15 12:12:34 +08:00
|
|
|
const persistedTransport = bookingTransportSnapshot(bookingDetail?.oceanBooking)
|
|
|
|
|
const transportFrozen = completeBookingTransport(createdTransport)
|
|
|
|
|
&& completeBookingTransport(persistedTransport)
|
|
|
|
|
&& sameBookingTransport(createdTransport, persistedTransport)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 创建响应与详情五项运输字段冻结一致`, bookingDetailResponse,
|
|
|
|
|
transportFrozen,
|
|
|
|
|
BOOKING_TRANSPORT_FIELDS.map((field) => `${field}=${persistedTransport[field] || 'missing'}`).join(', '), OPTIONS)
|
|
|
|
|
if (!transportFrozen) return null
|
|
|
|
|
|
2026-08-17 13:24:48 +08:00
|
|
|
const releasedAllocationMatches = bookingDetail?.containers?.some?.((item) =>
|
2026-08-14 23:21:47 +08:00
|
|
|
id(item?.containerBookingId) === containerId)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 仅在 RELEASED 且柜次分配稳定后推送`, bookingDetailResponse,
|
|
|
|
|
bookingDetail?.oceanBooking?.status === 'RELEASED' && releasedAllocationMatches,
|
|
|
|
|
`status=${bookingDetail?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}`, OPTIONS)
|
|
|
|
|
if (bookingDetail?.oceanBooking?.status !== 'RELEASED' || !releasedAllocationMatches) return null
|
|
|
|
|
|
2026-09-02 13:03:03 +08:00
|
|
|
const push = await request(context, 'GET', `${SMART}/plans/${planId}`)
|
|
|
|
|
const pushed = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 自动关联的预装计划`, push, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 补真实柜号后自动预装计划保持 EDITING`, push,
|
|
|
|
|
pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version),
|
|
|
|
|
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
|
2026-08-14 22:59:10 +08:00
|
|
|
if (pushed?.status !== 'EDITING') return null
|
2026-08-11 10:00:11 +08:00
|
|
|
|
2026-08-15 17:40:01 +08:00
|
|
|
const unconfirmedTaskResponse = await context.request('GET',
|
|
|
|
|
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
|
|
|
context.addStep(MODULE, `${sourceOrderNo} 计划确认前不生成 PDA 任务`, unconfirmedTaskResponse,
|
|
|
|
|
unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404',
|
|
|
|
|
`HTTP=${unconfirmedTaskResponse.http}, code=${codeOf(unconfirmedTaskResponse)}, requestId=${unconfirmedTaskResponse.requestId || 'missing'}, traceId=${unconfirmedTaskResponse.traceId || 'missing'}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
const confirmableResponse = await context.request('GET',
|
|
|
|
|
`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
|
|
|
const confirmable = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 计划级确认即时版本`, confirmableResponse, '', OPTIONS)
|
|
|
|
|
const confirmableVersion = Number(confirmable?.summary?.version)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 放舱后整计划允许确认且尚未就绪 PDA`, confirmableResponse,
|
|
|
|
|
Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING'
|
|
|
|
|
&& confirmable?.summary?.readiness?.canConfirm === true
|
|
|
|
|
&& confirmable?.summary?.readiness?.readyForPda === false
|
|
|
|
|
&& confirmable?.summary?.readiness?.pdaTaskId == null,
|
|
|
|
|
`version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS)
|
|
|
|
|
if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return null
|
|
|
|
|
|
|
|
|
|
const confirmPath = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`
|
|
|
|
|
const confirmBody = Object.freeze({ expectedVersion: confirmableVersion })
|
|
|
|
|
const confirmHeaders = Object.freeze({ 'Idempotency-Key': `${operationBase}-plan-confirm`.slice(0, 128) })
|
|
|
|
|
const confirmResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
|
|
|
|
|
const confirmed = context.apiSuccess(MODULE, `计划级确认 ${sourceOrderNo} 并生成唯一 PDA 任务`, confirmResponse, '', OPTIONS)
|
|
|
|
|
const confirmedTaskId = id(confirmed?.readiness?.pdaTaskId)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 计划确认返回 CONFIRMED 与 readyForPda`, confirmResponse,
|
|
|
|
|
id(confirmed?.planId) === planId && id(confirmed?.containerBookingId) === containerId
|
|
|
|
|
&& confirmed?.confirmationStatus === 'CONFIRMED'
|
|
|
|
|
&& confirmed?.readiness?.confirmationStatus === 'CONFIRMED'
|
|
|
|
|
&& confirmed?.readiness?.readyForPda === true && Boolean(confirmedTaskId)
|
|
|
|
|
&& Number(confirmed?.version) > confirmableVersion,
|
|
|
|
|
`confirmationStatus=${confirmed?.confirmationStatus || 'missing'}, readyForPda=${confirmed?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS)
|
|
|
|
|
if (!confirmedTaskId || confirmed?.confirmationStatus !== 'CONFIRMED') return null
|
|
|
|
|
|
|
|
|
|
const confirmReplay = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 计划确认同键同参精确重放`, confirmReplay,
|
|
|
|
|
confirmReplay.http === 200 && codeOf(confirmReplay) === '200'
|
|
|
|
|
&& id(confirmReplay.body?.data?.readiness?.pdaTaskId) === confirmedTaskId
|
|
|
|
|
&& Number(confirmReplay.body?.data?.version) === Number(confirmed.version),
|
|
|
|
|
`HTTP=${confirmReplay.http}, code=${codeOf(confirmReplay)}, requestId=${confirmReplay.requestId || 'missing'}, traceId=${confirmReplay.traceId || 'missing'}`, OPTIONS)
|
|
|
|
|
|
2026-08-11 10:00:11 +08:00
|
|
|
const taskResponse = await context.request('GET',
|
2026-08-14 10:09:46 +08:00
|
|
|
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
2026-08-11 10:00:11 +08:00
|
|
|
const task = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 的 PDA 任务`, taskResponse, '', OPTIONS)
|
|
|
|
|
const taskId = id(task?.id)
|
|
|
|
|
const pickItem = task?.items?.find?.((row) => id(row.loadingPlanItemId) === id(planItem.id))
|
|
|
|
|
context.assert(MODULE, `余量 ${sourceOrderNo} 不继承已拣数量`, taskResponse,
|
2026-08-15 17:40:01 +08:00
|
|
|
taskId === confirmedTaskId && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId
|
2026-08-14 10:09:46 +08:00
|
|
|
&& task?.currentContainerNo === realContainerNo && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1
|
2026-08-11 10:00:11 +08:00
|
|
|
&& Number(pickItem?.pickedPackageCount) === 0,
|
|
|
|
|
`taskId=${taskId || 'missing'}, picked=${pickItem?.pickedPackageCount ?? 'missing'}, planned=${pickItem?.plannedPackageCount ?? 'missing'}`, OPTIONS)
|
|
|
|
|
if (!taskId || !pickItem?.id) return null
|
|
|
|
|
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(taskId)
|
|
|
|
|
|
2026-08-24 14:01:02 +08:00
|
|
|
const labelResponse = await context.requestBinary('POST',
|
|
|
|
|
`/api/platform/inbounds/${encodeURIComponent(id(candidate.inboundId))}/carton-label-pdf?tenantId=${encodeURIComponent(tenantId)}`)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 进入 LOADING 前生成可交接箱唛池`, labelResponse,
|
|
|
|
|
labelResponse.http === 200 && String(labelResponse.contentType || '').toLowerCase().includes('pdf')
|
|
|
|
|
&& Number(labelResponse.bytes?.length) > 0,
|
|
|
|
|
`HTTP=${labelResponse.http}, contentType=${labelResponse.contentType || 'missing'}, bytes=${labelResponse.bytes?.length ?? 0}`, OPTIONS)
|
|
|
|
|
if (labelResponse.http !== 200 || !Number(labelResponse.bytes?.length)) return null
|
|
|
|
|
|
2026-08-11 10:00:11 +08:00
|
|
|
const claim = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, {
|
|
|
|
|
expectedAssignmentVersion: 0, operationId: `${operationBase}-claim`,
|
|
|
|
|
})
|
2026-08-14 10:57:05 +08:00
|
|
|
const claimed = context.apiSuccess(MODULE, `领取 ${sourceOrderNo} 的 PDA 任务`, claim, '', OPTIONS)
|
|
|
|
|
const claimedItem = claimed?.items?.find?.((row) => id(row.id) === id(pickItem.id)) || pickItem
|
|
|
|
|
const inventoryResponse = await context.request('GET',
|
|
|
|
|
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
|
|
|
const inventory = context.apiSuccess(MODULE, `冻结 ${sourceOrderNo} 的 PDA 库存快照`, inventoryResponse, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 库存快照绑定稳定任务身份`, inventoryResponse,
|
|
|
|
|
id(inventory?.taskId) === taskId && id(inventory?.loadingPlanId) === planId
|
|
|
|
|
&& id(inventory?.containerBookingId) === containerId && id(inventory?.warehouseId) === warehouseId
|
|
|
|
|
&& Number(inventory?.taskVersion) === Number(claimed?.version)
|
|
|
|
|
&& typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64,
|
|
|
|
|
`taskId=${id(inventory?.taskId) || 'missing'}, taskVersion=${inventory?.taskVersion ?? 'missing'}, loadingPlanId=${id(inventory?.loadingPlanId) || 'missing'}, containerBookingId=${id(inventory?.containerBookingId) || 'missing'}`, OPTIONS)
|
|
|
|
|
if (!inventory?.snapshotToken) return null
|
2026-08-11 10:00:11 +08:00
|
|
|
|
2026-08-14 10:57:05 +08:00
|
|
|
const batchPath = `${PDA}/tasks/${taskId}/batch`
|
|
|
|
|
const batchKey = `${operationBase}-batch`.slice(0, 128)
|
|
|
|
|
const batchPayload = {
|
|
|
|
|
warehouseId, loadingPlanId: planId, containerBookingId: containerId,
|
|
|
|
|
expectedVersion: Number(inventory.taskVersion), inventorySnapshotToken: inventory.snapshotToken,
|
|
|
|
|
items: [{
|
|
|
|
|
itemId: id(claimedItem.id), action: 'PICK', pickedPackageCount: 1,
|
2026-09-04 12:18:36 +08:00
|
|
|
version: Number(claimedItem.version),
|
2026-08-14 10:57:05 +08:00
|
|
|
}],
|
2026-08-11 10:00:11 +08:00
|
|
|
}
|
2026-08-14 10:57:05 +08:00
|
|
|
const staleBatch = await request(context, 'POST', batchPath, {
|
|
|
|
|
...batchPayload, expectedVersion: Number(inventory.taskVersion) + 1,
|
|
|
|
|
}, { 'Idempotency-Key': `${operationBase}-batch-stale`.slice(0, 128) })
|
|
|
|
|
context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧任务版本被拒绝且不结算`, staleBatch,
|
|
|
|
|
isConflict(staleBatch) && hasTrace(staleBatch),
|
|
|
|
|
`HTTP=${staleBatch.http}, code=${codeOf(staleBatch)}, requestId=${staleBatch.requestId || 'missing'}, traceId=${staleBatch.traceId || 'missing'}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
const batch = await request(context, 'POST', batchPath, batchPayload, { 'Idempotency-Key': batchKey })
|
|
|
|
|
const picked = context.apiSuccess(MODULE, `原子提交 ${sourceOrderNo} 的完整 PDA 草稿`, batch, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 批次拣出1箱并等待人工确认`, batch,
|
|
|
|
|
picked?.taskStatus === 'LOADING' && picked?.workflowStatus === 'AWAITING_REVIEW'
|
|
|
|
|
&& picked?.items?.some?.((row) => id(row.id) === id(claimedItem.id)
|
|
|
|
|
&& Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1),
|
2026-08-11 10:00:11 +08:00
|
|
|
`workflow=${picked?.workflowStatus || 'missing'}, taskStatus=${picked?.taskStatus || 'missing'}`, OPTIONS)
|
|
|
|
|
|
2026-08-14 10:57:05 +08:00
|
|
|
const replay = await request(context, 'POST', batchPath, batchPayload, { 'Idempotency-Key': batchKey })
|
|
|
|
|
const replayed = context.apiSuccess(MODULE, `${sourceOrderNo} PDA 批次幂等重放`, replay, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 批次重放不重复累计`, replay,
|
|
|
|
|
id(replayed?.id) === taskId && Number(replayed?.version) === Number(picked?.version)
|
|
|
|
|
&& replayed?.items?.some?.((row) => id(row.id) === id(claimedItem.id)
|
|
|
|
|
&& Number(row.pickedPackageCount) === 1),
|
|
|
|
|
`taskId=${id(replayed?.id) || 'missing'}, taskVersion=${replayed?.version ?? 'missing'}`, OPTIONS)
|
2026-08-11 10:00:11 +08:00
|
|
|
|
|
|
|
|
if (sourcePackageCount > 1) {
|
2026-08-14 10:57:05 +08:00
|
|
|
const differentReplay = await request(context, 'POST', batchPath, {
|
|
|
|
|
...batchPayload,
|
|
|
|
|
items: [{ ...batchPayload.items[0], pickedPackageCount: 2 }],
|
|
|
|
|
}, { 'Idempotency-Key': batchKey })
|
2026-08-11 10:00:11 +08:00
|
|
|
context.addStep(MODULE, `${sourceOrderNo} 同幂等键异参被拒绝`, differentReplay,
|
|
|
|
|
isConflict(differentReplay) && hasTrace(differentReplay),
|
|
|
|
|
`HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:59:05 +08:00
|
|
|
if (!await ensureCompletionEvidence(context, taskId, warehouseId, operationBase)) return null
|
2026-08-24 13:53:10 +08:00
|
|
|
|
2026-08-14 10:57:05 +08:00
|
|
|
const completePath = `${PDA}/tasks/${taskId}/complete`
|
|
|
|
|
const completePayload = {
|
|
|
|
|
expectedTaskVersion: Number(picked?.version), operationId: `${operationBase}-complete`.slice(0, 128),
|
|
|
|
|
remark: context.marker,
|
|
|
|
|
}
|
|
|
|
|
const complete = await request(context, 'POST', completePath, completePayload)
|
2026-09-02 13:59:05 +08:00
|
|
|
let completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS)
|
|
|
|
|
let completedResponse = complete
|
|
|
|
|
for (let attempt = 0; attempt < 90 && completed?.taskStatus !== 'COMPLETED'
|
|
|
|
|
&& completed?.taskStatus !== 'COMPLETION_FAILED'; attempt += 1) {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1_000))
|
|
|
|
|
completedResponse = await context.request('GET',
|
|
|
|
|
`${PDA}/tasks/${taskId}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
|
|
|
if (completedResponse.http === 200 && codeOf(completedResponse) === '200') {
|
|
|
|
|
completed = completedResponse.body?.data
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, completedResponse,
|
2026-08-14 10:57:05 +08:00
|
|
|
completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED'
|
|
|
|
|
&& completed?.items?.some?.((row) => id(row.id) === id(claimedItem.id)
|
|
|
|
|
&& Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1),
|
|
|
|
|
`workflow=${completed?.workflowStatus || 'missing'}, taskStatus=${completed?.taskStatus || 'missing'}`, OPTIONS)
|
|
|
|
|
if (completed?.taskStatus !== 'COMPLETED' || completed?.workflowStatus !== 'COMPLETED') return null
|
|
|
|
|
|
|
|
|
|
const completeReplay = await request(context, 'POST', completePath, completePayload)
|
|
|
|
|
const completedReplay = context.apiSuccess(MODULE, `${sourceOrderNo} PDA 完成幂等重放`, completeReplay, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, `${sourceOrderNo} 完成重放保持同一已结算任务`, completeReplay,
|
|
|
|
|
id(completedReplay?.id) === taskId && Number(completedReplay?.version) === Number(completed?.version)
|
|
|
|
|
&& completedReplay?.taskStatus === 'COMPLETED' && completedReplay?.workflowStatus === 'COMPLETED',
|
|
|
|
|
`taskId=${id(completedReplay?.id) || 'missing'}, taskVersion=${completedReplay?.version ?? 'missing'}`, OPTIONS)
|
|
|
|
|
|
2026-08-11 10:00:11 +08:00
|
|
|
const treeResponse = await context.request('GET', treePath(rootOrderId, tenantId))
|
|
|
|
|
const tree = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 拆分后的根聚合`, treeResponse, '', OPTIONS)
|
2026-08-14 10:09:46 +08:00
|
|
|
const candidatesAfter = await context.request('GET', candidatesPath)
|
2026-08-11 10:00:11 +08:00
|
|
|
const candidateRowsAfter = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 不可重复消费`, candidatesAfter, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, `来源 ${sourceOrderNo} 同时最多成功拆分一次`, candidatesAfter,
|
|
|
|
|
Array.isArray(candidateRowsAfter) && !candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId),
|
|
|
|
|
`sourceOrderId=${sourceOrderId}, stillCandidate=${Array.isArray(candidateRowsAfter) && candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId)}`, OPTIONS)
|
|
|
|
|
|
2026-08-14 10:09:46 +08:00
|
|
|
return { tree, treeResponse, oceanBookingId, containerId, planId, taskId, sourceInboundId: id(candidate.inboundId) }
|
2026-08-10 14:15:04 +08:00
|
|
|
}
|
2026-08-10 12:23:43 +08:00
|
|
|
|
|
|
|
|
export async function run(context) {
|
2026-08-11 10:00:11 +08:00
|
|
|
const fixture = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading
|
2026-08-10 14:15:04 +08:00
|
|
|
const tenantId = id(fixture?.tenantId)
|
2026-09-10 21:18:52 +08:00
|
|
|
const inboundId = id(fixture?.incrementalInboundId || fixture?.inboundId)
|
2026-08-11 10:00:11 +08:00
|
|
|
const warehouseId = id(fixture?.warehouseId)
|
2026-08-10 14:15:04 +08:00
|
|
|
const containerBookingId = id(fixture?.containerBookingId)
|
2026-08-11 10:00:11 +08:00
|
|
|
if (!tenantId || !inboundId || !warehouseId || !containerBookingId || fixture?.marker !== context.marker) {
|
|
|
|
|
context.addStep(MODULE, '取得本轮 marker 隔离拆分 fixture', null, false,
|
|
|
|
|
`marker=${fixture?.marker || 'missing'}, inboundId=${inboundId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
|
2026-08-10 14:15:04 +08:00
|
|
|
return
|
2026-08-10 12:23:43 +08:00
|
|
|
}
|
2026-08-10 14:15:04 +08:00
|
|
|
|
|
|
|
|
const inboundPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`
|
|
|
|
|
const inboundResponse = await context.request('GET', inboundPath)
|
2026-08-11 10:00:11 +08:00
|
|
|
const inbound = context.apiSuccess(MODULE, '读取本轮来源入库单', inboundResponse,
|
2026-08-10 14:15:04 +08:00
|
|
|
`tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS)
|
|
|
|
|
const orderId = id(inbound?.summary?.orderId)
|
2026-08-14 10:09:46 +08:00
|
|
|
const channelCode = id(inbound?.summary?.channelCode || fixture?.channelCode)
|
|
|
|
|
const channelName = inbound?.summary?.channelName || fixture?.channelName || channelCode
|
|
|
|
|
const warehouseName = inbound?.summary?.warehouseName || fixture?.warehouseName
|
|
|
|
|
const customerName = inbound?.summary?.customerName || `验收客户-${tenantId}`
|
2026-08-11 10:00:11 +08:00
|
|
|
context.assert(MODULE, '来源入库单归属本轮客户与根订单', inboundResponse,
|
2026-08-14 10:09:46 +08:00
|
|
|
isPositiveId(orderId) && id(inbound?.summary?.tenantId) === tenantId
|
|
|
|
|
&& Boolean(channelCode && channelName && warehouseName),
|
|
|
|
|
`orderId=${orderId || 'missing'}, tenantId=${id(inbound?.summary?.tenantId) || 'missing'}, channelCode=${channelCode || 'missing'}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
|
|
|
|
|
if (!orderId || !channelCode || !channelName || !warehouseName) return
|
2026-08-10 14:15:04 +08:00
|
|
|
|
2026-08-11 10:00:11 +08:00
|
|
|
const firstTreeResponse = await context.request('GET', treePath(orderId, tenantId))
|
|
|
|
|
const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse,
|
2026-08-10 14:15:04 +08:00
|
|
|
`orderId=${orderId}, tenantId=${tenantId}`, OPTIONS)
|
2026-09-10 21:18:52 +08:00
|
|
|
const originalPackageCount = Number(firstTree?.originalPackageCount)
|
|
|
|
|
const initialLoadedPackageCount = Number(firstTree?.cumulativeLoadedPackageCount)
|
|
|
|
|
const initialRemainingPackageCount = Number(firstTree?.currentRemainderPackageCount)
|
|
|
|
|
const initialActive = activeRemainders(firstTree)
|
|
|
|
|
const initialActiveSequence = Number(initialActive[0]?.splitSequence)
|
2026-08-11 10:00:11 +08:00
|
|
|
context.assert(MODULE, '新批次由来源单承接已装且只创建一个余量子单', firstTreeResponse,
|
2026-09-10 21:18:52 +08:00
|
|
|
treeShape(firstTree, orderId)
|
|
|
|
|
&& Number.isInteger(originalPackageCount) && originalPackageCount > 1
|
|
|
|
|
&& Number.isInteger(initialLoadedPackageCount) && initialLoadedPackageCount > 0
|
|
|
|
|
&& Number.isInteger(initialRemainingPackageCount) && initialRemainingPackageCount > 0
|
|
|
|
|
&& originalPackageCount === initialLoadedPackageCount + initialRemainingPackageCount
|
|
|
|
|
&& initialActive.length === 1 && Number.isInteger(initialActiveSequence)
|
|
|
|
|
&& snapshotMatches(firstTree, originalPackageCount, initialLoadedPackageCount,
|
|
|
|
|
initialRemainingPackageCount, initialActiveSequence)
|
2026-08-11 10:00:11 +08:00
|
|
|
&& firstTree.children.filter((row) => row.splitRole === 'LOADED').length === 1
|
|
|
|
|
&& firstTree.children.filter((row) => row.splitRole === 'REMAINDER').length === 1,
|
|
|
|
|
`original=${firstTree?.originalPackageCount ?? 'missing'}, loaded=${firstTree?.cumulativeLoadedPackageCount ?? 'missing'}, remaining=${firstTree?.currentRemainderPackageCount ?? 'missing'}`, OPTIONS)
|
|
|
|
|
if (!treeShape(firstTree, orderId)) return
|
|
|
|
|
|
|
|
|
|
const firstLoaded = firstTree.children.find((row) => row.splitRole === 'LOADED')
|
|
|
|
|
context.assert(MODULE, '新拆分未生成 LOADED 子单且完成目标保持来源单', firstTreeResponse,
|
|
|
|
|
id(firstLoaded?.orderId) === orderId && id(firstLoaded?.sourceOrderId) === orderId
|
2026-09-10 21:29:40 +08:00
|
|
|
&& firstLoaded?.orderNo === firstTree.parentOrderNo
|
|
|
|
|
&& Number(firstLoaded?.packageCount) === initialLoadedPackageCount,
|
2026-08-11 10:00:11 +08:00
|
|
|
`loadedOrderId=${id(firstLoaded?.orderId) || 'missing'}, sourceOrderId=${id(firstLoaded?.sourceOrderId) || 'missing'}`, OPTIONS)
|
2026-08-10 14:15:04 +08:00
|
|
|
|
2026-08-10 14:18:24 +08:00
|
|
|
const merchantLogin = await context.request('POST', '/api/auth/login', {
|
2026-09-01 21:55:27 +08:00
|
|
|
loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
|
2026-08-10 14:18:24 +08:00
|
|
|
}, '')
|
2026-08-11 10:00:11 +08:00
|
|
|
const merchantAuth = context.apiSuccess(MODULE, '客户账号登录验证隔离与权限', merchantLogin, '', OPTIONS)
|
2026-08-10 14:18:24 +08:00
|
|
|
const merchantToken = merchantAuth?.accessToken
|
|
|
|
|
if (merchantToken) {
|
2026-08-11 10:00:11 +08:00
|
|
|
const merchantTreeResponse = await context.request('GET',
|
2026-08-10 14:18:24 +08:00
|
|
|
`/api/merchant/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`, undefined, merchantToken)
|
2026-08-11 10:00:11 +08:00
|
|
|
const merchantTree = context.apiSuccess(MODULE, '客户查询本客户根履约树', merchantTreeResponse, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, '客户履约树与平台数量聚合一致', merchantTreeResponse,
|
2026-09-10 21:18:52 +08:00
|
|
|
treeShape(merchantTree, orderId)
|
|
|
|
|
&& snapshotMatches(merchantTree, originalPackageCount, initialLoadedPackageCount,
|
|
|
|
|
initialRemainingPackageCount, initialActiveSequence),
|
2026-08-11 10:00:11 +08:00
|
|
|
`children=${merchantTree?.children?.length ?? 'missing'}`, OPTIONS)
|
2026-08-10 14:18:24 +08:00
|
|
|
|
2026-08-11 10:00:11 +08:00
|
|
|
const forbidden = await request(context, 'GET', treePath(orderId, tenantId), undefined, {}, merchantToken)
|
|
|
|
|
context.addStep(MODULE, '客户账号无权调用平台父子订单接口', forbidden,
|
|
|
|
|
isForbidden(forbidden) && hasTrace(forbidden),
|
|
|
|
|
`HTTP=${forbidden.http}, code=${codeOf(forbidden)}, requestId=${forbidden.requestId || 'missing'}, traceId=${forbidden.traceId || 'missing'}`, OPTIONS)
|
2026-08-10 14:15:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const wrongTenant = (BigInt(tenantId) + 1n).toString()
|
2026-08-11 10:00:11 +08:00
|
|
|
const wrongTree = await context.request('GET', treePath(orderId, wrongTenant))
|
|
|
|
|
context.addStep(MODULE, '根履约树跨客户访问拒绝且返回追踪信息', wrongTree,
|
|
|
|
|
wrongTree.http === 200 && codeOf(wrongTree) !== '200' && hasTrace(wrongTree),
|
|
|
|
|
`HTTP=${wrongTree.http}, code=${codeOf(wrongTree)}, requestId=${wrongTree.requestId || 'missing'}, traceId=${wrongTree.traceId || 'missing'}`, OPTIONS)
|
2026-08-10 14:15:04 +08:00
|
|
|
|
2026-08-11 10:00:11 +08:00
|
|
|
const initialSlices = await context.request('GET',
|
|
|
|
|
`/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`)
|
|
|
|
|
const initialSliceRows = context.apiSuccess(MODULE, '按首次柜次查询来源与余量分片', initialSlices, '', OPTIONS)
|
2026-08-24 13:48:16 +08:00
|
|
|
const initialSourceRows = Array.isArray(initialSliceRows)
|
|
|
|
|
? initialSliceRows.filter((row) => id(row.sourceOrderId) === orderId)
|
|
|
|
|
: []
|
|
|
|
|
context.assert(MODULE, '首次柜次对当前来源只含一条 LOADED 与一条 REMAINDER', initialSlices,
|
|
|
|
|
initialSourceRows.length === 2
|
|
|
|
|
&& initialSourceRows.some((row) => row.splitRole === 'LOADED' && id(row.childOrderId) === id(row.sourceOrderId))
|
|
|
|
|
&& initialSourceRows.some((row) => row.splitRole === 'REMAINDER' && id(row.childOrderId) !== id(row.sourceOrderId)),
|
|
|
|
|
`sourceRows=${initialSourceRows.length}, containerRows=${Array.isArray(initialSliceRows) ? initialSliceRows.length : 'missing'}`, OPTIONS)
|
2026-08-10 14:15:04 +08:00
|
|
|
|
2026-08-11 10:00:11 +08:00
|
|
|
context.createdResources.phase3OrderSplitting = {
|
|
|
|
|
marker: context.marker, tenantId, rootOrderId: orderId, rootOrderNo: firstTree.parentOrderNo,
|
|
|
|
|
retainedAuditRecordIds: [], activeRemainderCount: activeRemainders(firstTree).length,
|
|
|
|
|
finalLoadedPackageCount: Number(firstTree.cumulativeLoadedPackageCount),
|
|
|
|
|
finalRemainingPackageCount: Number(firstTree.currentRemainderPackageCount), cleaned: false,
|
|
|
|
|
cleanupNote: '公开 API 不允许删除订单/柜次/PDA审计记录;只有全部分片完成且无活动余量时标记业务清理完成。',
|
|
|
|
|
}
|
|
|
|
|
let tree = firstTree
|
2026-09-10 21:18:52 +08:00
|
|
|
let completedFragments = 0
|
|
|
|
|
while (activeRemainders(tree).length > 0) {
|
2026-08-11 10:00:11 +08:00
|
|
|
const active = activeRemainders(tree)
|
|
|
|
|
const source = active[0]
|
2026-09-10 21:18:52 +08:00
|
|
|
const sourceSuffix = Number(source?.splitSequence)
|
|
|
|
|
const sourcePackageCount = Number(source?.packageCount)
|
|
|
|
|
const beforeLoaded = Number(tree.cumulativeLoadedPackageCount)
|
|
|
|
|
const beforeRemaining = Number(tree.currentRemainderPackageCount)
|
|
|
|
|
context.assert(MODULE, `链式拆分来源固定为根编号 -${sourceSuffix}`, null,
|
|
|
|
|
active.length === 1 && Number.isInteger(sourceSuffix) && sourceSuffix > 0
|
|
|
|
|
&& source?.orderNo === `${tree.parentOrderNo}-${sourceSuffix}`
|
|
|
|
|
&& Number.isInteger(sourcePackageCount) && sourcePackageCount === beforeRemaining,
|
2026-08-11 10:00:11 +08:00
|
|
|
`active=${active.map((row) => row.orderNo).join(',') || 'none'}`, OPTIONS)
|
|
|
|
|
if (!source) return
|
|
|
|
|
|
|
|
|
|
const result = await loadOneFragment(context, {
|
|
|
|
|
tenantId, rootOrderId: orderId, sourceOrderId: id(source.orderId), sourceOrderNo: source.orderNo,
|
2026-08-14 10:09:46 +08:00
|
|
|
customerName, channelCode, channelName, warehouseId, warehouseName,
|
2026-09-10 21:18:52 +08:00
|
|
|
sourcePackageCount, sequence: sourceSuffix,
|
2026-08-11 10:00:11 +08:00
|
|
|
})
|
|
|
|
|
if (!result?.tree) return
|
|
|
|
|
tree = result.tree
|
2026-09-10 21:18:52 +08:00
|
|
|
completedFragments += 1
|
|
|
|
|
const expectedLoaded = beforeLoaded + 1
|
|
|
|
|
const expectedRemaining = beforeRemaining - 1
|
|
|
|
|
const activeSuffix = expectedRemaining > 0 ? sourceSuffix + 1 : null
|
2026-08-11 10:00:11 +08:00
|
|
|
Object.assign(context.createdResources.phase3OrderSplitting, {
|
|
|
|
|
activeRemainderCount: activeRemainders(tree).length,
|
|
|
|
|
finalLoadedPackageCount: Number(tree.cumulativeLoadedPackageCount),
|
|
|
|
|
finalRemainingPackageCount: Number(tree.currentRemainderPackageCount),
|
|
|
|
|
})
|
2026-09-10 21:18:52 +08:00
|
|
|
context.assert(MODULE, `完成余量分片 -${sourceSuffix} 后根数量守恒`, result.treeResponse,
|
2026-08-11 10:00:11 +08:00
|
|
|
treeShape(tree, orderId)
|
2026-09-10 21:18:52 +08:00
|
|
|
&& snapshotMatches(tree, originalPackageCount, expectedLoaded, expectedRemaining, activeSuffix),
|
2026-08-11 10:00:11 +08:00
|
|
|
`original=${tree.originalPackageCount}, loaded=${tree.cumulativeLoadedPackageCount}, remaining=${tree.currentRemainderPackageCount}`, OPTIONS)
|
2026-09-10 21:18:52 +08:00
|
|
|
if (completedFragments > originalPackageCount) {
|
|
|
|
|
context.addStep(MODULE, '余量链在原始箱数边界内收敛', null, false,
|
|
|
|
|
`original=${originalPackageCount}, fragments=${completedFragments}`, OPTIONS)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-10 14:18:24 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-11 10:00:11 +08:00
|
|
|
const remainderCodes = tree.children.filter((row) => row.splitRole === 'REMAINDER').map((row) => row.orderNo)
|
2026-09-10 21:18:52 +08:00
|
|
|
const expectedRemainderCodes = Array.from({ length: completedFragments }, (_, index) =>
|
|
|
|
|
`${tree.parentOrderNo}-${initialActiveSequence + index}`)
|
2026-08-11 10:00:11 +08:00
|
|
|
context.assert(MODULE, '余量子单始终按根编号全局递增且禁止嵌套编号', null,
|
2026-09-10 21:18:52 +08:00
|
|
|
JSON.stringify(remainderCodes) === JSON.stringify(expectedRemainderCodes)
|
|
|
|
|
&& remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)),
|
2026-08-11 10:00:11 +08:00
|
|
|
`codes=${remainderCodes.join(',')}`, OPTIONS)
|
|
|
|
|
context.assert(MODULE, '最后一次全量装完不再生成子单且根整体完成', null,
|
|
|
|
|
activeRemainders(tree).length === 0 && Number(tree.currentRemainderPackageCount) === 0
|
|
|
|
|
&& Number(tree.cumulativeLoadedPackageCount) === Number(tree.originalPackageCount),
|
|
|
|
|
`loaded=${tree.cumulativeLoadedPackageCount}, remaining=${tree.currentRemainderPackageCount}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
const nestedSearch = await context.request('GET',
|
|
|
|
|
`/api/platform/v1/orders?keyword=${encodeURIComponent(`${tree.parentOrderNo}-1-1`)}&page=1&size=20`)
|
|
|
|
|
const nestedPage = context.apiSuccess(MODULE, '搜索非法嵌套编号不命中本轮子单', nestedSearch, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, '不存在根编号 -1-1 的嵌套子单', nestedSearch,
|
|
|
|
|
Array.isArray(nestedPage?.records) && !nestedPage.records.some((row) => id(row.id) === orderId
|
|
|
|
|
|| String(row.orderNo || '').startsWith(`${tree.parentOrderNo}-1-1`)),
|
|
|
|
|
`records=${nestedPage?.records?.length ?? 'missing'}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
Object.assign(context.createdResources.phase3OrderSplitting, {
|
|
|
|
|
activeRemainderCount: activeRemainders(tree).length,
|
|
|
|
|
finalLoadedPackageCount: Number(tree.cumulativeLoadedPackageCount),
|
|
|
|
|
finalRemainingPackageCount: Number(tree.currentRemainderPackageCount),
|
|
|
|
|
cleaned: activeRemainders(tree).length === 0,
|
|
|
|
|
cleanupNote: '公开 API 不允许删除已完成订单/柜次/PDA审计记录;本轮 marker 链路已全部完成且无活动余量。',
|
|
|
|
|
})
|
2026-08-10 12:23:43 +08:00
|
|
|
}
|