2026-08-29 11:10:28 +08:00
|
|
|
const MODULE = 'coload-groups'
|
|
|
|
|
const OPTIONS = { scenario: 'indivisible-coload-group', deploymentBlocker: true }
|
2026-08-29 11:42:52 +08:00
|
|
|
const CONTAINERS = '/api/platform/v1/containers'
|
|
|
|
|
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
|
|
|
|
const FIRST_LEG = '/api/platform/v1/first-leg'
|
|
|
|
|
const PDA = '/api/platform/v1/first-leg/pda-loading'
|
|
|
|
|
|
|
|
|
|
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
|
|
|
|
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
|
2026-08-31 12:13:20 +08:00
|
|
|
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
|
2026-08-29 11:10:28 +08:00
|
|
|
|
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
|
|
|
function code(response) { return id(response?.body?.code) || 'missing' }
|
|
|
|
|
function evidence(response, extra = '') {
|
|
|
|
|
return `HTTP=${response?.http ?? 'missing'}, code=${code(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}${extra ? `, ${extra}` : ''}`
|
|
|
|
|
}
|
|
|
|
|
function conflict(context, name, response, extra = '') {
|
|
|
|
|
const passed = response?.http === 200 && code(response) === 'A0409'
|
|
|
|
|
context.assert(MODULE, name, response, passed, evidence(response, extra), OPTIONS)
|
|
|
|
|
return passed
|
|
|
|
|
}
|
|
|
|
|
function planSignature(plan) {
|
|
|
|
|
return JSON.stringify({
|
|
|
|
|
id: id(plan?.id), version: Number(plan?.version), status: plan?.status || null,
|
|
|
|
|
items: [...(plan?.items || [])].map((item) => ({
|
|
|
|
|
id: id(item?.id), accountId: id(item?.accountId), inboundId: id(item?.inboundId),
|
|
|
|
|
packageCount: Number(item?.packageCount), version: Number(item?.version),
|
|
|
|
|
})).sort((left, right) => left.id.localeCompare(right.id)),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
function memberFacts(group) {
|
|
|
|
|
return [...(group?.members || [])].map((item) => ({
|
|
|
|
|
orderId: id(item?.orderId), inboundId: id(item?.inboundId),
|
|
|
|
|
fullPackageCount: Number(item?.fullPackageCount),
|
|
|
|
|
})).sort((left, right) => left.orderId.localeCompare(right.orderId))
|
|
|
|
|
}
|
2026-08-29 11:42:52 +08:00
|
|
|
function taskSignature(task) {
|
|
|
|
|
return JSON.stringify({
|
|
|
|
|
id: id(task?.id), version: Number(task?.version), taskStatus: task?.taskStatus || null,
|
|
|
|
|
workflowStatus: task?.workflowStatus || null,
|
|
|
|
|
items: [...(task?.items || [])].map((item) => ({
|
|
|
|
|
id: id(item?.id), inboundId: id(item?.inboundId), version: Number(item?.version),
|
|
|
|
|
planned: Number(item?.plannedPackageCount), picked: Number(item?.pickedPackageCount),
|
|
|
|
|
status: item?.itemStatus || null,
|
|
|
|
|
})).sort((left, right) => left.id.localeCompare(right.id)),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
function treeSignature(tree) {
|
|
|
|
|
return JSON.stringify({
|
|
|
|
|
parentOrderId: id(tree?.parentOrderId), originalPackageCount: Number(tree?.originalPackageCount),
|
|
|
|
|
cumulativeLoadedPackageCount: Number(tree?.cumulativeLoadedPackageCount),
|
|
|
|
|
currentRemainderPackageCount: Number(tree?.currentRemainderPackageCount),
|
|
|
|
|
children: [...(tree?.children || [])].map((item) => ({
|
|
|
|
|
orderId: id(item?.orderId), sourceOrderId: id(item?.sourceOrderId),
|
|
|
|
|
orderNo: item?.orderNo || null, splitRole: item?.splitRole || null,
|
|
|
|
|
packageCount: Number(item?.packageCount), loadingPlanId: id(item?.loadingPlanId),
|
|
|
|
|
containerBookingId: id(item?.containerBookingId),
|
|
|
|
|
})).sort((left, right) => `${left.orderId}|${left.splitRole}`.localeCompare(`${right.orderId}|${right.splitRole}`)),
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-08-29 11:10:28 +08:00
|
|
|
async function readPlan(context, planId, name) {
|
|
|
|
|
const response = await context.request('GET', `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}`)
|
|
|
|
|
return { response, data: context.apiSuccess(MODULE, name, response, `planId=${planId}`, OPTIONS) }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 10:17:03 +08:00
|
|
|
async function readGroupSummary(context, inboundCode, name) {
|
|
|
|
|
const response = await context.request('GET',
|
|
|
|
|
`/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(inboundCode)}`)
|
|
|
|
|
return { response, data: context.apiSuccess(MODULE, name, response,
|
|
|
|
|
evidence(response, `inboundCode=${inboundCode}`), OPTIONS) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function summaryInboundCodes(summary) {
|
|
|
|
|
return [...(summary?.members || [])].map((member) => String(member?.inboundCode || ''))
|
|
|
|
|
.filter(Boolean).sort()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 11:42:52 +08:00
|
|
|
async function createSecondGroup(context, third, fourth) {
|
|
|
|
|
const response = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
|
|
|
|
|
{ orderIds: [third.orderId, fourth.orderId] }, undefined,
|
|
|
|
|
{ 'Idempotency-Key': `${context.marker}-coload-second-create`.slice(0, 128) })
|
|
|
|
|
const group = context.apiSuccess(MODULE, '创建第二个同柜不可拆组用于足量完成链', response,
|
|
|
|
|
evidence(response), OPTIONS)
|
|
|
|
|
const frozen = memberFacts(group)
|
|
|
|
|
context.assert(MODULE, '第二组冻结两票权威入库单与满箱数', response,
|
|
|
|
|
Boolean(group?.groupId) && group?.status === 'ACTIVE' && frozen.length === 2
|
|
|
|
|
&& frozen.every((member) => [third, fourth].some((fixture) => fixture.orderId === member.orderId
|
|
|
|
|
&& fixture.inboundId === member.inboundId && fixture.packages === member.fullPackageCount)),
|
|
|
|
|
`groupId=${id(group?.groupId) || 'missing'}, members=${JSON.stringify(frozen)}`, OPTIONS)
|
|
|
|
|
return group
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
|
|
|
|
|
const simulationResponse = await context.request('POST', CONTAINERS, {
|
2026-08-29 11:59:11 +08:00
|
|
|
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
|
|
|
|
|
capacityCbm: 76, maxWeightKg: 28000,
|
2026-08-29 11:42:52 +08:00
|
|
|
})
|
|
|
|
|
const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse,
|
|
|
|
|
evidence(simulationResponse), OPTIONS)
|
|
|
|
|
const bookingId = id(simulation?.id)
|
|
|
|
|
if (!bookingId) return null
|
|
|
|
|
const planResponse = await context.request('POST', `${SMART}/plans`, {
|
|
|
|
|
bookingId, remark: `${context.marker}-${suffix}`,
|
|
|
|
|
})
|
|
|
|
|
let plan = context.apiSuccess(MODULE, `${suffix} 创建组装柜计划`, planResponse,
|
|
|
|
|
evidence(planResponse), OPTIONS)
|
|
|
|
|
const planId = id(plan?.id)
|
|
|
|
|
if (!planId) return null
|
|
|
|
|
const assignResponse = await context.request('POST', `${SMART}/plans/${encodeURIComponent(planId)}/group-assignments`, {
|
|
|
|
|
inboundId: fixture.inboundId, expectedVersion: Number(plan?.version),
|
|
|
|
|
operationId: `${context.marker}-${suffix}-assign`.slice(0, 128),
|
|
|
|
|
})
|
|
|
|
|
plan = context.apiSuccess(MODULE, `${suffix} 整组原子分配`, assignResponse,
|
|
|
|
|
evidence(assignResponse, `groupId=${groupId}`), OPTIONS)
|
|
|
|
|
const pushResponse = await context.request('POST', `${SMART}/plans/${encodeURIComponent(planId)}/push-preload`, {
|
|
|
|
|
expectedVersion: Number(plan?.version),
|
|
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-${suffix}-push`.slice(0, 128) })
|
|
|
|
|
const pushed = context.apiSuccess(MODULE, `${suffix} 整组完整推送预装`, pushResponse,
|
|
|
|
|
evidence(pushResponse), OPTIONS)
|
|
|
|
|
return pushed ? { containerBookingId: bookingId, planId, plan: pushed, simulation } : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createNormalPushedPlan(context, fixture, suffix) {
|
|
|
|
|
const simulationResponse = await context.request('POST', CONTAINERS, {
|
2026-08-29 11:59:11 +08:00
|
|
|
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
|
|
|
|
|
capacityCbm: 76, maxWeightKg: 28000,
|
2026-08-29 11:42:52 +08:00
|
|
|
})
|
|
|
|
|
const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse,
|
|
|
|
|
evidence(simulationResponse), OPTIONS)
|
|
|
|
|
const containerBookingId = id(simulation?.id)
|
|
|
|
|
if (!containerBookingId) return null
|
|
|
|
|
const planResponse = await context.request('POST', `${SMART}/plans`, {
|
|
|
|
|
bookingId: containerBookingId, remark: `${context.marker}-${suffix}`,
|
|
|
|
|
})
|
|
|
|
|
let plan = context.apiSuccess(MODULE, `${suffix} 创建普通单装柜计划`, planResponse,
|
|
|
|
|
evidence(planResponse), OPTIONS)
|
|
|
|
|
const planId = id(plan?.id)
|
|
|
|
|
if (!planId) return null
|
|
|
|
|
const assignedCount = Math.max(1, Number(fixture.packages) - 1)
|
|
|
|
|
const assignResponse = await context.request('POST',
|
|
|
|
|
`${SMART}/plans/${encodeURIComponent(planId)}/inbound-assignments`, {
|
|
|
|
|
inboundId: fixture.inboundId, packageCount: assignedCount,
|
|
|
|
|
expectedVersion: Number(plan?.version), operationId: `${context.marker}-${suffix}-assign`.slice(0, 128),
|
|
|
|
|
})
|
|
|
|
|
plan = context.apiSuccess(MODULE, `${suffix} 未成组普通单按数量分配`, assignResponse,
|
|
|
|
|
evidence(assignResponse), OPTIONS)
|
|
|
|
|
const assignedTotal = (plan?.items || []).filter((item) => id(item?.inboundId) === fixture.inboundId)
|
|
|
|
|
.reduce((sum, item) => sum + Number(item?.packageCount || 0), 0)
|
|
|
|
|
context.assert(MODULE, `${suffix} 普通单维持既有部分分配语义`, assignResponse,
|
|
|
|
|
assignedTotal === assignedCount && assignedCount < Number(fixture.packages),
|
|
|
|
|
`assigned=${assignedTotal}, source=${fixture.packages}`, OPTIONS)
|
|
|
|
|
const pushResponse = await context.request('POST', `${SMART}/plans/${encodeURIComponent(planId)}/push-preload`, {
|
|
|
|
|
expectedVersion: Number(plan?.version),
|
|
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-${suffix}-push`.slice(0, 128) })
|
|
|
|
|
const pushed = context.apiSuccess(MODULE, `${suffix} 未成组普通单推送预装不回归`, pushResponse,
|
|
|
|
|
evidence(pushResponse), OPTIONS)
|
|
|
|
|
return pushed ? { containerBookingId, planId, plan: pushed, simulation, assignedCount } : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createReleasedBookingAndLink(context, fixture, chains) {
|
|
|
|
|
const inboundResponse = await context.request('GET',
|
|
|
|
|
`/api/platform/inbounds/${encodeURIComponent(fixture.inboundId)}?tenantId=${encodeURIComponent(fixture.tenantId)}`)
|
|
|
|
|
const inbound = context.apiSuccess(MODULE, 'PDA链读取订舱主数据', inboundResponse,
|
|
|
|
|
evidence(inboundResponse), OPTIONS)
|
|
|
|
|
const agentResponse = await context.request('GET',
|
|
|
|
|
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=20')
|
|
|
|
|
const agentPage = context.apiSuccess(MODULE, '读取已关联MATSON的订舱代理', agentResponse,
|
|
|
|
|
evidence(agentResponse), OPTIONS)
|
|
|
|
|
const agent = (agentPage?.records || []).find((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE')
|
|
|
|
|
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
|
|
|
|
|
context.assert(MODULE, 'PDA链订舱代理与船公司主数据齐全', agentResponse,
|
|
|
|
|
Boolean(agent?.id && agent?.name && carrier?.id),
|
|
|
|
|
`agentId=${id(agent?.id) || 'missing'}, carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS)
|
|
|
|
|
if (!agent?.id || !carrier?.id) return null
|
|
|
|
|
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18)
|
|
|
|
|
const master = {
|
|
|
|
|
customerId: fixture.tenantId, customerName: inbound?.summary?.customerName || `验收客户-${fixture.tenantId}`,
|
|
|
|
|
channelCode: fixture.channelCode, channelName: fixture.channelName || fixture.channelCode,
|
|
|
|
|
warehouseId: fixture.warehouseId, warehouseName: fixture.receivingWarehouseName || inbound?.summary?.warehouseName,
|
|
|
|
|
}
|
|
|
|
|
const draft = oceanBookingDraft(context, suffix, master, {
|
|
|
|
|
bookingAgentId: id(agent.id), bookingAgentName: agent.name,
|
|
|
|
|
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON',
|
2026-08-29 11:59:11 +08:00
|
|
|
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: chains.length,
|
|
|
|
|
capacityCbm: 76, maxWeightKg: 28000 }],
|
2026-08-29 11:42:52 +08:00
|
|
|
})
|
|
|
|
|
const createResponse = await context.request('POST', '/api/platform/v1/ocean-bookings',
|
|
|
|
|
{ expectedVersion: 0, data: draft }, undefined,
|
|
|
|
|
{ 'Idempotency-Key': `${context.marker}-coload-pda-booking-create`.slice(0, 128) })
|
|
|
|
|
const created = context.apiSuccess(MODULE, 'PDA链创建订舱草稿', createResponse,
|
|
|
|
|
evidence(createResponse), OPTIONS)
|
|
|
|
|
const oceanBookingId = id(created?.oceanBooking?.id)
|
|
|
|
|
if (!oceanBookingId) return null
|
|
|
|
|
const releaseResponse = await context.request('POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, {
|
|
|
|
|
expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker,
|
|
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-pda-booking-release`.slice(0, 128) })
|
|
|
|
|
const released = context.apiSuccess(MODULE, 'PDA链放舱并冻结两柜需求', releaseResponse,
|
|
|
|
|
evidence(releaseResponse), OPTIONS)
|
2026-08-29 11:59:11 +08:00
|
|
|
const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '40HC')
|
2026-08-29 11:42:52 +08:00
|
|
|
context.assert(MODULE, '放舱需求数量覆盖短拣与足量两柜', releaseResponse,
|
|
|
|
|
released?.oceanBooking?.status === 'RELEASED' && Number(requirement?.quantity) === chains.length,
|
|
|
|
|
`status=${released?.oceanBooking?.status || 'missing'}, quantity=${requirement?.quantity ?? 'missing'}`, OPTIONS)
|
|
|
|
|
if (!requirement?.id) return null
|
|
|
|
|
for (let index = 0; index < chains.length; index += 1) {
|
|
|
|
|
const chain = chains[index]
|
|
|
|
|
const detailResponse = await context.request('GET', `${CONTAINERS}/${chain.containerBookingId}`)
|
|
|
|
|
const detail = context.apiSuccess(MODULE, `${chain.label} 关联前读取柜版本`, detailResponse,
|
|
|
|
|
evidence(detailResponse), OPTIONS)
|
2026-08-31 12:13:20 +08:00
|
|
|
const containerNo = iso6346ContainerNumber(`${context.marker}-coload-${index + 1}`)
|
2026-08-29 11:42:52 +08:00
|
|
|
const linkResponse = await context.request('POST', `${CONTAINERS}/${chain.containerBookingId}/ocean-booking-link`, {
|
|
|
|
|
oceanBookingId, requirementId: id(requirement.id), containerNo,
|
|
|
|
|
expectedVersion: Number(detail?.version),
|
|
|
|
|
})
|
|
|
|
|
const linked = context.apiSuccess(MODULE, `${chain.label} 关联RELEASED订舱并补真实柜号`, linkResponse,
|
|
|
|
|
evidence(linkResponse), OPTIONS)
|
|
|
|
|
context.assert(MODULE, `${chain.label} 保持原计划柜次身份`, linkResponse,
|
|
|
|
|
id(linked?.id) === chain.containerBookingId && id(linked?.oceanBookingId) === oceanBookingId
|
|
|
|
|
&& linked?.containerNo === containerNo,
|
|
|
|
|
`containerBookingId=${id(linked?.id) || 'missing'}, containerNo=${linked?.containerNo || 'missing'}`, OPTIONS)
|
|
|
|
|
chain.containerNo = containerNo
|
|
|
|
|
}
|
|
|
|
|
return oceanBookingId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function confirmAndClaimTask(context, chain, fixture) {
|
|
|
|
|
const key = chain.key || chain.label.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32) || 'chain'
|
|
|
|
|
const detailResponse = await context.request('GET',
|
|
|
|
|
`${FIRST_LEG}/loading-plans/${encodeURIComponent(chain.planId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
|
|
|
|
|
const detail = context.apiSuccess(MODULE, `${chain.label} 读取预装确认门禁`, detailResponse,
|
|
|
|
|
evidence(detailResponse), OPTIONS)
|
|
|
|
|
context.assert(MODULE, `${chain.label} 订舱关联后整份计划可确认`, detailResponse,
|
|
|
|
|
detail?.summary?.status === 'EDITING' && detail?.summary?.readiness?.canConfirm === true,
|
|
|
|
|
`status=${detail?.summary?.status || 'missing'}, canConfirm=${detail?.summary?.readiness?.canConfirm}`, OPTIONS)
|
|
|
|
|
if (detail?.summary?.readiness?.canConfirm !== true) return null
|
|
|
|
|
const confirmResponse = await context.request('POST',
|
|
|
|
|
`${FIRST_LEG}/loading-plans/${encodeURIComponent(chain.planId)}/confirm`, {
|
|
|
|
|
expectedVersion: Number(detail?.summary?.version),
|
|
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-${key}-confirm`.slice(0, 128) })
|
|
|
|
|
const confirmed = context.apiSuccess(MODULE, `${chain.label} 原子确认并生成PDA任务`, confirmResponse,
|
|
|
|
|
evidence(confirmResponse), OPTIONS)
|
|
|
|
|
const taskId = id(confirmed?.readiness?.pdaTaskId)
|
|
|
|
|
if (!taskId) return null
|
|
|
|
|
const taskResponse = await context.request('GET',
|
|
|
|
|
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(chain.planId)}&warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
|
|
|
|
|
const task = context.apiSuccess(MODULE, `${chain.label} 读取唯一待领取PDA任务`, taskResponse,
|
|
|
|
|
evidence(taskResponse), OPTIONS)
|
|
|
|
|
const claimResponse = await context.request('POST', `${PDA}/tasks/${taskId}/claim`, {
|
|
|
|
|
expectedAssignmentVersion: Number(task?.assignment?.version || 0),
|
|
|
|
|
operationId: `${context.marker}-${key}-claim`.slice(0, 128),
|
|
|
|
|
})
|
|
|
|
|
const claimed = context.apiSuccess(MODULE, `${chain.label} 领取PDA任务`, claimResponse,
|
|
|
|
|
evidence(claimResponse), OPTIONS)
|
|
|
|
|
const inventoryResponse = await context.request('GET',
|
|
|
|
|
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
|
|
|
|
|
const inventory = context.apiSuccess(MODULE, `${chain.label} 读取实时库存快照`, inventoryResponse,
|
|
|
|
|
evidence(inventoryResponse), OPTIONS)
|
|
|
|
|
context.assert(MODULE, `${chain.label} PDA任务包含同柜组全部入库码`, taskResponse,
|
|
|
|
|
chain.inboundIds.every((inboundId) => (claimed?.items || []).some((item) => id(item?.inboundId) === inboundId))
|
|
|
|
|
&& typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64,
|
|
|
|
|
`taskId=${taskId}, inboundIds=${[...new Set((claimed?.items || []).map((item) => id(item?.inboundId)))].join(',')}`, OPTIONS)
|
|
|
|
|
return { task: claimed, inventory, taskId }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fulfillmentTrees(context, fixtures, name) {
|
|
|
|
|
const snapshots = []
|
|
|
|
|
for (const fixture of fixtures) {
|
|
|
|
|
const response = await context.request('GET',
|
|
|
|
|
`/api/platform/v1/orders/${encodeURIComponent(fixture.orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(fixture.tenantId)}`)
|
|
|
|
|
const tree = context.apiSuccess(MODULE, `${name} ${fixture.orderId} 履约树`, response,
|
|
|
|
|
evidence(response), OPTIONS)
|
|
|
|
|
snapshots.push({ fixture, response, tree, signature: treeSignature(tree) })
|
|
|
|
|
}
|
|
|
|
|
return snapshots
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function uploadCompletionEvidence(context, fixture, label) {
|
|
|
|
|
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']) {
|
|
|
|
|
const path = `/api/platform/v1/files/loading-evidence/${fixture.tenantId}/${fixture.inboundId}?evidenceType=${encodeURIComponent(evidenceType)}`
|
|
|
|
|
const form = new FormData()
|
|
|
|
|
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-${label}-${evidenceType}.png`)
|
|
|
|
|
let uploadResponse
|
|
|
|
|
try {
|
|
|
|
|
const raw = await fetch(`${context.options.gateway}${path}`, {
|
|
|
|
|
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
|
|
|
|
|
body: form, signal: AbortSignal.timeout(60_000),
|
|
|
|
|
})
|
|
|
|
|
const body = await raw.json()
|
|
|
|
|
uploadResponse = { method: 'POST', path, http: raw.status, body,
|
|
|
|
|
requestId: body?.requestId || raw.headers.get('x-request-id'),
|
|
|
|
|
traceId: body?.traceId || raw.headers.get('x-trace-id') }
|
|
|
|
|
} catch (error) {
|
|
|
|
|
uploadResponse = { method: 'POST', path, http: 0, body: null, raw: String(error) }
|
|
|
|
|
}
|
|
|
|
|
const uploaded = context.apiSuccess(MODULE, `${label} 上传 ${evidenceType} 柜级证据`, uploadResponse,
|
|
|
|
|
evidence(uploadResponse), OPTIONS)
|
|
|
|
|
const fileId = id(uploaded?.fileId || uploaded?.id)
|
|
|
|
|
if (!fileId) return false
|
|
|
|
|
const bindResponse = await context.request('POST',
|
|
|
|
|
`/api/platform/v1/inbounds/${fixture.inboundId}/loading/evidence?tenantId=${encodeURIComponent(fixture.tenantId)}`, {
|
|
|
|
|
expectedVersion: 0, evidenceType, fileId,
|
2026-08-29 11:59:11 +08:00
|
|
|
sealNo: evidenceType === 'SEAL' ? `SEAL-${context.marker}-${label}`.slice(0, 64) : null,
|
2026-08-29 11:42:52 +08:00
|
|
|
remark: context.marker,
|
|
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-${label}-${evidenceType}`.slice(0, 128) })
|
|
|
|
|
const bound = context.apiSuccess(MODULE, `${label} 绑定 ${evidenceType} 柜级证据`, bindResponse,
|
|
|
|
|
evidence(bindResponse), OPTIONS)
|
|
|
|
|
if (!bound) return false
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pollCompletedTask(context, taskId, warehouseId, label) {
|
|
|
|
|
let response = null
|
|
|
|
|
let task = null
|
|
|
|
|
for (let attempt = 1; attempt <= 90; attempt += 1) {
|
|
|
|
|
response = await context.request('GET',
|
|
|
|
|
`${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
|
|
|
if (code(response) === '200') task = response.body?.data
|
|
|
|
|
if (task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED') break
|
|
|
|
|
if (task?.taskStatus === 'COMPLETION_FAILED') break
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1_000))
|
|
|
|
|
}
|
|
|
|
|
context.assert(MODULE, `${label} 异步完成最终收敛`, response,
|
|
|
|
|
task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED',
|
|
|
|
|
evidence(response, `taskStatus=${task?.taskStatus || 'missing'}, workflow=${task?.workflowStatus || 'missing'}`), OPTIONS)
|
|
|
|
|
return { response, task }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 15:55:48 +08:00
|
|
|
async function createRouteVariantFixture(context, base, variant) {
|
2026-08-29 11:10:28 +08:00
|
|
|
const orders = await import(new URL('./orders.mjs', import.meta.url))
|
|
|
|
|
const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url))
|
|
|
|
|
const originalMarker = context.marker
|
|
|
|
|
const originalOrder = context.createdResources.orders
|
|
|
|
|
const originalMeasurement = context.createdResources.inboundMeasurement
|
|
|
|
|
const originalProfile = context.context.orderProfile
|
|
|
|
|
const originalDestination = context.context.orderDestinationType
|
|
|
|
|
const originalFulfillment = context.context.orderFulfillmentType
|
|
|
|
|
const originalRoute = context.context.waitSplitRoute
|
|
|
|
|
const originalVariant = context.context.waitSplitVariant
|
2026-08-29 11:42:52 +08:00
|
|
|
const originalDetailCount = context.context.phase3LoadingIncrementalDetailCount
|
|
|
|
|
const originalPackageCount = context.context.phase3LoadingIncrementalPackageCount
|
2026-08-29 11:10:28 +08:00
|
|
|
try {
|
2026-08-30 15:55:48 +08:00
|
|
|
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
|
|
|
|
context.marker = `${originalMarker}-${variant === 'DESTINATION' ? 'DEST' : 'ROUTE'}-${attempt}`
|
2026-08-29 11:10:28 +08:00
|
|
|
context.context.orderProfile = {
|
2026-08-30 15:55:48 +08:00
|
|
|
destinationType: variant === 'DESTINATION' && attempt >= 3 ? 'OVERSEAS' : 'FBA', fulfillmentType: 'STANDARD',
|
2026-08-29 11:10:28 +08:00
|
|
|
pickupMethod: 'PICKUP', taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false,
|
|
|
|
|
}
|
|
|
|
|
context.context.orderDestinationType = context.context.orderProfile.destinationType
|
|
|
|
|
context.context.orderFulfillmentType = 'STANDARD'
|
|
|
|
|
context.context.waitSplitVariant = {
|
2026-08-29 11:42:52 +08:00
|
|
|
sequence: 90 + attempt, skuCount: 4, packageCountPerSku: 1, packageCounts: [1, 1, 1, 1],
|
|
|
|
|
totalPackageCount: 4, palletCount: 1, grossWeightPerPalletKg: 45,
|
2026-08-29 11:10:28 +08:00
|
|
|
measurementProfile: { code: 'SMALL', first: { length: 29.1, width: 19.1, height: 18.1 },
|
|
|
|
|
replacement: { length: 29.5, width: 19.5, height: 18.5 }, outer: { length: 34, width: 24, height: 24 } },
|
|
|
|
|
}
|
2026-08-29 11:42:52 +08:00
|
|
|
context.context.phase3LoadingIncrementalDetailCount = 4
|
|
|
|
|
context.context.phase3LoadingIncrementalPackageCount = 1
|
2026-08-29 11:10:28 +08:00
|
|
|
context.context.waitSplitRoute = {
|
|
|
|
|
originWarehouseName: base.receivingWarehouseName || '', randomize: true,
|
2026-08-30 15:55:48 +08:00
|
|
|
channelCode: variant === 'DESTINATION' ? base.channelCode : '',
|
2026-08-29 11:10:28 +08:00
|
|
|
randomIndex: 50 + attempt * 31, destinationIndex: 80 + attempt * 37, taxModeIndex: attempt,
|
|
|
|
|
}
|
|
|
|
|
await orders.run(context)
|
|
|
|
|
await measurement.run(context)
|
|
|
|
|
const order = context.createdResources.orders || {}
|
|
|
|
|
const inbound = context.createdResources.inboundMeasurement || {}
|
|
|
|
|
const candidate = {
|
|
|
|
|
marker: context.marker, orderId: id(order.orderId), inboundId: id(inbound.inboundId),
|
|
|
|
|
tenantId: id(order.tenantId), warehouseId: id(order.receivingWarehouseId),
|
|
|
|
|
receivingWarehouseName: order.receivingWarehouseName || null,
|
|
|
|
|
channelCode: order.channelCode || null, destinationType: order.destinationType || null,
|
|
|
|
|
destinationWarehouseId: id(order.destinationWarehouseId),
|
|
|
|
|
destinationWarehouseCode: order.destinationWarehouseCode || null,
|
|
|
|
|
packages: (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0),
|
|
|
|
|
processStatus: inbound.processStatus,
|
|
|
|
|
}
|
2026-08-30 15:55:48 +08:00
|
|
|
const sameLoadingRoute = candidate.warehouseId === base.warehouseId
|
|
|
|
|
&& candidate.channelCode === base.channelCode
|
|
|
|
|
const differentDestination = candidate.destinationType !== base.destinationType
|
2026-08-29 11:10:28 +08:00
|
|
|
|| candidate.destinationWarehouseId !== id(base.destinationWarehouseId)
|
|
|
|
|
|| candidate.destinationWarehouseCode !== id(base.destinationWarehouseCode)
|
2026-08-30 15:55:48 +08:00
|
|
|
const matched = variant === 'DESTINATION'
|
|
|
|
|
? sameLoadingRoute && differentDestination
|
|
|
|
|
: !sameLoadingRoute
|
|
|
|
|
if (candidate.orderId && candidate.inboundId && candidate.processStatus === 'WAIT_SPLIT' && matched) return candidate
|
2026-08-29 11:10:28 +08:00
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
} finally {
|
|
|
|
|
context.marker = originalMarker
|
|
|
|
|
if (originalOrder === undefined) delete context.createdResources.orders
|
|
|
|
|
else context.createdResources.orders = originalOrder
|
|
|
|
|
if (originalMeasurement === undefined) delete context.createdResources.inboundMeasurement
|
|
|
|
|
else context.createdResources.inboundMeasurement = originalMeasurement
|
|
|
|
|
context.context.orderProfile = originalProfile
|
|
|
|
|
context.context.orderDestinationType = originalDestination
|
|
|
|
|
context.context.orderFulfillmentType = originalFulfillment
|
|
|
|
|
context.context.waitSplitRoute = originalRoute
|
|
|
|
|
context.context.waitSplitVariant = originalVariant
|
2026-08-29 11:42:52 +08:00
|
|
|
context.context.phase3LoadingIncrementalDetailCount = originalDetailCount
|
|
|
|
|
context.context.phase3LoadingIncrementalPackageCount = originalPackageCount
|
2026-08-29 11:10:28 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function run(context) {
|
|
|
|
|
const fixtures = (context.createdResources?.waitSplitFixtures || []).map((item) => ({
|
|
|
|
|
...item, orderId: id(item.orderId), inboundId: id(item.inboundId), tenantId: id(item.tenantId),
|
|
|
|
|
warehouseId: id(item.receivingWarehouseId), packages: Number(item.totalPackageCount),
|
|
|
|
|
}))
|
|
|
|
|
const usable = fixtures.filter((item) => item.orderId && item.inboundId && item.tenantId
|
|
|
|
|
&& item.warehouseId && item.channelCode && item.packages > 0)
|
2026-08-30 15:55:48 +08:00
|
|
|
context.assert(MODULE, '取得至少八票本轮 WAIT_SPLIT 新数据', null, usable.length >= 8,
|
2026-08-29 11:10:28 +08:00
|
|
|
`count=${usable.length}, marker=${context.marker}`, OPTIONS)
|
2026-08-30 15:55:48 +08:00
|
|
|
if (usable.length < 8) return
|
2026-08-29 11:10:28 +08:00
|
|
|
|
2026-08-30 15:55:48 +08:00
|
|
|
const [first, second, third, fourth, fifth, sixth, seventh, eighth] = usable
|
2026-08-29 11:42:52 +08:00
|
|
|
const sameRoute = [second, third, fourth, fifth, sixth, seventh].every((item) => item.warehouseId === first.warehouseId
|
2026-08-30 15:55:48 +08:00
|
|
|
&& item.channelCode === first.channelCode)
|
|
|
|
|
&& eighth.warehouseId === first.warehouseId && eighth.channelCode === first.channelCode
|
|
|
|
|
context.assert(MODULE, '正向样本只要求同一国内仓和渠道', null, sameRoute,
|
|
|
|
|
`warehouse=${first.warehouseId}, channel=${first.channelCode}`,
|
2026-08-29 11:10:28 +08:00
|
|
|
OPTIONS)
|
|
|
|
|
if (!sameRoute) return
|
|
|
|
|
|
|
|
|
|
const groupOrders = [first.orderId, second.orderId]
|
|
|
|
|
const createKey = `${context.marker}-coload-create`.slice(0, 128)
|
|
|
|
|
const createdResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
|
|
|
|
|
{ orderIds: groupOrders }, undefined, { 'Idempotency-Key': createKey })
|
|
|
|
|
const group = context.apiSuccess(MODULE, '创建同柜不可拆组', createdResponse,
|
|
|
|
|
evidence(createdResponse, `orders=${groupOrders.join(',')}`), OPTIONS)
|
|
|
|
|
const groupId = id(group?.groupId)
|
|
|
|
|
const frozen = memberFacts(group)
|
|
|
|
|
context.assert(MODULE, '组冻结两票权威入库单与满箱数', createdResponse,
|
|
|
|
|
groupId && group?.status === 'ACTIVE' && frozen.length === 2
|
|
|
|
|
&& frozen.every((member) => [first, second].some((item) => item.orderId === member.orderId
|
|
|
|
|
&& item.inboundId === member.inboundId && item.packages === member.fullPackageCount)),
|
|
|
|
|
`groupId=${groupId || 'missing'}, status=${group?.status || 'missing'}, members=${JSON.stringify(frozen)}`, OPTIONS)
|
|
|
|
|
if (!groupId) return
|
|
|
|
|
|
|
|
|
|
const replayResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
|
|
|
|
|
{ orderIds: groupOrders }, undefined, { 'Idempotency-Key': createKey })
|
|
|
|
|
const replay = context.apiSuccess(MODULE, '创建同键同载荷精确重放', replayResponse,
|
|
|
|
|
evidence(replayResponse, `groupId=${id(replayResponse?.body?.data?.groupId)}`), OPTIONS)
|
|
|
|
|
context.assert(MODULE, '创建重放不生成第二组', replayResponse,
|
|
|
|
|
id(replay?.groupId) === groupId && replay?.version === group?.version
|
|
|
|
|
&& JSON.stringify(memberFacts(replay)) === JSON.stringify(frozen),
|
|
|
|
|
`original=${groupId}, replay=${id(replay?.groupId)}, version=${replay?.version ?? 'missing'}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
const changedPayloadResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
|
|
|
|
|
{ orderIds: [first.orderId, third.orderId] }, undefined, { 'Idempotency-Key': createKey })
|
|
|
|
|
conflict(context, '创建同键异载荷返回并发冲突', changedPayloadResponse)
|
|
|
|
|
|
2026-08-30 15:55:48 +08:00
|
|
|
const differentDestination = await createRouteVariantFixture(context, first, 'DESTINATION')
|
|
|
|
|
context.assert(MODULE, '创建同国内仓同渠道但不同目的仓港的正向样本', null, Boolean(differentDestination),
|
|
|
|
|
differentDestination
|
|
|
|
|
? `orderId=${differentDestination.orderId}, warehouse=${differentDestination.warehouseId}, channel=${differentDestination.channelCode}, destination=${differentDestination.destinationType}/${differentDestination.destinationWarehouseId || differentDestination.destinationWarehouseCode}`
|
|
|
|
|
: '六次正式下单均未取得跨目的仓样本,未借用历史订单', OPTIONS)
|
|
|
|
|
if (differentDestination) {
|
|
|
|
|
const crossDestinationResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
|
|
|
|
|
{ orderIds: [fifth.orderId, differentDestination.orderId] }, undefined,
|
|
|
|
|
{ 'Idempotency-Key': `${context.marker}-coload-cross-destination`.slice(0, 128) })
|
|
|
|
|
const crossDestinationGroup = context.apiSuccess(MODULE, '不同目的仓港仍可创建同柜不可拆组',
|
|
|
|
|
crossDestinationResponse, evidence(crossDestinationResponse), OPTIONS)
|
|
|
|
|
const crossDestinationFacts = memberFacts(crossDestinationGroup)
|
|
|
|
|
context.assert(MODULE, '跨目的仓组完整冻结两票当前入仓片段', crossDestinationResponse,
|
|
|
|
|
crossDestinationFacts.length === 2
|
|
|
|
|
&& [fifth.inboundId, differentDestination.inboundId]
|
|
|
|
|
.every((inboundId) => crossDestinationFacts.some((member) => member.inboundId === inboundId)),
|
|
|
|
|
`members=${JSON.stringify(crossDestinationFacts)}`, OPTIONS)
|
|
|
|
|
if (crossDestinationGroup?.groupId) {
|
|
|
|
|
const cleanupResponse = await context.request('POST',
|
|
|
|
|
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(crossDestinationGroup.groupId)}/cancel`, {
|
|
|
|
|
expectedVersion: Number(crossDestinationGroup.version), reason: '释放跨目的仓正向验收组成员',
|
|
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-cross-destination-cancel`.slice(0, 128) })
|
|
|
|
|
context.apiSuccess(MODULE, '跨目的仓正向组整组取消并释放成员', cleanupResponse,
|
|
|
|
|
evidence(cleanupResponse), OPTIONS)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const differentRoute = await createRouteVariantFixture(context, first, 'LOADING_ROUTE')
|
|
|
|
|
context.assert(MODULE, '创建本轮不同国内仓或渠道反向样本', null, Boolean(differentRoute),
|
2026-08-29 11:10:28 +08:00
|
|
|
differentRoute
|
2026-08-30 15:55:48 +08:00
|
|
|
? `orderId=${differentRoute.orderId}, warehouse=${differentRoute.warehouseId}, channel=${differentRoute.channelCode}`
|
|
|
|
|
: '六次正式下单均未取得不同国内仓或渠道,未借用历史订单', OPTIONS)
|
2026-08-29 11:10:28 +08:00
|
|
|
if (differentRoute) {
|
|
|
|
|
const routeConflictResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
|
|
|
|
|
{ orderIds: [third.orderId, differentRoute.orderId] }, undefined,
|
|
|
|
|
{ 'Idempotency-Key': `${context.marker}-coload-route-conflict`.slice(0, 128) })
|
2026-08-30 15:55:48 +08:00
|
|
|
conflict(context, '跨国内仓或跨渠道创建整组被拒绝且不落组', routeConflictResponse,
|
2026-08-29 11:10:28 +08:00
|
|
|
`baseOrder=${third.orderId}, mismatchOrder=${differentRoute.orderId}`)
|
2026-08-31 10:17:03 +08:00
|
|
|
const zeroDriftResponses = await Promise.all([third, differentRoute].map((fixture) => context.request('GET',
|
|
|
|
|
`/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(fixture.inboundCode)}`)))
|
|
|
|
|
context.assert(MODULE, '跨线路拒绝后两个入仓码均无组事实', zeroDriftResponses[0],
|
|
|
|
|
zeroDriftResponses.every((response) => code(response) === 'A0404'),
|
|
|
|
|
`codes=${zeroDriftResponses.map((response) => code(response)).join('|')}`, OPTIONS)
|
2026-08-29 11:10:28 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-31 10:17:03 +08:00
|
|
|
const queried = await readGroupSummary(context, first.inboundCode, '按入仓码回读组业务摘要')
|
|
|
|
|
const frozenCodes = [first.inboundCode, second.inboundCode].sort()
|
|
|
|
|
context.assert(MODULE, '业务摘要只返回状态、成员数和完整入仓码', queried.response,
|
|
|
|
|
queried.data?.status === 'ACTIVE' && Number(queried.data?.memberCount) === 2
|
|
|
|
|
&& JSON.stringify(summaryInboundCodes(queried.data)) === JSON.stringify(frozenCodes),
|
|
|
|
|
`status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS)
|
2026-08-29 11:10:28 +08:00
|
|
|
|
|
|
|
|
const simulationResponse = await context.request('POST', '/api/platform/v1/containers', {
|
2026-08-29 11:42:52 +08:00
|
|
|
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '20GP',
|
|
|
|
|
capacityCbm: 33, maxWeightKg: 28000,
|
2026-08-29 11:10:28 +08:00
|
|
|
})
|
2026-08-29 11:59:11 +08:00
|
|
|
const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse,
|
2026-08-29 11:10:28 +08:00
|
|
|
evidence(simulationResponse), OPTIONS)
|
2026-08-29 11:59:11 +08:00
|
|
|
let bookingId = id(simulation?.id)
|
2026-08-29 11:10:28 +08:00
|
|
|
if (!bookingId) return
|
|
|
|
|
const planResponse = await context.request('POST', '/api/platform/v1/first-leg/smart-loading/plans', {
|
|
|
|
|
bookingId, remark: `${context.marker} 同柜不可拆组验收`,
|
|
|
|
|
})
|
2026-08-29 11:59:11 +08:00
|
|
|
let plan = context.apiSuccess(MODULE, '创建容量拒绝专用空计划', planResponse,
|
2026-08-29 11:10:28 +08:00
|
|
|
evidence(planResponse, `bookingId=${bookingId}`), OPTIONS)
|
2026-08-29 11:59:11 +08:00
|
|
|
let planId = id(plan?.id)
|
2026-08-29 11:10:28 +08:00
|
|
|
if (!planId) return
|
|
|
|
|
|
|
|
|
|
const candidateResponse = await context.request('GET',
|
|
|
|
|
`/api/platform/v1/first-leg/smart-loading/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(first.warehouseId)}`)
|
|
|
|
|
const candidates = context.apiSuccess(MODULE, '读取整票智能装柜候选', candidateResponse,
|
|
|
|
|
evidence(candidateResponse), OPTIONS) || []
|
|
|
|
|
const groupedCandidates = candidates.filter((item) => id(item?.coLoadGroupId) === groupId)
|
|
|
|
|
context.assert(MODULE, '每个组成员候选携带相同整组事实', candidateResponse,
|
|
|
|
|
groupedCandidates.length === 2 && groupedCandidates.every((item) => Number(item?.memberCount) === 2
|
2026-08-30 16:07:12 +08:00
|
|
|
&& Number(item?.groupTotalBoxes) === first.packages + second.packages && item?.locked !== true),
|
2026-08-29 11:10:28 +08:00
|
|
|
`count=${groupedCandidates.length}, groupTotal=${first.packages + second.packages}`, OPTIONS)
|
|
|
|
|
|
2026-08-29 11:59:11 +08:00
|
|
|
let isolatedCandidates = candidates
|
|
|
|
|
const competing = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true)
|
|
|
|
|
.map((item) => id(item?.coLoadGroupId)).filter((value) => value && value !== groupId))]
|
|
|
|
|
let isolatedCandidateResponse = candidateResponse
|
|
|
|
|
if (competing.length > 0) {
|
|
|
|
|
isolatedCandidateResponse = await context.request('GET',
|
|
|
|
|
`${SMART}/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(first.warehouseId)}`)
|
|
|
|
|
isolatedCandidates = context.apiSuccess(MODULE, '清理后重新隔离读取目标组候选', isolatedCandidateResponse,
|
|
|
|
|
evidence(isolatedCandidateResponse), OPTIONS) || []
|
|
|
|
|
}
|
|
|
|
|
const candidateGroupIds = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true)
|
|
|
|
|
.map((item) => id(item?.coLoadGroupId)).filter(Boolean))]
|
|
|
|
|
const targetCandidateInbounds = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === groupId)
|
|
|
|
|
.map((item) => id(item?.inboundId)).sort()
|
|
|
|
|
context.assert(MODULE, '推荐候选仅含当前目标同柜组且成员身份精确', isolatedCandidateResponse,
|
|
|
|
|
candidateGroupIds.length === 1 && candidateGroupIds[0] === groupId
|
|
|
|
|
&& JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()),
|
|
|
|
|
`groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=33cbm/28000kg`, OPTIONS)
|
2026-08-30 16:07:12 +08:00
|
|
|
const allCandidateVolume = isolatedCandidates
|
2026-08-29 11:59:11 +08:00
|
|
|
.reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0)
|
2026-08-30 16:07:12 +08:00
|
|
|
context.assert(MODULE, '推荐候选总量形成容量竞争场景', isolatedCandidateResponse,
|
|
|
|
|
allCandidateVolume > 33 * 1.1,
|
|
|
|
|
`targetGroupId=${groupId}, candidateVolume=${allCandidateVolume}, limit=${33 * 1.1}`, OPTIONS)
|
2026-08-29 11:59:11 +08:00
|
|
|
const beforeRecommend = plan
|
|
|
|
|
const recommendResponse = await context.request('POST',
|
2026-08-30 15:55:48 +08:00
|
|
|
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/recommend`, {
|
|
|
|
|
expectedVersion: Number(plan?.version),
|
|
|
|
|
operationId: `${context.marker}-coload-capacity-recommend`.slice(0, 128),
|
2026-08-29 11:59:11 +08:00
|
|
|
})
|
2026-08-30 16:07:12 +08:00
|
|
|
const capacityRecommendation = context.apiSuccess(MODULE, '容量竞争时智能推荐不因整组容量失败',
|
2026-08-30 15:58:14 +08:00
|
|
|
recommendResponse, evidence(recommendResponse, `targetGroupId=${groupId}`), OPTIONS)
|
|
|
|
|
const capacityGroupItems = (capacityRecommendation?.items || [])
|
|
|
|
|
.filter((item) => [first.inboundId, second.inboundId].includes(id(item?.inboundId)))
|
2026-08-30 16:07:12 +08:00
|
|
|
const capacityTotals = new Map()
|
|
|
|
|
for (const item of capacityGroupItems) {
|
|
|
|
|
capacityTotals.set(id(item?.inboundId),
|
|
|
|
|
(capacityTotals.get(id(item?.inboundId)) || 0) + Number(item?.packageCount || 0))
|
|
|
|
|
}
|
|
|
|
|
const capacityTookNone = capacityGroupItems.length === 0
|
|
|
|
|
const capacityTookWhole = [first, second].every((fixture) => capacityTotals.get(fixture.inboundId) === fixture.packages)
|
|
|
|
|
context.assert(MODULE, '容量竞争时同柜组只能整组全取或全不取', recommendResponse,
|
|
|
|
|
capacityTookNone || capacityTookWhole,
|
2026-08-30 15:58:14 +08:00
|
|
|
`targetGroupId=${groupId}, assignedMembers=${capacityGroupItems.map((item) => id(item?.inboundId)).join('|') || 'none'}, before=${planSignature(beforeRecommend)}, after=${planSignature(capacityRecommendation)}`, OPTIONS)
|
2026-08-29 11:59:11 +08:00
|
|
|
|
2026-08-30 15:55:48 +08:00
|
|
|
const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
|
|
|
|
|
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
|
|
|
|
|
capacityCbm: 76, maxWeightKg: 28000,
|
|
|
|
|
})
|
|
|
|
|
const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜',
|
|
|
|
|
recommendSimulationResponse, evidence(recommendSimulationResponse), OPTIONS)
|
|
|
|
|
const recommendBookingId = id(recommendSimulation?.id)
|
|
|
|
|
if (!recommendBookingId) return
|
|
|
|
|
const recommendPlanResponse = await context.request('POST', `${SMART}/plans`, {
|
|
|
|
|
bookingId: recommendBookingId, remark: `${context.marker} 智能推荐整组全取`,
|
|
|
|
|
})
|
|
|
|
|
let recommendPlan = context.apiSuccess(MODULE, '创建智能推荐整组成功专用计划', recommendPlanResponse,
|
|
|
|
|
evidence(recommendPlanResponse), OPTIONS)
|
|
|
|
|
const recommendPlanId = id(recommendPlan?.id)
|
|
|
|
|
if (!recommendPlanId) return
|
|
|
|
|
const recommendSuccessResponse = await context.request('POST',
|
|
|
|
|
`${SMART}/plans/${encodeURIComponent(recommendPlanId)}/recommend`, {
|
|
|
|
|
expectedVersion: Number(recommendPlan?.version),
|
|
|
|
|
operationId: `${context.marker}-coload-success-recommend`.slice(0, 128),
|
|
|
|
|
})
|
2026-08-30 16:14:07 +08:00
|
|
|
recommendPlan = context.apiSuccess(MODULE, '第二柜智能推荐请求成功', recommendSuccessResponse,
|
2026-08-30 15:55:48 +08:00
|
|
|
evidence(recommendSuccessResponse, `groupId=${groupId}`), OPTIONS)
|
|
|
|
|
const recommendedTotals = new Map()
|
|
|
|
|
for (const item of recommendPlan?.items || []) {
|
|
|
|
|
recommendedTotals.set(id(item?.inboundId),
|
|
|
|
|
(recommendedTotals.get(id(item?.inboundId)) || 0) + Number(item?.packageCount || 0))
|
|
|
|
|
}
|
2026-08-30 16:14:07 +08:00
|
|
|
const recommendTookNone = [first, second].every((fixture) => !recommendedTotals.has(fixture.inboundId))
|
|
|
|
|
const recommendTookWhole = [first, second]
|
|
|
|
|
.every((fixture) => recommendedTotals.get(fixture.inboundId) === fixture.packages)
|
2026-08-30 15:55:48 +08:00
|
|
|
context.assert(MODULE, '智能推荐不会只取同柜组部分成员或部分箱数', recommendSuccessResponse,
|
2026-08-30 16:14:07 +08:00
|
|
|
recommendTookNone || recommendTookWhole,
|
2026-08-30 15:55:48 +08:00
|
|
|
`groupId=${groupId}, inboundTotals=${JSON.stringify(Object.fromEntries(recommendedTotals))}`, OPTIONS)
|
2026-08-30 16:14:07 +08:00
|
|
|
if (recommendTookWhole) {
|
|
|
|
|
const recommendRemoveResponse = await context.request('DELETE',
|
|
|
|
|
`${SMART}/plans/${encodeURIComponent(recommendPlanId)}/group-assignments/${encodeURIComponent(groupId)}`, {
|
|
|
|
|
expectedPlanVersion: Number(recommendPlan?.version),
|
|
|
|
|
operationId: `${context.marker}-coload-success-recommend-remove`.slice(0, 128),
|
|
|
|
|
})
|
|
|
|
|
context.apiSuccess(MODULE, '智能推荐整组验收后原子移除并释放组锁', recommendRemoveResponse,
|
|
|
|
|
evidence(recommendRemoveResponse), OPTIONS)
|
|
|
|
|
} else {
|
2026-08-31 10:17:03 +08:00
|
|
|
const recommendGroup = await readGroupSummary(context, first.inboundCode, '智能推荐全不取后回读组状态')
|
|
|
|
|
context.assert(MODULE, '智能推荐全不取后整组仍ACTIVE', recommendGroup.response,
|
|
|
|
|
recommendGroup.data?.status === 'ACTIVE',
|
|
|
|
|
`status=${recommendGroup.data?.status || 'missing'}`, OPTIONS)
|
2026-08-30 16:14:07 +08:00
|
|
|
}
|
2026-08-30 15:55:48 +08:00
|
|
|
|
2026-08-29 11:59:11 +08:00
|
|
|
const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
|
|
|
|
|
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
|
|
|
|
|
capacityCbm: 76, maxWeightKg: 28000,
|
|
|
|
|
})
|
|
|
|
|
const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse,
|
|
|
|
|
evidence(workingSimulationResponse), OPTIONS)
|
|
|
|
|
bookingId = id(workingSimulation?.id)
|
|
|
|
|
if (!bookingId) return
|
|
|
|
|
const workingPlanResponse = await context.request('POST', `${SMART}/plans`, {
|
|
|
|
|
bookingId, remark: `${context.marker} 同柜不可拆组PDA主链`,
|
|
|
|
|
})
|
|
|
|
|
plan = context.apiSuccess(MODULE, '创建PDA主链同柜装柜计划', workingPlanResponse,
|
|
|
|
|
evidence(workingPlanResponse, `bookingId=${bookingId}`), OPTIONS)
|
|
|
|
|
planId = id(plan?.id)
|
|
|
|
|
if (!planId) return
|
|
|
|
|
|
2026-08-29 11:10:28 +08:00
|
|
|
const beforeIndividual = await readPlan(context, planId, '读取单票绕过前计划快照')
|
|
|
|
|
const individualResponse = await context.request('POST',
|
|
|
|
|
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/inbound-assignments`, {
|
|
|
|
|
inboundId: first.inboundId, packageCount: first.packages,
|
|
|
|
|
expectedVersion: Number(beforeIndividual.data?.version),
|
|
|
|
|
operationId: `${context.marker}-coload-individual`.slice(0, 128),
|
|
|
|
|
})
|
|
|
|
|
conflict(context, '组成员不能通过单票入口分配', individualResponse)
|
|
|
|
|
const afterIndividual = await readPlan(context, planId, '回读单票拒绝后计划快照')
|
|
|
|
|
context.assert(MODULE, '单票分配拒绝后计划零漂移', afterIndividual.response,
|
|
|
|
|
planSignature(beforeIndividual.data) === planSignature(afterIndividual.data),
|
|
|
|
|
`before=${planSignature(beforeIndividual.data)}, after=${planSignature(afterIndividual.data)}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
const assignResponse = await context.request('POST',
|
|
|
|
|
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/group-assignments`, {
|
|
|
|
|
inboundId: first.inboundId, expectedVersion: Number(afterIndividual.data?.version),
|
|
|
|
|
operationId: `${context.marker}-coload-group-assign`.slice(0, 128),
|
|
|
|
|
})
|
|
|
|
|
plan = context.apiSuccess(MODULE, '整组原子分配全部成员', assignResponse,
|
|
|
|
|
evidence(assignResponse, `groupId=${groupId}`), OPTIONS)
|
|
|
|
|
const assigned = (plan?.items || []).filter((item) => [first.inboundId, second.inboundId].includes(id(item?.inboundId)))
|
2026-08-29 11:42:52 +08:00
|
|
|
const assignedTotals = new Map()
|
|
|
|
|
for (const item of assigned) assignedTotals.set(id(item?.inboundId),
|
|
|
|
|
(assignedTotals.get(id(item?.inboundId)) || 0) + Number(item?.packageCount || 0))
|
2026-08-29 11:10:28 +08:00
|
|
|
context.assert(MODULE, '整组分配同计划且每票均为满箱数', assignResponse,
|
2026-08-29 11:42:52 +08:00
|
|
|
[first, second].every((fixture) => assignedTotals.get(fixture.inboundId) === fixture.packages),
|
|
|
|
|
`inboundTotals=${JSON.stringify(Object.fromEntries(assignedTotals))}`, OPTIONS)
|
2026-08-29 11:10:28 +08:00
|
|
|
|
2026-08-31 10:17:03 +08:00
|
|
|
const lockedQuery = await readGroupSummary(context, first.inboundCode, '回读整组锁定事实')
|
|
|
|
|
context.assert(MODULE, '整组业务状态只单调推进至LOCKED', lockedQuery.response,
|
|
|
|
|
lockedQuery.data?.status === 'LOCKED',
|
|
|
|
|
`status=${lockedQuery.data?.status || 'missing'}`, OPTIONS)
|
2026-08-29 11:10:28 +08:00
|
|
|
|
|
|
|
|
const targetItem = assigned[0]
|
|
|
|
|
const beforeMutation = planSignature(plan)
|
|
|
|
|
const adjustResponse = await context.request('PATCH',
|
|
|
|
|
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(id(targetItem?.id))}`, {
|
|
|
|
|
packageCount: Math.max(1, Number(targetItem?.packageCount) - 1), expectedPlanVersion: Number(plan?.version),
|
|
|
|
|
expectedItemVersion: Number(targetItem?.version), operationId: `${context.marker}-coload-adjust`.slice(0, 128),
|
|
|
|
|
})
|
|
|
|
|
conflict(context, '组成员不能单独调整箱数', adjustResponse)
|
|
|
|
|
const deleteResponse = await context.request('DELETE',
|
|
|
|
|
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(id(targetItem?.id))}`, {
|
|
|
|
|
expectedPlanVersion: Number(plan?.version), expectedItemVersion: Number(targetItem?.version),
|
|
|
|
|
operationId: `${context.marker}-coload-delete`.slice(0, 128),
|
|
|
|
|
})
|
|
|
|
|
conflict(context, '组成员不能单独删除', deleteResponse)
|
|
|
|
|
const afterMutation = await readPlan(context, planId, '回读组成员变更拒绝后计划')
|
|
|
|
|
context.assert(MODULE, '单成员调整删除拒绝后整组零漂移', afterMutation.response,
|
|
|
|
|
beforeMutation === planSignature(afterMutation.data),
|
|
|
|
|
`before=${beforeMutation}, after=${planSignature(afterMutation.data)}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
const cancelLockedResponse = await context.request('POST',
|
|
|
|
|
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(groupId)}/cancel`, {
|
2026-08-31 10:17:03 +08:00
|
|
|
expectedVersion: Number(group?.version) + 1, reason: '锁定组取消反向验收',
|
2026-08-29 11:10:28 +08:00
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-cancel-locked`.slice(0, 128) })
|
|
|
|
|
conflict(context, '已锁定组不能取消', cancelLockedResponse)
|
|
|
|
|
|
|
|
|
|
const removeResponse = await context.request('DELETE',
|
|
|
|
|
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/group-assignments/${encodeURIComponent(groupId)}`, {
|
|
|
|
|
expectedPlanVersion: Number(afterMutation.data?.version),
|
|
|
|
|
operationId: `${context.marker}-coload-group-remove`.slice(0, 128),
|
|
|
|
|
})
|
|
|
|
|
plan = context.apiSuccess(MODULE, '整组原子移除', removeResponse,
|
|
|
|
|
evidence(removeResponse), OPTIONS)
|
|
|
|
|
context.assert(MODULE, '整组移除后两个成员同时消失', removeResponse,
|
|
|
|
|
!(plan?.items || []).some((item) => [first.inboundId, second.inboundId].includes(id(item?.inboundId))),
|
|
|
|
|
`remainingItems=${plan?.items?.length ?? 'missing'}`, OPTIONS)
|
|
|
|
|
|
2026-08-31 10:17:03 +08:00
|
|
|
const activeQuery = await readGroupSummary(context, first.inboundCode, '回读整组解锁事实')
|
|
|
|
|
context.assert(MODULE, '整组移除后业务状态恢复ACTIVE', activeQuery.response,
|
|
|
|
|
activeQuery.data?.status === 'ACTIVE',
|
|
|
|
|
`status=${activeQuery.data?.status || 'missing'}`, OPTIONS)
|
2026-08-29 11:59:11 +08:00
|
|
|
const manualGroupResponse = await context.request('POST',
|
|
|
|
|
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/group-assignments`, {
|
|
|
|
|
inboundId: first.inboundId, expectedVersion: Number(plan?.version),
|
|
|
|
|
operationId: `${context.marker}-coload-reassign`.slice(0, 128),
|
2026-08-29 11:10:28 +08:00
|
|
|
})
|
2026-08-29 11:59:11 +08:00
|
|
|
plan = context.apiSuccess(MODULE, '整组移除后再次完整回配', manualGroupResponse,
|
|
|
|
|
evidence(manualGroupResponse), OPTIONS)
|
2026-08-29 11:10:28 +08:00
|
|
|
if (!plan) return
|
|
|
|
|
|
|
|
|
|
const pushKey = `${context.marker}-coload-push`.slice(0, 128)
|
|
|
|
|
const pushResponse = await context.request('POST',
|
|
|
|
|
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/push-preload`, {
|
|
|
|
|
expectedVersion: Number(plan.version),
|
|
|
|
|
}, undefined, { 'Idempotency-Key': pushKey })
|
|
|
|
|
const pushed = context.apiSuccess(MODULE, '整组完整时推送预装成功', pushResponse,
|
|
|
|
|
evidence(pushResponse), OPTIONS)
|
2026-08-29 11:42:52 +08:00
|
|
|
const pushedTotals = new Map()
|
|
|
|
|
for (const item of pushed?.items || []) pushedTotals.set(id(item?.inboundId),
|
|
|
|
|
(pushedTotals.get(id(item?.inboundId)) || 0) + Number(item?.packageCount || 0))
|
2026-08-29 11:10:28 +08:00
|
|
|
context.assert(MODULE, '推送后整组仍保持同计划满量事实', pushResponse,
|
2026-08-29 11:42:52 +08:00
|
|
|
[first, second].every((fixture) => pushedTotals.get(fixture.inboundId) === fixture.packages),
|
|
|
|
|
`planId=${planId}, inboundTotals=${JSON.stringify(Object.fromEntries(pushedTotals))}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
const secondGroup = await createSecondGroup(context, third, fourth)
|
|
|
|
|
const secondGroupId = id(secondGroup?.groupId)
|
|
|
|
|
if (!secondGroupId) return
|
|
|
|
|
const fullChainBase = await createAssignedPushedPlan(context, third, secondGroupId, 'coload-full')
|
|
|
|
|
const normalChainBase = await createNormalPushedPlan(context, seventh, 'normal-split')
|
|
|
|
|
if (!fullChainBase || !normalChainBase) return
|
|
|
|
|
|
|
|
|
|
const cancelCreateResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
|
|
|
|
|
{ orderIds: [fifth.orderId, sixth.orderId] }, undefined,
|
|
|
|
|
{ 'Idempotency-Key': `${context.marker}-coload-cancel-create`.slice(0, 128) })
|
|
|
|
|
const cancelGroup = context.apiSuccess(MODULE, '创建仅用于ACTIVE取消的同柜组', cancelCreateResponse,
|
|
|
|
|
evidence(cancelCreateResponse), OPTIONS)
|
|
|
|
|
const cancelGroupId = id(cancelGroup?.groupId)
|
|
|
|
|
if (!cancelGroupId) return
|
|
|
|
|
const cancelResponse = await context.request('POST',
|
|
|
|
|
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(cancelGroupId)}/cancel`, {
|
|
|
|
|
expectedVersion: Number(cancelGroup?.version), reason: '验收ACTIVE整组取消',
|
|
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-active-cancel`.slice(0, 128) })
|
|
|
|
|
const cancelled = context.apiSuccess(MODULE, 'ACTIVE同柜组整组取消成功', cancelResponse,
|
|
|
|
|
evidence(cancelResponse), OPTIONS)
|
2026-08-31 10:17:03 +08:00
|
|
|
const cancelledQueryResponse = await context.request('GET',
|
|
|
|
|
`/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(fifth.inboundCode)}`)
|
2026-08-29 11:42:52 +08:00
|
|
|
context.assert(MODULE, '取消组进入CANCELLED且成员不再受活动组约束', cancelResponse,
|
2026-08-31 10:17:03 +08:00
|
|
|
cancelled?.status === 'CANCELLED' && code(cancelledQueryResponse) === 'A0404',
|
|
|
|
|
`status=${cancelled?.status || 'missing'}, queryCode=${code(cancelledQueryResponse)}`, OPTIONS)
|
2026-08-29 11:42:52 +08:00
|
|
|
|
|
|
|
|
const partialChain = {
|
|
|
|
|
label: '同柜短拣链', key: 'coload-partial', groupId, containerBookingId: bookingId, planId,
|
|
|
|
|
fixtures: [first, second], inboundIds: [first.inboundId, second.inboundId],
|
|
|
|
|
}
|
|
|
|
|
const fullChain = {
|
|
|
|
|
label: '同柜足量链', key: 'coload-full', groupId: secondGroupId, ...fullChainBase,
|
|
|
|
|
fixtures: [third, fourth], inboundIds: [third.inboundId, fourth.inboundId],
|
|
|
|
|
}
|
|
|
|
|
const normalChain = {
|
|
|
|
|
label: '普通单拆分链', key: 'normal-split', ...normalChainBase,
|
|
|
|
|
fixtures: [seventh], inboundIds: [seventh.inboundId],
|
|
|
|
|
}
|
|
|
|
|
const oceanBookingId = await createReleasedBookingAndLink(context, first,
|
|
|
|
|
[partialChain, fullChain, normalChain])
|
|
|
|
|
if (!oceanBookingId) return
|
|
|
|
|
|
|
|
|
|
const partialPda = await confirmAndClaimTask(context, partialChain, first)
|
|
|
|
|
const fullPda = await confirmAndClaimTask(context, fullChain, third)
|
|
|
|
|
const normalPda = await confirmAndClaimTask(context, normalChain, seventh)
|
|
|
|
|
if (!partialPda || !fullPda || !normalPda) return
|
|
|
|
|
|
|
|
|
|
const partialTreesBefore = await fulfillmentTrees(context, partialChain.fixtures, '短拣前')
|
2026-08-30 16:07:12 +08:00
|
|
|
const partialTarget = (partialPda.task?.items || []).find((item) => Number(item?.plannedPackageCount) > 0)
|
|
|
|
|
context.assert(MODULE, '短拣链存在可记录SKIP的组成员明细', null, Boolean(partialTarget?.id),
|
2026-08-29 11:42:52 +08:00
|
|
|
`planned=${partialTarget?.plannedPackageCount ?? 'missing'}, taskItems=${partialPda.task?.items?.length ?? 0}`, OPTIONS)
|
|
|
|
|
if (!partialTarget?.id) return
|
2026-08-30 16:14:07 +08:00
|
|
|
const skippedInboundId = id(partialTarget.inboundId)
|
|
|
|
|
const partialBatchItems = (partialPda.task.items || []).map((item) => id(item.inboundId) === skippedInboundId
|
2026-08-30 16:07:12 +08:00
|
|
|
? { itemId: id(item.id), action: 'SKIP', pickedPackageCount: null,
|
2026-08-30 16:14:07 +08:00
|
|
|
finalPickedPackageCount: null, releaseMode: null,
|
2026-08-30 16:07:12 +08:00
|
|
|
locationIds: [], version: Number(item.version), reason: '同柜不可拆组短拣验收' }
|
2026-08-29 11:42:52 +08:00
|
|
|
: { itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
|
|
|
|
|
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
|
|
|
|
|
version: Number(item.version), reason: null })
|
|
|
|
|
const partialBatchResponse = await context.request('POST', `${PDA}/tasks/${partialPda.taskId}/batch`, {
|
|
|
|
|
warehouseId: first.warehouseId, loadingPlanId: partialChain.planId,
|
|
|
|
|
containerBookingId: partialChain.containerBookingId, expectedVersion: Number(partialPda.task.version),
|
|
|
|
|
inventorySnapshotToken: partialPda.inventory.snapshotToken, items: partialBatchItems,
|
|
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-partial-batch`.slice(0, 128) })
|
2026-08-30 16:07:12 +08:00
|
|
|
const partialBatch = context.apiSuccess(MODULE, '同柜组PDA原子批次记录短拣异常', partialBatchResponse,
|
2026-08-29 11:42:52 +08:00
|
|
|
evidence(partialBatchResponse), OPTIONS)
|
2026-08-30 16:07:12 +08:00
|
|
|
context.assert(MODULE, '短拣批次只进入待确认且保留SKIP事实', partialBatchResponse,
|
2026-08-29 11:42:52 +08:00
|
|
|
partialBatch?.taskStatus === 'LOADING' && partialBatch?.workflowStatus === 'AWAITING_REVIEW'
|
2026-08-30 16:14:07 +08:00
|
|
|
&& partialBatch?.items?.filter((item) => id(item?.inboundId) === skippedInboundId)
|
|
|
|
|
.every((item) => item?.itemStatus === 'SKIPPED' && Number(item?.pickedPackageCount) === 0),
|
2026-08-29 11:42:52 +08:00
|
|
|
`taskStatus=${partialBatch?.taskStatus || 'missing'}, workflow=${partialBatch?.workflowStatus || 'missing'}`, OPTIONS)
|
|
|
|
|
const partialTaskBeforeComplete = taskSignature(partialBatch)
|
|
|
|
|
const partialCompleteResponse = await context.request('POST', `${PDA}/tasks/${partialPda.taskId}/complete`, {
|
|
|
|
|
expectedTaskVersion: Number(partialBatch?.version),
|
|
|
|
|
operationId: `${context.marker}-coload-partial-complete`.slice(0, 128),
|
|
|
|
|
remark: '同柜不可拆组短拣不得完成',
|
|
|
|
|
})
|
|
|
|
|
conflict(context, '同柜组任一成员短拣时完成被拒绝', partialCompleteResponse,
|
|
|
|
|
`message=${partialCompleteResponse?.body?.message || 'missing'}`)
|
|
|
|
|
context.assert(MODULE, '短拣完成拒绝明确命中全组箱数守恒', partialCompleteResponse,
|
|
|
|
|
String(partialCompleteResponse?.body?.message || '').includes('全组箱数守恒'),
|
|
|
|
|
evidence(partialCompleteResponse, `message=${partialCompleteResponse?.body?.message || 'missing'}`), OPTIONS)
|
|
|
|
|
const partialTaskAfterResponse = await context.request('GET',
|
|
|
|
|
`${PDA}/tasks/${partialPda.taskId}?warehouseId=${encodeURIComponent(first.warehouseId)}`)
|
|
|
|
|
const partialTaskAfter = context.apiSuccess(MODULE, '短拣完成拒绝后回读PDA任务', partialTaskAfterResponse,
|
|
|
|
|
evidence(partialTaskAfterResponse), OPTIONS)
|
|
|
|
|
const partialTreesAfter = await fulfillmentTrees(context, partialChain.fixtures, '短拣拒绝后')
|
2026-08-31 10:17:03 +08:00
|
|
|
const partialGroup = await readGroupSummary(context, first.inboundCode, '短拣拒绝后回读组状态')
|
2026-08-29 11:42:52 +08:00
|
|
|
context.assert(MODULE, '短拣完成拒绝后任务、父子树和组锁零漂移', partialTaskAfterResponse,
|
|
|
|
|
partialTaskBeforeComplete === taskSignature(partialTaskAfter)
|
|
|
|
|
&& partialTreesBefore.every((before, index) => before.signature === partialTreesAfter[index]?.signature
|
|
|
|
|
&& (partialTreesAfter[index]?.tree?.children || []).length === 0)
|
2026-08-31 10:17:03 +08:00
|
|
|
&& partialGroup.data?.status === 'LOCKED',
|
|
|
|
|
`taskStable=${partialTaskBeforeComplete === taskSignature(partialTaskAfter)}, children=${partialTreesAfter.map((item) => item.tree?.children?.length ?? -1).join('|')}, groupStatus=${partialGroup.data?.status || 'missing'}`, OPTIONS)
|
2026-08-29 11:42:52 +08:00
|
|
|
|
|
|
|
|
const fullTreesBefore = await fulfillmentTrees(context, fullChain.fixtures, '足量完成前')
|
|
|
|
|
const fullBatchItems = (fullPda.task?.items || []).map((item) => ({
|
|
|
|
|
itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
|
|
|
|
|
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
|
|
|
|
|
version: Number(item.version), reason: null,
|
|
|
|
|
}))
|
|
|
|
|
const fullBatchResponse = await context.request('POST', `${PDA}/tasks/${fullPda.taskId}/batch`, {
|
|
|
|
|
warehouseId: third.warehouseId, loadingPlanId: fullChain.planId,
|
|
|
|
|
containerBookingId: fullChain.containerBookingId, expectedVersion: Number(fullPda.task.version),
|
|
|
|
|
inventorySnapshotToken: fullPda.inventory.snapshotToken, items: fullBatchItems,
|
|
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-full-batch`.slice(0, 128) })
|
|
|
|
|
const fullBatch = context.apiSuccess(MODULE, '同柜组PDA全成员足量批量拣货', fullBatchResponse,
|
|
|
|
|
evidence(fullBatchResponse), OPTIONS)
|
|
|
|
|
context.assert(MODULE, '足量批次全明细进入待确认', fullBatchResponse,
|
|
|
|
|
fullBatch?.workflowStatus === 'AWAITING_REVIEW'
|
|
|
|
|
&& fullBatch?.items?.every((item) => Number(item?.pickedPackageCount) === Number(item?.plannedPackageCount)),
|
|
|
|
|
`workflow=${fullBatch?.workflowStatus || 'missing'}, items=${fullBatch?.items?.length ?? 0}`, OPTIONS)
|
|
|
|
|
if (!await uploadCompletionEvidence(context, third, 'coload-full')) return
|
|
|
|
|
const fullCompleteResponse = await context.request('POST', `${PDA}/tasks/${fullPda.taskId}/complete`, {
|
|
|
|
|
expectedTaskVersion: Number(fullBatch?.version),
|
|
|
|
|
operationId: `${context.marker}-coload-full-complete`.slice(0, 128), remark: context.marker,
|
|
|
|
|
})
|
|
|
|
|
const acceptedFull = context.apiSuccess(MODULE, '同柜组足量完成命令可靠受理', fullCompleteResponse,
|
|
|
|
|
evidence(fullCompleteResponse), OPTIONS)
|
|
|
|
|
context.assert(MODULE, '足量完成先返回PROCESSING或已完成事实', fullCompleteResponse,
|
|
|
|
|
['PROCESSING', 'COMPLETED'].includes(acceptedFull?.workflowStatus),
|
|
|
|
|
`taskStatus=${acceptedFull?.taskStatus || 'missing'}, workflow=${acceptedFull?.workflowStatus || 'missing'}`, OPTIONS)
|
|
|
|
|
const fullFinal = await pollCompletedTask(context, fullPda.taskId, third.warehouseId, '同柜组足量')
|
|
|
|
|
if (fullFinal.task?.taskStatus !== 'COMPLETED') return
|
|
|
|
|
const fullTreesAfter = await fulfillmentTrees(context, fullChain.fixtures, '足量完成后')
|
2026-08-31 10:17:03 +08:00
|
|
|
const consumedGroup = await readGroupSummary(context, third.inboundCode, '足量完成后回读组消费事实')
|
2026-08-29 11:42:52 +08:00
|
|
|
const completedInboundStates = []
|
|
|
|
|
for (const fixture of fullChain.fixtures) {
|
|
|
|
|
const response = await context.request('GET',
|
|
|
|
|
`/api/platform/inbounds/${encodeURIComponent(fixture.inboundId)}?tenantId=${encodeURIComponent(fixture.tenantId)}`)
|
|
|
|
|
const inbound = context.apiSuccess(MODULE, `足量完成后回读入库单 ${fixture.inboundId}`, response,
|
|
|
|
|
evidence(response), OPTIONS)
|
|
|
|
|
completedInboundStates.push(inbound?.summary?.processStatus)
|
|
|
|
|
}
|
2026-08-29 11:59:11 +08:00
|
|
|
const fullRemainders = fullTreesAfter.flatMap((item) => item?.tree?.children || [])
|
|
|
|
|
.filter((item) => item?.splitRole === 'REMAINDER')
|
2026-08-31 10:17:03 +08:00
|
|
|
context.assert(MODULE, '足量完成整组已消费、全部入库完成且不生成余量子单', consumedGroup.response,
|
|
|
|
|
consumedGroup.data?.status === 'CONSUMED'
|
2026-08-29 11:42:52 +08:00
|
|
|
&& completedInboundStates.every((status) => status === 'LOADED')
|
2026-08-29 11:59:11 +08:00
|
|
|
&& fullRemainders.length === 0,
|
2026-08-31 10:17:03 +08:00
|
|
|
`groupStatus=${consumedGroup.data?.status || 'missing'}, inboundStates=${completedInboundStates.join('|')}, remainderChildren=${fullRemainders.length}, childRoles=${fullTreesAfter.flatMap((item) => item?.tree?.children || []).map((item) => item?.splitRole).join('|') || 'none'}`, OPTIONS)
|
2026-08-29 11:42:52 +08:00
|
|
|
|
|
|
|
|
const normalTreeBefore = await fulfillmentTrees(context, normalChain.fixtures, '普通单完成前')
|
|
|
|
|
const normalBatchItems = (normalPda.task?.items || []).map((item) => ({
|
|
|
|
|
itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
|
|
|
|
|
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
|
|
|
|
|
version: Number(item.version), reason: null,
|
|
|
|
|
}))
|
|
|
|
|
const normalBatchResponse = await context.request('POST', `${PDA}/tasks/${normalPda.taskId}/batch`, {
|
|
|
|
|
warehouseId: seventh.warehouseId, loadingPlanId: normalChain.planId,
|
|
|
|
|
containerBookingId: normalChain.containerBookingId, expectedVersion: Number(normalPda.task.version),
|
|
|
|
|
inventorySnapshotToken: normalPda.inventory.snapshotToken, items: normalBatchItems,
|
|
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-normal-batch`.slice(0, 128) })
|
|
|
|
|
const normalBatch = context.apiSuccess(MODULE, '未成组普通单PDA批量拣货不回归', normalBatchResponse,
|
|
|
|
|
evidence(normalBatchResponse), OPTIONS)
|
|
|
|
|
if (!await uploadCompletionEvidence(context, seventh, 'normal-split')) return
|
|
|
|
|
const normalCompleteResponse = await context.request('POST', `${PDA}/tasks/${normalPda.taskId}/complete`, {
|
|
|
|
|
expectedTaskVersion: Number(normalBatch?.version),
|
|
|
|
|
operationId: `${context.marker}-normal-complete`.slice(0, 128), remark: context.marker,
|
|
|
|
|
})
|
|
|
|
|
context.apiSuccess(MODULE, '未成组普通单完成命令受理', normalCompleteResponse,
|
|
|
|
|
evidence(normalCompleteResponse), OPTIONS)
|
|
|
|
|
const normalFinal = await pollCompletedTask(context, normalPda.taskId, seventh.warehouseId, '未成组普通单')
|
|
|
|
|
if (normalFinal.task?.taskStatus !== 'COMPLETED') return
|
|
|
|
|
const normalTreeAfter = await fulfillmentTrees(context, normalChain.fixtures, '普通单完成后')
|
|
|
|
|
const normalTree = normalTreeAfter[0]?.tree
|
|
|
|
|
const remainder = (normalTree?.children || []).find((item) => item?.splitRole === 'REMAINDER')
|
|
|
|
|
context.assert(MODULE, '未成组普通单仍按既有规则生成唯一余量子单', normalTreeAfter[0]?.response,
|
|
|
|
|
normalTreeBefore[0]?.tree?.children?.length === 0 && Boolean(remainder?.orderId)
|
|
|
|
|
&& Number(normalTree?.cumulativeLoadedPackageCount) === Number(normalChain.assignedCount)
|
|
|
|
|
&& Number(normalTree?.currentRemainderPackageCount) === Number(seventh.packages) - Number(normalChain.assignedCount),
|
|
|
|
|
`children=${normalTree?.children?.length ?? 0}, remainderOrderId=${id(remainder?.orderId) || 'missing'}`, OPTIONS)
|
|
|
|
|
if (!remainder?.orderId) return
|
2026-08-30 15:55:48 +08:00
|
|
|
const splitTreeBeforeGroup = treeSignature(normalTree)
|
2026-08-29 11:42:52 +08:00
|
|
|
const splitGroupResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
|
2026-08-30 15:55:48 +08:00
|
|
|
{ orderIds: [id(remainder.orderId), eighth.orderId] }, undefined,
|
|
|
|
|
{ 'Idempotency-Key': `${context.marker}-coload-split-allow`.slice(0, 128) })
|
|
|
|
|
const splitGroup = context.apiSuccess(MODULE, '拆分余量子单仍可与同仓同渠道订单创建同柜组',
|
|
|
|
|
splitGroupResponse, evidence(splitGroupResponse,
|
|
|
|
|
`rootOrderId=${seventh.orderId}, remainderOrderId=${id(remainder.orderId)}`), OPTIONS)
|
|
|
|
|
const splitMembers = memberFacts(splitGroup)
|
|
|
|
|
context.assert(MODULE, '拆分身份不影响当前WAIT_SPLIT片段完整纳组', splitGroupResponse,
|
|
|
|
|
splitMembers.length >= 2
|
|
|
|
|
&& splitMembers.some((item) => item.orderId === id(remainder.orderId))
|
|
|
|
|
&& splitMembers.some((item) => item.orderId === eighth.orderId)
|
|
|
|
|
&& splitMembers.every((item) => item.fullPackageCount > 0),
|
|
|
|
|
`members=${JSON.stringify(splitMembers)}`, OPTIONS)
|
2026-08-31 10:17:03 +08:00
|
|
|
const splitSummary = await readGroupSummary(context, eighth.inboundCode,
|
|
|
|
|
'拆分组按入仓码返回完整成员')
|
|
|
|
|
context.assert(MODULE, '拆分组摘要返回全部当前有效成员入仓码且不重复', splitSummary.response,
|
|
|
|
|
Number(splitSummary.data?.memberCount) === splitMembers.length
|
|
|
|
|
&& new Set(summaryInboundCodes(splitSummary.data)).size === splitMembers.length,
|
|
|
|
|
`memberCount=${splitSummary.data?.memberCount ?? 'missing'}, members=${summaryInboundCodes(splitSummary.data).join('|')}`, OPTIONS)
|
2026-08-29 11:42:52 +08:00
|
|
|
const splitTreeAfterResponse = await context.request('GET',
|
|
|
|
|
`/api/platform/v1/orders/${encodeURIComponent(seventh.orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(seventh.tenantId)}`)
|
2026-08-30 15:55:48 +08:00
|
|
|
const splitTreeAfter = context.apiSuccess(MODULE, '拆分余量建组后回读根履约树', splitTreeAfterResponse,
|
2026-08-29 11:42:52 +08:00
|
|
|
evidence(splitTreeAfterResponse), OPTIONS)
|
2026-08-30 15:55:48 +08:00
|
|
|
context.assert(MODULE, '拆分余量建组不改写既有父子数量事实', splitTreeAfterResponse,
|
|
|
|
|
splitTreeBeforeGroup === treeSignature(splitTreeAfter),
|
|
|
|
|
`treeStable=${splitTreeBeforeGroup === treeSignature(splitTreeAfter)}`, OPTIONS)
|
|
|
|
|
if (splitGroup?.groupId) {
|
|
|
|
|
const splitCancelResponse = await context.request('POST',
|
|
|
|
|
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(splitGroup.groupId)}/cancel`, {
|
|
|
|
|
expectedVersion: Number(splitGroup.version), reason: '完成拆分余量正向验收',
|
|
|
|
|
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-split-cancel`.slice(0, 128) })
|
|
|
|
|
context.apiSuccess(MODULE, '拆分余量正向组整组取消', splitCancelResponse,
|
|
|
|
|
evidence(splitCancelResponse), OPTIONS)
|
|
|
|
|
}
|
2026-08-29 11:10:28 +08:00
|
|
|
|
2026-08-30 16:07:12 +08:00
|
|
|
let uiAcceptance = null
|
|
|
|
|
if (differentDestination) {
|
|
|
|
|
const uiGroupResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
|
|
|
|
|
{ orderIds: [fifth.orderId, differentDestination.orderId] }, undefined,
|
|
|
|
|
{ 'Idempotency-Key': `${context.marker}-coload-ui-preserve`.slice(0, 128) })
|
|
|
|
|
const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse,
|
|
|
|
|
evidence(uiGroupResponse), OPTIONS)
|
|
|
|
|
const uiSimulationResponse = await context.request('POST', CONTAINERS, {
|
|
|
|
|
channelCode: fifth.channelCode, warehouseId: fifth.warehouseId, containerType: '40HC',
|
|
|
|
|
capacityCbm: 76, maxWeightKg: 28000,
|
|
|
|
|
})
|
|
|
|
|
const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse,
|
|
|
|
|
evidence(uiSimulationResponse), OPTIONS)
|
|
|
|
|
const uiBookingId = id(uiSimulation?.id)
|
|
|
|
|
const uiPlanResponse = uiBookingId ? await context.request('POST', `${SMART}/plans`, {
|
|
|
|
|
bookingId: uiBookingId, remark: `${context.marker} Admin整组候选人工验收保留`,
|
|
|
|
|
}) : null
|
|
|
|
|
const uiPlan = uiPlanResponse ? context.apiSuccess(MODULE, '为Admin页面保留未分配空计划', uiPlanResponse,
|
|
|
|
|
evidence(uiPlanResponse, `bookingId=${uiBookingId}`), OPTIONS) : null
|
|
|
|
|
const uiMembers = memberFacts(uiGroup)
|
|
|
|
|
context.assert(MODULE, '页面验收保留组仍为ACTIVE且未锁定', uiGroupResponse,
|
|
|
|
|
uiGroup?.status === 'ACTIVE' && !uiGroup?.lockedPlanId && uiMembers.length === 2
|
|
|
|
|
&& Boolean(uiBookingId) && Boolean(uiPlan?.id),
|
|
|
|
|
`groupId=${id(uiGroup?.groupId)}, groupNo=${uiGroup?.groupNo || 'missing'}, bookingId=${uiBookingId}, planId=${id(uiPlan?.id)}, members=${JSON.stringify(uiMembers)}`, OPTIONS)
|
|
|
|
|
uiAcceptance = {
|
|
|
|
|
groupId: id(uiGroup?.groupId), groupNo: uiGroup?.groupNo || null,
|
|
|
|
|
orderIds: [fifth.orderId, differentDestination.orderId],
|
|
|
|
|
inboundIds: [fifth.inboundId, differentDestination.inboundId],
|
|
|
|
|
bookingId: uiBookingId, planId: id(uiPlan?.id),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 11:10:28 +08:00
|
|
|
context.createdResources.coloadGroupAcceptance = {
|
|
|
|
|
marker: context.marker, groupId, groupNo: group?.groupNo || null, planId, bookingId,
|
|
|
|
|
orderIds: groupOrders, inboundIds: [first.inboundId, second.inboundId],
|
2026-08-29 11:42:52 +08:00
|
|
|
fullGroupId: secondGroupId, fullTaskId: fullPda.taskId, fullPlanId: fullChain.planId,
|
|
|
|
|
partialTaskId: partialPda.taskId, normalTaskId: normalPda.taskId,
|
|
|
|
|
oceanBookingId, cancelledGroupId: cancelGroupId, remainderOrderId: id(remainder.orderId),
|
2026-08-30 15:55:48 +08:00
|
|
|
splitGroupId: id(splitGroup?.groupId), crossDestinationOrderId: id(differentDestination?.orderId),
|
2026-08-30 16:07:12 +08:00
|
|
|
uiAcceptance,
|
2026-08-29 11:42:52 +08:00
|
|
|
publicBypassResult: 'individual assignment/adjust/delete and short-pick completion rejected; full group consumed',
|
2026-08-29 11:10:28 +08:00
|
|
|
}
|
|
|
|
|
}
|