fix(lastleg): complete generated pallet receiving flow
This commit is contained in:
@@ -66,7 +66,7 @@ export async function run(context) {
|
||||
| 费用项目、价格配置与基础 Finance | `fee-items.mjs`、`pricing.mjs`、`finance.mjs` | `fee-items-contract.test.mjs`、`pricing-contract.test.mjs`、`finance-contract.test.mjs` |
|
||||
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
|
||||
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
|
||||
| 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、国内报关证据、出仓及离仓后目的港资料 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs`、`phase3-container-documents.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` |
|
||||
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
|
||||
| 同柜且不可拆组 | `coload-groups.mjs`(可选;前置正式生成至少7票同线路 `WAIT_SPLIT`,另造不同线路反向票;覆盖创建/取消幂等、原子分配/移除、目标组隔离超容拒绝与零漂移、推送防绕过、PDA短拣完成拒绝且无余量子单、足量整组消费、普通单拆分不回归及已有父子单建组拒绝) | `coload-groups-contract.test.mjs` |
|
||||
@@ -89,7 +89,7 @@ export async function run(context) {
|
||||
| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 |
|
||||
| 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed |
|
||||
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(四类柜资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
|
||||
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO、PU)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
|
||||
| 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
|
||||
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 |
|
||||
| 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
@@ -102,9 +102,9 @@ export async function run(context) {
|
||||
|
||||
海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
|
||||
|
||||
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定经过本轮 `LOADED` 柜次的 `DESTINATION_CLEARANCE`、`CUSTOMS_DECLARATION`、`DELIVERY_ORDER`、`PICKUP_ORDER` 四类资料生命周期,再确认国内出仓、核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。四类资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
|
||||
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER`、`PICKUP_ORDER` 三类目的港资料,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
|
||||
|
||||
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再对目标柜全部尾程订单生成人工计划;PDA 卸柜完成后再运行 `phase5-terminal-drayage`,只复用公共 `ACTIVE + TRUCKING` 供应商,依次推进选车队、预约、提柜和还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
|
||||
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。卸柜完成后再运行 `phase5-terminal-drayage`,只复用公共 `ACTIVE + TRUCKING` 供应商,依次推进选车队、预约、提柜和还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
|
||||
|
||||
```powershell
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes
|
||||
|
||||
@@ -22,23 +22,28 @@ test('three generic container documents are frozen and customs stays on customs-
|
||||
|
||||
test('document lifecycle is fail-closed, traceable and covers replay, hash conflict and zero drift', () => {
|
||||
for (const token of [
|
||||
'finalizeSucceeded !== true', "finalStatus !== 'LOADED'", 'deploymentBlocker: true',
|
||||
'finalizeSucceeded !== true', "finalStatus !== 'LOADED'", "outbound?.finalStatus !== 'WAITING_FOR_LOADING'",
|
||||
"outbound?.finalInboundStatus !== 'LOADED'", 'deploymentBlocker: true',
|
||||
'同键同载荷重放', '同键异文件返回A0409', '完成同键重放', '完成同键异载荷返回A0409',
|
||||
'负向请求后持久事实零漂移', 'requestId=', 'traceId=', 'phase3ContainerDocuments',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
})
|
||||
|
||||
test('focused pickup-ready chain orders documents before outbound and candidate visibility', () => {
|
||||
test('focused pickup-ready chain orders destination documents after outbound and before candidate visibility', () => {
|
||||
const modules = normalizeModuleOrder(['phase4-overseas-pda'])
|
||||
const required = [
|
||||
'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs',
|
||||
'phase3-outbound-dispatch', 'phase3-carrier-schedule-queries',
|
||||
'phase3-loading-finalize', 'phase3-customs', 'phase3-outbound-dispatch',
|
||||
'phase3-container-documents', 'phase3-carrier-schedule-queries',
|
||||
'phase4-carrier-pickup-ready', 'phase4-overseas-pda',
|
||||
]
|
||||
for (const module of required) assert.ok(modules.includes(module), module)
|
||||
for (let index = 1; index < required.length; index += 1) {
|
||||
assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index])
|
||||
}
|
||||
assert.ok(modules.indexOf('phase3-loading-finalize') < modules.indexOf('phase3-customs'))
|
||||
assert.ok(modules.indexOf('phase3-customs') < modules.indexOf('phase3-outbound-dispatch'))
|
||||
assert.ok(modules.indexOf('phase3-outbound-dispatch') < modules.indexOf('phase3-container-documents'))
|
||||
assert.ok(modules.indexOf('phase3-outbound-dispatch') < modules.indexOf('phase3-carrier-schedule-queries'))
|
||||
assert.ok(modules.indexOf('phase3-container-documents') < modules.indexOf('phase4-carrier-pickup-ready'))
|
||||
assert.ok(modules.indexOf('phase3-carrier-schedule-queries') < modules.indexOf('phase4-carrier-pickup-ready'))
|
||||
assert.ok(modules.indexOf('phase4-carrier-pickup-ready') < modules.indexOf('phase4-overseas-pda'))
|
||||
assert.match(entry, /phase3-container-documents/)
|
||||
assert.match(entry, /phase3-container-documents-phase4-/)
|
||||
})
|
||||
|
||||
@@ -96,6 +96,7 @@ async function uploadAndVerify(context, { type, uploadPath, readPath, completePa
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3Loading
|
||||
const outbound = context.createdResources?.phase3OutboundDispatch
|
||||
const containerId = id(fixture?.containerBookingId)
|
||||
const planId = id(fixture?.loadingPlanId)
|
||||
const warehouseId = id(fixture?.warehouseId)
|
||||
@@ -106,6 +107,13 @@ export async function run(context) {
|
||||
OPTIONS)
|
||||
return
|
||||
}
|
||||
if (outbound?.marker !== context.marker || id(outbound?.containerBookingId) !== containerId
|
||||
|| outbound?.finalStatus !== 'WAITING_FOR_LOADING' || outbound?.finalInboundStatus !== 'LOADED') {
|
||||
context.addStep(MODULE, '取得本轮已确认国内离仓货柜', null, false,
|
||||
`marker=${outbound?.marker || 'missing'}, status=${outbound?.finalStatus || 'missing'}, inboundStatus=${outbound?.finalInboundStatus || 'missing'}, containerBookingId=${id(outbound?.containerBookingId) || 'missing'}`,
|
||||
OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
const specs = [
|
||||
{
|
||||
|
||||
@@ -125,76 +125,46 @@ async function uploadVersion(context, path, label, expectedVersion, operation, c
|
||||
return { response, document }
|
||||
}
|
||||
|
||||
async function verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }) {
|
||||
async function verifyDomesticCustomsEvidence(context, { containerBookingId, loadingPlanId, warehouseId }) {
|
||||
const reason = `${context.marker}-人工验收节点资料`
|
||||
const simpleDocuments = [
|
||||
{ type: 'DESTINATION_CLEARANCE', base: `/api/platform/v1/containers/${containerBookingId}/clearance`,
|
||||
uploadPath: `/api/platform/v1/containers/${containerBookingId}/clearance/document/upload` },
|
||||
{ type: 'CUSTOMS_DECLARATION', base: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs`,
|
||||
query: `warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
uploadPath: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs/document/upload?warehouseId=${encodeURIComponent(warehouseId)}` },
|
||||
]
|
||||
const completed = []
|
||||
for (const document of simpleDocuments) {
|
||||
const first = await uploadVersion(context, document.uploadPath, `首次上传${document.type}`, 0,
|
||||
`${document.type.toLowerCase()}-v1`, `${context.marker} ${document.type} v1`)
|
||||
if (!first.document?.fileId) return false
|
||||
const readPath = `${document.base}${document.query ? `?${document.query}` : ''}`
|
||||
const firstRead = await context.request('GET', readPath)
|
||||
const firstEcho = context.apiSuccess(MODULE, `回显${document.type}首版`, firstRead, '', OPTIONS)
|
||||
context.assert(MODULE, `${document.type}首版回显身份一致`, firstRead,
|
||||
id(firstEcho?.id) === id(first.document.id) && id(firstEcho?.fileId) === id(first.document.fileId),
|
||||
`documentId=${id(firstEcho?.id) || 'missing'}, version=${firstEcho?.version ?? 'missing'}`, OPTIONS)
|
||||
const firstDownloadPath = document.type === 'DESTINATION_CLEARANCE'
|
||||
? `${document.base}/document/${id(first.document.fileId)}/download`
|
||||
: `${document.base}/document/${id(first.document.fileId)}/download?${document.query}`
|
||||
const firstDownload = await download(context, firstDownloadPath, context.token)
|
||||
context.assert(MODULE, `下载${document.type}首版`, firstDownload,
|
||||
firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'),
|
||||
`HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS)
|
||||
const second = await uploadVersion(context, document.uploadPath, `更新${document.type}为第二版`,
|
||||
Number(firstEcho?.version), `${document.type.toLowerCase()}-v2`, `${context.marker} ${document.type} v2`)
|
||||
context.assert(MODULE, `${document.type}更新保持同一聚合并递增版本`, second.response,
|
||||
id(second.document?.id) === id(first.document.id) && Number(second.document?.version) === Number(firstEcho?.version) + 1,
|
||||
`documentId=${id(second.document?.id) || 'missing'}, version=${second.document?.version ?? 'missing'}`, OPTIONS)
|
||||
const completeResponse = await request(context, 'POST', `${document.base}/complete${document.query ? `?${document.query}` : ''}`, {
|
||||
expectedVersion: Number(second.document?.version), operationReason: reason,
|
||||
}, context.token, { 'Idempotency-Key': key(context, `${document.type.toLowerCase()}-complete`) })
|
||||
const complete = context.apiSuccess(MODULE, `完成${document.type}`, completeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${document.type}完成态、时间和版本可回读`, completeResponse,
|
||||
id(complete?.id) === id(first.document.id) && Boolean(complete?.completedAt)
|
||||
&& Number(complete?.version) === Number(second.document?.version) + 1,
|
||||
`completedAt=${complete?.completedAt || 'missing'}, version=${complete?.version ?? 'missing'}`, OPTIONS)
|
||||
completed.push(complete)
|
||||
}
|
||||
|
||||
for (const type of ['DELIVERY_ORDER', 'PICKUP_ORDER']) {
|
||||
const base = `/api/platform/v1/containers/${containerBookingId}/documents/${type}`
|
||||
const first = await uploadVersion(context, `${base}/upload`, `首次上传${type}`, 0,
|
||||
`${type.toLowerCase()}-v1`, `${context.marker} ${type} v1`)
|
||||
if (!first.document?.fileId) return false
|
||||
const firstDownload = await download(context, `${base}/${id(first.document.fileId)}/download`, context.token)
|
||||
context.assert(MODULE, `下载${type}首版`, firstDownload,
|
||||
firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'),
|
||||
`HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS)
|
||||
const second = await uploadVersion(context, `${base}/upload`, `更新${type}为第二版`,
|
||||
Number(first.document.version), `${type.toLowerCase()}-v2`, `${context.marker} ${type} v2`)
|
||||
const echoResponse = await context.request('GET', base)
|
||||
const echo = context.apiSuccess(MODULE, `回显${type}当前版本`, echoResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${type}首次与更新保持同一聚合且当前版本唯一`, echoResponse,
|
||||
id(echo?.id) === id(first.document.id) && id(echo?.fileId) === id(second.document?.fileId)
|
||||
&& Number(echo?.version) === Number(first.document.version) + 1,
|
||||
`documentId=${id(echo?.id) || 'missing'}, version=${echo?.version ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
const type = 'CUSTOMS_DECLARATION'
|
||||
const query = `warehouseId=${encodeURIComponent(warehouseId)}`
|
||||
const base = `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs`
|
||||
const uploadPath = `${base}/document/upload?${query}`
|
||||
const first = await uploadVersion(context, uploadPath, `首次上传${type}`, 0,
|
||||
`${type.toLowerCase()}-v1`, `${context.marker} ${type} v1`)
|
||||
if (!first.document?.fileId) return false
|
||||
const firstRead = await context.request('GET', `${base}?${query}`)
|
||||
const firstEcho = context.apiSuccess(MODULE, `回显${type}首版`, firstRead, '', OPTIONS)
|
||||
context.assert(MODULE, `${type}首版回显身份一致`, firstRead,
|
||||
id(firstEcho?.id) === id(first.document.id) && id(firstEcho?.fileId) === id(first.document.fileId),
|
||||
`documentId=${id(firstEcho?.id) || 'missing'}, version=${firstEcho?.version ?? 'missing'}`, OPTIONS)
|
||||
const firstDownload = await download(context,
|
||||
`${base}/document/${id(first.document.fileId)}/download?${query}`, context.token)
|
||||
context.assert(MODULE, `下载${type}首版`, firstDownload,
|
||||
firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'),
|
||||
`HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS)
|
||||
const second = await uploadVersion(context, uploadPath, `更新${type}为第二版`,
|
||||
Number(firstEcho?.version), `${type.toLowerCase()}-v2`, `${context.marker} ${type} v2`)
|
||||
context.assert(MODULE, `${type}更新保持同一聚合并递增版本`, second.response,
|
||||
id(second.document?.id) === id(first.document.id) && Number(second.document?.version) === Number(firstEcho?.version) + 1,
|
||||
`documentId=${id(second.document?.id) || 'missing'}, version=${second.document?.version ?? 'missing'}`, OPTIONS)
|
||||
const completeResponse = await request(context, 'POST', `${base}/complete?${query}`, {
|
||||
expectedVersion: Number(second.document?.version), operationReason: reason,
|
||||
}, context.token, { 'Idempotency-Key': key(context, `${type.toLowerCase()}-complete`) })
|
||||
const complete = context.apiSuccess(MODULE, `完成${type}`, completeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${type}完成态、时间和版本可回读`, completeResponse,
|
||||
id(complete?.id) === id(first.document.id) && Boolean(complete?.completedAt)
|
||||
&& Number(complete?.version) === Number(second.document?.version) + 1,
|
||||
`completedAt=${complete?.completedAt || 'missing'}, version=${complete?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const overviewResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}/overview`)
|
||||
const overview = context.apiSuccess(MODULE, '四类资料完成后回查整柜聚合', overviewResponse, '', OPTIONS)
|
||||
const overview = context.apiSuccess(MODULE, '国内报关完成后回查整柜聚合', overviewResponse, '', OPTIONS)
|
||||
const documents = Array.isArray(overview?.documents) ? overview.documents : []
|
||||
const photos = Array.isArray(overview?.pda?.photos) ? overview.pda.photos.filter((item) => item?.status === 'ACTIVE') : []
|
||||
context.assert(MODULE, '整柜聚合回显四类当前资料与真实PDA照片证据', overviewResponse,
|
||||
['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER']
|
||||
.every((type) => documents.some((item) => item?.documentType === type))
|
||||
&& completed.every((item) => documents.some((value) => id(value?.id) === id(item?.id) && value?.completed === true))
|
||||
context.assert(MODULE, '整柜聚合回显国内报关资料与真实PDA照片证据', overviewResponse,
|
||||
documents.some((item) => item?.documentType === type
|
||||
&& id(item?.id) === id(complete?.id) && item?.completed === true)
|
||||
&& photos.length >= 5 && photos.every((item) => /^\/api\/platform\/v1\/containers\/\d+\/pda\/photos\/\d+\/download$/.test(String(item?.downloadPath || ''))),
|
||||
`documents=${documents.map((item) => item?.documentType).join('|') || 'none'}, activePhotos=${photos.length}`, OPTIONS)
|
||||
for (const [index, photo] of photos.entries()) {
|
||||
@@ -505,8 +475,8 @@ export async function run(context) {
|
||||
context.addStep(MODULE, '回执 fileId 不能伪造为客户报关资料引用', forgedReference, failure(forgedReference),
|
||||
`HTTP=${forgedReference.http}, code=${codeOf(forgedReference)}`, OPTIONS)
|
||||
|
||||
if (!(await verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }))) {
|
||||
context.addStep(MODULE, '四类整柜资料必须完成后才允许进入出仓确认', null, false,
|
||||
if (!(await verifyDomesticCustomsEvidence(context, { containerBookingId, loadingPlanId, warehouseId }))) {
|
||||
context.addStep(MODULE, '国内报关资料与PDA证据必须完成后才允许进入出仓确认', null, false,
|
||||
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, warehouseId=${warehouseId}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
+5
-6
@@ -9,19 +9,18 @@ const read = (name) => readFileSync(resolve(here, name), 'utf8')
|
||||
const readFirstlegTest = (name) => readFileSync(resolve(here,
|
||||
'../../firstleg/src/test/java/com/baiyue/firstleg/portschedule/service', name), 'utf8')
|
||||
|
||||
test('current-marker departure batch proves operational documents photos customs and container-level confirm', () => {
|
||||
test('current-marker departure batch separates domestic customs evidence from post-departure destination documents', () => {
|
||||
const customs = read('phase3-customs.mjs')
|
||||
const documents = read('phase3-container-documents.mjs')
|
||||
const outbound = read('phase3-outbound-dispatch.mjs')
|
||||
for (const type of ['DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER']) {
|
||||
assert.match(customs, new RegExp(type))
|
||||
}
|
||||
assert.doesNotMatch(customs, /DESTINATION_CLEARANCE|DELIVERY_ORDER|PICKUP_ORDER/)
|
||||
for (const type of ['DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER']) assert.match(documents, new RegExp(type))
|
||||
for (const evidence of ['上传', '回读', '完成']) assert.match(documents, new RegExp(evidence))
|
||||
assert.match(customs, /整柜聚合回显四类当前资料与真实PDA照片证据/)
|
||||
assert.match(customs, /整柜聚合回显国内报关资料与真实PDA照片证据/)
|
||||
assert.match(customs, /photos\.length >= 5/)
|
||||
assert.match(customs, /\/customs\/loading-plans/)
|
||||
assert.match(customs, /complete-all-buy/)
|
||||
assert.match(customs, /四类整柜资料必须完成后才允许进入出仓确认/)
|
||||
assert.match(customs, /国内报关资料与PDA证据必须完成后才允许进入出仓确认/)
|
||||
assert.match(outbound, /CUSTOMS_CLEARANCE/)
|
||||
assert.match(outbound, /拖车与独立船期仅作聚合参考且不参与出仓门禁/)
|
||||
assert.match(outbound, /\$\{BASE\}\/\$\{encodeURIComponent\(containerBookingId\)\}\/confirm/)
|
||||
|
||||
@@ -56,7 +56,11 @@ test('PDA module covers the container-number receiving contract and excludes out
|
||||
assert.match(source, /overseasWarehouseId, password/)
|
||||
assert.match(source, /inbound\?\.overseasWarehouseId/)
|
||||
assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/)
|
||||
assert.match(source, /目的仓 .* 不得代替物理仓/)
|
||||
assert.match(source, /禁止在PDA收货时临时新建脱离计划的托盘/)
|
||||
assert.match(source, /rememberGeneratedPlans\(context, plans\)/)
|
||||
assert.match(source, /resolveGeneratedPallet\(context, inbound\)/)
|
||||
assert.match(source, /generatedPalletsByInbound/)
|
||||
assert.doesNotMatch(source, /创建当前柜实收托盘|创建当前柜实收库位|async function createPallet/)
|
||||
assert.match(source, /retained as acceptance evidence/)
|
||||
assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/)
|
||||
assert.match(source, /const cartonCandidates =/)
|
||||
@@ -159,7 +163,7 @@ test('PDA receiving lifecycle is ordered from candidate claim through photos, mo
|
||||
const orderedTokens = [
|
||||
"'查询当前客户待收柜号'", "'选择柜号并建立WAIT_SIGN会话'",
|
||||
'uploadPhotos(context, token)', "'绑定车牌封签开柜三角色照片并签收'",
|
||||
"`解析${inbound.destinationType}实物托盘`", 'receiveFba(context, token',
|
||||
'resolveGeneratedPallet(context, inbound)', "`解析${inbound.destinationType}实物托盘`", 'receiveFba(context, token',
|
||||
'receiveOverseas(context, token', "'回查柜内逐入仓码实收进度'",
|
||||
"'完成当前柜并写入库存与卸柜事件'", "'完成后current返回404正常空态'",
|
||||
]
|
||||
|
||||
@@ -163,29 +163,42 @@ export async function disablePicker(context, picker, index) {
|
||||
context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS)
|
||||
}
|
||||
|
||||
async function createPallet(context, inbound, index) {
|
||||
const warehouseId = id(inbound?.overseasWarehouseId)
|
||||
if (!longId(warehouseId)) return null
|
||||
const suffix = markerSuffix(context)
|
||||
const locationCode = `PDA-${suffix}-${index + 1}`.slice(0, 64)
|
||||
const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
|
||||
zoneCode: `PDA-${index + 1}`, locationCode,
|
||||
locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`,
|
||||
})
|
||||
context.addStep(MODULE, `创建当前柜实收库位${index + 1}`, location, success(location), trace(location), OPTIONS)
|
||||
const locationId = id(location?.body?.data)
|
||||
if (!longId(locationId)) return null
|
||||
context.createdResources.phase4OverseasPda.locationIds.push(locationId)
|
||||
const palletCode = `PDA-${suffix}-${index + 1}`.slice(0, 128)
|
||||
const unitPrice = Number((1.01 + index).toFixed(2))
|
||||
const pallet = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice, status: 'IDLE', locationId,
|
||||
remark: `${context.marker}-receiving-retained`,
|
||||
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
|
||||
})
|
||||
context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS)
|
||||
const palletId = id(pallet?.body?.data)
|
||||
return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, unitPrice, retained: true } : null
|
||||
function rememberGeneratedPlans(context, plans) {
|
||||
if (!Array.isArray(plans)) return
|
||||
for (const plan of plans) {
|
||||
const inboundCode = id(plan?.lines?.[0]?.inboundCode)
|
||||
if (!nonBlank(inboundCode)) continue
|
||||
const generated = Array.isArray(plan?.pallets) ? plan.pallets : []
|
||||
context.createdResources.phase4OverseasPda.generatedPalletsByInbound[inboundCode] = generated.map((pallet) => ({
|
||||
warehouseId: id(plan?.overseasWarehouseId),
|
||||
locationId: id(pallet?.warehouseLocationId),
|
||||
palletId: id(pallet?.warehousePalletId),
|
||||
palletCode: id(pallet?.palletNo),
|
||||
unitPrice: Number(pallet?.unitPrice || 0),
|
||||
retained: true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGeneratedPallet(context, inbound) {
|
||||
const candidates = context.createdResources.phase4OverseasPda.generatedPalletsByInbound[inbound.inboundCode] || []
|
||||
const pallet = candidates[0]
|
||||
if (!pallet || !longId(pallet.palletId) || !nonBlank(pallet.palletCode)) return null
|
||||
const warehouseId = id(inbound?.overseasWarehouseId || pallet.warehouseId)
|
||||
const listed = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets?page=1&size=10&keyword=${encodeURIComponent(pallet.palletCode)}`)
|
||||
const stored = records(listed?.body?.data).find((row) => id(row?.id) === pallet.palletId && row?.palletCode === pallet.palletCode)
|
||||
const locations = stored && longId(stored?.locationId)
|
||||
? await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations?page=1&size=200`)
|
||||
: null
|
||||
const location = records(locations?.body?.data).find((row) => id(row?.id) === id(stored?.locationId))
|
||||
const resolved = success(listed) && success(locations) && stored && longId(stored?.locationId) && nonBlank(location?.locationCode)
|
||||
context.addStep(MODULE, `复用入仓码${inbound.inboundCode}自动计划托盘`, listed, resolved,
|
||||
`palletCode=${pallet.palletCode}, palletId=${pallet.palletId}, locationId=${id(stored?.locationId) || 'missing'}, locationCode=${location?.locationCode || 'missing'}; ${trace(listed)}`, OPTIONS)
|
||||
if (!resolved) return null
|
||||
return { ...pallet, warehouseId, locationId: id(stored.locationId), locationCode: location.locationCode,
|
||||
unitPrice: Number(stored.unitPrice || pallet.unitPrice || 0) }
|
||||
}
|
||||
|
||||
async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) {
|
||||
@@ -306,34 +319,59 @@ async function convertReceivingContainers(context, producers) {
|
||||
convertible,
|
||||
`sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS)
|
||||
if (!convertible) return false
|
||||
const body = { sourceContainerBookingId: id(summary.sourceContainerBookingId) }
|
||||
const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128)
|
||||
const convertedResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
|
||||
{ 'Idempotency-Key': key }, context.overseasToken)
|
||||
const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data)
|
||||
? convertedResponse.body.data : []
|
||||
const convertedInboundCodes = new Set(plans
|
||||
.flatMap((item) => Array.isArray(item?.lines) ? item.lines : [])
|
||||
.map((line) => id(line?.inboundCode)).filter(nonBlank))
|
||||
context.addStep(MODULE, `整柜转换为逐入仓码独立计划 ${containerNo}`, convertedResponse,
|
||||
plans.length === inboundCodes
|
||||
&& plans.every((item) => Array.isArray(item?.lines) && item.lines.length > 0)
|
||||
&& convertedInboundCodes.size === inboundCodes,
|
||||
`plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS)
|
||||
if (plans.length !== inboundCodes) return false
|
||||
const replayResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
|
||||
{ 'Idempotency-Key': key }, context.overseasToken)
|
||||
context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse,
|
||||
success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans),
|
||||
trace(replayResponse), OPTIONS)
|
||||
if (!success(replayResponse)) return false
|
||||
const bookingId = id(summary.sourceContainerBookingId)
|
||||
const previewPath = `/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(bookingId)}`
|
||||
const previewResponse = await context.requestOverseas('GET', previewPath,
|
||||
undefined, {}, context.overseasToken)
|
||||
const preview = success(previewResponse) ? previewResponse.body?.data : null
|
||||
context.addStep(MODULE, `读取PDA前柜级计划与自动托盘预览 ${containerNo}`, previewResponse,
|
||||
preview?.ready === true && Number(preview?.totalOrderCount) === totalOrders
|
||||
&& Number(preview?.pendingOrderCount) === pendingPlans,
|
||||
`orders=${preview?.totalOrderCount ?? 'missing'}, pendingOrders=${preview?.pendingOrderCount ?? 'missing'}, pendingPallets=${preview?.pendingPalletCount ?? 'missing'}; ${trace(previewResponse)}`,
|
||||
OPTIONS)
|
||||
if (preview?.ready !== true) return false
|
||||
if (pendingPlans > 0) {
|
||||
const body = { sourceContainerBookingId: bookingId,
|
||||
snapshotVersion: Number(preview.snapshotVersion), snapshotHash: preview.snapshotHash }
|
||||
const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128)
|
||||
const convertedResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
|
||||
{ 'Idempotency-Key': key }, context.overseasToken)
|
||||
const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data)
|
||||
? convertedResponse.body.data : []
|
||||
const convertedInboundCodes = new Set(plans
|
||||
.flatMap((item) => Array.isArray(item?.lines) ? item.lines : [])
|
||||
.map((line) => id(line?.inboundCode)).filter(nonBlank))
|
||||
context.addStep(MODULE, `整柜转换为逐入仓码独立计划及实体托盘 ${containerNo}`, convertedResponse,
|
||||
plans.length === inboundCodes
|
||||
&& plans.every((item) => Array.isArray(item?.lines) && item.lines.length > 0
|
||||
&& Array.isArray(item?.pallets) && item.pallets.length > 0
|
||||
&& item.pallets.every((pallet) => nonBlank(pallet?.palletNo) && longId(id(pallet?.warehousePalletId))))
|
||||
&& convertedInboundCodes.size === inboundCodes,
|
||||
`plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS)
|
||||
if (plans.length !== inboundCodes) return false
|
||||
rememberGeneratedPlans(context, plans)
|
||||
const replayResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
|
||||
{ 'Idempotency-Key': key }, context.overseasToken)
|
||||
context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse,
|
||||
success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans),
|
||||
trace(replayResponse), OPTIONS)
|
||||
if (!success(replayResponse)) return false
|
||||
} else {
|
||||
const retainedPlans = context.createdResources?.phase4TailOrderControls?.generatedPlans
|
||||
rememberGeneratedPlans(context, retainedPlans)
|
||||
context.addStep(MODULE, `复用尾程控制已生成的整柜计划与实体托盘 ${containerNo}`, previewResponse,
|
||||
summary?.allPlansReady === true && existingPlans === totalOrders
|
||||
&& Number(preview?.existingPlannedPalletCount) > 0,
|
||||
`plans=${existingPlans}, plannedPallets=${preview?.existingPlannedPalletCount ?? 'missing'}; ${trace(previewResponse)}`,
|
||||
OPTIONS)
|
||||
}
|
||||
const progressResponse = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`,
|
||||
undefined, {}, context.overseasToken)
|
||||
const progress = success(progressResponse) ? progressResponse.body?.data?.summary : null
|
||||
context.addStep(MODULE, `整柜转换后计划完整度闭环 ${containerNo}`, progressResponse,
|
||||
context.addStep(MODULE, `PDA前整柜计划完整度闭环 ${containerNo}`, progressResponse,
|
||||
progress?.allPlansReady === true && Number(progress?.planCount) === totalOrders
|
||||
&& Number(progress?.pendingPlanCount) === 0,
|
||||
`allPlansReady=${progress?.allPlansReady === true}, plans=${progress?.planCount ?? 'missing'}, pending=${progress?.pendingPlanCount ?? 'missing'}, plannedArrivalAt=${progress?.plannedArrivalAt || 'missing'}; ${trace(progressResponse)}`, OPTIONS)
|
||||
@@ -598,10 +636,10 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
|
||||
if (!success(afterResolve.response) || receivingSnapshot(afterResolve.data) !== beforeResolve) return
|
||||
const pallets = new Map()
|
||||
for (const [index, inbound] of current.data.inbounds.entries()) {
|
||||
const pallet = await createPallet(context, inbound, index)
|
||||
const pallet = await resolveGeneratedPallet(context, inbound)
|
||||
if (!pallet) {
|
||||
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`,
|
||||
`无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource')
|
||||
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}自动计划托盘`,
|
||||
`按柜确认没有为该入仓码留下可解析的实体托盘;禁止在PDA收货时临时新建脱离计划的托盘`, 'warehouse-resource')
|
||||
continue
|
||||
}
|
||||
pallets.set(inbound.inboundCode, pallet)
|
||||
@@ -684,6 +722,7 @@ export async function run(context) {
|
||||
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [],
|
||||
completedContainerFacts: [],
|
||||
overseasWarehouseId: null, overseasWarehouseName: null,
|
||||
generatedPalletsByInbound: {},
|
||||
cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence',
|
||||
}
|
||||
const merchantToken = await loginMerchantForNegativeProbe(context)
|
||||
@@ -727,7 +766,7 @@ export async function run(context) {
|
||||
if (allProducers.length) await awaitProducerConsumption(context, allProducers)
|
||||
if (producers.length && !(await convertReceivingContainers(context, producers))) {
|
||||
blockerGap(context, '实际处理柜整柜转换门禁',
|
||||
'目标柜未能通过正式平台convert-container逐入仓码生成独立计划')
|
||||
'目标柜未能通过正式柜级预览与确认形成逐入仓码计划及实体托盘')
|
||||
return
|
||||
}
|
||||
if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) {
|
||||
|
||||
@@ -26,10 +26,11 @@ test('module covers the public tail-order write surface before PDA receiving', (
|
||||
'暂扣命令同键同载荷重放', '暂扣同键异载荷返回A0409', '暂扣尾程订单不能生成人工计划',
|
||||
'解除本轮单票尾程暂扣', '保存尾程八个人工节点快照', '人工节点同键同载荷重放',
|
||||
'人工节点同键异载荷返回A0409', '人工节点读取固定为MANUAL且不回退柜级节点',
|
||||
'人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', '人工计划同键异载荷返回A0409',
|
||||
'预览整柜待生成入仓计划清单', '按柜确认并自动生成入仓计划与实体托盘',
|
||||
'按柜确认同键同快照幂等重放', '确认后预览待生成票数和托数归零',
|
||||
]) assert.ok(source.includes(evidence), `missing ${evidence}`)
|
||||
assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/)
|
||||
assert.match(source, /creationMode === 'MANUAL'/)
|
||||
assert.match(source, /creationMode === 'AUTO'/)
|
||||
assert.match(source, /requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /palletMergeBatchId/)
|
||||
})
|
||||
@@ -49,11 +50,17 @@ test('module is marker-owned and never borrows historical tail orders', () => {
|
||||
assert.doesNotMatch(source, /records\([^)]*\)\[0\]/)
|
||||
})
|
||||
|
||||
test('manual plans preserve long identifiers and are retained for PDA consumption', () => {
|
||||
test('container conversion preserves long identifiers and generated pallets for PDA consumption', () => {
|
||||
assert.match(source, /orderId: id\(order\.id\)/)
|
||||
assert.doesNotMatch(source, /plannedWarehouse(?:Id|Code|Name):/)
|
||||
assert.match(source, /!order\.overseasWarehouseId/)
|
||||
assert.match(source, /planNos: plans/)
|
||||
assert.match(source, /MANUAL_PLANS_READY/)
|
||||
assert.match(source, /remain as the marker-owned prerequisites consumed by overseas PDA receiving/)
|
||||
assert.match(source, /container-preview/)
|
||||
assert.match(source, /snapshotVersion/)
|
||||
assert.match(source, /snapshotHash/)
|
||||
assert.match(source, /convert-container/)
|
||||
assert.match(source, /自动托盘进入托盘列表并保留FBA目的仓/)
|
||||
assert.match(source, /planNos: generated\.map/)
|
||||
assert.match(source, /generatedPlans: generated/)
|
||||
assert.match(source, /AUTO_CONTAINER_PLANS_READY/)
|
||||
assert.match(source, /remain as marker-owned prerequisites consumed by overseas PDA receiving/)
|
||||
})
|
||||
|
||||
@@ -207,11 +207,95 @@ function completeManualNodes(marker) {
|
||||
})).map((node, index) => index === 3 ? { ...node, nodeAt: null } : node)
|
||||
}
|
||||
|
||||
function previewGroupRank(group) {
|
||||
return ({ FBA: 0, ONE_CLICK_DROPSHIP: 1, PRIVATE_ADDRESS: 2 })[group?.destinationType] ?? 99
|
||||
}
|
||||
|
||||
function previewGroupsSorted(groups) {
|
||||
const rows = Array.isArray(groups) ? groups : []
|
||||
return rows.every((group, index) => {
|
||||
if (index === 0) return true
|
||||
const previous = rows[index - 1]
|
||||
const rankDifference = previewGroupRank(previous) - previewGroupRank(group)
|
||||
return rankDifference < 0 || (rankDifference === 0
|
||||
&& String(previous?.destinationWarehouseCode || '').localeCompare(
|
||||
String(group?.destinationWarehouseCode || '')) <= 0)
|
||||
})
|
||||
}
|
||||
|
||||
async function convertContainerPlans(context, producer, details) {
|
||||
const bookingId = id(producer.containerBookingId)
|
||||
const previewPath = query(`${PLANS}/container-preview`, { sourceContainerBookingId: bookingId })
|
||||
const preview = await api(context, 'GET', '预览整柜待生成入仓计划清单', previewPath)
|
||||
const groups = Array.isArray(preview.data?.groups) ? preview.data.groups : []
|
||||
const previewOrders = groups.flatMap((group) => Array.isArray(group?.orders) ? group.orders : [])
|
||||
const pendingPalletCount = Number(preview.data?.pendingPalletCount)
|
||||
context.assert(MODULE, '柜级预览列清尾程单、入仓码、目的仓、业务类型与建议托数', preview.response,
|
||||
preview.data?.ready === true && id(preview.data?.sourceContainerBookingId) === bookingId
|
||||
&& previewOrders.length === details.length && pendingPalletCount > 0
|
||||
&& previewOrders.every((order) => nonBlank(order?.businessOrderNo) && nonBlank(order?.inboundCode)
|
||||
&& nonBlank(order?.destinationType) && nonBlank(order?.destinationWarehouseCode)
|
||||
&& Number(order?.suggestedPalletCount) > 0 && order?.readiness === 'READY'
|
||||
&& Array.isArray(order?.blockers) && order.blockers.length === 0),
|
||||
`orders=${previewOrders.length}, pendingPallets=${pendingPalletCount}, groups=${groups.length}; ${trace(preview.response)}`,
|
||||
OPTIONS)
|
||||
context.assert(MODULE, '柜级预览按FBA、一件代发、私人地址及目的仓稳定排序', preview.response,
|
||||
previewGroupsSorted(groups), groups.map((group) => `${group.destinationType}:${group.destinationWarehouseCode}`).join(','), OPTIONS)
|
||||
context.assert(MODULE, '一件代发的FBA目的仓快照保留给自动托盘', preview.response,
|
||||
previewOrders.filter((order) => order?.destinationType === 'ONE_CLICK_DROPSHIP'
|
||||
&& nonBlank(order?.destinationWarehouseCode)).every((order) => nonBlank(order?.fbaWarehouseCode)),
|
||||
`dropshipFba=${previewOrders.filter((order) => nonBlank(order?.fbaWarehouseCode)).length}`, OPTIONS)
|
||||
if (!success(preview.response) || preview.data?.ready !== true || pendingPalletCount <= 0) return null
|
||||
|
||||
const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.data.snapshotVersion),
|
||||
snapshotHash: preview.data.snapshotHash }
|
||||
const key = `${context.marker}-container-plan-confirm`.slice(0, 128)
|
||||
const converted = await api(context, 'POST', '按柜确认并自动生成入仓计划与实体托盘',
|
||||
`${PLANS}/convert-container`, body, { 'Idempotency-Key': key })
|
||||
const plans = Array.isArray(converted.data) ? converted.data : []
|
||||
const pallets = plans.flatMap((plan) => Array.isArray(plan?.pallets) ? plan.pallets.map((pallet) => ({ plan, pallet })) : [])
|
||||
context.assert(MODULE, '按柜确认逐票生成独立计划且托盘总数与预览一致', converted.response,
|
||||
plans.length === details.length && pallets.length === pendingPalletCount
|
||||
&& plans.every((plan) => plan?.creationMode === 'AUTO' && nonBlank(plan?.planNo)
|
||||
&& Array.isArray(plan?.lines) && plan.lines.length === 1)
|
||||
&& pallets.every(({ pallet }) => nonBlank(pallet?.palletNo) && id(pallet?.warehousePalletId)
|
||||
&& id(pallet?.warehouseLocationId) && pallet?.assignmentStatus === 'RESERVED'),
|
||||
`plans=${plans.length}, pallets=${pallets.length}, expectedPallets=${pendingPalletCount}; ${trace(converted.response)}`,
|
||||
OPTIONS)
|
||||
if (!success(converted.response)) return null
|
||||
|
||||
for (const { plan, pallet } of pallets) {
|
||||
const line = plan.lines[0]
|
||||
const palletPath = query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, {
|
||||
page: 1, size: 10, keyword: pallet.palletNo,
|
||||
})
|
||||
const listed = await api(context, 'GET', `回查自动托盘${pallet.palletNo}`, palletPath)
|
||||
const stored = records(listed.data).find((row) => row?.palletCode === pallet.palletNo)
|
||||
context.assert(MODULE, '自动托盘进入托盘列表并保留FBA目的仓', listed.response,
|
||||
id(stored?.id) === id(pallet.warehousePalletId) && stored?.status === 'OCCUPIED'
|
||||
&& (!nonBlank(line?.fbaWarehouseCode) || stored?.fbaWarehouseCode === line.fbaWarehouseCode),
|
||||
`pallet=${pallet.palletNo}, expectedFba=${line?.fbaWarehouseCode || 'none'}, actualFba=${stored?.fbaWarehouseCode || 'none'}; ${trace(listed.response)}`,
|
||||
OPTIONS)
|
||||
}
|
||||
|
||||
const replay = await api(context, 'POST', '按柜确认同键同快照幂等重放',
|
||||
`${PLANS}/convert-container`, body, { 'Idempotency-Key': key })
|
||||
context.assert(MODULE, '按柜确认重放不新增计划或托盘', replay.response,
|
||||
JSON.stringify(replay.data) === JSON.stringify(converted.data), trace(replay.response), OPTIONS)
|
||||
const after = await api(context, 'GET', '回查整柜计划与托盘均已生成', previewPath)
|
||||
context.assert(MODULE, '确认后预览待生成票数和托数归零', after.response,
|
||||
Number(after.data?.pendingOrderCount) === 0 && Number(after.data?.pendingPalletCount) === 0
|
||||
&& Number(after.data?.totalPlannedPalletCount) >= pendingPalletCount,
|
||||
`pendingOrders=${after.data?.pendingOrderCount}, pendingPallets=${after.data?.pendingPalletCount}, totalPlannedPallets=${after.data?.totalPlannedPalletCount}; ${trace(after.response)}`,
|
||||
OPTIONS)
|
||||
return plans
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4TailOrderControls = {
|
||||
marker: context.marker, containerNo: null, orderIds: [], planNos: [], representativeOrderId: null,
|
||||
finalStatus: null,
|
||||
cleanup: 'Manual inbound plans remain as the marker-owned prerequisites consumed by overseas PDA receiving',
|
||||
cleanup: 'Automatically generated container inbound plans and pallets remain as marker-owned prerequisites consumed by overseas PDA receiving',
|
||||
}
|
||||
const producer = targetProducer(context)
|
||||
if (!producer?.containerNo || !producer?.tenantId) {
|
||||
@@ -299,38 +383,13 @@ export async function run(context) {
|
||||
nodesRead.data?.manualOverrideEnabled === true && nodesRead.data?.nodes?.length === 8
|
||||
&& nodesRead.data.nodes.every((node) => node?.source === 'MANUAL'), trace(nodesRead.response), OPTIONS)
|
||||
|
||||
const plans = []
|
||||
for (const original of refreshedDetails) {
|
||||
const refreshed = await detail(context, original, `创建人工计划前刷新订单${id(original.id)}版本`)
|
||||
const order = refreshed.data?.order
|
||||
if (!success(refreshed.response) || !order) return
|
||||
const body = manualPlanBody(order, order.version, context.marker)
|
||||
const key = `${context.marker}-manual-plan-${id(order.id)}`.slice(0, 128)
|
||||
const created = await api(context, 'POST', `为本轮尾程订单${id(order.id)}创建人工计划`, `${PLANS}/manual`, body,
|
||||
{ 'Idempotency-Key': key })
|
||||
context.assert(MODULE, '人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', created.response,
|
||||
Boolean(created.data?.planNo) && created.data?.creationMode === 'MANUAL'
|
||||
&& Boolean(created.data?.overseasWarehouseCode) && Boolean(created.data?.overseasWarehouseName)
|
||||
&& created.data?.plannedArrivalDate === body.plannedArrivalDate
|
||||
&& created.data?.palletType === body.palletType && created.data?.urgent === true
|
||||
&& Number(created.data?.palletCount) === body.palletCount
|
||||
&& created.data?.lines?.some((line) => id(line?.orderId) === id(order.id) && line?.inboundCode === order.inboundCode),
|
||||
`orderId=${id(order.id)}, planNo=${created.data?.planNo || 'missing'}; ${trace(created.response)}`, OPTIONS)
|
||||
if (!success(created.response) || !created.data?.planNo) return
|
||||
const replay = await api(context, 'POST', `人工计划${created.data.planNo}幂等重放`, `${PLANS}/manual`, body,
|
||||
{ 'Idempotency-Key': key })
|
||||
context.assert(MODULE, '人工计划重放复用同一计划且零新增', replay.response,
|
||||
replay.data?.planNo === created.data.planNo, trace(replay.response), OPTIONS)
|
||||
if (plans.length === 0) {
|
||||
const hashConflict = await context.requestOverseas('POST', `${PLANS}/manual`,
|
||||
{ ...body, remark: `${context.marker}-DIFFERENT` }, { 'Idempotency-Key': key }, context.overseasToken)
|
||||
context.addStep(MODULE, '人工计划同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS)
|
||||
}
|
||||
plans.push(created.data.planNo)
|
||||
}
|
||||
const generated = await convertContainerPlans(context, producer, refreshedDetails)
|
||||
if (!generated) return
|
||||
context.createdResources.phase4TailOrderControls = {
|
||||
...context.createdResources.phase4TailOrderControls,
|
||||
containerNo: producer.containerNo, orderIds: refreshedDetails.map((order) => id(order.id)),
|
||||
planNos: plans, representativeOrderId: id(representative.id), finalStatus: 'MANUAL_PLANS_READY',
|
||||
planNos: generated.map((plan) => plan.planNo), representativeOrderId: id(representative.id),
|
||||
generatedPlans: generated,
|
||||
finalStatus: 'AUTO_CONTAINER_PLANS_READY',
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user