323 lines
21 KiB
JavaScript
323 lines
21 KiB
JavaScript
const MODULE = 'coload-groups'
|
|
const OPTIONS = { scenario: 'indivisible-coload-group', deploymentBlocker: true }
|
|
|
|
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))
|
|
}
|
|
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) }
|
|
}
|
|
|
|
async function createDifferentRouteFixture(context, base) {
|
|
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
|
|
try {
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
context.marker = `${originalMarker}-ROUTE-${attempt}`
|
|
context.context.orderProfile = {
|
|
destinationType: attempt === 3 ? 'OVERSEAS' : 'FBA', fulfillmentType: 'STANDARD',
|
|
pickupMethod: 'PICKUP', taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false,
|
|
}
|
|
context.context.orderDestinationType = context.context.orderProfile.destinationType
|
|
context.context.orderFulfillmentType = 'STANDARD'
|
|
context.context.waitSplitVariant = {
|
|
sequence: 90 + attempt, skuCount: 1, packageCountPerSku: 2, packageCounts: [2],
|
|
totalPackageCount: 2, palletCount: 1, grossWeightPerPalletKg: 45,
|
|
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 } },
|
|
}
|
|
context.context.waitSplitRoute = {
|
|
originWarehouseName: base.receivingWarehouseName || '', randomize: true,
|
|
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,
|
|
}
|
|
const differs = candidate.warehouseId !== base.warehouseId
|
|
|| candidate.channelCode !== base.channelCode
|
|
|| candidate.destinationType !== base.destinationType
|
|
|| candidate.destinationWarehouseId !== id(base.destinationWarehouseId)
|
|
|| candidate.destinationWarehouseCode !== id(base.destinationWarehouseCode)
|
|
if (candidate.orderId && candidate.inboundId && candidate.processStatus === 'WAIT_SPLIT' && differs) return candidate
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
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)
|
|
context.assert(MODULE, '取得至少四票本轮 WAIT_SPLIT 新数据', null, usable.length >= 4,
|
|
`count=${usable.length}, marker=${context.marker}`, OPTIONS)
|
|
if (usable.length < 4) return
|
|
|
|
const [first, second, third, fourth] = usable
|
|
const sameRoute = [second, third, fourth].every((item) => item.warehouseId === first.warehouseId
|
|
&& item.channelCode === first.channelCode && item.destinationType === first.destinationType
|
|
&& id(item.destinationWarehouseId) === id(first.destinationWarehouseId)
|
|
&& id(item.destinationWarehouseCode) === id(first.destinationWarehouseCode))
|
|
context.assert(MODULE, '正向样本属于同一仓库渠道和目的仓港', null, sameRoute,
|
|
`warehouse=${first.warehouseId}, channel=${first.channelCode}, destination=${first.destinationType}/${id(first.destinationWarehouseId) || id(first.destinationWarehouseCode)}`,
|
|
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)
|
|
|
|
const differentRoute = await createDifferentRouteFixture(context, first)
|
|
context.assert(MODULE, '创建本轮不同仓库渠道或目的仓港反向样本', null, Boolean(differentRoute),
|
|
differentRoute
|
|
? `orderId=${differentRoute.orderId}, warehouse=${differentRoute.warehouseId}, channel=${differentRoute.channelCode}, destination=${differentRoute.destinationType}/${differentRoute.destinationWarehouseId || differentRoute.destinationWarehouseCode}`
|
|
: '三次正式下单均未取得不同线路,未借用历史订单', OPTIONS)
|
|
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) })
|
|
conflict(context, '跨仓库渠道或目的仓港创建整组被拒绝且不落组', routeConflictResponse,
|
|
`baseOrder=${third.orderId}, mismatchOrder=${differentRoute.orderId}`)
|
|
const zeroDriftResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query',
|
|
{ orderIds: [third.orderId, differentRoute.orderId] })
|
|
const zeroDrift = context.apiSuccess(MODULE, '回读跨线路拒绝后的组事实', zeroDriftResponse,
|
|
evidence(zeroDriftResponse), OPTIONS) || []
|
|
context.assert(MODULE, '跨线路拒绝后两个订单均无组事实', zeroDriftResponse, zeroDrift.length === 0,
|
|
`count=${zeroDrift.length}`, OPTIONS)
|
|
}
|
|
|
|
const queryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query',
|
|
{ orderIds: [fourth.orderId, second.orderId, first.orderId, third.orderId] })
|
|
const queried = context.apiSuccess(MODULE, '按订单批量回读组事实', queryResponse,
|
|
evidence(queryResponse), OPTIONS) || []
|
|
context.assert(MODULE, '批查只返回组内订单且长ID保持字符串', queryResponse,
|
|
queried.length === 2 && queried.every((item) => groupOrders.includes(id(item?.orderId))
|
|
&& id(item?.group?.groupId) === groupId && typeof item?.group?.groupId === 'string'),
|
|
`count=${queried.length}, orderIds=${queried.map((item) => id(item?.orderId)).join(',')}`, OPTIONS)
|
|
|
|
const simulationResponse = await context.request('POST', '/api/platform/v1/containers', {
|
|
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
|
|
capacityCbm: 76, maxWeightKg: 28000,
|
|
})
|
|
const simulation = context.apiSuccess(MODULE, '创建本轮组装柜模拟柜', simulationResponse,
|
|
evidence(simulationResponse), OPTIONS)
|
|
const bookingId = id(simulation?.id)
|
|
if (!bookingId) return
|
|
const planResponse = await context.request('POST', '/api/platform/v1/first-leg/smart-loading/plans', {
|
|
bookingId, remark: `${context.marker} 同柜不可拆组验收`,
|
|
})
|
|
let plan = context.apiSuccess(MODULE, '创建同柜组装柜计划', planResponse,
|
|
evidence(planResponse, `bookingId=${bookingId}`), OPTIONS)
|
|
const planId = id(plan?.id)
|
|
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
|
|
&& Number(item?.groupTotalBoxes) === first.packages + second.packages && item?.locked !== true),
|
|
`count=${groupedCandidates.length}, groupTotal=${first.packages + second.packages}`, OPTIONS)
|
|
|
|
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)))
|
|
context.assert(MODULE, '整组分配同计划且每票均为满箱数', assignResponse,
|
|
assigned.length === 2 && assigned.every((item) => [first, second].some((fixture) =>
|
|
fixture.inboundId === id(item?.inboundId) && fixture.packages === Number(item?.packageCount))),
|
|
`items=${assigned.map((item) => `${id(item?.inboundId)}:${Number(item?.packageCount)}`).join(',')}`, OPTIONS)
|
|
|
|
const lockedQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query',
|
|
{ orderIds: groupOrders })
|
|
const lockedQuery = context.apiSuccess(MODULE, '回读整组锁定事实', lockedQueryResponse,
|
|
evidence(lockedQueryResponse), OPTIONS) || []
|
|
context.assert(MODULE, '整组只锁定到当前计划一次', lockedQueryResponse,
|
|
lockedQuery.length === 2 && lockedQuery.every((item) => item?.group?.status === 'LOCKED'
|
|
&& id(item?.group?.lockedPlanId) === planId),
|
|
`status=${lockedQuery[0]?.group?.status || 'missing'}, lockedPlanId=${id(lockedQuery[0]?.group?.lockedPlanId)}`, OPTIONS)
|
|
|
|
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`, {
|
|
expectedVersion: Number(lockedQuery[0]?.group?.version), reason: '锁定组取消反向验收',
|
|
}, 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)
|
|
|
|
const activeQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query',
|
|
{ orderIds: groupOrders })
|
|
const activeQuery = context.apiSuccess(MODULE, '回读整组解锁事实', activeQueryResponse,
|
|
evidence(activeQueryResponse), OPTIONS) || []
|
|
context.assert(MODULE, '整组移除后恢复ACTIVE且不残留计划锁', activeQueryResponse,
|
|
activeQuery.length === 2 && activeQuery.every((item) => item?.group?.status === 'ACTIVE'
|
|
&& item?.group?.lockedPlanId == null),
|
|
`status=${activeQuery[0]?.group?.status || 'missing'}, lockedPlanId=${id(activeQuery[0]?.group?.lockedPlanId) || 'null'}`, OPTIONS)
|
|
|
|
const recommendResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/recommend`, {
|
|
expectedVersion: Number(plan?.version), operationId: `${context.marker}-coload-recommend`.slice(0, 128),
|
|
})
|
|
plan = context.apiSuccess(MODULE, '智能推荐按超级项处理同柜组', recommendResponse,
|
|
evidence(recommendResponse), OPTIONS)
|
|
const recommendedIds = new Set((plan?.items || []).map((item) => id(item?.inboundId)))
|
|
const recommendedMembers = [first.inboundId, second.inboundId].filter((value) => recommendedIds.has(value))
|
|
context.assert(MODULE, '智能推荐对整组全取或全不取', recommendResponse,
|
|
recommendedMembers.length === 0 || recommendedMembers.length === 2,
|
|
`recommendedMembers=${recommendedMembers.join(',') || 'none'}`, OPTIONS)
|
|
if (recommendedMembers.length === 0) {
|
|
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),
|
|
})
|
|
plan = context.apiSuccess(MODULE, '推荐容量未纳入时整组手工回配', manualGroupResponse,
|
|
evidence(manualGroupResponse), OPTIONS)
|
|
}
|
|
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)
|
|
context.assert(MODULE, '推送后整组仍保持同计划满量事实', pushResponse,
|
|
[first, second].every((fixture) => (pushed?.items || []).some((item) =>
|
|
id(item?.inboundId) === fixture.inboundId && Number(item?.packageCount) === fixture.packages)),
|
|
`planId=${planId}, itemCount=${pushed?.items?.length ?? 'missing'}`, OPTIONS)
|
|
|
|
context.createdResources.coloadGroupAcceptance = {
|
|
marker: context.marker, groupId, groupNo: group?.groupNo || null, planId, bookingId,
|
|
orderIds: groupOrders, inboundIds: [first.inboundId, second.inboundId],
|
|
publicBypassResult: 'individual assignment/adjust/delete rejected before push',
|
|
}
|
|
}
|