提交
This commit is contained in:
@@ -172,6 +172,8 @@ export async function run(context) {
|
||||
|
||||
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
|
||||
|
||||
`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。
|
||||
|
||||
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
|
||||
|
||||
`dropship-physical-carton-chain.mjs` 复用同一条正式链路,专门覆盖多 SKU 共用物理箱:20 箱×2 SKU/2 托、5 箱×3 SKU/1 托、7 箱×2 SKU/预计 2 托后合为 1 托。每个场景从新订单、测量、箱唛冻结和 PDA 清点开始,按去重箱唛核对预装、装柜、尾程计划、海外整托收货、库存候选和一件代发出库;每箱必须保留全部 SKU 明细,但任何汇总不得按 SKU 行重复累计箱数。使用 `--modules dropship-physical-carton-chain`,会保留本轮业务数据和 JSON 报告,不自动清理。
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { run as createOrder } from './orders.mjs'
|
||||
import { run as measure } from './inbound-measurement.mjs'
|
||||
import { run as createCustomer } from './merchant-qualification-fixture.mjs'
|
||||
|
||||
const MODULE = 'dropship-mixed-palletization'
|
||||
const BASE = '/api/platform/v1/first-leg/pda-dropship'
|
||||
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
|
||||
|
||||
// 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。
|
||||
export async function run(context) {
|
||||
const originalMarker = context.marker
|
||||
const savedContext = { ...context.context }
|
||||
const originalManualCount = context.options.prepareDropshipManualCount
|
||||
const records = []
|
||||
const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false }
|
||||
context.createdResources.dropshipMixedPalletization = result
|
||||
function check(name, response, passed, details = '') {
|
||||
context.assert(MODULE, name, response, passed, details, OPTIONS)
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
async function command(name, method, path, body, headers = {}) {
|
||||
const response = await context.request(method, path, body, undefined, headers)
|
||||
const value = context.apiSuccess(MODULE, name, response, '', OPTIONS)
|
||||
if (String(response?.body?.code) !== '200') throw new Error(`${name}: ${response?.body?.code}: ${response?.body?.message}`)
|
||||
return value
|
||||
}
|
||||
async function rejected(name, method, path, body) {
|
||||
const response = await context.request(method, path, body)
|
||||
check(name, response, response.http === 200 && ['A0404', 'A0409'].includes(String(response.body?.code)), response.body?.message)
|
||||
}
|
||||
async function readTask(record) {
|
||||
return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`)
|
||||
}
|
||||
try {
|
||||
check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId))
|
||||
for (const [index, count] of [2, 3, 1].entries()) {
|
||||
const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index]
|
||||
context.marker = `${originalMarker}-${label}`
|
||||
if (index === 1) await createCustomer(context, { publishEnvironment: false })
|
||||
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
|
||||
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
|
||||
context.context.orderDestinationType = 'OVERSEAS'
|
||||
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
|
||||
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
|
||||
context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count],
|
||||
totalPackageCount: count, palletCount: 1, skuCodes: [`MIX-SAME-${originalMarker}`.slice(-60), `MIX-SECOND-${originalMarker}`.slice(-60)] }
|
||||
if (records.length) context.context.waitSplitRoute = { originWarehouseName: records[0].order.receivingWarehouseName,
|
||||
channelCode: records[0].order.channelCode, destinationIndex: 0 }
|
||||
context.options.prepareDropshipManualCount = true
|
||||
const start = context.steps.length
|
||||
await createOrder(context)
|
||||
await measure(context)
|
||||
const source = context.createdResources.dropshipManualCount
|
||||
check(`${label}真实下单审核测量全部通过`, null, Boolean(source?.taskId)
|
||||
&& !context.steps.slice(start).some((step) => !step.passed))
|
||||
records.push({ label, order: { ...context.createdResources.orders }, measurement: { ...context.createdResources.inboundMeasurement }, source: { ...source } })
|
||||
check(`${label}实测物理箱数正确`, null, source.totalCartons === count)
|
||||
}
|
||||
check('两客户三票保持独立归属', null, records.length === 3 && new Set(records.map((r) => r.order.tenantId)).size === 2)
|
||||
check('三票同仓同渠道同目的仓', null, records.every((r) => r.source.warehouseId === records[0].source.warehouseId
|
||||
&& r.order.channelCode === records[0].order.channelCode && r.order.destinationWarehouseId === records[0].order.destinationWarehouseId))
|
||||
context.marker = originalMarker
|
||||
const warehouseId = records[0].source.warehouseId
|
||||
const query = `warehouseId=${warehouseId}`
|
||||
const generated = await command('读取第一票预生成托盘', 'GET', `${BASE}/inbound-orders/${records[0].source.inboundId}/pallets?accountId=${records[0].order.tenantId}`)
|
||||
const palletNo = generated?.[0]?.palletNo
|
||||
check('取得空的预生成DP托盘', null, Boolean(palletNo) && generated[0].status === 'OPEN' && generated[0].cartonCount === 0)
|
||||
result.palletNo = palletNo; result.warehouseId = warehouseId
|
||||
const palletPath = `${BASE}/pallets/resolve?${query}&palletNo=${encodeURIComponent(palletNo)}`
|
||||
const initial = await command('先扫空托盘只读识别', 'GET', palletPath)
|
||||
check('空托盘不会虚构客户组成', null, initial.pallet.cartonCount === 0 && initial.members.length === 0)
|
||||
await rejected('无效托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?${query}&palletNo=DP-000000-0000`)
|
||||
await rejected('跨仓托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?warehouseId=1&palletNo=${encodeURIComponent(palletNo)}`)
|
||||
await rejected('无效箱码被拒绝', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=NO-SUCH-${originalMarker}`)
|
||||
result.stage = 'COUNTING'
|
||||
const counted = []
|
||||
// 交替扫描客户 A/B,证明页面无需先手工切换入仓单。
|
||||
const scans = records.flatMap((record) => Array.from({ length: record.source.totalCartons }, (_, index) => ({ record, boxCode: `${record.source.inboundCode}#${record.source.totalCartons}-${index + 1}` })))
|
||||
scans.sort((a, b) => Number(a.boxCode.split('-').at(-1)) - Number(b.boxCode.split('-').at(-1)))
|
||||
for (const [index, { record, boxCode }] of scans.entries()) {
|
||||
const resolved = await command('跨客户只读扫码预览', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=${encodeURIComponent(boxCode)}`)
|
||||
check('箱码返回原客户和原入仓任务', null, String(resolved.task.taskId) === record.source.taskId
|
||||
&& String(resolved.task.tenantId) === record.order.tenantId && resolved.carton.skuLines.length === 2)
|
||||
const before = await readTask(record)
|
||||
check('读取箱码未改变清点和归托事实', null, before.countedCartons === counted.filter((x) => x.taskId === record.source.taskId).length)
|
||||
if (before.taskStatus === 'PENDING') await command('确认时领取原任务', 'POST', `${BASE}/tasks/${record.source.taskId}/claim`, { warehouseId })
|
||||
const body = { warehouseId, boxCode, palletNo: index === 0 ? null : palletNo,
|
||||
skuLines: resolved.carton.skuLines.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode,
|
||||
actualQuantity: line.expectedQuantity, discrepancyAction: 'MATCH' })), operationId: `${originalMarker}-count-${index}` }
|
||||
const submitted = await command('按原任务确认逐箱实收', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
|
||||
const replayed = await command('清点同键重放不重复入账', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
|
||||
check('重放保持箱身份和清点数量', null, submitted.carton.cartonId === replayed.carton.cartonId && submitted.countedCartons === replayed.countedCartons)
|
||||
counted.push({ taskId: record.source.taskId, boxCode, cartonId: submitted.carton.cartonId, inboundId: record.source.inboundId, skuLines: body.skuLines })
|
||||
if (index === 0) await command('先扫箱再扫托盘批量归托', 'POST', `${BASE}/tasks/${record.source.taskId}/pallets/bind-pending`, { warehouseId, palletNo, operationId: `${originalMarker}-bind` })
|
||||
}
|
||||
const beforeSeal = await command('封托前逐客户汇总核对', 'GET', palletPath)
|
||||
check('六个实物箱两客户三票汇总守恒', null, beforeSeal.pallet.cartonCount === 6 && beforeSeal.members.length === 3
|
||||
&& new Set(beforeSeal.members.map((m) => String(m.customerId))).size === 2
|
||||
&& beforeSeal.members.every((m) => m.cartonCount === records.find((r) => r.source.taskId === String(m.taskId))?.source.totalCartons))
|
||||
const moved = counted.find((item) => item.taskId === records[1].source.taskId)
|
||||
await command('按客户B原任务移回待绑托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-move-out` })
|
||||
const movedTask = await readTask(records[1])
|
||||
check('只改变原票归托数量', null, movedTask.unboundCartons === 1 && movedTask.countedCartons === 3)
|
||||
await command('客户B已点箱归回当前混托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: palletNo, operationId: `${originalMarker}-move-back` })
|
||||
const open = await command('归回后读取权威托盘', 'GET', palletPath)
|
||||
const sealPath = `${BASE}/tasks/${records[0].source.taskId}/pallets/${open.pallet.palletId}/seal`
|
||||
await command('混托整托封托', 'POST', sealPath, { warehouseId, expectedVersion: open.pallet.version, operationId: `${originalMarker}-seal` })
|
||||
let sealed = await command('回查已封托状态', 'GET', palletPath)
|
||||
check('托盘已封托且六箱完整', null, sealed.pallet.status === 'SEALED' && sealed.pallet.cartonCount === 6)
|
||||
for (const record of records) {
|
||||
record.task = await readTask(record)
|
||||
const inbound = await command('封托后回查原票待分柜', 'GET', `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`)
|
||||
check('参与票全部完成且进入待分柜', null, record.task.taskStatus === 'COMPLETED' && inbound.summary.processStatus === 'WAIT_SPLIT')
|
||||
}
|
||||
const actual = sealed.pallet.cartons
|
||||
check('每箱客户归属和双SKU实收未合并', null, actual.length === counted.length && actual.every((carton) => {
|
||||
const source = counted.find((item) => item.boxCode === carton.boxCode)
|
||||
return source && String(carton.inboundId) === source.inboundId && carton.skuLines.length === 2
|
||||
&& carton.skuLines.every((line) => source.skuLines.some((expected) => String(line.inboundDetailId) === String(expected.inboundDetailId) && line.actualQuantity === expected.actualQuantity))
|
||||
}))
|
||||
await rejected('已封托禁止移箱', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-sealed-move` })
|
||||
await command('未入装柜计划允许解除封托', 'POST', `${BASE}/tasks/${records[0].source.taskId}/pallets/${sealed.pallet.palletId}/unseal`, { warehouseId, expectedVersion: sealed.pallet.version, operationId: `${originalMarker}-unseal` })
|
||||
for (const record of records) check('解封同步恢复所有参与任务', null, (await readTask(record)).taskStatus === 'CLAIMED')
|
||||
const reopened = await command('回查解封后开放状态', 'GET', palletPath)
|
||||
await command('核对后重新封托', 'POST', sealPath, { warehouseId, expectedVersion: reopened.pallet.version, operationId: `${originalMarker}-reseal` })
|
||||
sealed = await command('最终托盘事实', 'GET', palletPath)
|
||||
result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted
|
||||
result.stage = 'MIXED_PALLET_SEALED_VERIFIED'
|
||||
for (const record of records) record.task = await readTask(record)
|
||||
} finally {
|
||||
context.marker = originalMarker; context.context = savedContext
|
||||
context.options.prepareDropshipManualCount = originalManualCount
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user