From 139f6b19325aa16c2d66ee9ead02438cc2674ca3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 23 Oct 2026 04:48:02 +0800 Subject: [PATCH] test(acceptance): reuse fixed customers and align native fulfillment flows --- scripts/README.md | 4 + scripts/admin-test-env-acceptance.mjs | 98 +++++++++++++++---- scripts/admin-test-env-modules/README.md | 20 +++- ...t-departure-prerequisite-contract.test.mjs | 43 ++++++++ ...ient-relabel-fulfillment-contract.test.mjs | 33 +++++++ .../client-relabel-fulfillment.mjs | 22 ++++- .../current-boxed-inventory-contract.test.mjs | 43 ++++++++ .../current-boxed-inventory.mjs | 49 ++++++++++ .../finance-direct-paid-isolation.mjs | 66 +++++++------ ...container-fee-allocation-contract.test.mjs | 2 +- .../firstleg-container-fee-allocation.mjs | 10 +- ...t-cross-customer-fixture-contract.test.mjs | 9 ++ .../fleet-trip-fulfillment-contract.test.mjs | 35 +++++++ .../fleet-trip-fulfillment.mjs | 35 ++++++- .../looseboard-fulfillment.mjs | 13 ++- .../looseboard-pool-binding-contract.test.mjs | 17 +++- .../looseboard-pool-binding.mjs | 5 +- ...seboard-pool-preparation-contract.test.mjs | 15 +++ .../looseboard-pool-preparation.mjs | 2 +- .../looseboard-pool-receipt-contract.test.mjs | 47 +++++++-- .../looseboard-pool-receipt.mjs | 32 +++++- ...nt-qualification-fixture-contract.test.mjs | 41 +++++++- .../merchant-qualification-fixture.mjs | 17 +++- .../orders-pricing-fixture-contract.test.mjs | 10 +- scripts/admin-test-env-modules/orders.mjs | 11 ++- ...e-cross-service-recovery-contract.test.mjs | 12 ++- .../phase3-container-customs-fixture.mjs | 6 +- ...-phase4-producer-fixture-contract.test.mjs | 27 ++++- ...phase4-overseas-business-contract.test.mjs | 12 +++ ...overseas-client-services-contract.test.mjs | 58 +++++++++-- .../phase4-overseas-client-services.mjs | 94 ++++++++++++------ ...rseas-inbound-operations-contract.test.mjs | 4 +- .../phase4-overseas-inbound-operations.mjs | 61 +++++++++++- ...-overseas-pda-operations-contract.test.mjs | 10 +- .../phase4-overseas-pda-operations.mjs | 46 ++++----- .../phase4-overseas-return-followups.mjs | 27 ++++- ...-overseas-return-receipt-contract.test.mjs | 22 ++++- .../phase5-root-fulfillment-contract.test.mjs | 23 ++++- .../phase5-root-fulfillment.mjs | 27 ++++- ...ement-sample-fulfillment-contract.test.mjs | 11 +++ .../procurement-sample-fulfillment.mjs | 7 +- .../us-truck-fulfillment.mjs | 9 +- .../warehouse-transfer-allocation.mjs | 1 + ...ehouse-transfer-outbound-contract.test.mjs | 4 +- .../warehouse-transfer-outbound.mjs | 4 +- ...rehouse-transfer-receipt-contract.test.mjs | 19 +++- .../warehouse-transfer-receipt.mjs | 60 +++++++----- ...-test-env-report-summary-contract.test.mjs | 23 ++++- scripts/event-delivery-fault-proxy.py | 20 +++- scripts/event-delivery-fault-proxy.test.mjs | 36 ++++++- scripts/test-env-business-reset-policy.json | 2 +- 51 files changed, 1076 insertions(+), 228 deletions(-) create mode 100644 scripts/admin-test-env-modules/appointment-departure-prerequisite-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/current-boxed-inventory-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/current-boxed-inventory.mjs diff --git a/scripts/README.md b/scripts/README.md index 8b9eed3e2..6d8f5b8bb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,9 @@ # 后端脚本管理与全量 HTTP 验收索引 +Admin 全量可显式传 `--stop-file <本轮唯一停止文件>`。运行前文件必须不存在;需要中止时创建该文件,脚本等待当前模块或前置准备到达边界,退出后续模块,仍执行入口的故障配置恢复与健康核验并保存失败报告。中止结果不计为全量通过。不要强杀含故障注入的进程;同批次不得覆盖重跑,后继运行改用新报告及停止文件。 + +装柜事件 ACK 丢失代理保留同一 Lastleg 服务上的两类只读请求:`POST /internal/lastleg/v1/order-workflow-facts` 和 `GET /internal/lastleg/appointment-work/containers/{id}/route`。它们按原正文和服务身份透传,不进入事件选取、故障计数或事件证据;未列出的路径和方法继续拒绝,避免故障验收阻断正常尾程查询。对应离线行为由 `event-delivery-fault-proxy.test.mjs` 验证。 + `procurement-sample-test-env.mjs` 复用固定客户后只读取钱包,不再创建充值流水;执行前要求现有可用余额至少 3 元(货款 1 元加运费冻结 2 元),不足或余额字段异常即停止。仍会创建本轮采购业务与附件,不复用历史完成单据。 `dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及服务端幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据、固定客户、`ADMIN_TEST_OVERSEAS_PDA_ACTORS` 和 `ADMIN_TEST_FLEET_RESOURCES`;不自动创建账号或车辆司机主档。可加 `--stop-after-draft` 准备浏览器验收样本:新建两托及正式预约后停在零已拣草稿,不分配车辆、不送审、不拣货;报告标记 `DRAFT_FIXTURE_ONLY`,不代表完整链路通过,不与 `--resume-report` 混用。报告位于 `reports/dispatch-loading-/`,保留失败前的请求和业务数据;同标识拒绝重跑。`--resume-report <原报告>` 仅接受准备写入前失败或全部准备写入均收到明确拒绝的失败报告,回查原单仍为零拣货草稿后重新准备车辆、三类审核照片与审核,不重复建货或建单;未知或成功写入不能使用此恢复入口。固定PDA账号仅注销会话,不停用;`--keep-pda-for-ui` 在成功后将账号交接信息保存到系统临时目录,页面验收使用私有配置中的既有凭据重新登录,不能将已注销Token当有效会话。所有 HTTP 会话结束时注销,不执行 SQL、删除、重置或部署。新增入口验证运行语法检查和 `script-documentation-contract.test.mjs`,经 `local-check.ps1`。 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index c404e3d8d..d7995a53b 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -442,6 +442,7 @@ export function parseArguments(argv) { pdaPassword: values.get('pda-password') || envValue('ADMIN_PDA_PASSWORD', 'ADMIN_GRAY_PDA_PASSWORD'), modules, requestedModules, + stopFile: values.get('stop-file') || '', fixedCustomerId: environment === 'test' ? process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || '' : '', customerName: values.get('customer-name') || envValue('ADMIN_TEST_CUSTOMER_NAME', 'ADMIN_GRAY_CUSTOMER_NAME'), merchantUsername, @@ -655,6 +656,10 @@ export class AcceptanceRun { this.options.fixedPdaActors ??= JSON.parse(process.env.ADMIN_TEST_OVERSEAS_PDA_ACTORS || '[]') this.options.fixedDomesticDenyActor ??= JSON.parse(process.env.ADMIN_TEST_DOMESTIC_DENY_ACTOR || 'null') this.options.fixedFleetResources ??= JSON.parse(process.env.ADMIN_TEST_FLEET_RESOURCES || '[]') + if (!this.options.fixedSecondaryCustomer && process.env.ADMIN_TEST_SECONDARY_CUSTOMER_ID) { + this.options.fixedSecondaryCustomer = { customerId: process.env.ADMIN_TEST_SECONDARY_CUSTOMER_ID, + username: process.env.ADMIN_TEST_SECONDARY_MERCHANT_USERNAME || '', password: process.env.ADMIN_TEST_SECONDARY_MERCHANT_PASSWORD || '' } + } } const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12) this.marker = `${options.environment === 'gray' ? 'GRAY' : 'ADMIN'}-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}` @@ -847,6 +852,19 @@ export class AcceptanceRun { this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal }) } + /** 在模块边界结束,随后仍由main的finally恢复故障注入配置并写出失败报告。 */ + async stopAtBoundary() { + if (this.stopRequested) return true + if (!this.options.stopFile) return false + try { await readFile(this.options.stopFile) } + catch (error) { if (error.code === 'ENOENT') return false; throw error } + this.stopRequested = true + this.fatal = '收到停止文件,未继续执行后续模块;本报告为中止结果' + this.addStep('execution-control', '停止后续模块并进入正常清理', null, false, + '等待当前模块边界,不强杀进程;故障配置清理由总入口finally执行', { deploymentBlocker: true }) + return true + } + captureWaitSplitFixture(sequence) { const order = this.createdResources.orders || {} const measurement = this.createdResources.inboundMeasurement || {} @@ -1424,8 +1442,9 @@ export class AcceptanceRun { const hadWaitSplitVariant = Object.hasOwn(this.context, 'waitSplitVariant') const savedWaitSplitVariant = this.context.waitSplitVariant const primaryDropshipVariant = { - ...(savedWaitSplitVariant || {}), skuCount: 1, packageCountPerSku: 4, - packageCounts: [4], palletCount: 2, + // 三箱PDA换标、一箱客户换标、一箱转运,共用同一订单,不另造订单或主档。 + ...(savedWaitSplitVariant || {}), skuCount: 1, packageCountPerSku: 5, + packageCounts: [5], palletCount: 2, } if (primaryDropship) { this.context.waitSplitVariant = primaryDropshipVariant @@ -1554,6 +1573,7 @@ export class AcceptanceRun { } async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD', pickupRequired = false) { + const shippingRequired = pickupRequired || label === 'SECONDARY' const matrixMarker = this.marker const attempt = (this.phase4ProducerAttempts.get(label) || 0) + 1 this.phase4ProducerAttempts.set(label, attempt) @@ -1579,22 +1599,41 @@ export class AcceptanceRun { this.context.orderDestinationType = destinationType this.context.orderFulfillmentType = fulfillmentType this.context.phase4ProducerMatrixMarker = matrixMarker - if (destinationType === 'FBA' && label === 'FBA-SECOND') { - const existingFba = (this.createdResources.phase4InboundProducerFixtures?.containers || []) - .filter(item => item?.destinationType === 'FBA' - && item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') - const primaryCode = existingFba.map(item => String(item?.destinationWarehouseCode || '').trim().toUpperCase()).find(Boolean) - this.context.waitSplitRoute = { - ...(this.context.waitSplitRoute || {}), - // The normal order route rotates the destination list from randomIndex; - // force this producer onto the next distinct priced FBA warehouse so - // dispatch can exercise a multi-destination candidate set. - randomIndex: 17, - destinationIndex: 1, - excludeDestinationCodes: primaryCode ? [primaryCode] : [], - } - } try { + if (label === 'DROPSHIP-PRIMARY' && this.options.fixedCustomerId + && this.options.modules.includes('phase4-overseas-client-services')) { + // 同一五箱订单还供应转运收货;造单前按既有异仓资源选线,避免出库后才发现无目标托盘。 + const { findReusableTransferReceiptTarget } = await import(new URL('./admin-test-env-modules/warehouse-transfer-receipt.mjs', import.meta.url)) + const target = await findReusableTransferReceiptTarget({ + sourceWarehouseId: String(this.options.fixedPdaActors?.[0]?.warehouseId || ''), + request: async (method, path) => { + const response = await this.request(method, path) + const data = this.apiSuccess('phase4-overseas-pda-fixture', '转运造单前核验既有目标资源', response) + if (response.http !== 200 || String(response.body?.code) !== '200') throw Error('转运目标资源查询失败') + return data + }, + check: (name, ok) => { if (!ok) throw Error(name) }, + }) + if (!target) throw Error('缺少转运收货既有异仓空闲托盘;未创建来源订单') + this.createdResources.phase4TransferReceiptTargetPlan = target + this.context.waitSplitRoute = { ...(this.context.waitSplitRoute || {}), + includeDestinationCodes: [target.pallet.fbaWarehouseCode] } + } + if (destinationType === 'FBA' && label === 'FBA-SECOND') { + const existingFba = (this.createdResources.phase4InboundProducerFixtures?.containers || []) + .filter(item => item?.destinationType === 'FBA' + && item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') + const primaryCode = existingFba.map(item => String(item?.destinationWarehouseCode || '').trim().toUpperCase()).find(Boolean) + this.context.waitSplitRoute = { + ...(this.context.waitSplitRoute || {}), + // The normal order route rotates the destination list from randomIndex; + // force this producer onto the next distinct priced FBA warehouse so + // dispatch can exercise a multi-destination candidate set. + randomIndex: 17, + destinationIndex: 1, + excludeDestinationCodes: primaryCode ? [primaryCode] : [], + } + } // Cross-region variants only need a normal completed loading chain. The destructive A0 // inventory-snapshot mutation is covered once by an explicitly requested phase3-loading // run; repeating it for every producer is unsafe for a populated shared warehouse and can @@ -1624,12 +1663,12 @@ export class AcceptanceRun { } } if (producerKey) writeShippingEvidence(false) - if (pickupRequired && !producerKey) { + if (shippingRequired && !producerKey) { this.assert('phase4-overseas-pda-fixture', `${label}产生者身份完整`, null, false, 'phase3LoadingCompletedProducer.resourceKey=missing', { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) } - if (pickupRequired && producerKey) { + if (shippingRequired && producerKey) { const customs = await import(new URL('./admin-test-env-modules/phase3-customs.mjs', import.meta.url)) await this.executeModule(`phase3-customs-phase4-${label}`, () => customs.run(this)) const serviceCenter = await import(new URL('./admin-test-env-modules/service-center-flow.mjs', import.meta.url)) @@ -1642,6 +1681,15 @@ export class AcceptanceRun { await this.executeModule(`phase3-container-documents-phase4-${label}`, () => documents.run(this)) const carrierQueries = await import(new URL('./admin-test-env-modules/phase3-carrier-schedule-queries.mjs', import.meta.url)) await this.executeModule(`phase3-carrier-schedule-queries-phase4-${label}`, () => carrierQueries.run(this)) + if (!pickupRequired) { + // 第二柜先真实出仓并建立原生船期任务,保留不可接柜状态供PDA拒绝用例;Admin预约前再推进开船。 + const source = this.createdResources.phase3CarrierScheduleQuery + const ready = source?.ownedByModule === true && source.marker === variantMarker && Boolean(source.queryId) + writeShippingEvidence(false, ready ? String(source.queryId) : null) + this.assert('phase4-overseas-pda-fixture', `${label}已真实出仓但尚未开放接柜`, null, ready, + `resourceKey=${producerKey}, queryPresent=${Boolean(source?.queryId)}`, + { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } else { const carrierNodes = await import(new URL('./admin-test-env-modules/phase4-carrier-nodes.mjs', import.meta.url)) await this.executeModule(`phase4-carrier-pickup-ready-phase4-${label}`, () => carrierNodes.runUntilPickupReady(this)) @@ -1654,6 +1702,7 @@ export class AcceptanceRun { shippingReady, `resourceKey=${producerKey}, queryId=${pickup?.queryId || 'missing'}, ready=${pickup?.ready === true}`, { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } } } finally { if (restoreFixture) restoreFixture() @@ -1728,7 +1777,7 @@ export class AcceptanceRun { && overseasReady?.shippingReady === true && Boolean(overseasReady?.carrierScheduleQueryId) && awaitingVariants.length >= 1 && awaitingVariants.every((item) => item.shippingReady === false - && item.carrierScheduleQueryId == null) + && Boolean(item.carrierScheduleQueryId)) this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null, matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true && shippingMatrixReady, `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}, shippingReady=${containers.filter((item) => item?.shippingReady === true).length}, awaiting=${awaitingVariants.length}`, @@ -1876,6 +1925,7 @@ export class AcceptanceRun { this.expiresAt = Number(login.expiresIn) > 0 ? Date.now() + Number(login.expiresIn) * 1000 : 0 const handlers = { customers: () => this.runCustomers() } for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) { + if (await this.stopAtBoundary()) break const module = this.options.modules[moduleIndex] if (this.expiresAt > 0 && Date.now() + 120_000 >= this.expiresAt) { await this.refreshSession(`模块 ${module} 执行前Token临近到期`) @@ -1938,6 +1988,10 @@ export class AcceptanceRun { reason, { scenario: 'phase3-container-customs-mixed-fixture', deploymentBlocker: true }) } } + if (await this.stopAtBoundary()) { + if (restoreFixture) restoreFixture() + break + } let handler = handlers[module] if (!handler) { try { @@ -1957,6 +2011,7 @@ export class AcceptanceRun { } finally { if (restoreFixture) restoreFixture() } + if (await this.stopAtBoundary()) break if (module === 'merchant-qualification-fixture' && this.options.fixedCustomerId && !this.context.merchantQualificationFixture?.reused) { this.fatal = '固定客户身份校验失败,停止后续造数' @@ -1991,7 +2046,7 @@ export class AcceptanceRun { deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, }) - const forbiddenValues = [this.options.password, this.options.merchantPassword, + const forbiddenValues = [this.options.password, this.options.merchantPassword, this.options.fixedSecondaryCustomer?.password, this.token, this.refreshToken] .filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ @@ -2015,6 +2070,7 @@ export class AcceptanceRun { plannedModules: [...this.options.modules], defaultModules: [...DEFAULT_MODULES], defaultPlanSelected: DEFAULT_MODULES.every((name) => this.options.modules.includes(name)), + stoppedByRequest: this.stopRequested === true, preparationOnly: this.options.prepareDomesticPdaH5 === true || this.options.prepareDropshipManualCount === true || this.options.prepareMeasurementReview === true, returnMainChainOnly: this.options.returnMainChainOnly === true, evidenceType: 'HTTP', diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index b0b86691f..af0ac8201 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,20 +1,32 @@ # Admin 测试环境 HTTP 模块 +`current-boxed-inventory.mjs` 是 PDA 与客户换标/转运共用的内部助手:仅读取本轮已收货入仓码,交叉核对正式箱候选、SKU 件数和箱所在托盘位置,不拼接 `CM-` 箱号或推算件数。共用的一件代发来源为五箱、两托,同一订单分别供应三箱 PDA 换标、一箱客户换标和一箱转运;废弃按正式候选提交具体 `cartonIds`。对应 `current-boxed-inventory-contract.test.mjs`。 + +预约取消保留原批次的逐票货物分配,脚本通过原预约更新接口恢复 PENDING,再核对分批与合并;取消不应被当成释放实物或另造新批次。客户后续服务完整读取尾程分页,包含一件代发来源;F40 排车候选同时限定本轮已转换计划和 RECEIVED 阶段,不以全仓默认二十条列表证明完整性。 + +第二柜先走原生报关、出仓及船期任务,保持尚不可接柜以供 PDA 拒绝用例;Admin 预约前由 `ensureAppointmentDeparture` 推进该柜实际船节点至开船并等待尾程消费,不用订单人工覆盖或 SQL 绕过货物核实门槛。对应 `appointment-departure-prerequisite-contract.test.mjs`。固定车辆司机选择前读取完整车次分页,已完成但未释放的资源仍视为占用;F40 来源核验读取完整分页后才证明唯一归属。 + 样品采购履约每次聚合回查均核对原客户、原采购单号和有效版本;创建重放及客户最终详情同样校验身份,串单立即停止后续写入。`procurement-sample-fulfillment-contract.test.mjs` 离线走完整个分次收货、双箱打包、两段交寄及 1 元货款、2 元运费冻结、1.5 元实际运费结算链,另以串单和重放重复扣款验证失败路径;这不代表真实钱包或部署已验收。 +货物废弃的库存列表按客户、仓、目的仓和 SKU 聚合,不带入仓码;脚本按该 SKU 的代表余额 ID 回查原始详情,并要求客户、仓、入仓码及全部数量桶与聚合值一致,不能给聚合行补造来源,也不能让混合来源汇总冒充单一余额。散板余位、询价和实收的 Long 版本接受服务端规范十进制编码,仍拒绝空值、负值、前导零和不安全整数。 + +散板来货沿当前 PDA 接口创建空托盘草稿,生成并绑定业务托盘后,读取原预报箱号与 SKU,通过逐箱清点保存显式 TEST 尺寸、重量和件数,再打托及运营实收确认。两托三箱的受控输入逐箱登记,不把托盘草稿接口当作测量接口,也不从整托总数猜测箱规;这些合成材料不代表真机测量或照片效果验收。 + 货物废弃在同一申请上传照片后,先重复提交同一照片 ID,要求 409/A0409、诊断标识齐全,且申请与库存快照不变,才继续正常 PDA 确认。实际废弃总账必须具有有效流水 ID,匹配原客户、仓库、托盘、SKU 和入仓码,扣减前后数量差为一箱;独立箱级流水仍不能用公共库存总账替代。对应离线反例位于 `cargo-discard-fulfillment-contract.test.mjs`,不额外创建客户、申请或照片。 本土卡车履约每次请求前后保存检查点:总编排提供 `checkpoint` 时沿用其证据持久化,否则独占创建 `reports/us-truck-checkpoints/.json`。同批次文件已存在即拒绝重跑,不覆盖旧证据;落盘失败不发送请求,发送后未知结果停止后续命令。记录原请求体、拒绝预期、诊断和最终状态,不记录会话凭据。对应磁盘失败、未知响应及新执行覆盖拒绝均有离线合同。 本土卡车默认履约在正式实收和分配前,各用原单增加一条无效命令:逐托箱数超过请求总箱数、重复选同一托盘。必须明确返回参数错误并带诊断标识,随后回查原单数量、状态、历史和版本完全不变,才继续正常链。两托原单仅分配首托时,还尝试进入待派车,必须返回 409/A0409 状态冲突并保留诊断标识,整个原单回查不变后才继续分配第二托;未知结果、无关拒绝或意外接受均停止。此边界不增加客户或业务单据,不替代超托审核。 -客户原贴标签收并释放车次资源后,默认 phase5 的 `verifyClientRelabelSettlement` 有限轮询原申请,核对来源单、仓库、原测试费用、客户确认及 `SETTLED`;串单、金额变化、错误状态或超时均失败,证据随原 `relabelFulfillment.settlement` 进入 phase5 检查点,不发送补结算命令。合同位于 `phase5-overseas-fulfillment-contract.test.mjs`。转运链存在源码待回归风险:PDA 完工调用 `settlePdaCompletion`,而 `completeWarehouseTransferFulfillment` 当前只接受 `FROZEN/NOT_REQUIRED`;脚本保留正式收货及结算断言,不能将离线替身通过视为此服务端时序已通过真实验收。 +客户原贴标签收并释放车次资源后,默认 phase5 的 `verifyClientRelabelSettlement` 有限轮询原申请,核对来源单、仓库、原测试费用、客户确认及 `SETTLED`;串单、金额变化、错误状态或超时均失败,证据随原 `relabelFulfillment.settlement` 进入 phase5 检查点,不发送补结算命令。合同位于 `phase5-overseas-fulfillment-contract.test.mjs`。转运关联换标的源码收货门槛接受 `FROZEN/NOT_REQUIRED/SETTLE_PENDING/SETTLED`,与 PDA 完工发起结算的时序衔接,结算中或已结算不重复发送费用与扣款命令;脚本仍保留正式收货及最终结算断言,不能将离线替身通过视为此服务端时序已通过真实验收。 `warehouse-transfer-receipt.mjs` 在默认原转运出库后复用异仓既有公共空闲托盘,要求目的 FBA 一致、主档启用及库位可预留;不足时失败,不创建或修改公共资源。按原出库仓位正式收货,核对完成命令的权威目标、目标库存位置、唯一新 SKU 入库一箱流水、原键重放及客户详情,再有限轮询关联换标 `COMPLETED/SETTLED`。逐请求保存 `reports/warehouse-transfer-receipt-checkpoints/.json`;未知结果停写,不覆盖同批次,不直接修改资金状态。离线合同为 `warehouse-transfer-receipt-contract.test.mjs`;只补正常已有库存转运链,在途、多来源、部分收货与异常矩阵仍未全覆盖,真实 HTTP 待运行。 `warehouse-transfer-outbound.mjs` 由默认客户业务链在原转运关联换标确认后调用,仅接受登记的新 SKU 实托预留。出库前核对原单和零 SHIP 流水;出库后独立核对同客户、同仓、原入仓码、新 SKU、实际托盘号的唯一扣一箱流水,原键重放后单据及流水均不变,客户回查一致且关联换标同步 `OUTBOUNDED`。不另建转运单或客户;未知结果停写,证据保存于 `reports/warehouse-transfer-outbound-checkpoints/.json`,同批次禁止覆盖重跑。离线合同为 `warehouse-transfer-outbound-contract.test.mjs`;收货及关联换标结算由 warehouse-transfer-receipt.mjs 继续核验,尚未运行真实 HTTP。 -已有库存转运的关联换标已复用 `client-relabel-fulfillment.mjs` 接入默认客户业务链:只接受原分配登记的申请、客户和仓位,复用同仓 PDA,审核、冻结、扫码、组托、完工及客户确认后,要求换标为 `WAIT_TRANSFER_OUTBOUND`,原转运为 `ALLOCATED`,唯一有效预留属于实际新托盘、新 SKU 和原入仓码。检查点独立保存 `reports/transfer-relabel-checkpoints/.json`,包含原转运及关联换标证据。普通贴标按 `TAIL_ORDER` 从尾程订单审核,关联换标按 `RELABEL_ORDER` 从换标单审核;错渠道停止。转运出库已由下述助手衔接;收货和关联换标结算已由收货助手衔接,真实 HTTP 未执行。 +固定客户全量在 DROPSHIP 来源造单前先只读筛选其他海外仓既有公共空闲托盘、目的 FBA、可预留库位和启用主档,再把来源线路限制到该目的仓并核验真实报价;收货前重新核对资源,不能把预检当预留,也不改写公共托盘目的仓或补造主档。 + +已有库存转运的关联换标已复用 `client-relabel-fulfillment.mjs` 接入默认客户业务链:只接受原分配登记的申请、客户和仓位,复用同仓 PDA,审核、冻结、扫码、组托、完工及客户确认后,要求换标为 `WAIT_TRANSFER_OUTBOUND`,原转运为 `ALLOCATED`,唯一有效预留属于实际新托盘、新 SKU 和原入仓码。拆合托后按当前库存版本查询源、目标非空托盘的尺寸重量;箱级事实不完整时阻断,不以整托输入绕过。无箱级实测的托盘通过正式 PDA 接口提交报告中明确标记的受控 TEST 尺寸重量,属于合成测试输入,不代表硬件测量。幂等键包含当前换标申请号,避免同一操作员执行客户贴标和转运贴标时串单。检查点独立保存 `reports/transfer-relabel-checkpoints/.json`,包含原转运及关联换标证据。普通贴标按 `TAIL_ORDER` 从尾程订单审核,关联换标按 `RELABEL_ORDER` 从换标单审核;错渠道停止。转运出库已由下述助手衔接;收货和关联换标结算已由收货助手衔接,真实 HTTP 未执行。 `warehouse-transfer-allocation.mjs` 由默认 `phase4-overseas-client-services.mjs` 在本轮已有库存转运审核通过后调用。仅分配原入仓单、同 SKU、同客户同仓的真实可用仓位,核对关联换标申请唯一、同键重放及客户详情一致,达到 `WAIT_RELABEL`。不另建客户或替代转运单;请求前后保存 `reports/warehouse-transfer-checkpoints/.json`,未知结果停写,同批次禁止覆盖重跑。离线合同为 `warehouse-transfer-allocation-contract.test.mjs`;关联换标完工与转运出库已衔接,收货已由后续助手衔接,尚未运行真实 HTTP。 @@ -192,7 +204,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖 `phase4-overseas-return-followups.mjs` 已接入默认全量,在退货签收合箱后执行换标、客户确认及普通/换标后出库;显式只选签收模块仍不运行后续。前置必须是同批次、同客户、主链断言通过的退货资源,仓库严格沿用实际退货收货仓并核对当前启用状态,不回退其他仓。固定客户模式保留跨客户、跨仓和并发边界缺口,不创建第二客户;后续复用既有 PDA 池,两个模块均退出本轮客户会话。写请求网络结果未知不自动重试。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;当前只补脚本,真实 HTTP 及页面仍待执行。 -退货后续模块对普通退货出库及换标后退货出库统一核对最新 PDA 扫描位置、逐 SKU 总量/可用量/箱数扣减、唯一负 OUTBOUND 流水及历史箱明细保留,并在拣货重放、错误载荷与重复拣货拒绝、完成及完成重放后回查事实不变;库存及流水按本轮客户和仓库完整分页,缺失或截断直接失败。作用域内串行执行,公开 API 流水按行数、类型、出库号、SKU 和增量判唯一;该证据不代替底层数据库并发验证。仅状态推进或离线合同通过不能宣称真实出库记账通过。 +退货后续模块对普通退货出库及换标后退货出库统一核对最新 PDA 扫描位置、逐 SKU 总量/可用量/箱数扣减、唯一负 OUTBOUND 流水及历史箱明细保留,并在拣货重放、错误载荷与重复拣货拒绝、完成及完成重放后回查事实不变;库存及流水按本轮客户和仓库完整分页,缺失或截断直接失败。换标来源明确返回 `palletCode=UNBOUND` 且 `palletId=0` 时,按未组托退货箱提交空来源托盘;实体来源仍先回查真实托盘版本,不能把任意解析失败当作未组托。作用域内串行执行,公开 API 流水按行数、类型、出库号、SKU 和增量判唯一;该证据不代替底层数据库并发验证。仅状态推进或离线合同通过不能宣称真实出库记账通过。 退货转换标请求按当前契约传 `items[{skuCode, quantity}]`、`fileScope` 和真实 FBA 目的仓 ID/编码,返回 `PENDING_REVIEW` 与 `skuCodes`。分页查找启用的 SCK8,缺失则阻断;总箱标文件回查 `cartonTotalFileId`。独立退货出库候选使用查询参数 `warehouseId`。同模块的 `completeReturnRelabel` 在独立出库之后继续审核本轮换标申请、PDA 认领与数量完成、来源托盘解析、正式生成目标托盘、组托及原键重放、上传合成完工图片、提交完工、客户确认及原键重放,最终回查原退货单仍为签收完成且换标关联一致。它不执行其他客户库存操作;目标托盘是本轮换标业务资源,不是国内基础托盘。客户确认后 `outboundRelabelledReturn` 逐箱核对本轮换标号、客户、仓库、来源单与数量,只为已完成换标的一件库存创建出库,检查创建原键重放、PDA 拣货/完成与平台、客户库存 `OUTBOUNDED` 终态;另存 `relabelOutboundNo`,不能用此前未冻结库存的独立出库结果替代。 @@ -228,6 +240,8 @@ PDA 待复核通过后,`manual-loading-complete-fixture.mjs` 冻结无证据 `fee-items` 需要同一 marker 的客户订单和入库单。执行 `--modules fee-items` 时,入口自动扩展为 `merchant-qualification-fixture,orders,fee-items`,无需手工补齐前置模块。 +跨客户场景可显式配置 `ADMIN_TEST_SECONDARY_CUSTOMER_ID`、`ADMIN_TEST_SECONDARY_MERCHANT_USERNAME`、`ADMIN_TEST_SECONDARY_MERCHANT_PASSWORD`,仅复用第二个启用的既有客户。资质准备按调用场景选择主客户或第二客户,先验证账号真实归属与两个客户不同;混合报关、跨客户柜费、财务隔离消费同一配置,不创建替代客户。缺失、不完整或指定为同一客户时停止对应跨客户造数;主流程仍使用 `ADMIN_TEST_FIXED_CUSTOMER_ID`,临时切换客户上下文结束后恢复。 + 固定客户模式下 `fee-items` 只查询现有费用目录、核对112条正式基线及客户权限,不新增、编辑或删除费用主档,增删改生命周期单独记录为未覆盖。所有模式均不自动删除历史 `ACPT_` 项目;非固定模式的隔离写入仅清理本轮返回的项目和明细 ID。 `finance-full-fixtures` 在固定客户模式下只复用已有不包税计费规则:唯一规则或唯一默认规则必须属于当前客户并匹配所需计费方式;缺失、不匹配或默认规则有歧义即停止,不自动创建规则版本或切换默认规则。供应商同样复用既有启用记录;下述创建隔离供应商的说明仅适用于非固定客户模式。 diff --git a/scripts/admin-test-env-modules/appointment-departure-prerequisite-contract.test.mjs b/scripts/admin-test-env-modules/appointment-departure-prerequisite-contract.test.mjs new file mode 100644 index 000000000..44fa3085c --- /dev/null +++ b/scripts/admin-test-env-modules/appointment-departure-prerequisite-contract.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { ensureAppointmentDeparture } from './phase4-overseas-inbound-operations.mjs' + +const producer = { marker: 'RUN', carrierScheduleQueryId: '10', tenantId: '20', containerBookingId: '30', containerNo: 'BOX' } +function fixture(fault = '', departed = false) { + const calls = [], task = { id: '10', customerId: fault === 'foreign-customer' ? 'OTHER' : '20', + containerBookingId: '30', trackingNo: 'BOX', version: '0', latestActualNode: departed ? 'DEPARTED_ORIGIN_PORT' : null } + const context = { marker: 'RUN', createdResources: {}, async checkpoint() {}, addStep() {}, + async request(method, path, body) { + calls.push({ method, path, body }) + if (method === 'POST') { + if (fault === 'unknown') throw Error('unknown write result') + assert.equal(body.expectedVersion, Number(task.version)) + task.version = String(Number(task.version) + 1); task.latestActualNode = body.standardNode; task.latestActualAt = body.actualAt + } + const data = path.includes('/last-leg/orders/') ? { order: { customerId: '20', sourceContainerBookingId: '30', + containerNode: task.latestActualNode, manualNodeOverride: false } } : { query: { query: structuredClone(task) } } + return { http: 200, body: { code: '200', data } } + } } + return { context, calls } +} +test('appointment source advances native container shipping and proves Lastleg consumption before appointment writes', async () => { + const { context, calls } = fixture() + assert.equal((await ensureAppointmentDeparture(context, producer, '40')).status, 'CONSUMED') + assert.deepEqual(calls.filter(call => call.method === 'POST').map(call => call.body.standardNode), + ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT']) + assert.ok(calls.filter(call => call.method === 'POST').every(call => call.path.startsWith('/api/platform/v1/carrier-schedule-queries/10/manual-nodes'))) +}) +test('already departed source is reused without adding shipping nodes', async () => { + const { context, calls } = fixture('', true) + await ensureAppointmentDeparture(context, producer, '40') + assert.ok(calls.every(call => call.method === 'GET')) +}) +test('foreign source and unknown write stop without further mutation', async () => { + const foreign = fixture('foreign-customer') + await assert.rejects(ensureAppointmentDeparture(foreign.context, producer, '40'), /身份/) + assert.ok(foreign.calls.every(call => call.method === 'GET')) + const unknown = fixture('unknown') + await assert.rejects(ensureAppointmentDeparture(unknown.context, producer, '40'), /unknown/) + assert.equal(unknown.calls.filter(call => call.method === 'POST').length, 1) + assert.equal(unknown.context.createdResources.appointmentDeparturePrerequisite.commands[0].status, 'UNKNOWN_RESULT') +}) diff --git a/scripts/admin-test-env-modules/client-relabel-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/client-relabel-fulfillment-contract.test.mjs index 33c8b4bd4..fa3f50fc4 100644 --- a/scripts/admin-test-env-modules/client-relabel-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/client-relabel-fulfillment-contract.test.mjs @@ -7,6 +7,8 @@ function harness(fault = '', transfer = false) { const proof = { marker: 'RELABEL-TEST', relabelNo: 'TB202610220001', customerId: '101', warehouseId: '201', sourcePositionId: '301', sourcePalletNo: 'SOURCE', sourceOrderNo: 'WC202610220001' } let status = 'PENDING_REVIEW', moved = false, scanned = false, version = 0, taskStatus = 'AVAILABLE' + const palletMeasurements = ['SOURCE', 'TARGET'].map((palletCode, index) => ({ palletId: String(500 + index), palletCode, + packages: index ? 1 : 2, stockVersion: `CURRENT-${palletCode}`, cartonAggregated: false })) const item = { itemId: '401', expectedPackages: 1, cartonMark: 'BOX-TEST', fbaCartonMark: 'FBA-TEST', operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: 'SKU', newSkuCode: 'NEW', quantity: 1 }] } const detail = () => ({ application: { relabelNo: proof.relabelNo, customerOrderNo: proof.sourceOrderNo, @@ -35,6 +37,13 @@ function harness(fault = '', transfer = false) { if (fault === 'unknown scan') throw new Error('unknown scan response') scanned = true; if (!repeated) version++; data = task() if (fault === 'scan replay drift' && repeated) data.relabelledPackages = 2 + } else if (path.endsWith('/relabel-pallet-measurements')) data = structuredClone(palletMeasurements) + else if (path.endsWith('/relabel-pallet-measure')) { + const row = palletMeasurements.find(value => value.palletCode === body.palletCode) + assert.equal(body.stockVersion, row.stockVersion) + if (fault !== 'measurement not saved') Object.assign(row, { lengthCm: body.lengthCm, widthCm: body.widthCm, + heightCm: body.heightCm, grossWeightKg: body.grossWeightKg, volumeCbm: 0.96 }) + data = structuredClone(palletMeasurements) } else if (path.endsWith('/relabel-pallet-label')) data = { palletId: '501', palletCode: 'TARGET' } else if (path.endsWith('/pallets/resolve')) { const source = body.code === 'SOURCE' @@ -79,6 +88,23 @@ test('original customer relabel reaches ready for dispatch through real command await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /不自动重跑/) }) +test('same operator and batch can execute customer and transfer applications without sharing operation keys', async () => { + const first = harness(), second = harness('', true) + second.inputs.proof.relabelNo = 'TB202610220002' + second.inputs.transferProof.relabelNo = second.inputs.proof.relabelNo + await verifyClientRelabelFulfillment(first.context, first.inputs) + const used = new Map(first.calls.filter(call => call.body?.idempotencyKey).map(call => [call.body.idempotencyKey, call.path])) + const request = second.context.request + second.context.request = async (method, path, body, token) => { + if (body?.idempotencyKey && used.has(body.idempotencyKey) && used.get(body.idempotencyKey) !== path) + return { http: 200, body: { code: 'A0404' } } + return request(method, path, body, token) + } + const result = await verifyClientRelabelFulfillment(second.context, second.inputs) + assert.equal(result.execution.status, 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND') + assert.ok(second.calls.filter(call => call.body?.idempotencyKey).every(call => !used.has(call.body.idempotencyKey))) +}) + for (const fault of ['wrong customer', 'wrong warehouse', 'no funds', 'unknown scan', 'scan replay drift', 'no source deduction', 'disk']) { test(`relabel stops before completion on ${fault}`, async () => { const h = harness(fault) @@ -109,6 +135,13 @@ test('已有库存转运原关联换标完成后回到原转运新SKU预留', as assert.equal(h.inputs.transferProof.outboundAllocations[0].palletId, '501') }) +test('转运拆合托补测未保存时禁止完工和客户确认', async () => { + const h = harness('measurement not saved', true) + await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /补测回执/) + assert.equal(h.inputs.proof.execution.status, 'FAILED') + assert.equal(h.calls.some(call => call.path.endsWith('/complete') || call.path.endsWith('/confirm-completion')), false) +}) + for (const fault of ['wrong fulfillment', 'wrong target']) { test(`转运关联换标拒绝 ${fault}`, async () => { const h = harness(fault, true) diff --git a/scripts/admin-test-env-modules/client-relabel-fulfillment.mjs b/scripts/admin-test-env-modules/client-relabel-fulfillment.mjs index 6c2a57f0a..bc6f1686b 100644 --- a/scripts/admin-test-env-modules/client-relabel-fulfillment.mjs +++ b/scripts/admin-test-env-modules/client-relabel-fulfillment.mjs @@ -38,7 +38,7 @@ export async function verifyClientRelabelFulfillment(context, { proof, transferP context.assert(MODULE, name, null, Boolean(passed), '', { scenario: 'client-relabel-fulfillment', deploymentBlocker: true }) if (!passed) throw new Error(name) } - const key = action => `${context.marker}-CLIENT-RELABEL-${action}` + const key = action => `${context.marker}-CLIENT-RELABEL-${proof.relabelNo}-${action}` const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}` const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}` const work = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(proof.relabelNo)}` @@ -120,6 +120,26 @@ export async function verifyClientRelabelFulfillment(context, { proof, transferP require('换标原箱源减一目标增一且组托重放复用操作', validId(bound.operationId) && bound.status === 'COMPLETED' && boundReplay.replayed === true && String(bound.operationId) === String(boundReplay.operationId) && Number(fromAfter.totalPackages) === Number(from.totalPackages) - 1 && Number(toAfter.totalPackages) === Number(to.totalPackages) + 1) + if (transferProof) { + // 明确的受控TEST补测输入;不按箱数、原托尺寸或计费重量推算,也不作为真实硬件实测证据。 + const input = { lengthCm: 120, widthCm: 100, heightCm: 80, grossWeightKg: 12.5 } + proof.palletMeasurementInput = { syntheticTestMaterials: true, input } + let measurements = await command('POST', `${PALLETS}/relabel-pallet-measurements`, { relabelNo: proof.relabelNo }, pdaToken) + const codes = new Set([from.palletCode, target.palletCode]) + require('补测只接受本次源目标非空托盘及当前库存版本', Array.isArray(measurements) && measurements.length > 0 + && measurements.every(row => validId(row.palletId) && codes.has(row.palletCode) && Number(row.packages) > 0 && row.stockVersion)) + for (const row of [...measurements]) { + if (['grossWeightKg', 'volumeCbm'].every(field => Number(row[field]) > 0) + && (row.cartonAggregated || ['lengthCm', 'widthCm', 'heightCm'].every(field => Number(row[field]) > 0))) continue + require('箱级实测不完整时不能用整托输入绕过', row.cartonAggregated !== true) + measurements = await command('POST', `${PALLETS}/relabel-pallet-measure`, { relabelNo: proof.relabelNo, + palletCode: row.palletCode, stockVersion: row.stockVersion, idempotencyKey: key(`measure-${row.palletId}`), ...input }, pdaToken) + const measured = measurements.find(value => String(value.palletId) === String(row.palletId)) + require('补测回执保持本托库存版本和明确TEST输入', measured?.stockVersion === row.stockVersion + && Object.entries(input).every(([field, value]) => Number(measured[field]) === value) && Number(measured.volumeCbm) > 0) + } + proof.palletMeasurements = measurements + } task = await command('GET', work, undefined, pdaToken) const photo = await command('POST', `${work}/photos/files`, { name: `${context.marker}-synthetic-relabel.png`, type: 'image/png', content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') }, pdaToken, true) diff --git a/scripts/admin-test-env-modules/current-boxed-inventory-contract.test.mjs b/scripts/admin-test-env-modules/current-boxed-inventory-contract.test.mjs new file mode 100644 index 000000000..2a959d2b8 --- /dev/null +++ b/scripts/admin-test-env-modules/current-boxed-inventory-contract.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readCurrentBoxedInventory } from './current-boxed-inventory.mjs' + +function fixture(fault = '') { + const calls = [] + const context = { marker: 'RUN', createdResources: { phase4OverseasPda: { + marker: fault === 'old-run' ? 'OLD' : 'RUN', finalStatus: 'COMPLETED', overseasWarehouseId: 'WH', + generatedPalletsByInbound: { IN: [{}] }, + } }, apiSuccess(_m, _n, response) { return response.body.data }, + async request(method, path, body, token) { + calls.push({ method, path }); assert.equal(method, 'GET'); assert.equal(token, 'merchant') + let data + if (path.includes('/cargo-discards/')) data = [{ inboundCode: 'IN', orderId: '100', overseasWarehouseId: 'WH', + cartons: [{ cartonId: '200', cartonMark: 'BOX', palletNo: 'REAL' }] }] + else if (path.includes('/inventory-source-cartons?')) { + data = [{ cartonMark: 'BOX', items: [{ skuCode: 'SKU', quantity: fault === 'unknown-quantity' ? null : '10' }] }] + if (fault === 'duplicate-carton') data.push(data[0]) + } else data = [{ sourceType: 'INBOUND', inboundCode: fault === 'foreign-inbound' ? 'OTHER' : 'IN', + overseasWarehouseId: fault === 'foreign-warehouse' ? 'OTHER' : 'WH', inventoryBalanceId: '10', inboundOrderId: '100', skuCode: 'SKU', + positions: [{ positionId: '20', palletNo: fault === 'wrong-pallet' ? 'UNBOUND' : 'REAL', availablePackages: '2' }] }] + return { http: 200, body: { code: '200', data } } + } } + return { context, calls } +} + +test('boxed source joins only current inbound, authoritative carton SKU quantities and real physical pallet', async () => { + const { context, calls } = fixture() + const [source] = await readCurrentBoxedInventory(context, 'merchant', 'WH', 'test') + assert.equal(source.cartons[0].cartonMark, 'BOX') + assert.equal(source.cartons[0].positionId, '20') + assert.equal(source.cartons[0].items[0].quantity, '10') + assert.ok(calls.every(call => !call.path.includes('OTHER'))) +}) +for (const fault of ['unknown-quantity', 'wrong-pallet', 'foreign-inbound', 'foreign-warehouse']) test(`boxed source rejects unsupported or foreign facts: ${fault}`, async () => { + assert.deepEqual(await readCurrentBoxedInventory(fixture(fault).context, 'merchant', 'WH', 'test'), []) +}) +test('old marker blocks before HTTP; duplicate authoritative carton marks stop instead of choosing first', async () => { + const old = fixture('old-run') + await assert.rejects(readCurrentBoxedInventory(old.context, 'merchant', 'WH', 'test'), /本轮/) + assert.equal(old.calls.length, 0) + await assert.rejects(readCurrentBoxedInventory(fixture('duplicate-carton').context, 'merchant', 'WH', 'test'), /重复/) +}) diff --git a/scripts/admin-test-env-modules/current-boxed-inventory.mjs b/scripts/admin-test-env-modules/current-boxed-inventory.mjs new file mode 100644 index 000000000..d932c6ef0 --- /dev/null +++ b/scripts/admin-test-env-modules/current-boxed-inventory.mjs @@ -0,0 +1,49 @@ +const id = value => value == null ? '' : String(value) +const positive = value => /^[1-9][0-9]*$/.test(id(value)) && Number.isSafeInteger(Number(value)) + +/** 仅组合本轮已收货入仓单的权威箱、SKU 和实际托盘位置,不生成箱号或推算每箱件数。 */ +export async function readCurrentBoxedInventory(context, token, warehouseId, module) { + const pda = context.createdResources?.phase4OverseasPda + if (pda?.marker !== context.marker || pda.finalStatus !== 'COMPLETED' + || id(pda.overseasWarehouseId) !== id(warehouseId)) throw Error('真实箱来源缺少本轮已收货仓库事实') + const inbounds = Object.keys(pda.generatedPalletsByInbound || {}) + if (!inbounds.length) throw Error('真实箱来源缺少本轮入仓单') + const get = async path => { + const response = await context.request('GET', path, undefined, token, {}) + const data = context.apiSuccess(module, '核对本轮库存真实箱与SKU来源', response) + if (response?.http !== 200 || String(response?.body?.code) !== '200' || !Array.isArray(data)) + throw Error('真实箱来源接口未返回完整列表') + return data + } + const physical = await get('/api/merchant/v1/last-leg/cargo-discards/inventory-candidates') + const result = [] + for (const inboundCode of inbounds) { + const scope = { operatingWarehouseId: id(warehouseId), inboundCode } + const cartons = await get('/api/merchant/v1/last-leg/warehouse-transfers/inventory-source-cartons?' + new URLSearchParams(scope)) + if (!cartons.length) continue + const inventory = await get('/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?' + + new URLSearchParams({ operationWarehouseId: id(warehouseId), inboundCode })) + for (const source of inventory.filter(row => row.sourceType === 'INBOUND' && row.inboundCode === inboundCode + && id(row.overseasWarehouseId) === id(warehouseId) && id(row.inventoryBalanceId) && id(row.inboundOrderId))) { + const boxes = physical.filter(row => row.inboundCode === inboundCode && id(row.overseasWarehouseId) === id(warehouseId) + && id(row.orderId) === id(source.inboundOrderId)).flatMap(row => row.cartons || []) + const seen = new Set() + const actual = [] + for (const carton of cartons) { + if (!carton.cartonMark || seen.has(carton.cartonMark)) throw Error('权威来源箱号缺失或重复') + seen.add(carton.cartonMark) + const matches = boxes.filter(box => box.cartonMark === carton.cartonMark) + if (matches.length !== 1) continue + const box = matches[0] + const positions = (source.positions || []).filter(position => position.palletNo === box.palletNo + && box.palletNo && box.palletNo.toUpperCase() !== 'UNBOUND' && Number(position.availablePackages) > 0) + const items = carton.items || [] + if (positions.length !== 1 || !items.length || items.some(item => !item.skuCode || !positive(item.quantity)) + || !items.some(item => item.skuCode === source.skuCode)) continue + actual.push({ ...carton, cartonId: id(box.cartonId), positionId: id(positions[0].positionId), palletNo: box.palletNo }) + } + if (actual.length) result.push({ ...source, cartons: actual }) + } + } + return result +} diff --git a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs index c98617651..49395045a 100644 --- a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs +++ b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs @@ -236,24 +236,27 @@ async function verifyFrozenSource(context, fixture) { } async function createSecondCustomer(context) { - if (context.options?.fixedCustomerId) throw new Error('缺少第二个固定客户,停止跨客户财务写入') + if (context.options?.fixedCustomerId && !context.options.fixedSecondaryCustomer) throw new Error('缺少第二个固定客户,停止跨客户财务写入') const originalMarker = context.marker const originalContext = context.context?.merchantQualificationFixture const originalResource = context.createdResources?.merchantQualificationFixture + const originalCustomerId = context.context?.customerId const originalUsername = process.env.MERCHANT_TEST_USERNAME const originalPassword = process.env.MERCHANT_TEST_PASSWORD context.marker = `${originalMarker}-FIN-ISO` try { const module = await import(new URL('./merchant-qualification-fixture.mjs', import.meta.url)) - await module.run(context) + await module.run(context, { publishEnvironment: false, requireDistinctCustomer: Boolean(context.options?.fixedCustomerId) }) const fixture = context.context?.merchantQualificationFixture if (!fixture?.tenantId || !fixture?.username || !fixture?.password || fixture.marker !== context.marker) return null const loginResponse = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '') const login = context.apiSuccess(MODULE, '第二隔离客户登录', loginResponse, diagnostic(loginResponse), OPTIONS) - return { customerId: id(fixture.tenantId), token: login?.accessToken } + return { customerId: id(fixture.tenantId), token: login?.accessToken, refreshToken: login?.refreshToken } } finally { context.marker = originalMarker + if (originalCustomerId === undefined) delete context.context.customerId + else context.context.customerId = originalCustomerId if (originalContext) context.context.merchantQualificationFixture = originalContext else delete context.context.merchantQualificationFixture if (originalResource) context.createdResources.merchantQualificationFixture = originalResource @@ -267,32 +270,39 @@ async function createSecondCustomer(context) { async function verifyCustomerIsolation(context, fixture) { const second = await createSecondCustomer(context) - const target = rows(fixture.statements).find((item) => statementId(item)) - const apSource = rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).find(Boolean) - if (!second?.customerId || !second?.token || !target || !apSource) { - blocker(context, '建立第二隔离客户与跨客户单据 Fixture', - `customer=${second?.customerId || 'missing'}, token=${Boolean(second?.token)}, statement=${statementId(target) || 'missing'}, payableSource=${apSource || 'missing'}`, - null, 'second-customer-fixture-unavailable') - return + try { + const target = rows(fixture.statements).find((item) => statementId(item)) + const apSource = rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).find(Boolean) + if (!second?.customerId || !second?.token || !target || !apSource) { + blocker(context, '建立第二隔离客户与跨客户单据 Fixture', + `customer=${second?.customerId || 'missing'}, token=${Boolean(second?.token)}, statement=${statementId(target) || 'missing'}, payableSource=${apSource || 'missing'}`, + null, 'second-customer-fixture-unavailable') + return + } + const targetId = statementId(target) + const read = await context.request('GET', `${CLIENT}/bills/${encodeURIComponent(targetId)}`, undefined, second.token) + const confirm = await context.request('POST', `${CLIENT}/statements/${encodeURIComponent(targetId)}/confirm`, + { expectedVersion: Number(target.version ?? 0) }, second.token, + { 'Idempotency-Key': key(context, 'foreign-confirm') }) + const receipt = await write(context, 'POST', `${FINANCE}/receipts`, { + customerId: second.customerId, receivedDate: today(), amount: 0.01, currency: 'CNY', + paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)], remark: context.marker, + allocations: [{ billId: targetId, amount: 0.01 }], + }, 'foreign-receipt') + const payment = await write(context, 'POST', `${FINANCE}/payable-settlements`, { + customerId: second.customerId, supplierId: id(fixture.supplier?.id ?? fixture.supplier?.supplierId), + feeSourceIds: [apSource], + dueDate: today(), currency: 'CNY', + }, 'foreign-payable') + context.assert(MODULE, '第二客户不能读取或确认他人账单且平台不能跨客户分配收付款', confirm, + [read, confirm, receipt, payment].every(rejected), + `read=${diagnostic(read)}; confirm=${diagnostic(confirm)}; receipt=${diagnostic(receipt)}; payment=${diagnostic(payment)}`, OPTIONS) + } finally { + if (second?.token) { + const logout = await context.request('POST', '/api/auth/logout', { refreshToken: second.refreshToken }, second.token) + context.apiSuccess(MODULE, '退出第二固定客户隔离测试会话', logout, diagnostic(logout), OPTIONS) + } } - const targetId = statementId(target) - const read = await context.request('GET', `${CLIENT}/bills/${encodeURIComponent(targetId)}`, undefined, second.token) - const confirm = await context.request('POST', `${CLIENT}/statements/${encodeURIComponent(targetId)}/confirm`, - { expectedVersion: Number(target.version ?? 0) }, second.token, - { 'Idempotency-Key': key(context, 'foreign-confirm') }) - const receipt = await write(context, 'POST', `${FINANCE}/receipts`, { - customerId: second.customerId, receivedDate: today(), amount: 0.01, currency: 'CNY', - paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)], remark: context.marker, - allocations: [{ billId: targetId, amount: 0.01 }], - }, 'foreign-receipt') - const payment = await write(context, 'POST', `${FINANCE}/payable-settlements`, { - customerId: second.customerId, supplierId: id(fixture.supplier?.id ?? fixture.supplier?.supplierId), - feeSourceIds: [apSource], - dueDate: today(), currency: 'CNY', - }, 'foreign-payable') - context.assert(MODULE, '第二客户不能读取或确认他人账单且平台不能跨客户分配收付款', confirm, - [read, confirm, receipt, payment].every(rejected), - `read=${diagnostic(read)}; confirm=${diagnostic(confirm)}; receipt=${diagnostic(receipt)}; payment=${diagnostic(payment)}`, OPTIONS) } export async function run(context) { diff --git a/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs index 9ca244e90..aac88ce7f 100644 --- a/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs +++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs @@ -45,7 +45,7 @@ test('module freezes all required real HTTP invariants and provisions a formal c '非柜内客户不能读取柜次费用', 'requestId=', 'traceId=', 'deploymentBlocker: true', ]) assert.ok(source.includes(token), token) for (const token of [ - 'createMerchant(context)', 'createCustomerWaitSplit', 'provisionCrossCustomerPlan', + 'createMerchant(context, { publishEnvironment: false, requireDistinctCustomer:', 'createCustomerWaitSplit', 'provisionCrossCustomerPlan', 'originalUsername', 'originalPassword', 'delete process.env.MERCHANT_TEST_USERNAME', 'delete process.env.MERCHANT_TEST_PASSWORD', ]) assert.ok(source.includes(token), token) diff --git a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs index 0d8d892e5..33a7d151f 100644 --- a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs +++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs @@ -1,7 +1,7 @@ const MODULE = 'firstleg-container-fee-allocation' const OPTIONS = { scenario: 'firstleg-container-fee-allocation-http', deploymentBlocker: true } -import { run as createMerchant } from './merchant-qualification-fixture.mjs' +import { run as createMerchant, requireFixedSecondaryCustomer } from './merchant-qualification-fixture.mjs' import { channelGroup } from './smart-loading-channel-group.mjs' function id(value) { return value == null ? '' : String(value) } @@ -105,7 +105,7 @@ async function createCustomerWaitSplit(context, suffix, fixedRoute = null) { const runMarker = context.marker context.marker = `${runMarker}-${suffix}` if (fixedRoute) context.context.waitSplitRoute = fixedRoute - await createMerchant(context) + await createMerchant(context, { publishEnvironment: false, requireDistinctCustomer: suffix === 'ALLOC-B' }) const orders = await import(new URL(`./orders.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url)) const measurementModule = await import(new URL(`./inbound-measurement.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url)) await orders.run(context) @@ -122,12 +122,14 @@ async function createCustomerWaitSplit(context, suffix, fixedRoute = null) { } async function provisionCrossCustomerPlan(context) { - if (context.options?.fixedCustomerId) throw new Error('缺少第二个固定客户,停止跨客户柜费造数') + if (context.options?.fixedCustomerId && !context.options.fixedSecondaryCustomer) throw new Error('缺少第二个固定客户,停止跨客户柜费造数') + if (context.options?.fixedCustomerId) requireFixedSecondaryCustomer(context.options) const originalMarker = context.marker const originalUsername = process.env.MERCHANT_TEST_USERNAME const originalPassword = process.env.MERCHANT_TEST_PASSWORD const originalRoute = context.context.waitSplitRoute const originalMerchant = context.context.merchantQualificationFixture + const originalCustomerId = context.context.customerId try { const first = await createCustomerWaitSplit(context, 'ALLOC-A') const route = { @@ -173,6 +175,8 @@ async function provisionCrossCustomerPlan(context) { context.marker = originalMarker context.context.waitSplitRoute = originalRoute context.context.merchantQualificationFixture = originalMerchant + if (originalCustomerId === undefined) delete context.context.customerId + else context.context.customerId = originalCustomerId if (originalUsername === undefined) delete process.env.MERCHANT_TEST_USERNAME else process.env.MERCHANT_TEST_USERNAME = originalUsername if (originalPassword === undefined) delete process.env.MERCHANT_TEST_PASSWORD diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs index 8289b1741..0e4649f55 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs @@ -41,6 +41,12 @@ function model(fault = '', countPatch = {}) { async requestMultipart(method, raw, file, token = this.token) { calls.push({ method, raw, token, multipart: true }); assert.ok(file.content.length > 0) if (raw.includes('/license?')) return response({ id: 'DRIVER', recordVersion: '1', licenseFileId: 'LICENSE' }) + const evidenceKind = raw.match(/^\/api\/platform\/v1\/last-leg\/dispatches\/DS-ONE\/evidence\/(VEHICLE|DRIVER_PHOTO|DRIVER)$/)?.[1] + if (evidenceKind) { + assert.equal(token, 'admin-secret') + assert.equal(file.type, 'image/png') + return response({ fileId: { VEHICLE: '901', DRIVER_PHOTO: '902', DRIVER: '903' }[evidenceKind] }) + } assert.match(raw, /\/warehouse-transfers\/IN[01]\/measurement-photos\/files$/); assert.equal(token, options.pdaToken) return response({ fileId: 'PHOTO', sizeBytes: file.content.length }) }, @@ -168,6 +174,9 @@ function model(fault = '', countPatch = {}) { if (path.startsWith('/api/platform/v1/last-leg/dispatches/DS-ONE')) { if (method === 'PUT') { assert.equal(body.recordVersion, native.recordVersion) + assert.deepEqual(body.vehicle.evidenceFileIds, ['901']) + assert.deepEqual(body.driver.evidence.photoFileIds, ['902']) + assert.deepEqual(body.driver.evidence.fileIds, ['903']) Object.assign(trip.trip, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId }) native.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: 'VEHICLE', plate: trip.trip.vehiclePlate }, driver: { driverId: 'DRIVER' } } native.recordVersion++ diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index 3e231a42b..47821a734 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -141,6 +141,8 @@ function server(fault = '') { return ok({ id: DRIVER, reviewStatus: 'APPROVED', recordVersion: ++driverVersion }) } if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) { + if (!new URL(path, 'https://unused.test').searchParams.has('keyword')) + return ok({ records: [], page: 1, size: 200, total: 0 }) const rows = fault === 'missing-trip' ? [] : [trip, ...(nextTrip ? [nextTrip] : [])] if (fault === 'duplicate-trip') rows.push({ ...trip, tripNo: 'TR-B' }) const wrong = { 'wrong-plate': { vehiclePlate: `${trip.vehiclePlate}-OTHER` }, @@ -321,6 +323,18 @@ test('fixed customer reuses master records and uploads only this dispatch review assert.ok(s.calls.filter(call => call.method !== 'GET').every(call => call.path.startsWith(D))) }) +test('review preparation preserves existing maintenance and appointment numbers while filling missing TEST destination numbers', async () => { + const s = server() + s.detail.vehicleInfo.maintenance = { loadType: '托盘', destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'ORIGINAL' }] } + s.detail.dispatchInfo.fbaAppointments = [{ destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', appointmentNo: 'ORIGINAL' }, + { destinationKey: 'FBA:ABE3', destinationWarehouseCode: 'ABE3', status: 'PENDING' }] + const proof = await prepare(s) + assert.equal(proof.updateRequest.maintenance.loadType, '托盘') + assert.equal(proof.updateRequest.maintenance.destinationAppointments[0].appointmentNo, 'ORIGINAL') + assert.match(proof.updateRequest.maintenance.destinationAppointments[1].appointmentNo, /^TEST-/) + assert.equal(proof.updateRequest.maintenance.destinationAppointments[1].destinationKey, 'FBA:ABE3') +}) + test('fixed fleet resources block missing, locally occupied and ineligible pairs without master writes', async () => { const pair = { vehicleId: VID, driverId: DRIVER } for (const fault of ['missing', 'occupied', 'vehicle-status', 'driver-qualification']) { @@ -329,6 +343,7 @@ test('fixed fleet resources block missing, locally occupied and ineligible pairs createdResources: { fleetTrips: fault === 'occupied' ? [{ ...pair }] : [proof] }, async request(method, path) { calls.push(path); assert.equal(method, 'GET') + if (path.includes('/fleet/trips?')) return ok({ records: [], page: 1, size: 200, total: 0 }) return path.includes('/vehicles/') ? ok({ id: VID, status: fault === 'vehicle-status' ? 'DISPATCHED' : 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' }) : ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: fault !== 'driver-qualification', reason: 'ELIGIBLE' } }) }, apiSuccess(_m, _l, response) { return response.body.data } } @@ -343,6 +358,7 @@ test('verified resource closure makes a fixed pair available again', async () => createdResources: { fleetTrips: [{ vehicleId: VID, driverId: DRIVER, closure: { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, proof] }, async request(method, path) { assert.equal(method, 'GET') + if (path.includes('/fleet/trips?')) return ok({ records: [], page: 1, size: 200, total: 0 }) return path.includes('/vehicles/') ? ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' }) : ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } }) }, apiSuccess(_m, _l, response) { return response.body.data } } @@ -351,6 +367,25 @@ test('verified resource closure makes a fixed pair available again', async () => assert.equal(proof.driverId, DRIVER) }) +test('fixed pool skips externally occupied resources, even completed trips without release', async () => { + for (const status of ['PLANNED', 'COMPLETED']) { + const proof = {}, calls = [] + const context = { options: { fixedFleetResources: [{ vehicleId: '11', driverId: '12' }, { vehicleId: VID, driverId: DRIVER }] }, + createdResources: { fleetTrips: [proof] }, apiSuccess(_m, _l, response) { return response.body.data }, + async request(method, path) { + calls.push(path); assert.equal(method, 'GET') + if (path.includes('/fleet/trips?')) return ok({ records: [{ tripNo: 'FOREIGN', status, vehicleId: '11', driverId: '12' }], page: 1, size: 200, total: 1 }) + return path.includes('/vehicles/') ? ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' }) + : ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } }) + } } + await reuseFleetResources(context, 'test', proof) + assert.equal(proof.vehicleId, VID); assert.equal(proof.driverId, DRIVER) + assert.ok(calls.every(path => !path.includes('/vehicles/11') && !path.includes('/drivers/12'))) + context.options.fixedFleetResources.pop() + await assert.rejects(reuseFleetResources(context, 'test', {}), /固定车辆司机/) + } +}) + test('zero-scan LOADING must explicitly return to review before editing and pass a new review round', async () => { const s = server(); s.detail.stage = 'LOADING'; s.detail.reviewState = 'APPROVED' const proof = await prepare(s) diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index 102c47fe4..9eed3287f 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -51,7 +51,7 @@ async function discoverTrip(context, module, proof) { for (let page = 1; ; page++) { const query = new URLSearchParams({ page: String(page), size: '200', keyword: vehiclePlate, warehouseId: proof.warehouseId }) const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按实际车牌读取自动资源记录') - if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0 + if (!Array.isArray(data.records) || data.total == null || data.total === '' || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0 || Number(data.page) !== page || Number(data.size) !== 200) throw new Error('车次分页不完整,不能证明唯一关联') total ??= Number(data.total) if (Number(data.total) !== total || data.records.length > 200 || (!data.records.length && rows.length < total)) throw new Error('车次分页变化或缺页') @@ -83,9 +83,28 @@ export async function reuseFleetResources(context, module, proof, arrival) { const pairs = context.options.fixedFleetResources const busy = (context.createdResources.fleetTrips || []).filter(item => item !== proof && item.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') - const pair = Array.isArray(pairs) && pairs.find(item => /^[1-9][0-9]*$/.test(id(item?.vehicleId)) + const localPairs = Array.isArray(pairs) ? pairs.filter(item => /^[1-9][0-9]*$/.test(id(item?.vehicleId)) && /^[1-9][0-9]*$/.test(id(item?.driverId)) - && !busy.some(trip => id(trip.vehicleId) === id(item.vehicleId) || id(trip.driverId) === id(item.driverId))) + && !busy.some(trip => id(trip.vehicleId) === id(item.vehicleId) || id(trip.driverId) === id(item.driverId))) : [] + if (!localPairs.length) throw Error('缺少本轮可用的固定车辆司机组合;禁止自动新建主档或证照') + const trips = [], seen = new Set(); let total + for (let page = 1; page <= 100; page++) { + const data = await command(context, module, 'GET', `${BASE}/trips?page=${page}&size=200`, undefined, '核对共享环境车辆司机占用') + if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0 + || Number(data.page) !== page || Number(data.size) !== 200) throw Error('固定车辆司机占用分页无效') + total ??= Number(data.total) + if (Number(data.total) !== total || data.records.length > 200 || (!data.records.length && trips.length < total)) + throw Error('固定车辆司机占用分页变化或缺页') + for (const row of data.records) { + if (!row.tripNo || seen.has(row.tripNo)) throw Error('固定车辆司机占用车次重复或缺少业务号') + seen.add(row.tripNo); trips.push(row) + } + if (trips.length >= total) break + } + if (trips.length !== total) throw Error('固定车辆司机占用分页不完整') + const pair = localPairs.find(item => !trips.some(trip => trip.status !== 'CANCELLED' + && ((id(trip.vehicleId) === id(item.vehicleId) && !trip.vehicleRelease?.actualEndAt) + || (id(trip.driverId) === id(item.driverId) && !trip.driverRelease?.actualEndAt)))) if (!pair) throw Error('缺少本轮可用的固定车辆司机组合;禁止自动新建主档或证照') proof.expectedDriverEndAt = new Date(Math.max(Date.now() + 48 * 3600000, arrival ? Date.parse(arrival) + 6 * 3600000 : 0)).toISOString() const vehicle = await command(context, module, 'GET', `/api/platform/v1/last-leg/master-data/vehicles/${id(pair.vehicleId)}`, undefined, '核对固定车辆状态') @@ -145,7 +164,17 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) upload.fileId = id(file.fileId) await context.checkpoint?.() } + const maintenance = structuredClone(before.vehicleInfo?.maintenance || {}) + const appointments = new Map((maintenance.destinationAppointments || []).map(row => [row.destinationKey, row])) + for (const row of before.dispatchInfo?.fbaAppointments || []) { + if (!row.destinationKey || !row.destinationWarehouseCode) continue + const existing = row.appointmentNo || appointments.get(row.destinationKey)?.appointmentNo + appointments.set(row.destinationKey, { destinationKey: row.destinationKey, + appointmentNo: existing || `TEST-${proof.marker}-${row.destinationWarehouseCode}`.slice(0, 128) }) + } + maintenance.destinationAppointments = [...appointments.values()] proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'), + maintenance, publishLooseboardDemand: before.publishLooseboardDemand, vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId, evidenceFileIds: [proof.reviewPhotos.VEHICLE.fileId] }, driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt, diff --git a/scripts/admin-test-env-modules/looseboard-fulfillment.mjs b/scripts/admin-test-env-modules/looseboard-fulfillment.mjs index 5b020348b..998e62149 100644 --- a/scripts/admin-test-env-modules/looseboard-fulfillment.mjs +++ b/scripts/admin-test-env-modules/looseboard-fulfillment.mjs @@ -20,11 +20,14 @@ export async function attachLooseboardToTrip(context, tripProof, pdaToken, && Number.isSafeInteger(row.packageCount) && row.packageCount >= row.estimatedPalletCount) const destinationKey = tripProof.stops?.[1]?.destinationKey check('原车存在明确卸货目的地', typeof destinationKey === 'string' && destinationKey.length > 0) - // 当前受控样本以每实托100×100×90cm、40kg执行,原单箱数均分且总数守恒。 - const measurements = Array.from({ length: row.estimatedPalletCount }, (_, index) => ({ - cartonCount: Math.floor(row.packageCount / row.estimatedPalletCount) + (index < row.packageCount % row.estimatedPalletCount ? 1 : 0), - lengthCm: 100, widthCm: 100, heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40, - })) + // 明确的两托三箱TEST样本,逐箱输入,不把整托数值或箱数推算成实际箱规。 + check('受控箱规仅适用于本轮两托三箱样本', row.estimatedPalletCount === 2 && row.packageCount === 3) + const measurements = [ + { cartonCount: 2, lengthCm: 100, widthCm: 100, heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40, + boxes: [{ length: 100, width: 100, height: 45, weight: 20, quantity: 1 }, { length: 100, width: 100, height: 45, weight: 20, quantity: 1 }] }, + { cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40, + boxes: [{ length: 100, width: 100, height: 90, weight: 40, quantity: 1 }] }, + ] proof.measurements = { kind: 'CONTROLLED_TEST_MEASUREMENT', measurements } await helpers.prepare(context, { demand, tripProof, destinationKey, unitPrice: 25 }) await helpers.receive(context, { demand, tripProof, pdaToken, measurements }) diff --git a/scripts/admin-test-env-modules/looseboard-pool-binding-contract.test.mjs b/scripts/admin-test-env-modules/looseboard-pool-binding-contract.test.mjs index e0e1327a5..d48725a68 100644 --- a/scripts/admin-test-env-modules/looseboard-pool-binding-contract.test.mjs +++ b/scripts/admin-test-env-modules/looseboard-pool-binding-contract.test.mjs @@ -34,7 +34,7 @@ function fixture(fault = '') { } else if (method === 'GET' && path.endsWith('/OF-1')) data = { offer } else if (method === 'GET' && path.endsWith('/BK-1')) data = created.inquiry else if (method === 'POST' && path.endsWith('/OF-1/inquiries')) { - assert.equal(body.sourceOrderNo, 'LB-1'); assert.equal(body.sourceOrderVersion, 2) + assert.equal(body.sourceOrderNo, 'LB-1'); assert.equal(Number(body.sourceOrderVersion), 2) assert.equal(body.customerId, '101'); assert.equal(body.expectedPalletCount, 2) assert.equal(body.cargo.packageCount, 3) if (fault === 'network') return { http: 0 } @@ -68,6 +68,21 @@ test('pool binding preserves original demand and confirms only one commercial or assert.equal(f.writes(), 4) }) +for (const native of [true, false]) test(`native Long inquiry versions remain bounded: ${native ? 'encoded strings' : 'invalid empty value'}`, async () => { + const f = fixture() + // 生产 fixture 已将版本规范为JS整数;HTTP的Long版本仍为规范十进制字符串。 + if (!native) f.args.demand.version = '' + const request = f.context.request + f.context.request = async (...args) => { + const response = await request(...args) + response.body.data = JSON.parse(JSON.stringify(response.body.data, (key, value) => + /^(version|recordVersion|tripRecordVersion|offerRecordVersion)$/.test(key) ? native ? String(value) : '' : value)) + return response + } + if (native) assert.equal((await bindLooseboardDemand(f.context, f.args)).status, 'CONFIRMED_ORIGINAL_ORDER') + else { await assert.rejects(bindLooseboardDemand(f.context, f.args), /散板原单/); assert.equal(f.writes(), 0) } +}) + for (const [fault, maxWrites] of [['customer', 0], ['capacity', 0], ['network', 1], ['replay', 2], ['new order', 3], ['price', 3], ['binding', 4], ['disk', 0]]) { test(`pool binding stops on ${fault} without another order or automatic retry`, async () => { const f = fixture(fault) diff --git a/scripts/admin-test-env-modules/looseboard-pool-binding.mjs b/scripts/admin-test-env-modules/looseboard-pool-binding.mjs index c169cc982..eeef0ca01 100644 --- a/scripts/admin-test-env-modules/looseboard-pool-binding.mjs +++ b/scripts/admin-test-env-modules/looseboard-pool-binding.mjs @@ -1,7 +1,7 @@ const BASE = '/api/platform/v1/last-leg/fleet/capacity-offers' const MODULE = 'phase4-overseas-business' const OPTIONS = { scenario: 'looseboard-pool-binding', deploymentBlocker: true } -const version = value => Number.isSafeInteger(value) && value >= 0 +const version = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) /** 将本轮散板原单绑定本轮已发布车次余位,禁止无来源另建商业订单。 */ export async function bindLooseboardDemand(context, { demand, tripProof, offerNo, destinationKey, unitPrice }) { @@ -38,7 +38,8 @@ export async function bindLooseboardDemand(context, { demand, tripProof, offerNo const original = await request('GET', originalPath) const summary = original?.summary check('散板原单仍可接车且无已有承运', summary?.orderNo === demand.orderNo && String(summary.customerId) === String(demand.customerId) - && summary.status === 'PENDING_ACCEPTANCE' && version(summary.version) && summary.version === demand.version + && summary.status === 'PENDING_ACCEPTANCE' && version(summary.version) && version(demand.version) + && Number(summary.version) === Number(demand.version) && !summary.fleetArrangement && original.remark?.startsWith(context.marker)) const offer = (await request('GET', `${BASE}/${encodeURIComponent(offerNo)}`))?.offer const destination = offer?.destinations?.find(row => row.destinationKey === destinationKey) diff --git a/scripts/admin-test-env-modules/looseboard-pool-preparation-contract.test.mjs b/scripts/admin-test-env-modules/looseboard-pool-preparation-contract.test.mjs index e47319caf..219eed5f1 100644 --- a/scripts/admin-test-env-modules/looseboard-pool-preparation-contract.test.mjs +++ b/scripts/admin-test-env-modules/looseboard-pool-preparation-contract.test.mjs @@ -62,6 +62,21 @@ for (const valid of [false, true]) test(`prepare owned pool and pass original de assert.equal(f.calls.some(c => /\/vehicles|\/drivers|\/iam/.test(c.path)), false) }) +for (const encoded of ['0', '12', '', null, '01', '-1', '9007199254740992']) test(`pool source version uses bounded native Long encoding: ${String(encoded)}`, async () => { + const f = fixture('', true) + const request = f.context.request + f.context.request = async (method, path, body) => { + const response = await request(method, path, body) + if (path.endsWith('/capacity-plan')) response.body.data.recordVersion = encoded + return response + } + if (['0', '12'].includes(encoded)) await prepareLooseboardPool(f.context, f.args, f.bind) + else { + await assert.rejects(prepareLooseboardPool(f.context, f.args, f.bind), /容量依据/) + assert.equal(f.counts().creates, 0) + } +}) + for (const fault of ['picked', 'driver', 'foreign cargo', 'loaded', 'network', 'draft replay', 'publish replay', 'disk']) { test(`pool preparation fails closed on ${fault}`, async () => { const f = fixture(fault) diff --git a/scripts/admin-test-env-modules/looseboard-pool-preparation.mjs b/scripts/admin-test-env-modules/looseboard-pool-preparation.mjs index 4766b5ba4..292cd87e9 100644 --- a/scripts/admin-test-env-modules/looseboard-pool-preparation.mjs +++ b/scripts/admin-test-env-modules/looseboard-pool-preparation.mjs @@ -4,7 +4,7 @@ import { bindLooseboardDemand } from './looseboard-pool-binding.mjs' const API = '/api/platform/v1/last-leg' const MODULE = 'phase4-overseas-business' const OPTIONS = { scenario: 'looseboard-pool-preparation', deploymentBlocker: true } -const validVersion = value => Number.isSafeInteger(value) && value >= 0 +const validVersion = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) /** 复用本轮已审核且未扫码车次,发布可核验余位并衔接散板原单。 */ export async function prepareLooseboardPool(context, { demand, tripProof, destinationKey, unitPrice }, bind = bindLooseboardDemand) { diff --git a/scripts/admin-test-env-modules/looseboard-pool-receipt-contract.test.mjs b/scripts/admin-test-env-modules/looseboard-pool-receipt-contract.test.mjs index 6516f0128..1dfc7237a 100644 --- a/scripts/admin-test-env-modules/looseboard-pool-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/looseboard-pool-receipt-contract.test.mjs @@ -9,6 +9,7 @@ function fixture(fault = '') { tripNo: 'TR-1', bookingNo: 'BK-1', status: 'CONFIRMED_ORIGINAL_ORDER', createRequest: { cargo, expectedPalletCount: 2 } } } const booking = { bookingNo: 'BK-1', tripNo: 'TR-1', customerId: '101', status: 'CONFIRMED', recordVersion: 1, cargo } const pallets = [], calls = [], snapshots = []; let incomingWrites = 0, receiptWrites = 0, palletized = false + const cartons = [1, 2, 3].map(index => ({ cartonMark: `BX-${index}`, transferItemId: '81', status: 'EXPECTED' })) const source = () => ({ incomingNo: 'IN-1', warehouseId: fault === 'warehouse' ? '99' : '42', destinationKey: cargo.destinationKey, status: palletized ? 'WAIT_CUSTOMER_CONFIRMATION' : 'WAIT_PDA_RECEIVING', receiptHash: 'hash', receivedPackages: fault === 'quantity' ? 4 : 3, @@ -27,15 +28,30 @@ function fixture(fault = '') { if (fault === 'replay' && incomingWrites > 1) data.booking.incomingNo = 'IN-2' } else if (path.endsWith('/fulfillment')) data = { preview: { tripRecordVersion: 2, recordVersion: 2, incoming: source() } } else if (path.endsWith('/pallets/drafts')) { - assert.equal(token, 'pda'); assert.equal(body.cartonCount, pallets.length ? 1 : 2) - pallets.push({ id: String(pallets.length + 1), cartonCount: body.cartonCount, status: 'DRAFT' }) + assert.equal(token, 'pda'); assert.deepEqual(Object.keys(body), ['idempotencyKey']) + pallets.push({ id: String(pallets.length + 1), cartonCount: 0, status: 'DRAFT' }) data = { pallets } } else if (path.endsWith('/generate')) { const pallet = pallets.find(p => path.includes(`/pallets/${p.id}/`)) - Object.assign(pallet, { palletNo: `P-${pallet.id}`, warehousePalletId: String(70 + Number(pallet.id)) }) + Object.assign(pallet, { palletNo: `P-${pallet.id}`, warehousePalletId: String(70 + Number(pallet.id)), status: 'GENERATED' }) data = { pallets } - } else if (path.endsWith('/bind')) data = { pallets } + } else if (path.endsWith('/bind')) { pallets.find(p => path.includes(`/pallets/${p.id}/`)).status = 'BOUND'; data = { pallets } } + else if (path.endsWith('/cartons/count')) { + assert.equal(token, 'pda'); assert.equal(body.forecastPackageCount, 3) + assert.equal(body.boxes.length, 3) + assert.ok(pallets.every(row => row.status === 'BOUND')) + if (fault !== 'uncounted') { + for (const input of body.boxes) Object.assign(cartons.find(box => box.cartonMark === input.cartonMark), input, { status: 'COUNTED' }) + for (const pallet of pallets) { + const boxes = body.boxes.filter(box => box.transferPalletId === pallet.id) + Object.assign(pallet, { cartonCount: boxes.length, volumeCbm: boxes.reduce((sum, box) => sum + box.length * box.width * box.height / 1000000, 0), + grossWeightKg: boxes.reduce((sum, box) => sum + box.weight, 0) }) + } + } + data = { pallets, cartons } + } else if (path.endsWith('/palletize')) { + assert.ok(cartons.every(box => box.status === 'COUNTED')) assert.deepEqual(body.photoFileIds, ['9']) palletized = true; pallets.forEach(p => { p.status = 'COMPLETED' }) data = { status: 'WAIT_CUSTOMER_CONFIRMATION', pallets } @@ -45,7 +61,9 @@ function fixture(fault = '') { assert.equal(body.unitPrice, 25) data = { tripRecordVersion: 3, booking: { ...booking, status: 'RECEIVED', incomingNo: 'IN-1', receipt: { source: { ...source(), status: 'READY_FOR_DISPATCH' } } }, replayed: receiptWrites > 1 } - } else throw Error(`Unexpected ${method} ${path}`) + } else if (method === 'GET' && path.includes('/warehouse-transfers/')) data = { operatingWarehouseId: '42', packageCount: 3, + items: [{ id: '81', skuCode: 'DECLARED-SKU' }], cartons, pallets } + else throw Error(`Unexpected ${method} ${path}`) if (path.includes('/warehouse-transfers/')) data.transferNo = fault === 'wrong incoming' ? 'OTHER' : 'IN-1' return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'req', traceId: 'trace' } }, @@ -56,7 +74,9 @@ function fixture(fault = '') { } } return { context, calls, snapshots, counts: () => ({ incomingWrites, receiptWrites }), args: { demand, tripProof, pdaToken: 'pda', measurements: [2, 1].map(cartonCount => ({ cartonCount, lengthCm: 100, widthCm: 100, - heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40 })) } } + heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40, + boxes: cartonCount === 2 ? [{ length: 100, width: 100, height: 45, weight: 20, quantity: 1 }, { length: 100, width: 100, height: 45, weight: 20, quantity: 1 }] + : [{ length: 100, width: 100, height: 90, weight: 40, quantity: 1 }] })) } } } test('original looseboard booking receives two pallets three boxes and confirms once with replay', async () => { @@ -69,7 +89,19 @@ test('original looseboard booking receives two pallets three boxes and confirms await assert.rejects(receiveLooseboardPool(f.context, f.args)) }) -for (const fault of ['network', 'replay', 'warehouse', 'quantity', 'file', 'disk', 'wrong incoming']) { +for (const native of [true, false]) test(`native Long receipt versions remain bounded: ${native ? 'encoded strings' : 'invalid unsafe value'}`, async () => { + const f = fixture(), request = f.context.request + f.context.request = async (...args) => { + const response = await request(...args) + response.body.data = JSON.parse(JSON.stringify(response.body.data, (key, value) => + /^(recordVersion|tripRecordVersion)$/.test(key) ? native ? String(value) : '9007199254740992' : value)) + return response + } + if (native) assert.equal((await receiveLooseboardPool(f.context, f.args)).status, 'RECEIVED_READY_FOR_DISPATCH') + else { await assert.rejects(receiveLooseboardPool(f.context, f.args), /来货前/); assert.equal(f.counts().incomingWrites, 0) } +}) + +for (const fault of ['network', 'replay', 'warehouse', 'quantity', 'file', 'disk', 'wrong incoming', 'uncounted']) { test(`receipt stops on ${fault} before operational confirmation`, async () => { const f = fixture(fault) await assert.rejects(receiveLooseboardPool(f.context, f.args)) @@ -77,6 +109,7 @@ for (const fault of ['network', 'replay', 'warehouse', 'quantity', 'file', 'disk assert.equal(f.counts().receiptWrites, 0) if (fault === 'network') assert.equal(f.counts().incomingWrites, 1) if (fault === 'disk') assert.equal(f.calls.length, 0) + if (fault === 'uncounted') assert.equal(f.calls.some(call => call.path.endsWith('/palletize')), false) }) } diff --git a/scripts/admin-test-env-modules/looseboard-pool-receipt.mjs b/scripts/admin-test-env-modules/looseboard-pool-receipt.mjs index e2b7136e2..51074e537 100644 --- a/scripts/admin-test-env-modules/looseboard-pool-receipt.mjs +++ b/scripts/admin-test-env-modules/looseboard-pool-receipt.mjs @@ -1,7 +1,7 @@ const API = '/api/platform/v1/last-leg' const OPTIONS = { scenario: 'looseboard-pool-receipt', deploymentBlocker: true } const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') -const validVersion = value => Number.isSafeInteger(value) && value >= 0 +const validVersion = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) const id = value => String(value ?? '') /** 原承运来货经PDA实收及运营确认;测量值由调用方明确给出,不借历史库存。 */ @@ -13,6 +13,11 @@ export async function receiveLooseboardPool(context, { demand, tripProof, pdaTok || typeof context.checkpoint !== 'function' || !Array.isArray(measurements) || !measurements.length || measurements.length !== binding.createRequest.expectedPalletCount || measurements.some(m => !Number.isSafeInteger(m.cartonCount) || m.cartonCount <= 0 + || !Array.isArray(m.boxes) || m.boxes.length !== m.cartonCount + || m.boxes.some(box => ['length', 'width', 'height', 'weight'].some(field => !Number.isFinite(box[field]) || box[field] <= 0) + || !Number.isSafeInteger(box.quantity) || box.quantity <= 0) + || Math.abs(m.boxes.reduce((sum, box) => sum + box.length * box.width * box.height / 1000000, 0) - m.volumeCbm) > 0.000001 + || m.boxes.reduce((sum, box) => sum + box.weight, 0) !== m.grossWeightKg || ['lengthCm', 'widthCm', 'heightCm', 'volumeCbm', 'grossWeightKg'].some(k => !Number.isFinite(m[k]) || m[k] <= 0)) || measurements.reduce((sum, m) => sum + m.cartonCount, 0) !== cargo?.packageCount) { throw Error('散板实收缺少本轮原单承运、PDA身份或逐托测量与箱数依据') @@ -63,11 +68,17 @@ export async function receiveLooseboardPool(context, { demand, tripProof, pdaTok check('来货范围与初始实收状态正确', initial?.incomingNo === proof.incomingNo && id(initial.warehouseId) === id(tripProof.warehouseId) && initial.destinationKey === cargo.destinationKey && initial.status === 'WAIT_PDA_RECEIVING' && initial.pallets?.length === 0) const pda = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}` + const forecast = await io('GET', pda, undefined, pdaToken) + check('逐箱实收只使用原来货预报箱号和唯一预报SKU', forecast?.transferNo === proof.incomingNo + && id(forecast.operatingWarehouseId) === id(tripProof.warehouseId) && forecast.packageCount === cargo.packageCount + && forecast.items?.length === 1 && forecast.items[0].skuCode && forecast.cartons?.length === cargo.packageCount + && new Set(forecast.cartons.map(box => box.cartonMark)).size === cargo.packageCount + && forecast.cartons.every(box => box.cartonMark && box.status === 'EXPECTED' && id(box.transferItemId) === id(forecast.items[0].id))) for (const [index, measurement] of measurements.entries()) { - const draft = await io('POST', `${pda}/pallets/drafts`, { ...measurement, idempotencyKey: key(`measure-${index}`) }, pdaToken) + const draft = await io('POST', `${pda}/pallets/drafts`, { idempotencyKey: key(`measure-${index}`) }, pdaToken) const newPallets = draft?.pallets?.filter(row => !proof.pallets.some(p => id(p.id) === id(row.id))) || [] check('逐托测量只新增本次一个草稿', draft?.transferNo === proof.incomingNo && draft?.pallets?.length === index + 1 && newPallets.length === 1 - && newPallets[0].status === 'DRAFT' && newPallets[0].cartonCount === measurement.cartonCount + && newPallets[0].status === 'DRAFT' && newPallets[0].cartonCount === 0 && /^[1-9]\d*$/.test(id(newPallets[0].id))) const palletId = newPallets[0].id, path = `${pda}/pallets/${encodeURIComponent(palletId)}` const generated = await io('POST', `${path}/generate`, { idempotencyKey: key(`generate-${index}`) }, pdaToken) @@ -77,6 +88,21 @@ export async function receiveLooseboardPool(context, { demand, tripProof, pdaTok await io('POST', `${path}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key(`bind-${index}`) }, pdaToken) proof.pallets.push({ id: palletId, warehousePalletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo, packages: measurement.cartonCount }) } + let offset = 0 + const boxes = measurements.flatMap((measurement, index) => measurement.boxes.map(input => { + const carton = forecast.cartons[offset++] + return { cartonMark: carton.cartonMark, transferItemId: id(carton.transferItemId), transferPalletId: id(proof.pallets[index].id), + length: input.length, width: input.width, height: input.height, weight: input.weight, + items: [{ skuCode: forecast.items[0].skuCode, quantity: input.quantity }] } + })) + proof.controlledBoxInputs = { syntheticTestMaterials: true, boxes } + const counted = await io('POST', `${pda}/cartons/count`, { idempotencyKey: key('count-boxes'), forecastPackageCount: forecast.packageCount, boxes }, pdaToken) + check('逐箱尺寸重量SKU实点与实际托盘归属已保存', counted?.transferNo === proof.incomingNo && counted.cartons?.length === boxes.length + && boxes.every(input => counted.cartons.some(box => box.cartonMark === input.cartonMark && box.status === 'COUNTED' + && id(box.transferPalletId) === input.transferPalletId && ['length', 'width', 'height', 'weight'].every(field => Number(box[field]) === input[field]) + && box.items?.length === 1 && box.items[0].skuCode === input.items[0].skuCode && Number(box.items[0].quantity) === input.items[0].quantity)) + && proof.pallets.every((p, index) => counted.pallets?.some(row => id(row.id) === id(p.id) && row.cartonCount === p.packages + && Number(row.volumeCbm) === measurements[index].volumeCbm && Number(row.grossWeightKg) === measurements[index].grossWeightKg))) const uploaded = await io('POST', `${pda}/measurement-photos/files`, { name: `${context.marker}-TEST-looseboard.png`, type: 'image/png', content: PNG }, pdaToken, true) check('实收测试图片已正式上传', /^[1-9]\d*$/.test(id(uploaded?.fileId))) const received = await io('POST', `${pda}/palletize`, { idempotencyKey: key('palletize'), photoFileIds: [id(uploaded.fileId)] }, pdaToken) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 842e9811d..3b254de0d 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -10,8 +10,8 @@ function fixedContext(profileId = '2100000000000000001') { return { calls, marker: 'FIXED-CUSTOMER-TEST', context: {}, createdResources: {}, options: { fixedCustomerId: 'TEN2100000000000000001', merchantUsername: 'fixture-user', merchantPassword: 'fixture-password' }, - async request(method, path) { - calls.push({ method, path }) + async request(method, path, body) { + calls.push({ method, path, body }) const data = path.endsWith('/login') ? { accessToken: 'fixture-token', refreshToken: 'fixture-refresh' } : path.endsWith('/profile') ? { id: profileId, status: 'ACTIVE', tenantName: 'Fixture' } : { status: 'READY' } @@ -51,6 +51,31 @@ test('second customer fixture cannot silently reuse the first customer', async ( assert.equal(context.calls.length, 0) }) +test('second fixed customer validates its own credentials and profile without creating a customer or publishing primary credentials', async () => { + const context = fixedContext('2100000000000000002') + context.options.fixedSecondaryCustomer = { customerId: 'TEN2100000000000000002', username: 'second-test', password: 'second-password' } + const primaryOptions = structuredClone(context.options) + const before = { username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD } + await run(context, { publishEnvironment: false }) + assert.equal(context.context.merchantQualificationFixture.tenantId, '2100000000000000002') + assert.equal(context.context.merchantQualificationFixture.username, 'second-test') + assert.deepEqual(context.calls[0].body, { loginId: 'second-test', password: 'second-password' }) + assert.equal(context.createdResources.merchantQualificationFixture.password, undefined) + assert.deepEqual(context.options, primaryOptions) + assert.deepEqual({ username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }, before) + assert.deepEqual(context.calls.filter(call => call.method !== 'GET').map(call => call.path), ['/api/auth/login', '/api/auth/logout']) +}) + +for (const fault of ['same customer', 'wrong profile', 'missing credentials']) test(`second fixed customer rejects ${fault}`, async () => { + const context = fixedContext() + context.options.fixedSecondaryCustomer = { customerId: fault === 'same customer' ? context.options.fixedCustomerId : '2100000000000000002', + username: 'second-test', password: fault === 'missing credentials' ? '' : 'second-password' } + await assert.rejects(run(context, { publishEnvironment: false }), /第二个客户|第二个固定客户|归属或状态不匹配/) + assert.ok(context.calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path))) + if (fault !== 'wrong profile') assert.equal(context.calls.length, 0) + else assert.equal(context.calls.at(-1).path, '/api/auth/logout') +}) + test('TEST fixture without fixed customer fails before any request', async () => { const context = fixedContext() context.options = { environment: 'test' } @@ -92,6 +117,18 @@ test('both request entrypoints reject customer creation before network access', } }) +test('report security rejects the secondary password even under an ordinary evidence field', async () => { + for (const leak of [false, true]) { + const password = 'secondary-private-test-credential' + const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true, modules: [], + fixedSecondaryCustomer: { customerId: '456', username: 'second-test', password } }) + runner.request = async () => ({ http: 200, body: { code: 'A0401' } }) + runner.createdResources.note = leak ? `unexpected detail: ${password}` : 'ordinary diagnostic evidence' + const report = await runner.run() + assert.equal(report.steps.find(step => step.module === 'report-security').passed, !leak) + } +}) + const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8') const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index 10c50ad36..fafb0a69b 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -44,11 +44,20 @@ export function normalizeFixedCustomerId(value) { return text } +export function requireFixedSecondaryCustomer(options) { + const secondary = options.fixedSecondaryCustomer + if (!secondary?.customerId || !secondary.username || !secondary.password) + throw new Error('固定客户模式未配置第二个客户,跨客户场景阻断;禁止自动建客或用同一客户替代隔离验证') + if (normalizeFixedCustomerId(secondary.customerId) === normalizeFixedCustomerId(options.fixedCustomerId)) + throw new Error('第二个固定客户必须不同于主客户,禁止替代隔离验证') + return secondary +} + async function reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer) { - if (requireDistinctCustomer) throw new Error('固定客户模式未配置第二个客户,跨客户场景阻断;禁止自动建客或用同一客户替代隔离验证') - const tenantId = normalizeFixedCustomerId(context.options.fixedCustomerId) - const username = context.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME - const password = context.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD + const secondary = requireDistinctCustomer ? requireFixedSecondaryCustomer(context.options) : null + const tenantId = normalizeFixedCustomerId(secondary?.customerId || context.options.fixedCustomerId) + const username = secondary ? secondary.username : context.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME + const password = secondary ? secondary.password : context.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD if (!username || !password) throw new Error('请配置固定客户的 MERCHANT_TEST_USERNAME / MERCHANT_TEST_PASSWORD;不会新建客户或重置密码') const loginResponse = await context.request('POST', '/api/auth/login', {loginId:username,password}, '') const login = context.apiSuccess(MODULE, '登录已配置的固定客户账号', loginResponse) diff --git a/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs index 43a4ae583..880acf485 100644 --- a/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs @@ -2,10 +2,18 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { eligibleOrderDestinations, findPricedOrderRoute, findPricedOrderRouteAcrossOrigins } from './orders.mjs' +import { eligibleOrderDestinations, restrictOrderDestinations, findPricedOrderRoute, findPricedOrderRouteAcrossOrigins } from './orders.mjs' const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8') +test('来源选线只能使用已有转运收货资源支持的目的仓,缺失时不得回退其他线路', () => { + const destinations = [{ warehouseCode: 'ABE3' }, { warehouseCode: 'ABE4' }, { warehouseCode: 'ONT8' }] + assert.deepEqual(restrictOrderDestinations(destinations, { includeDestinationCodes: [' abe4 '] }), [destinations[1]]) + assert.deepEqual(restrictOrderDestinations(destinations, { includeDestinationCodes: ['MISSING'] }), []) + assert.deepEqual(restrictOrderDestinations(destinations, { includeDestinationCodes: ['ABE4'], excludeDestinationCodes: ['ABE4'] }), []) + assert.deepEqual(restrictOrderDestinations(destinations, { excludeDestinationCodes: ['ABE3'] }), destinations.slice(1)) +}) + test('orders fixture verifies the complete pricing tuple before selecting an FBA warehouse', () => { assert.match(source, /\/api\/merchant\/v1\/first-leg\/pricing\/estimate/) for (const field of ['originWarehouseId', 'channelCode', 'destinationCode', 'destinationType', diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 06f97550a..b89280b95 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -314,6 +314,14 @@ export function requestedOrderDestinationType(context) { const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA' } +export function restrictOrderDestinations(destinations, route) { + const codes = values => new Set((values || []).map(value => String(value).trim().toUpperCase()).filter(Boolean)) + const included = codes(route.includeDestinationCodes), excluded = codes(route.excludeDestinationCodes) + return destinations.filter(value => { + const code = String(value.warehouseCode || '').trim().toUpperCase() + return (!included.size || included.has(code)) && !excluded.has(code) + }) +} export function eligibleOrderDestinations(values, destinationType) { const active = (Array.isArray(values) ? values : []).filter((warehouse) => warehouse?.status === 'ACTIVE') return destinationType === 'FBA' @@ -633,8 +641,7 @@ async function loadMasterData(context, merchantToken) { // a producer intended for the second FBA destination can silently reuse ABE4. if (destinationType !== 'PRIVATE') { destinations = [...new Map(destinations.map(value => [String(value.warehouseCode).trim().toUpperCase(), value])).values()] - const excluded = new Set((fixedRoute.excludeDestinationCodes || []).map(value => String(value).trim().toUpperCase()).filter(Boolean)) - if (excluded.size) destinations = destinations.filter(value => !excluded.has(String(value.warehouseCode).trim().toUpperCase())) + destinations = restrictOrderDestinations(destinations, fixedRoute) } let pricingResult diff --git a/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs index 83651295e..06ae0ee24 100644 --- a/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs +++ b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs @@ -117,11 +117,19 @@ test('final acceptance reconciles terminal task and one completion audit after o assert.match(loadingPickService, /private void finalizeCompletion/) }) -test('PDA treats accepted completion as processing, returns to list, and never claims completion early', () => { +test('PDA observes authoritative completion and keeps processing distinct from success', () => { assert.match(pdaCompletionState, /PROCESSING_STATUSES = new Set\(\['COMPLETING', 'PROCESSING'\]\)/) assert.match(pdaCompletionTests, /accepted completion is processing/) assert.match(pdaPage, /loading-completion-state/) - assert.match(pdaPage, /(?:PROCESSING|COMPLETING)[\s\S]{0,1200}navigateBackOr/) + const method = pdaPage.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] + assert.ok(method) + assert.match(method, /await observeLoadingCompletion\(/) + const processing = method.match(/if \(phase === COMPLETION_PHASE\.PROCESSING\) \{([^}]+)\}/)?.[1] + assert.ok(processing) + assert.match(processing, /persistSession\(\)/) + assert.match(processing, /return/) + assert.doesNotMatch(processing, /navigateBackOr|['"]success['"]|装柜已完成/) + assert.match(pdaCompletionTests, /page only announces success without persisting a replay request after authoritative completion/) assert.doesNotMatch(pdaPage, /completeContainerPickingTask[\s\S]{0,700}showToast\('已确认装柜完成'/, 'accepted PROCESSING response must not show a completed success toast') assert.match(pdaPage, /shouldReconcileCompletion/) diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 2fa7bf161..904ced9fa 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -260,7 +260,11 @@ export async function withPlatformBookingScope(context, runLoading) { } export async function prepare(context) { - if (context.options?.fixedCustomerId) throw new Error('混合报关柜需要第二个固定客户,停止订单及装柜造数') + if (context.options?.fixedCustomerId && !context.options.fixedSecondaryCustomer) throw new Error('混合报关柜需要第二个固定客户,停止订单及装柜造数') + if (context.options?.fixedCustomerId) { + const { requireFixedSecondaryCustomer } = await import('./merchant-qualification-fixture.mjs') + requireFixedSecondaryCustomer(context.options) + } const outerMarker = context.marker const isolatedResourceKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'] const isolatedResourceSnapshot = new Map(isolatedResourceKeys.map((name) => [name, { diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index a57f4ea97..7074712f1 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -6,10 +6,28 @@ import { fileURLToPath } from 'node:url' import { requestedOrderDestinationType } from './orders.mjs' import { collectPhase4InboundProducerFixture } from './phase3-loading-finalize.mjs' +import { AcceptanceRun } from '../admin-test-env-acceptance.mjs' const here = dirname(fileURLToPath(import.meta.url)) const read = (name) => readFileSync(resolve(here, name), 'utf8') +test('转运异仓资源不足在造单前阻断,并恢复外层批次和路线', async () => { + let created = false + const originalRoute = { randomIndex: 3 } + const run = { marker: 'MATRIX', phase4ProducerAttempts: new Map(), + options: { fixedCustomerId: '101', fixedPdaActors: [{ warehouseId: '201' }], modules: ['phase4-overseas-client-services'] }, + context: { waitSplitRoute: originalRoute }, createdResources: { phase3Loading: { marker: 'OUTER' } }, + request: async method => { assert.equal(method, 'GET'); return { http: 200, body: { code: '200', data: { total: '0', records: [] } } } }, + apiSuccess: (_module, _name, response) => response.body.data, + preparePhase3LoadingFixture: async () => { created = true; throw Error('unexpected order creation') }, + } + await assert.rejects(AcceptanceRun.prototype.runPhase4ProducerVariant.call(run, 'DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true), /未创建来源订单/) + assert.equal(created, false) + assert.equal(run.marker, 'MATRIX') + assert.equal(run.context.waitSplitRoute, originalRoute) + assert.deepEqual(run.createdResources.phase3Loading, { marker: 'OUTER' }) +}) + test('order fixture defaults to FBA and preserves explicit OVERSEAS or PRIVATE routes', () => { assert.equal(requestedOrderDestinationType({ context: {} }), 'FBA') assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'overseas' } }), 'OVERSEAS') @@ -95,7 +113,7 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) const entry = read('../admin-test-env-acceptance.mjs') assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) - assert.match(entry, /packageCounts: \[4\], palletCount: 2/) + assert.match(entry, /packageCounts: \[5\], palletCount: 2/) assert.match(entry, /sealedSourcePalletNos: Array\.isArray\(fixture\.sealedSourcePalletNos\)/) assert.match(entry, /orderFulfillmentType/) assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/) @@ -125,7 +143,7 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(pda, /FBA与OVERSEAS箱码resolve均为纯查询且零状态写入/) }) -test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => { +test('all receiving and appointment sources use native outbound; waiting source stays below pickup readiness', () => { const entry = read('../admin-test-env-acceptance.mjs') assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/) @@ -138,7 +156,10 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before assert.match(entry, /const hasSecondaryProducer =/) assert.match(entry, /if \(!hasSecondaryProducer\(\)\)/) assert.doesNotMatch(entry, /if \(Number\(readiness\(\)\.distinctContainerCount \|\| 0\) < 4\)/) - const start = entry.indexOf('if (pickupRequired && producerKey)') + assert.match(entry, /const shippingRequired = pickupRequired \|\| label === 'SECONDARY'/) + assert.match(entry, /if \(!pickupRequired\)/) + assert.match(entry, /writeShippingEvidence\(false, ready \? String\(source.queryId\) : null\)/) + const start = entry.indexOf('if (shippingRequired && producerKey)') const end = entry.indexOf('} finally {', start) const pickupChain = entry.slice(start, end) const ordered = [ diff --git a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs index 2e69b8d57..51e75a7c5 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs @@ -23,6 +23,18 @@ test('fixed partner warehouse is reused and never retired', async () => { const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8') +test('卡车履约接受正式Long字符串版本并保持原单重放一致', async () => { + const context = fixture(), request = context.request.bind(context) + context.request = async (...args) => { + const response = structuredClone(await request(...args)) + if (args[1].includes('/us-truck-orders/') && response.body?.data?.summary) + response.body.data.summary.version = String(response.body.data.summary.version) + return response + } + await run(context) + assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'HTTP_VERIFIED') +}) + test('卡车请求先持久化,正常与失败响应均留有证据', async () => { const context = fixture(), original = context.request.bind(context) context.request = async (method, path, ...args) => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs index 6b81bc8ec..167c48cf4 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs @@ -2,11 +2,53 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import { verifyDiscardApproval, submitClientTransfer } from './phase4-overseas-client-services.mjs' +import { verifyDiscardApproval, submitClientTransfer, currentOrders, discardSnapshot } from './phase4-overseas-client-services.mjs' import { parseArguments } from '../admin-test-env-acceptance.mjs' const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8') +for (const fault of ['', 'foreign inbound', 'mixed aggregate']) test(`discard resolves source balance rather than inventing inbound on aggregate: ${fault || 'native grouped list'}`, async () => { + const aggregate = { id: '901', customerId: '101', overseasWarehouseId: '201', skuCode: 'SKU', totalPackages: '5', availablePackages: '5', + holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', inTransitPackages: '0', receivingPackages: '0', procurementPackages: '0' } + const proof = { customerId: '101', warehouseId: '201', skuCode: 'SKU', inboundCode: 'RK', sourceOrderId: '301' } + const calls = [] + const context = { token: 'admin', assert(_m, name, _r, passed) { assert.ok(passed, name) }, + apiSuccess(_m, _n, r) { assert.equal(r.body.code, '200'); return r.body.data }, + async request(method, path) { + assert.equal(method, 'GET'); calls.push(path) + const pathname = new URL(path, 'https://offline.invalid').pathname + const data = pathname.endsWith('/balances') ? { total: '1', records: [aggregate] } + : pathname.endsWith('/ledger') ? { total: '0', records: [] } + : pathname.endsWith('/balances/901') ? { ...aggregate, inboundCode: fault === 'foreign inbound' ? 'OTHER' : 'RK', totalPackages: fault === 'mixed aggregate' ? '4' : '5' } + : [{ orderId: '301', inboundCode: 'RK' }] + return { http: 200, body: { code: '200', data } } + } } + if (fault) await assert.rejects(discardSnapshot(context, 'merchant', proof), /来源余额身份与聚合数量一致/) + else { + const result = await discardSnapshot(context, 'merchant', proof) + assert.equal(result.balances[0].inboundCode, 'RK') + assert.equal(aggregate.inboundCode, undefined) + assert.ok(calls.some(path => path.includes('/balances/901?customerId=101'))) + } +}) + +for (const fault of ['', 'duplicate-page', 'missing-total']) test(`current customer orders preserve dropship and fully page before selection: ${fault || 'page two'}`, async () => { + const calls = [], row = { id: 'DROP', containerNo: 'CURRENT', orderType: 'DROPSHIP' } + const context = { createdResources: { phase4OverseasPda: { completedContainers: ['CURRENT'], orderId: 'DROP' } }, + assert(_m, label, _r, passed) { assert.ok(passed, label) }, apiSuccess(_m, _n, response) { return response.body.data }, + async request(method, path) { + calls.push(path); assert.equal(method, 'GET') + const url = new URL(path, 'https://offline.invalid'); assert.equal(url.searchParams.has('sailingStage'), false) + const data = url.pathname.endsWith('/DROP') ? { order: row, cargo: [] } + : { total: fault === 'missing-total' ? null : 201, records: url.searchParams.get('page') === '1' || fault === 'duplicate-page' + ? Array.from({ length: 200 }, (_, n) => ({ id: `OTHER-${n}`, containerNo: 'HISTORY' })) : [row] } + return { http: 200, body: { code: '200', data } } + } } + if (fault) await assert.rejects(currentOrders(context, 'merchant'), /分页/) + else assert.deepEqual((await currentOrders(context, 'merchant')).map(item => item.row.id), ['DROP']) + assert.ok(calls.every(path => !path.endsWith('/OTHER'))) +}) + for (const fault of ['', 'network', 'server', 'rejected', 'wrong order', 'wrong status', 'throws']) { test(`transfer submit sends once and stops on uncertain result: ${fault || 'success'}`, async () => { const calls = [] @@ -73,7 +115,9 @@ test('relabel transfer and discard cover create replay conflict list detail and 'operationWarehouseId:', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:', 'sourceInboundOrderId:', 'destinationFbaWarehouseCode:', ]) assert.match(source, new RegExp(field)) - assert.match(source, /query\(`\$\{RELABEL\}\/inventory-candidates`, \{/) + assert.match(source, /await readCurrentBoxedInventory\(context, token,/) + assert.match(source, /cartonMark: carton.cartonMark/) + assert.match(source, /cartonIds: \[id\(carton.cartonId\)\]/) for (const token of [ '客户创建贴标申请', '客户贴标申请幂等重放', '贴标同幂等键异载荷返回A0409', '客户查询自身贴标申请列表', '客户查询自身贴标申请详情', 'PENDING_REVIEW', @@ -124,9 +168,10 @@ test('discard approval retains reservation and keeps actual confirmation evidenc for (const fault of ['', 'pending deducts', 'reject does not release', 'approve deducts', 'create conflict mutates']) { test(`discard approval stock boundaries: ${fault || 'formal approval contract'}`, async () => { let state = 'PENDING_REVIEW', approved = false, conflictSeen = false - const baseline = { balances: [{customerId:'123',overseasWarehouseId:'42',availablePackages:'4'}], ledger:[], + const baseline = { balances: [{id:'777',customerId:'123',overseasWarehouseId:'42',skuCode:'SKU',inboundCode:'IP',totalPackages:'4',availablePackages:'4', + holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',inTransitPackages:'0',receivingPackages:'0',procurementPackages:'0'}], ledger:[], source:{orderId:'9',overseasWarehouseId:'42',inboundCode:'IP',availablePackages:'4'},detail:null } - const proof = {sourceOrderId:'9',customerId:'123',warehouseId:'42',inboundCode:'IP',snapshots:{before:baseline}} + const proof = {sourceOrderId:'9',customerId:'123',warehouseId:'42',inboundCode:'IP',skuCode:'SKU',snapshots:{before:baseline}} const calls=[] const context={marker:'fixture',token:'admin',addStep(){}, assert(_m,name,_r,passed){ assert.ok(passed,name); if(name==='重复批准不会提前扣库存或新增历史') throw new Error('CHECKPOINT_PASSED') }, @@ -139,9 +184,10 @@ for (const fault of ['', 'pending deducts', 'reject does not release', 'approve else if(method==='POST' && path.endsWith('/cargo-discards')) { if(body.remark.endsWith('-DIFFERENT')) {conflictSeen=true;return {http:200,body:{code:'A0409'},requestId:'req',traceId:'trace'}} approved=true;state='PENDING_REVIEW';data={summary:{discardNo:'NEW'}} - } else if(path.endsWith('/balances')) { + } else if(path.endsWith('/balances') || path.endsWith('/balances/777')) { const changed=(state==='PENDING_REVIEW' && fault==='pending deducts') || (state==='APPROVED' && fault==='approve deducts') || (conflictSeen && fault==='create conflict mutates') - data={total:'1',records:[{...baseline.balances[0],availablePackages:changed?'3':'4'}]} + const balance={...baseline.balances[0],availablePackages:changed?'3':'4'} + data=path.endsWith('/777')?balance:{total:'1',records:[balance]} } else if(path.endsWith('/ledger')) data={total:'0',records:[]} else if(path.endsWith('/inventory-candidates')) data=[{...baseline.source,availablePackages:state==='REJECTED' && fault!=='reject does not release'?'4':'3'}] else data={summary:{discardNo:approved?'NEW':'OLD',status:state}} diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 2aca7d363..1f4e42a7f 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -6,6 +6,7 @@ import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs' import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' import { fundRelabelWallet } from './phase4-overseas-pda-operations.mjs' import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs' +import { readCurrentBoxedInventory } from './current-boxed-inventory.mjs' const MODULE = 'phase4-overseas-client-services' const SCENARIO = 'phase4-overseas-client-services-lifecycle' @@ -63,14 +64,28 @@ async function loginMerchant(context) { return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null } -async function currentOrders(context, token) { +export async function currentOrders(context, token) { const pda = context.createdResources.phase4OverseasPda const containerNos = [...new Set([...(pda.completedContainers || []), pda.containerNo].map(id).filter(Boolean))] const result = [] for (const containerNo of containerNos) { - const page = await merchant(context, token, 'GET', `查询本轮完成柜${containerNo}的客户尾程订单`, - query(ORDERS, { page: 1, size: 200, containerNo })) - for (const row of records(page.data)) { + const all = [], seen = new Set(); let total + for (let pageNo = 1; pageNo <= 100; pageNo++) { + const page = await merchant(context, token, 'GET', `查询本轮完成柜${containerNo}的客户尾程订单`, + query(ORDERS, { page: pageNo, size: 200, containerNo })) + const count = Number(page.data?.total) + if (page.data?.total == null || !Number.isSafeInteger(count) || count < 0 || !Array.isArray(page.data?.records) + || (total !== undefined && count !== total) || (!page.data.records.length && all.length < count)) + throw Error('客户尾程订单分页不完整或总数变化') + total ??= count + for (const row of page.data.records) { + if (!id(row.id) || seen.has(id(row.id))) throw Error('客户尾程订单分页身份重复或缺失') + seen.add(id(row.id)); all.push(row) + } + if (all.length >= total) break + } + if (all.length !== total) throw Error('客户尾程订单分页未完整读取') + for (const row of all) { if (id(row?.containerNo) !== containerNo || !id(row?.id)) continue const detail = await merchant(context, token, 'GET', `读取本轮尾程订单${id(row.id)}详情`, `${ORDERS}/${encodeURIComponent(id(row.id))}`) @@ -91,27 +106,21 @@ function usableCargo(order) { && nonBlank(item?.productNameZh) && Number(item?.packageCount) > 0) } -async function verifyRelabel(context, token, order) { +async function verifyRelabel(context, token, orders) { + const boxed = await readCurrentBoxedInventory(context, token, + context.createdResources.phase4OverseasPda.overseasWarehouseId, MODULE) + const source = boxed.find(row => orders.some(order => order.row.inboundCode === row.inboundCode && usableCargo(order)?.skuCode === row.skuCode)) + const order = source && orders.find(order => order.row.inboundCode === source.inboundCode && usableCargo(order)?.skuCode === source.skuCode) + if (!order) throw Error('客户换标缺少本轮未占用真实箱库存') + const carton = source.cartons[0] const cargo = usableCargo(order) if (!cargo || !nonBlank(order.row?.overseasWarehouseId)) { blocker(context, '本轮贴标申请候选', `orderId=${id(order.row?.id) || 'missing'}, sku=${cargo?.skuCode || 'missing'}, warehouse=${order.row?.overseasWarehouseId || 'missing'}`) return false } - const inventory = await merchant(context, token, 'GET', '客户查询贴标权威库存候选', - query(`${RELABEL}/inventory-candidates`, { - operationWarehouseId: order.row.overseasWarehouseId, - inboundCode: order.row.inboundCode, - })) - const source = (Array.isArray(inventory.data) ? inventory.data : []).find((item) => - id(item?.inventoryBalanceId) && id(item?.inboundOrderId) - && item?.skuCode === cargo.skuCode && item.inboundCode === order.row.inboundCode - && id(item.overseasWarehouseId) === id(order.row.overseasWarehouseId) - && item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0 - && nonBlank(position.palletNo) && position.palletNo.toUpperCase() !== 'UNBOUND')) - const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0 - && nonBlank(item.palletNo) && item.palletNo.toUpperCase() !== 'UNBOUND') - context.assert(MODULE, '贴标申请使用当前客户真实库存余额、位置和来源入库单', inventory.response, + const position = source.positions.find(item => id(item.positionId) === carton.positionId) + context.assert(MODULE, '贴标申请使用当前客户真实库存余额、位置和来源入库单', null, Boolean(source && position), `balanceId=${id(source?.inventoryBalanceId) || 'missing'}, positionId=${id(position?.positionId) || 'missing'}, inboundOrderId=${id(source?.inboundOrderId) || 'missing'}`, OPTIONS) if (!source || !position) return false @@ -132,9 +141,9 @@ async function verifyRelabel(context, token, order) { shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null, newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), labelRequirement: context.marker, operationType: source.destinationFbaWarehouseCode ? 'CARTON_AND_SKU' : 'SKU_LABEL', - skuItems: [{ skuCode: cargo.skuCode, newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: 1, remark: context.marker }], + skuItems: carton.items.map(item => ({ skuCode: item.skuCode, newSkuCode: `${item.skuCode}-NEW`.slice(0, 128), quantity: Number(item.quantity), remark: context.marker })), relabelFileId: null, relabelFileName: null, relabelFileUrl: null, - cartonMark: `CM-${String(context.marker).slice(-32)}`, + cartonMark: carton.cartonMark, fbaCartonMark: source.destinationFbaWarehouseCode ? `FBA-${String(context.marker).slice(-32)}` : null, destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, }], @@ -210,8 +219,12 @@ export async function submitClientTransfer(context, token, transferNo) { } async function verifyTransfer(context, token, orders) { - const order = orders.find((value) => nonBlank(value.row?.inboundCode) - && nonBlank(value.row?.fbaWarehouseCode) && nonBlank(value.row?.overseasWarehouseId) && usableCargo(value)) + const boxed = await readCurrentBoxedInventory(context, token, + context.createdResources.phase4OverseasPda.overseasWarehouseId, MODULE) + const source = boxed.find(row => row.destinationFbaWarehouseCode && orders.some(order => order.row.inboundCode === row.inboundCode + && usableCargo(order)?.skuCode === row.skuCode)) + const order = source && orders.find(value => value.row.inboundCode === source.inboundCode && usableCargo(value)?.skuCode === source.skuCode) + const carton = source?.cartons[0] const cargo = order ? usableCargo(order) : null if (!order || !cargo) { blocker(context, '本轮仓储转运申请候选', @@ -226,14 +239,14 @@ async function verifyTransfer(context, token, orders) { deliveryAddress: `${order.row.fbaWarehouseCode} ${context.marker}`.slice(0, 1000), packageCount: packages, volumeCbm: volume, deliveryMethod: 'TRUCK', estimatedArrivalAt: future(10), fbaShipmentNo: cargo.shipmentNo || `${context.marker}-TRANSFER-SHIP`, trackingNo: cargo.fbaTrackingNo || null, - fbaBoxNos: cargo.shippingMark || null, remark: context.marker, needRelabel: true, + fbaBoxNos: carton.cartonMark, remark: context.marker, needRelabel: true, labelDeadline: future(7), relabelContactName: '客户贴标验收联系人', relabelContactPhone: '13800000006', relabelChargeAmount: 0.01, relabelDeliveryMethod: 'TRUCK', idempotencyKey: `${context.marker}-CLIENT-TRANSFER`.slice(0, 255), items: [{ skuCode: cargo.skuCode, productNameZh: cargo.productNameZh, - packageCount: packages, shippingMark: cargo.shippingMark || null }], - relabelItems: [{ operationType: 'CARTON_AND_SKU', customerCartonMarks: [cargo.shippingMark || `CM-${context.marker}`], - originalSku: cargo.skuCode, newSku: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: 1, + packageCount: packages, shippingMark: carton.cartonMark }], + relabelItems: [{ operationType: 'CARTON_AND_SKU', customerCartonMarks: [carton.cartonMark], + originalSku: cargo.skuCode, newSku: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: Number(carton.items[0].quantity), fbaCartonMark: `FBA-${context.marker}`.slice(0, 255), destinationWarehouseCode: order.row.fbaWarehouseCode, remark: context.marker }], } @@ -267,6 +280,7 @@ async function verifyTransfer(context, token, orders) { const transferProof = context.createdResources.phase4OverseasClientServices.transferFulfillment = { marker: context.marker, customerId: id(order.row.customerId), warehouseId: id(order.row.overseasWarehouseId), transferNo, inboundCode: order.row.inboundCode, skuCode: cargo.skuCode, status: 'APPROVED', + requiredSourcePositionId: carton.positionId, sourceCartonMark: carton.cartonMark, } await allocateWarehouseTransfer(context, { proof: transferProof, merchantToken: token }) const relabelProof = transferProof.relabelFulfillment = { @@ -294,15 +308,18 @@ export async function verifyDiscard(context, token, markerOrders) { const candidates = context.apiSuccess(MODULE, '客户查询货物废弃库存候选', response, trace(response), OPTIONS) const markerOrderIds = new Set(markerOrders.map((value) => id(value.row?.id))) const candidate = Array.isArray(candidates) ? candidates.find((value) => markerOrderIds.has(id(value?.orderId)) - && Array.isArray(value?.skus) && value.skus.some((sku) => nonBlank(sku?.skuCode) && Number(sku?.availablePackages) > 0)) : null + && value.cartonSelectionSupported === true && value.cartons?.some(box => id(box.cartonId) + && box.skus?.some(sku => nonBlank(sku.skuCode) && Number(sku.availablePackages) > 0))) : null if (!candidate) { blocker(context, '本轮货物废弃库存候选', `candidateOrders=${Array.isArray(candidates) ? candidates.map((value) => id(value?.orderId)).join(',') : 'missing'}, markerOrders=${[...markerOrderIds].join(',') || 'missing'}; 禁止借历史一件代发库存`, response) return false } - const sku = candidate.skus.find((value) => nonBlank(value?.skuCode) && Number(value?.availablePackages) > 0) + const carton = candidate.cartons.find(box => id(box.cartonId) + && box.skus?.some(sku => nonBlank(sku.skuCode) && Number(sku.availablePackages) > 0)) + const sku = carton.skus.find(value => nonBlank(value.skuCode) && Number(value.availablePackages) > 0) const body = { - items: [{ orderId: id(candidate.orderId), skuCode: sku.skuCode, discardPackages: 1 }], + items: [{ orderId: id(candidate.orderId), skuCode: sku.skuCode, discardPackages: 1, cartonIds: [id(carton.cartonId)] }], idempotencyKey: `${context.marker}-CLIENT-DISCARD`.slice(0, 255), remark: context.marker, } const proof = context.createdResources.phase4OverseasClientServices.discardApproval = { @@ -369,7 +386,7 @@ async function discardCommand(context, token, method, name, path, body) { if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`) return result.data } -async function discardSnapshot(context, token, proof, discardNo) { +export async function discardSnapshot(context, token, proof, discardNo) { const all = async (kind) => { const rows = [] let total @@ -388,6 +405,19 @@ async function discardSnapshot(context, token, proof, discardNo) { throw new Error('废弃库存分页超过上限') } const balances = await all('balances'), ledger = await all('ledger') + // 列表按客户、仓、目的仓和SKU聚合,不携带入仓码;用代表余额ID回查真实来源并核对汇总数,不能给聚合行补造来源。 + for (let index = 0; index < balances.length; index++) { + const aggregate = balances[index] + if (aggregate.skuCode !== proof.skuCode) continue + const raw = await discardCommand(context, context.token, 'GET', '废弃核对实际来源余额详情', + query(`/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(id(aggregate.id))}`, { customerId: proof.customerId })) + discardRequire(context, '废弃来源余额身份与聚合数量一致', id(raw?.id) === id(aggregate.id) + && id(raw.customerId) === proof.customerId && raw.overseasWarehouseId === proof.warehouseId + && raw.inboundCode === proof.inboundCode && raw.skuCode === proof.skuCode + && ['totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages'] + .every(field => /^\d+$/.test(String(raw[field])) && Number(raw[field]) === Number(aggregate[field]))) + balances[index] = raw + } const candidates = await discardCommand(context, token, 'GET', '废弃回查本轮客户真实候选', `${DISCARD}/inventory-candidates`) const source = candidates.find(row => id(row.orderId) === proof.sourceOrderId) const detail = discardNo ? await discardCommand(context, token, 'GET', '废弃回查申请真实状态', `${DISCARD}/${encodeURIComponent(discardNo)}`) : null @@ -469,7 +499,7 @@ async function runWithSession(context, token) { blocker(context, '本轮贴标订单明细', `orders=${orders.length}; 缺少真实SKU/品名/箱数或实际海外仓`) return } - const relabelDone = await verifyRelabel(context, token, relabelOrder) + const relabelDone = await verifyRelabel(context, token, orders) const transferDone = await verifyTransfer(context, token, orders) const discardDone = await verifyDiscard(context, token, orders) context.createdResources.phase4OverseasClientServices.finalStatuses = { diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs index c84d709e3..8b7ca806f 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs @@ -66,7 +66,7 @@ function authority({ failSave = false, mutateOrder = false, unknown = 0, drift = if (pathname.endsWith('/orders/order1')) return response(state.order) if (pathname.endsWith('/cargo-verification')) { if (method === 'GET') { - const allocated = state.appointments.filter(a => a.appointmentStatus === 'PENDING' && !a.replacementBatchKeys.length) + const allocated = state.appointments.filter(a => !a.replacementBatchKeys.length) .flatMap(a => a.items).reduce((n, i) => n + i.packageCount, 0) return response({ source: sourceFacts, verification: state.verification, allocatedPackages: String(allocated), availablePackages: String((state.verification?.packageCount ?? packages) - allocated), unknownAllocationCount: String(unknown), sourceChanged: drift }) @@ -123,7 +123,7 @@ test('four real boxes retain original order facts through verification, cancel, assert.equal(state.order.order.packageCount, 4) assert.equal(state.order.order.version, 7) assert.deepEqual(state.verification, { version: 2, sourceVersion: 'source-v1', packageCount: 4 }) - assert.equal(state.appointments.length, 5) + assert.equal(state.appointments.length, 4) assert.equal(state.history.size, 1) assert.equal(state.history.get(result.cancelledAppointmentNo).length, 1) assert.equal(result.replacedBatchKeys.length, 3) diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs index 88540b3c0..2f70d6df7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs @@ -1,3 +1,5 @@ +import { NODE_SEQUENCES, STANDARD_NODES, manualNodePayload } from './phase4-carrier-nodes.mjs' + const MODULE = 'phase4-overseas-inbound-operations' const OPTIONS = { scenario: 'phase4-inbound-admin-and-inventory-commands', deploymentBlocker: true } const PLAN_BASE = '/api/platform/v1/last-leg/inbound-plans' @@ -112,12 +114,14 @@ export async function prepareAppointmentFixture(context, { customerId, plan, fba && history.records[0].snapshot?.plannedDate === detail.plannedDate, appointmentNo) for (const v of verified) { const restored = await request(context, 'GET', '取消后逐票可分配量恢复', query(`${APPOINTMENT_BASE}/sources/${encodeURIComponent(v.sourceKey)}/cargo-verification`, { customerId, destinationKey: v.destinationKey })) - requireProof(context, '取消未删除核实事实且恢复剩余需求', count(restored.allocatedPackages) === 0 && count(restored.availablePackages) === v.packageCount + requireProof(context, '取消保留核实事实及原批次货物分配', count(restored.allocatedPackages) === v.packageCount && count(restored.availablePackages) === 0 && restored.verification?.version === v.cargoVerificationVersion && restored.sourceChanged === false, v.sourceKey) } - const active = await request(context, 'POST', '为本轮后续验收重新建立有效批次', `${PLAN_BASE}/fba-appointments/batch`, { ...createBody, idempotencyKey: `${createBody.idempotencyKey}-active` }) - requireProof(context, '重新安排生成新批次且逐票数量不变', active.batchKey === `FBA_APPOINTMENT:${active.id}` && active.batchKey !== cancelled.batchKey - && active.appointmentNo !== appointmentNo && active.appointmentStatus === 'PENDING' && JSON.stringify(exactItems(active)) === JSON.stringify(exactItems(detail)), active?.appointmentNo || 'missing') + const active = await request(context, 'PUT', '在原批次重新安排本轮有效预约', detailPath, + { version: cancelled.version, idempotencyKey: `${createBody.idempotencyKey}-active`, details: createBody.details }) + requireProof(context, '重新安排复用原批次且逐票数量不变', active.batchKey === cancelled.batchKey + && active.appointmentNo === appointmentNo && active.version === cancelled.version + 1 + && active.appointmentStatus === 'PENDING' && JSON.stringify(exactItems(active)) === JSON.stringify(exactItems(detail)), active?.appointmentNo || 'missing') const activeContext = await request(context, 'GET', '回查保留的有效批次', `${APPOINTMENT_BASE}/batches/${encodeURIComponent(active.batchKey)}`) requireProof(context, '保留批次上下文完整可供后续HTTP核对', activeContext?.appointment?.batchKey === active.batchKey && activeContext.appointment.appointmentStatus === 'PENDING', active.batchKey) @@ -242,6 +246,7 @@ async function inboundAdminLifecycle(context) { && id(supplement?.destinationWarehouseId) === id(sample.destinationWarehouseId), `palletId=${id(supplement?.id) || 'missing'}, status=${supplement?.assignmentStatus || 'missing'}`, OPTIONS) + await ensureAppointmentDeparture(context, producer, order.id) const appointmentProof = await prepareAppointmentFixture(context, { customerId, plan, fbaLines }) context.createdResources.phase4OverseasInboundAdmin = { marker: context.marker, customerId, orderId: id(order.id), planNo, @@ -249,6 +254,54 @@ async function inboundAdminLifecycle(context) { } } +/** 使用本轮自动船期任务推进实际开船事实,再等待尾程消费,不能用人工订单节点绕过来源校验。 */ +export async function ensureAppointmentDeparture(context, producer, orderId) { + if (producer?.marker !== context.marker || !id(producer.carrierScheduleQueryId) + || !id(producer.containerBookingId) || !id(producer.tenantId) || !id(orderId)) + throw Error('预约开船前置缺少本轮船期任务身份') + const base = `/api/platform/v1/carrier-schedule-queries/${encodeURIComponent(id(producer.carrierScheduleQueryId))}` + let detail = await request(context, 'GET', '核对预约来源的原生船期任务', base) + const task = detail?.query?.query + if (id(task?.id) !== id(producer.carrierScheduleQueryId) || id(task?.customerId) !== id(producer.tenantId) + || id(task?.containerBookingId) !== id(producer.containerBookingId) || task?.trackingNo !== producer.containerNo) + throw Error('预约来源船期任务客户或柜身份不一致') + const proof = context.createdResources.appointmentDeparturePrerequisite = { + marker: context.marker, queryId: id(task.id), containerBookingId: id(producer.containerBookingId), commands: [], status: 'PREPARING', + } + for (const node of STANDARD_NODES.slice(0, 4)) { + const current = detail.query.query + if ((NODE_SEQUENCES[current.latestActualNode] || 0) >= NODE_SEQUENCES[node]) continue + const now = new Date() + const local = value => new Date(value.getTime() - value.getTimezoneOffset() * 60000).toISOString().slice(0, 19) + const priorAt = current.latestActualAt && Date.parse(current.latestActualAt) + const actualAt = Number.isFinite(priorAt) ? new Date(priorAt + 60000) : new Date(now.getTime() - 10 * 60000) + const body = manualNodePayload({ expectedVersion: Number(current.version), standardNode: node, + observedAt: local(now), actualAt: local(actualAt), marker: context.marker }) + if (!Number.isSafeInteger(body.expectedVersion) || body.expectedVersion < 0) throw Error('预约来源船期版本无效') + const headers = { 'Idempotency-Key': key(context, `appointment-departure-${node}`) } + const command = { body, idempotencyKey: headers['Idempotency-Key'], status: 'PREPARED' } + proof.commands.push(command); await context.checkpoint?.() + command.status = 'UNKNOWN_RESULT' + await request(context, 'POST', '补齐预约来源原生实际船节点', `${base}/manual-nodes`, body, headers) + command.status = 'SUCCEEDED' + detail = await request(context, 'GET', '回查原生实际船节点', base) + if ((NODE_SEQUENCES[detail?.query?.query?.latestActualNode] || 0) < NODE_SEQUENCES[node]) + throw Error('实际船节点补录未推进原船期任务') + } + for (let attempt = 1; attempt <= 30; attempt++) { + const response = await context.request('GET', query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(orderId))}`, + { customerId: id(producer.tenantId) })) + const order = response?.body?.data?.order + if (ok(response) && id(order?.customerId) === id(producer.tenantId) + && id(order?.sourceContainerBookingId) === id(producer.containerBookingId) + && !order.manualNodeOverride && (NODE_SEQUENCES[order.containerNode] || 0) >= 40) { + proof.status = 'CONSUMED'; return proof + } + if (attempt < 30) await new Promise(resolve => setTimeout(resolve, 1000)) + } + throw Error('预约来源开船事实未被尾程消费,停止预约写入') +} + async function inventoryCommandLifecycle(context) { const pda = context.createdResources?.phase4OverseasPda const customerId = id(pda?.customerId) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index 16f76a0b0..d5c409e98 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -25,14 +25,16 @@ test('fixed customer relabel wallet only reads existing available funds', async }) test('relabel uses three physical cartons across current inbounds, never UNBOUND or duplicate positions', () => { - const a = { inventoryBalanceId: 'A', inboundOrderId: '1', positions: [ + const a = { inventoryBalanceId: 'A', inboundOrderId: '1', cartons: [ + { cartonMark: 'A1', positionId: '10' }, { cartonMark: 'A2', positionId: '10' }, { cartonMark: 'A3', positionId: '11' }], positions: [ { positionId: '10', palletNo: 'REAL-A', availablePackages: 2 }, { positionId: '11', palletNo: 'UNBOUND', availablePackages: 1 }] } - const b = { inventoryBalanceId: 'B', inboundOrderId: '2', positions: [ + const b = { inventoryBalanceId: 'B', inboundOrderId: '2', cartons: [{ cartonMark: 'B1', positionId: '20' }], positions: [ { positionId: '20', palletNo: 'REAL-B', availablePackages: 1 }] } const selected = selectPhysicalRelabelSources([b, a, a], 'A') assert.deepEqual(selected.map(line => line.position.palletNo), ['REAL-A', 'REAL-A', 'REAL-B']) assert.equal(selectPhysicalRelabelSources([a, a], 'A').length, 2) + assert.equal(selectPhysicalRelabelSources([{ ...a, cartons: [] }], 'A').length, 0) }) test('exception uses the untouched third carton, independent of response ordering', () => { @@ -87,9 +89,9 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409', 'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'COMPLETED', 'chargeAmount: RELABEL_CHARGE_AMOUNT', 'cartonMark:', 'fbaCartonMark:', - 'inventory-candidates', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:', + 'readCurrentBoxedInventory', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:', 'sourceInboundOrderId:', 'operationWarehouseId:', 'relabelItemForUnit', 'sourceLines.map', - 'sourceLines.push({ source, position, packageCount: 1 })', 'oldLabel: item.cartonMark', + 'sourceLines.push({ source, position, carton, packageCount: 1 })', 'oldLabel: item.cartonMark', 'id(candidate.itemId) !== id(item.itemId)', 'oldLabel: null', 'newLabel: item.fbaCartonMark || item.newSkuCode', "operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL'", 'skuItems:', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 1a33f830f..e50c9f308 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -1,5 +1,6 @@ import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs' import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs' +import { readCurrentBoxedInventory } from './current-boxed-inventory.mjs' const MODULE = 'phase4-overseas-pda-operations' export const RELABEL_CHARGE_AMOUNT = 0.01 @@ -364,14 +365,17 @@ export function selectPhysicalRelabelSources(candidates, preferredBalanceId) { const seen = new Set() for (const source of ordered) { if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue - for (const position of source.positions || []) { + const usedByPosition = new Map() + for (const carton of source.cartons || []) { + const position = (source.positions || []).find(row => id(row.positionId) === id(carton.positionId)) const positionId = id(position?.positionId) - if (!positionId || seen.has(positionId) || !nonBlank(position?.palletNo) + const cartonKey = `${id(source.inboundOrderId)}:${carton.cartonMark}` + if (!carton.cartonMark || seen.has(cartonKey) || !positionId || !nonBlank(position?.palletNo) || position.palletNo.toUpperCase() === 'UNBOUND') continue - seen.add(positionId) - const selected = Math.min(3 - sourceLines.length, Number(position.availablePackages)) - if (!Number.isInteger(selected) || selected <= 0) continue - for (let index = 0; index < selected; index++) sourceLines.push({ source, position, packageCount: 1 }) + const used = usedByPosition.get(positionId) || 0 + if (used >= Number(position.availablePackages)) continue + seen.add(cartonKey); usedByPosition.set(positionId, used + 1) + sourceLines.push({ source, position, carton, packageCount: 1 }) if (sourceLines.length === 3) return sourceLines } } @@ -388,25 +392,11 @@ async function verifyRelabel(context, token, merchantToken, fixture) { return false } if (!await fundRelabelWallet(context, customerId)) return false - // P4 intentionally leaves one carton UNBOUND. P5 requires physical source pallets, - // and may combine only the current completed container's same-destination inbounds. - const inboundCodes = [...new Set((context.context.phase4OverseasPdaOperations?.completedFixtures || []) - .filter((item) => item.containerNo === fixture.containerNo && item.destinationType === fixture.destinationType - && item.fbaWarehouseCode === fixture.fbaWarehouseCode) - .map((item) => item.inboundCode).filter(nonBlank))] - const inventoryCandidates = [] - let inventoryCandidatesResponse = null - for (const inboundCode of inboundCodes) { - inventoryCandidatesResponse = await context.request('GET', query( - '/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { - operationWarehouseId: fixture.overseasWarehouseId, inboundCode, - }), undefined, merchantToken, {}) - context.addStep(MODULE, 'P5读取本轮同柜同目的仓换标候选', inventoryCandidatesResponse, - success(inventoryCandidatesResponse), trace(inventoryCandidatesResponse), OPTIONS) - if (!success(inventoryCandidatesResponse)) return false - inventoryCandidates.push(...records(inventoryCandidatesResponse.body?.data).filter((item) => - item.inboundCode === inboundCode && id(item.overseasWarehouseId) === id(fixture.overseasWarehouseId))) - } + const boxed = await readCurrentBoxedInventory(context, merchantToken, fixture.overseasWarehouseId, MODULE) + // 同一次收货产生的真实有箱库存;优先选一个具备三箱来源的入仓单,避免借用历史库存。 + const candidate = boxed.find(row => selectPhysicalRelabelSources([row]).length === 3) + const inventoryCandidates = candidate ? [candidate] : [] + const inventoryCandidatesResponse = null const sourceLines = selectPhysicalRelabelSources(inventoryCandidates, inventory.balanceId) const requiredPackages = 3 - sourceLines.length context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse, @@ -426,7 +416,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: RELABEL_CHARGE_AMOUNT, remark: context.marker, idempotencyKey: key(context, 'RELABEL-CREATE'), deliveryMethod: 'TRUCK', printShippingLabel: false, - items: sourceLines.map(({ source, position, packageCount }, index) => { + items: sourceLines.map(({ source, position, carton, packageCount }, index) => { const newSkuCode = `${source.skuCode}-NEW-${markerSuffix}-${index + 1}`.slice(0, 128) const changesCartonLabel = nonBlank(source.destinationFbaWarehouseCode) return { @@ -437,11 +427,11 @@ async function verifyRelabel(context, token, merchantToken, fixture) { shipmentNo: `SHIP-${context.marker}-${index + 1}`.slice(0, 128), newSkuCode, labelRequirement: context.marker, - cartonMark: nonBlank(source.cartonMark) ? source.cartonMark : `CM-${String(context.marker).slice(-28)}-${index + 1}`, + cartonMark: carton.cartonMark, fbaCartonMark: changesCartonLabel ? `FBA-${String(context.marker).slice(-27)}-${index + 1}` : null, destinationFbaWarehouseCode: changesCartonLabel ? source.destinationFbaWarehouseCode : null, operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL', - skuItems: [{ skuCode: source.skuCode, newSkuCode, quantity: packageCount, remark: context.marker }], + skuItems: carton.items.map(item => ({ skuCode: item.skuCode, newSkuCode, quantity: Number(item.quantity), remark: context.marker })), } }), } diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs index 1b21b38cb..b5cf712da 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs @@ -214,14 +214,30 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken, } const candidates = await command(context.token, 'GET', '查询换标完成后的退货出库候选', query(`${root}/return/candidates`, { customerId, warehouseId })) + const completed = await command(pdaToken, 'GET', '读取本轮已完成换标任务的实际箱身份', + `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(relabelNo)}`) + require('同一换标任务已完成且实际箱身份唯一', completed.relabelNo === relabelNo + && completed.taskStatus === 'COMPLETED' && completed.applicationStatus === 'COMPLETED' + && completed.items?.length === 1 && id(completed.items[0].returnCartonId) + && Number(completed.items[0].relabelledPackages) === 1) + const cartonSources = await command(merchantToken, 'GET', '客户按原退货单核对已换标箱ID与箱号', + query('/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { + operationWarehouseId: warehouseId, inboundCode: returnNo, + })) + const matched = (Array.isArray(cartonSources) ? cartonSources : []).filter(row => + id(row.returnCartonId) === id(completed.items[0].returnCartonId)) + require('任务实际箱唯一映射为客户库存箱号', matched.length === 1 && nonBlank(matched[0].cartonMark) + && id(matched[0].overseasWarehouseId) === warehouseId) const selected = [] for (const row of (Array.isArray(candidates) ? candidates : []).filter(row => row.returnNo === returnNo)) { const detail = await command(merchantToken, 'GET', '客户核对出库候选的真实换标归属', `${inventory}/${encodeURIComponent(row.cartonMark)}`) - if (detail.relabelNo !== relabelNo) continue + if (detail.cartonMark !== matched[0].cartonMark) continue require('已换标候选归属数量与完成状态正确', detail.customerId === customerId && detail.overseasWarehouseId === warehouseId && detail.sourceReturnNo === returnNo && detail.status === 'AVAILABLE' && detail.relabelStatus === 'COMPLETED' - && Number(detail.totalQuantity) === 1 && detail.items?.length === 1) + && (!detail.relabelNo || detail.relabelNo === relabelNo) + && Number(detail.totalQuantity) === 1 && detail.items?.length === 1 + && detail.items[0].skuCode === completed.items[0].skuCode) selected.push(detail) } require('只出库本轮实际换标的一件库存', selected.length === 1) @@ -402,13 +418,16 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re const sources = await command(pdaToken, 'POST', '读取本轮换标实际来源托盘', `${pallets}/relabel-sources`, { relabelNo }) require('退货换标具有明确来源托盘', Array.isArray(sources) && sources.length > 0) for (const [index, source] of sources.entries()) { + const unbound = source.palletCode === 'UNBOUND' && id(source.palletId) === '0' + require('来源必须是实际托盘或明确未组托退货箱', unbound + || (nonBlank(source.palletCode) && source.palletCode !== 'UNBOUND' && /^[1-9]\d*$/.test(id(source.palletId)))) const target = await command(pdaToken, 'POST', '生成本轮带目的仓换标托盘', `${pallets}/relabel-pallet-label`, { relabelNo, idempotencyKey: key(context, `RETURN-PALLET-${index}`) }) - const from = await command(pdaToken, 'POST', '回查来源托盘版本', `${pallets}/pallets/resolve`, { code: source.palletCode }) + const from = unbound ? null : await command(pdaToken, 'POST', '回查来源托盘版本', `${pallets}/pallets/resolve`, { code: source.palletCode }) const to = await command(pdaToken, 'POST', '回查目标托盘版本', `${pallets}/pallets/resolve`, { code: target.palletCode }) require('组托仅使用本轮任务箱唛', source.cartonMarks?.length > 0 && source.cartonMarks.every(mark => task.items.some(item => item.cartonMark === mark))) - const body = { relabelNo, source: { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) }, + const body = { relabelNo, source: from ? { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) } : null, target: { palletCode: to.palletCode, expectedVersion: Number(to.palletVersion) }, actualSource: { positionId: source.positionId, expectedPositionVersion: Number(source.positionVersion), packages: task.items.filter(item => source.cartonMarks.includes(item.cartonMark)) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs index cf43cccce..07f2087e7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -32,7 +32,7 @@ test('relabelled outbound refuses another relabel application before creating an return { http: 200, body: { code: '200', data: path.includes('/candidates') ? [{ returnNo: 'RT-1', cartonMark: 'BOX' }] : { relabelNo: 'RL-OTHER' } } } } } - await assert.rejects(outboundRelabelledReturn(context, 'merchant', 'pda', 'RL-1', 'RT-1', '42'), /只出库本轮/) + await assert.rejects(outboundRelabelledReturn(context, 'merchant', 'pda', 'RL-1', 'RT-1', '42'), /实际箱身份/) assert.ok(calls.every(call => call.method === 'GET')) }) @@ -58,6 +58,11 @@ function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true data = { status: 'IN_PROGRESS' } } else if (path.endsWith('/complete')) { completed = true; data = { status: 'COMPLETED' } } else if (path.endsWith('/candidates')) { reserved = false; data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }] } + else if (path.endsWith('/relabel-tasks/RL-1')) data = { relabelNo: fault === 'foreign task' ? 'OTHER' : 'RL-1', + taskStatus: fault === 'pending task' ? 'IN_PROGRESS' : 'COMPLETED', applicationStatus: 'COMPLETED', + items: [{ returnCartonId: '100', skuCode: 'SKU', relabelledPackages: 1 }] } + else if (path.endsWith('/inventory-candidates')) data = [{ returnCartonId: fault === 'wrong source carton id' ? 'OTHER' : '100', + cartonMark: 'BOX', overseasWarehouseId: '42' }] else if (path.endsWith('/outbound-orders/return')) { reserved = true; data = { taskNo: 'TASK', outboundNo: 'OUT' } } else if (path.endsWith('/return-inventory')) { assert.equal(url.searchParams.get('customerId'), '123'); assert.equal(url.searchParams.get('overseasWarehouseId'), '42') @@ -77,7 +82,8 @@ function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true if (picked && fault === 'duplicate movement') rows.push(movement) data = { records: rows.reverse(), total: rows.length } } else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123', - overseasWarehouseId: '42', sourceReturnNo: 'RT-1', relabelNo: 'RL-1', relabelStatus: 'COMPLETED', + overseasWarehouseId: '42', sourceReturnNo: 'RT-1', relabelNo: null, relabelStatus: 'COMPLETED', + skuConversions: [], status: picked ? 'OUTBOUNDED' : reserved ? 'OUTBOUND_PENDING' : 'AVAILABLE', skuTypes: 1, totalQuantity: quantity, receivedAt: picked && fault === 'history time drift' ? '2026-10-02T17:54:38.305' : '2026-10-02T17:52:56.794', items: [{ skuCode: 'SKU', quantity: picked && fault === 'history erased' ? 0 : quantity }] } @@ -176,7 +182,13 @@ test('relabelled outbound shares accounting and strict PDA scan checks', async ( assert.equal(context.createdResources.phase4OverseasReturnReceipt.relabelOutboundNo, 'OUT') }) -test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => { +for (const fault of ['foreign task', 'wrong source carton id', 'pending task']) test(`completed relabel outbound rejects invalid authoritative task mapping: ${fault}`, async () => { + const { context, calls } = outboundHarness(fault, 1) + await assert.rejects(outboundRelabelledReturn(context, 'merchant', 'pda', 'RL-1', 'RT-1', '42'), /箱身份|库存箱号/) + assert.ok(calls.every(call => call.method === 'GET')) +}) + +for (const unbound of [false, true]) test(`return relabel lifecycle requires review, quantities, binding, photo and customer confirmation: unbound=${unbound}`, async () => { const calls = [] const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', skuCode: 'SKU-1', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] } const context = { marker: 'fixture', token: 'admin', createdResources: { phase4OverseasReturnReceipt: {} }, @@ -189,13 +201,14 @@ test('return relabel lifecycle requires review, quantities, binding, photo and c assert.equal(body.cartonMark, 'BOX') assert.equal(body.skuCode, 'SKU-1') } - if (path.endsWith('/relabel-sources')) data = [{ positionId: '12', positionVersion: 0, palletCode: 'OLD', cartonMarks: ['BOX'] }] + if (path.endsWith('/relabel-sources')) data = [{ positionId: '12', positionVersion: 0, palletId: unbound ? '0' : '42', palletCode: unbound ? 'UNBOUND' : 'OLD', cartonMarks: ['BOX'] }] if (path.endsWith('/relabel-pallet-label')) data = { palletCode: 'NEW' } if (path.endsWith('/resolve')) { assert.equal(path, '/api/pda/v1/last-leg/pallet-operations/pallets/resolve') data = { palletCode: body.code, palletVersion: 1 } } if (path.endsWith('/relabel-disassemble')) { + assert.deepEqual(body.source, unbound ? null : { palletCode: 'OLD', expectedVersion: 1 }) assert.deepEqual(body.actualSource, { positionId: '12', expectedPositionVersion: 0, packages: 1 }) data = { status: 'COMPLETED', operationId: '9', replayed: true } } @@ -218,6 +231,7 @@ test('return relabel lifecycle requires review, quantities, binding, photo and c assert.ok(index('/photos/files') < index('/RL-1/complete')) assert.ok(index('/RL-1/complete') < index('/confirm-completion')) assert.equal(calls.find(c => c.path.endsWith('/confirm-completion')).token, 'merchant') + assert.deepEqual(calls.filter(c => c.path.endsWith('/resolve')).map(c => c.body.code), unbound ? ['NEW'] : ['OLD', 'NEW']) }) for (const missing of ['cartonMark', 'skuCode', 'expectedPackages']) { diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs index 69e8f423a..444fea329 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -365,6 +365,24 @@ test('F40 selects actual source warehouse irrespective of authority list order a assert.doesNotMatch(source, /resolveSharedWarehouse|19300000000020000[34]/) }) +for (const fault of ['', 'changed-total', 'repeated-page', 'missing-page']) test(`F40 fully pages sources before proving unique receiving warehouse: ${fault || 'target on page two'}`, async () => { + const f = warehouseSelectionFixture(), original = f.context.request + f.context.request = async (method, path) => { + if (path.includes('/warehouse-admin?')) return original(method, path) + f.calls.push({ method, path }) + const url = new URL(path, 'https://offline.invalid'), page = Number(url.searchParams.get('page')) + const target = f.orders.filter(row => row.containerNo === url.searchParams.get('containerNo')) + const unrelated = Array.from({ length: 200 }, (_, n) => ({ id: `OTHER-${n}`, customerId: 'OTHER' })) + const records = page === 1 || fault === 'repeated-page' ? unrelated : fault === 'missing-page' ? [] : target + return { http: 200, body: { code: 200, data: { records, total: fault === 'changed-total' && page === 2 ? 202 : 201 } } } + } + if (fault) await assert.rejects(resolveF40ReceivingWarehouse(f.context, f.producers), /分页/) + else { + assert.equal((await resolveF40ReceivingWarehouse(f.context, f.producers)).id, 'actual-warehouse') + assert.equal(f.calls.filter(row => row.path.includes('page=2')).length, 2) + } +}) + test('F40 warehouse resolution rejects wrong customer, booking, inbound identity, duplicates and missing assignment', async () => { for (const mutate of [ f => { f.orders[0].customerId = 'foreign' }, @@ -569,6 +587,7 @@ test('early F40 failures become FAILED with only a safe stage and preserve the o function nativeFragmentFixture() { const context = fixture(), events = [], calls = [] + context.createdResources.phase4OverseasPda = { generatedPlanIds: ['81', '82'] } const producer = { tenantId: '90', inboundCode: 'IN-F40', sourceInboundId: '901', sourceOrderId: '100', rootOrderId: '100', containerBookingId: '902' } const rootProof = { fragments: [producer], currentStage: 'DISPATCH_AND_SIGNATURE_1' } @@ -584,7 +603,9 @@ function nativeFragmentFixture() { let data if (method === 'GET' && path.includes('/candidates?')) { assert.equal(new URL(path, 'https://offline.invalid').searchParams.get('warehouseId'), '80') - assert.equal(new URL(path, 'https://offline.invalid').searchParams.has('sourceStage'), false) + assert.equal(new URL(path, 'https://offline.invalid').searchParams.get('sourceStage'), 'RECEIVED') + assert.equal(new URL(path, 'https://offline.invalid').searchParams.get('planIds'), '81,82') + assert.equal(new URL(path, 'https://offline.invalid').searchParams.get('size'), '50') data = { complete: true, candidates: [nativeF40Candidate()] } } else if (method === 'POST' && path.endsWith('/dispatches')) { events.push('create') diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 650043673..78fa2a883 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -38,15 +38,28 @@ export async function resolveF40ReceivingWarehouse(context, producers) { && new Set(producers.map(row => id(row.tenantId))).size === 1, 'exact-producer-identities') const sources = [] for (const producer of producers) { - const page = await api(context, 'GET', 'F40 读取本柜实际尾程来源与接柜仓', - query('/api/platform/v1/last-leg/orders', { page: 1, size: 200, containerNo: producer.containerNo })) - const rows = Array.isArray(page?.records) ? page.records : [] + const rows = [], seen = new Set(); let total + for (let page = 1; page <= 100; page++) { + const data = await api(context, 'GET', 'F40 读取本柜实际尾程来源与接柜仓', + query('/api/platform/v1/last-leg/orders', { page, size: 200, containerNo: producer.containerNo })) + check(context, 'F40 来源柜客户入仓身份分页完整且总数稳定', Array.isArray(data?.records) + && Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0 + && (total === undefined || total === Number(data.total)) && data.records.length <= 200 + && (data.records.length > 0 || rows.length === Number(data.total)), `page=${page}`) + total ??= Number(data.total) + for (const row of data.records) { + const key = `${id(row.customerId)}:${id(row.id)}` + check(context, 'F40 来源柜客户入仓身份分页无重复或缺失', Boolean(id(row.id)) && !seen.has(key), `page=${page}`) + seen.add(key); rows.push(row) + } + if (rows.length >= total) break + } + check(context, 'F40 来源柜客户入仓身份分页完整', rows.length === total, `rows=${rows.length}, total=${total}`) const matching = rows.filter(row => row.containerNo === producer.containerNo && id(row.sourceContainerBookingId) === id(producer.containerBookingId) && id(row.customerId) === id(producer.tenantId) && id(row.sourceInboundId) === id(producer.sourceInboundId) && row.inboundCode === producer.inboundCode) check(context, 'F40 尾程来源柜客户入仓身份唯一且接柜仓明确', matching.length === 1 - && (page?.total == null || Number(page.total) <= rows.length) && Boolean(id(matching[0]?.id) && id(matching[0]?.overseasWarehouseId)), `container=${producer.containerNo}, exactSources=${matching.length}`) sources.push(matching[0]) @@ -504,8 +517,12 @@ export async function sendFragment(context, producer, picker, merchantToken, ind context.createdResources.phase5OverseasFulfillment = dispatchProof context = await fulfillmentEvidenceContext(context) try { + const planIds = context.createdResources?.phase4OverseasPda?.generatedPlanIds + check(context, 'F40 正式候选限定本轮转换计划', Array.isArray(planIds) && planIds.length > 0 + && planIds.every(value => /^[1-9][0-9]*$/.test(id(value))) && new Set(planIds.map(id)).size === planIds.length, + 'current-converted-plan-scope') const candidates = await api(context, 'GET', '读取该来源入仓码原生实收排车候选', - query(`${DISPATCH}/candidates`, { warehouseId })) + query(`${DISPATCH}/candidates`, { warehouseId, sourceStage: 'RECEIVED', planIds: planIds.map(id).join(','), page: 1, size: 50 })) check(context, 'F40 正式候选列表完整', candidates?.complete === true && Array.isArray(candidates.candidates), 'native-complete-candidate-list') const matches = candidates.candidates.filter(row => row.selection?.sourceType === 'INBOUND_PLAN' && row.inboundCode === producer.inboundCode && id(row.customerId) === customerId && id(row.warehouseId) === warehouseId) diff --git a/scripts/admin-test-env-modules/procurement-sample-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/procurement-sample-fulfillment-contract.test.mjs index d538c27c7..ea9066358 100644 --- a/scripts/admin-test-env-modules/procurement-sample-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/procurement-sample-fulfillment-contract.test.mjs @@ -131,6 +131,17 @@ test('sample procurement traverses receipt, packing, both shipments and exact wa assert.ok(proof.checks.every(check => check.passed)) }) +test('正式Long字符串版本贯穿采购正常履约', async () => { + const h = completeFixture(), request = h.context.request.bind(h.context) + h.context.request = async (...args) => { + const response = await request(...args) + const order = response.body?.data?.customerView?.order || response.body?.data?.order + if (order) order.version = String(order.version) + return response + } + assert.equal((await verifyProcurementSample(h.context, h.inputs)).status, 'PASSED') +}) + for (const fault of ['aggregate customer', 'aggregate order', 'aggregate version', 'create replay customer', 'customer final order', 'replay deducts']) { test(`procurement stops rather than passing ${fault}`, async () => { const h = completeFixture(fault) diff --git a/scripts/admin-test-env-modules/procurement-sample-fulfillment.mjs b/scripts/admin-test-env-modules/procurement-sample-fulfillment.mjs index 3b24ccc7c..bc355af29 100644 --- a/scripts/admin-test-env-modules/procurement-sample-fulfillment.mjs +++ b/scripts/admin-test-env-modules/procurement-sample-fulfillment.mjs @@ -4,6 +4,7 @@ export const PROCUREMENT_REQUIRED_AVAILABLE_BALANCE = '3.00' const PLATFORM = '/api/platform/v1/last-leg/overseas-procurements' const MERCHANT = '/api/merchant/v1/last-leg/overseas-procurements' const clone = value => value == null ? value : structuredClone(value) +const version = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : NaN const cents = value => { const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(String(value)) if (!match) throw new Error('钱包金额缺失或精度异常') @@ -48,11 +49,11 @@ export async function verifyProcurementSample(context, inputs) { detail = await request('GET', path(), undefined, '回查采购聚合详情') await check('采购聚合仍属于原客户原单且版本有效', detail?.customerView?.order?.orderNo === proof.orderNo && detail.customerView.order.customerId === customerId - && Number.isSafeInteger(detail.customerView.order.version) && detail.customerView.order.version >= 0) + && Number.isSafeInteger(version(detail.customerView.order.version))) return detail } async function command(operation, values = {}, replay = false) { - const body = { version: detail.customerView.order.version, idempotencyKey: key(operation.replaceAll('/', '-')), reason: context.marker, ...values } + const body = { version: version(detail.customerView.order.version), idempotencyKey: key(operation.replaceAll('/', '-')), reason: context.marker, ...values } await request('POST', path(operation), body, operation) if (replay) await request('POST', path(operation), body, `${operation} 原请求重放`) await read(); return body @@ -84,7 +85,7 @@ export async function verifyProcurementSample(context, inputs) { const repeated = await request('POST', MERCHANT, create, '客户同键重放', inputs.customerToken) await check('重放没有新建采购单', repeated?.order?.orderNo === proof.orderNo && repeated.order.customerId === customerId) await read() - await request('POST', path('review'), { version: detail.customerView.order.version, idempotencyKey: key('review'), decision: 'APPROVE', purchaseWarehouseId: String(inputs.warehouseId), remark: context.marker }, '审核通过并确定收货仓') + await request('POST', path('review'), { version: version(detail.customerView.order.version), idempotencyKey: key('review'), decision: 'APPROVE', purchaseWarehouseId: String(inputs.warehouseId), remark: context.marker }, '审核通过并确定收货仓') await read() const purchaseFile = await inputs.upload(proof.orderNo, 'PURCHASE') const voucher = await inputs.upload(proof.orderNo, 'VOUCHER') diff --git a/scripts/admin-test-env-modules/us-truck-fulfillment.mjs b/scripts/admin-test-env-modules/us-truck-fulfillment.mjs index 64125a326..3bd4e599d 100644 --- a/scripts/admin-test-env-modules/us-truck-fulfillment.mjs +++ b/scripts/admin-test-env-modules/us-truck-fulfillment.mjs @@ -1,6 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' const MODULE = 'phase4-overseas-business' const OPTIONS = { scenario: 'us-truck-fulfillment', deploymentBlocker: true } +const version = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : NaN /** 沿用本轮独立卡车原单,以正式履约命令完成实收、分配和运输。 */ export async function verifyUsTruckFulfillment(context, proof, { checkpoint = context.checkpoint } = {}) { @@ -47,7 +48,7 @@ export async function verifyUsTruckFulfillment(context, proof, { checkpoint = co } const identity = detail => check('卡车原单客户和版本有效', detail?.summary?.orderNo === proof.orderNo && String(detail.summary.customerId) === String(proof.customerId) - && Number.isSafeInteger(detail.summary.version) && detail.summary.version >= 0) + && Number.isSafeInteger(version(detail.summary.version))) try { let current = await request('GET', '') identity(current) @@ -68,12 +69,12 @@ export async function verifyUsTruckFulfillment(context, proof, { checkpoint = co for (const [action, state, allocationIndex] of stages) { if (action === 'allocate-pallets' && allocationIndex === 1) { await request('POST', '/prepare-dispatch', { - version: current.summary.version, idempotencyKey: `${context.marker}-truck-prepare-dispatch-partial`, remark: context.marker, + version: version(current.summary.version), idempotencyKey: `${context.marker}-truck-prepare-dispatch-partial`, remark: context.marker, }, 409) const unchanged = await request('GET', '') check('卡车部分分配禁止待派车且原单完全不变', JSON.stringify(unchanged) === JSON.stringify(current)) } - const beforeVersion = current.summary.version + const beforeVersion = version(current.summary.version) const body = { version: beforeVersion, idempotencyKey: `${context.marker}-truck-${action}`, remark: context.marker } if (action === 'receive-pallets') Object.assign(body, { actualPalletCount: palletCount, packageCount: packages, palletPackageCounts: packageCounts }) if (action === 'allocate-pallets') { @@ -90,7 +91,7 @@ export async function verifyUsTruckFulfillment(context, proof, { checkpoint = co } const next = await request('POST', `/${action}`, body) identity(next) - check(`卡车${action}状态和版本正确`, next.summary.status === state && next.summary.version === beforeVersion + 1) + check(`卡车${action}状态和版本正确`, next.summary.status === state && version(next.summary.version) === beforeVersion + 1) check('卡车原单实收托盘及箱数守恒', next.pallets?.length === palletCount && Number(next.summary.receivedPalletCount) === palletCount && new Set(next.pallets.map(row => String(row.id))).size === palletCount && next.pallets.every((row, index) => Number(row.packageCount) === packageCounts[index] diff --git a/scripts/admin-test-env-modules/warehouse-transfer-allocation.mjs b/scripts/admin-test-env-modules/warehouse-transfer-allocation.mjs index 3ac8f2755..e92e9ae7e 100644 --- a/scripts/admin-test-env-modules/warehouse-transfer-allocation.mjs +++ b/scripts/admin-test-env-modules/warehouse-transfer-allocation.mjs @@ -53,6 +53,7 @@ export async function allocateWarehouseTransfer(context, { proof, merchantToken, const positions = await request('GET', `${PLATFORM}/inventory/balances/${encodeURIComponent(candidate.inventoryBalanceId)}/positions${scope}`) check('库存位置回查必须返回完整数组', Array.isArray(positions)) position = positions.find(p => id(p.customerId) === proof.customerId && id(p.overseasWarehouseId) === proof.warehouseId + && (!proof.requiredSourcePositionId || id(p.id) === proof.requiredSourcePositionId) && p.inboundCode === proof.inboundCode && id(p.inboundOrderId) === id(candidate.inboundOrderId) && id(p.balanceId) === id(candidate.inventoryBalanceId) && validId(p.id) && validId(p.palletId) && Number(p.availablePackages) >= 1 diff --git a/scripts/admin-test-env-modules/warehouse-transfer-outbound-contract.test.mjs b/scripts/admin-test-env-modules/warehouse-transfer-outbound-contract.test.mjs index 2adbe9662..2f48dc9a9 100644 --- a/scripts/admin-test-env-modules/warehouse-transfer-outbound-contract.test.mjs +++ b/scripts/admin-test-env-modules/warehouse-transfer-outbound-contract.test.mjs @@ -27,7 +27,7 @@ function fixture(fault = '') { data = { total: rows.length, records: rows } } else if (path.includes('/relabel-applications/')) data = { application: { relabelNo: 'RL-1', customerOrderNo: 'WT-1', status: 'READY_FOR_OUTBOUND', fulfillmentStatus: fault === 'wrong relabel' ? 'COMPLETED' : 'OUTBOUNDED' } } else data = { transferNo: 'WT-1', status: shipped ? 'OUTBOUNDED' : 'ALLOCATED', relabelNo: 'RL-1', relabelApplicationId: '501', operatingWarehouseId: '201', - packageCount: 1, fbaWarehouseCode: 'FBA-1', outboundAt: shipped ? '2026-10-22T12:00:00' : null, + packageCount: 1, fbaWarehouseCode: 'FBA-1', actualShipAt: shipped && fault !== 'missing shipment time' ? '2026-10-22T12:00:00' : null, allocations: [{ ...allocation, status: shipped ? 'SHIPPED' : 'RESERVED' }] } return { http: 200, body: { code: '200', data } } } } @@ -47,7 +47,7 @@ test('原转运出库验证同单新SKU扣库及重放唯一性', async () => { assert.equal(f.calls.length, count) }) -for (const fault of ['network', 'existing shipment', 'foreign customer', 'wrong pallet', 'old sku', 'double shipment', 'replay ledger', 'wrong relabel', 'disk']) { +for (const fault of ['network', 'existing shipment', 'foreign customer', 'wrong pallet', 'old sku', 'double shipment', 'replay ledger', 'wrong relabel', 'missing shipment time', 'disk']) { test(`原转运出库拒绝 ${fault}`, async () => { const f = fixture(fault) await assert.rejects(outboundWarehouseTransfer(f.context, f.options)) diff --git a/scripts/admin-test-env-modules/warehouse-transfer-outbound.mjs b/scripts/admin-test-env-modules/warehouse-transfer-outbound.mjs index 82e64d991..e1cb95753 100644 --- a/scripts/admin-test-env-modules/warehouse-transfer-outbound.mjs +++ b/scripts/admin-test-env-modules/warehouse-transfer-outbound.mjs @@ -62,7 +62,7 @@ export async function outboundWarehouseTransfer(context, { proof, merchantToken, check('原转运出库前没有出库流水', (await ledger()).length === 0) const body = { idempotencyKey: `${context.marker}-TRANSFER-OUTBOUND`, remark: context.marker } const shipped = await request('POST', `${base}/outbound${scope}`, body) - check('原转运新SKU预留出库完成', same(shipped, 'OUTBOUNDED') && Boolean(shipped.outboundAt)) + check('原转运新SKU预留出库完成', same(shipped, 'OUTBOUNDED') && Boolean(shipped.actualShipAt)) const movements = await ledger() check('原转运唯一SHIP流水按实际新SKU托盘扣除一箱', movements.length === 1 && movements.every(row => validId(row.id) && row.businessType === 'SHIP' && row.businessOrderNo === proof.transferNo && row.inboundCode === proof.inboundCode @@ -72,7 +72,7 @@ export async function outboundWarehouseTransfer(context, { proof, merchantToken, const replay = await request('POST', `${base}/outbound${scope}`, body) check('出库重放保持原单和流水不变', stable(replay) === stable(shipped) && stable(await ledger()) === stable(movements)) const owned = await request('GET', `/api/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`, undefined, merchantToken) - check('客户回查原转运出库事实一致', same(owned, 'OUTBOUNDED') && owned.outboundAt === shipped.outboundAt) + check('客户回查原转运出库事实一致', same(owned, 'OUTBOUNDED') && owned.actualShipAt === shipped.actualShipAt) const relabel = await request('GET', `${BASE}/relabel-applications/${encodeURIComponent(proof.relabelNo)}`) check('原关联换标同步出库且未提前完成', relabel?.application?.relabelNo === proof.relabelNo && relabel.application.customerOrderNo === proof.transferNo && relabel.application.fulfillmentStatus === 'OUTBOUNDED' diff --git a/scripts/admin-test-env-modules/warehouse-transfer-receipt-contract.test.mjs b/scripts/admin-test-env-modules/warehouse-transfer-receipt-contract.test.mjs index 3e6b2864d..c047c7b6a 100644 --- a/scripts/admin-test-env-modules/warehouse-transfer-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/warehouse-transfer-receipt-contract.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { receiveWarehouseTransfer } from './warehouse-transfer-receipt.mjs' +import { findReusableTransferReceiptTarget, receiveWarehouseTransfer } from './warehouse-transfer-receipt.mjs' // 收货替身使用独立目标仓资源,覆盖资源不足、串单、重复入库和结算未完成。 function fixture(fault = '') { @@ -56,6 +56,23 @@ test('原转运复用异仓托盘收货并核对唯一入库和结算终态', as assert.equal(f.calls.length, count) }) +test('造单前只读选取异仓目的仓,翻过同仓和未绑定目的仓的托盘且不改主档', async () => { + const f = fixture() + const request = async (method, path) => { + assert.equal(method, 'GET') + if (path.includes('/pallets?') && path.includes('page=1&')) return { total: '201', records: Array.from({ length: 200 }, (_, i) => ({ + id: String(1000 + i), customerId: '0', warehouseId: i % 2 ? '201' : '202', status: 'IDLE', locationId: '701', + fbaWarehouseCode: i % 2 ? 'FBA-OTHER' : null })) } + if (path.includes('/pallets?')) return { total: '201', records: [{ id: '601', customerId: '0', warehouseId: '202', status: 'IDLE', locationId: '701', fbaWarehouseCode: 'FBA-1' }] } + return (await f.context.request(method, path)).body.data + } + const target = await findReusableTransferReceiptTarget({ sourceWarehouseId: '201', request, + check: (name, ok) => assert.ok(ok, name) }) + assert.equal(target.pallet.fbaWarehouseCode, 'FBA-1') + assert.equal(target.warehouse.id, '202') + assert.equal(f.calls.some(c => c.method !== 'GET'), false) +}) + for (const fault of ['no resource', 'occupied', 'location blocked', 'network', 'target drift', 'foreign customer', 'double receipt', 'replay drift', 'unsettled', 'disk']) { test(`转运收货遇到 ${fault} 明确失败且不补造资源`, async () => { const f = fixture(fault) diff --git a/scripts/admin-test-env-modules/warehouse-transfer-receipt.mjs b/scripts/admin-test-env-modules/warehouse-transfer-receipt.mjs index bc725f049..801f8ebbf 100644 --- a/scripts/admin-test-env-modules/warehouse-transfer-receipt.mjs +++ b/scripts/admin-test-env-modules/warehouse-transfer-receipt.mjs @@ -6,6 +6,38 @@ const validId = value => /^[1-9]\d*$/.test(id(value)) const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item) ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item) +/** 只读筛选既有异仓空闲资源,供造单前选线和收货前重新核验共用。 */ +export async function findReusableTransferReceiptTarget({ sourceWarehouseId, fbaWarehouseCode = '', request, check }) { + if (!validId(sourceWarehouseId)) throw Error('缺少转运来源作业仓') + let target + // 完整翻页寻找既有空闲资源,不创建公共托盘,也不借用已被其他业务占用的托盘。 + for (let page = 1; page <= 100 && !target; page++) { + const pallets = await request('GET', `${RESOURCES}/pallets?status=IDLE&page=${page}&size=200`) + check('目标托盘分页有效', Array.isArray(pallets?.records) && Number.isSafeInteger(Number(pallets.total)) && Number(pallets.total) >= 0) + for (const candidate of pallets.records) { + if (candidate.status !== 'IDLE' || id(candidate.customerId) !== '0' || !validId(candidate.warehouseId) + || id(candidate.warehouseId) === sourceWarehouseId || (!candidate.fbaWarehouseCode || (fbaWarehouseCode && candidate.fbaWarehouseCode !== fbaWarehouseCode)) + || !validId(candidate.id) || !validId(candidate.locationId)) continue + const resource = `${RESOURCES}/${candidate.warehouseId}` + const pallet = await request('GET', `${resource}/pallets/${candidate.id}`) + if (pallet?.status !== 'IDLE' || id(pallet.id) !== id(candidate.id) || id(pallet.customerId) !== '0' + || id(pallet.warehouseId) !== id(candidate.warehouseId) || id(pallet.locationId) !== id(candidate.locationId) + || pallet.fbaWarehouseCode !== candidate.fbaWarehouseCode || !pallet.palletCode) continue + const location = await request('GET', `${resource}/locations/${pallet.locationId}`) + if (id(location?.customerId) !== '0' || id(location?.warehouseId) !== id(pallet.warehouseId) + || id(location?.id) !== id(pallet.locationId) || !location.locationCode + || !(location.locationType === 'VIRTUAL' ? location.status === 'ACTIVE' : location.status === 'IDLE')) continue + const master = await request('GET', `/api/platform/v1/warehouse-admin/${pallet.warehouseId}?tenantId=0`) + const warehouse = master?.warehouse + if (id(warehouse?.id) !== id(pallet.warehouseId) || id(warehouse.tenantId) !== '0' + || warehouse.warehouseType !== 'OVERSEAS' || warehouse.status !== 'ACTIVE') continue + target = { pallet, location, warehouse }; break + } + if (page * 200 >= Number(pallets.total)) break + } + return target +} + /** 转运收货复用其他海外仓的空闲匹配托盘,并验证原单入库及关联换标结算。 */ export async function receiveWarehouseTransfer(context, { proof, merchantToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) { if (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== proof @@ -47,32 +79,8 @@ export async function receiveWarehouseTransfer(context, { proof, merchantToken, try { const original = await request('GET', base + scope) check('原转运出库来源不变', same(original, 'OUTBOUNDED') && Boolean(original.fbaWarehouseCode)) - let target - // 完整翻页寻找既有空闲资源,不创建公共托盘,也不借用已被其他业务占用的托盘。 - for (let page = 1; page <= 100 && !target; page++) { - const pallets = await request('GET', `${RESOURCES}/pallets?status=IDLE&page=${page}&size=200`) - check('目标托盘分页有效', Array.isArray(pallets?.records) && Number.isSafeInteger(Number(pallets.total)) && Number(pallets.total) >= 0) - for (const candidate of pallets.records) { - if (candidate.status !== 'IDLE' || id(candidate.customerId) !== '0' || !validId(candidate.warehouseId) - || id(candidate.warehouseId) === proof.warehouseId || candidate.fbaWarehouseCode !== original.fbaWarehouseCode - || !validId(candidate.id) || !validId(candidate.locationId)) continue - const resource = `${RESOURCES}/${candidate.warehouseId}` - const pallet = await request('GET', `${resource}/pallets/${candidate.id}`) - if (pallet?.status !== 'IDLE' || id(pallet.id) !== id(candidate.id) || id(pallet.customerId) !== '0' - || id(pallet.warehouseId) !== id(candidate.warehouseId) || id(pallet.locationId) !== id(candidate.locationId) - || pallet.fbaWarehouseCode !== original.fbaWarehouseCode || !pallet.palletCode) continue - const location = await request('GET', `${resource}/locations/${pallet.locationId}`) - if (id(location?.customerId) !== '0' || id(location?.warehouseId) !== id(pallet.warehouseId) - || id(location?.id) !== id(pallet.locationId) || !location.locationCode - || !(location.locationType === 'VIRTUAL' ? location.status === 'ACTIVE' : location.status === 'IDLE')) continue - const master = await request('GET', `/api/platform/v1/warehouse-admin/${pallet.warehouseId}?tenantId=0`) - const warehouse = master?.warehouse - if (id(warehouse?.id) !== id(pallet.warehouseId) || id(warehouse.tenantId) !== '0' - || warehouse.warehouseType !== 'OVERSEAS' || warehouse.status !== 'ACTIVE') continue - target = { pallet, location, warehouse }; break - } - if (page * 200 >= Number(pallets.total)) break - } + const target = await findReusableTransferReceiptTarget({ sourceWarehouseId: proof.warehouseId, + fbaWarehouseCode: original.fbaWarehouseCode, request, check }) check('必须存在其他海外仓同目的仓的空闲托盘与可预留库位', Boolean(target)) evidence.target = target const { pallet, location, warehouse } = target diff --git a/scripts/admin-test-env-report-summary-contract.test.mjs b/scripts/admin-test-env-report-summary-contract.test.mjs index 1171aecce..e008bf66f 100644 --- a/scripts/admin-test-env-report-summary-contract.test.mjs +++ b/scripts/admin-test-env-report-summary-contract.test.mjs @@ -1,6 +1,27 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { AcceptanceRun, parseArguments, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { mkdtemp, writeFile, unlink, rmdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +test('explicit stop file waits for a module boundary and records an interrupted failure once', async () => { + const directory = await mkdtemp(join(tmpdir(), 'acceptance-stop-')) + const stopFile = join(directory, 'stop') + const run = new AcceptanceRun({ stopFile, quiet: true, modules: [] }) + try { + assert.equal(parseArguments(['--stop-file', stopFile]).stopFile, stopFile) + assert.equal(await run.stopAtBoundary(), false) + await writeFile(stopFile, 'stop') + assert.equal(await run.stopAtBoundary(), true) + assert.equal(await run.stopAtBoundary(), true) + assert.match(run.fatal, /中止结果/) + assert.equal(run.steps.length, 1) + assert.equal(run.steps[0].passed, false) + assert.equal(buildAcceptanceSummary(run.steps, [], []).failed, 1) + assert.deepEqual(run.createdResources, {}) + } finally { await unlink(stopFile).catch(error => { if (error.code !== 'ENOENT') throw error }); await rmdir(directory) } +}) test('report summary separates assertion, module-run and unique-request metrics', () => { const sharedResponse = { diff --git a/scripts/event-delivery-fault-proxy.py b/scripts/event-delivery-fault-proxy.py index b853ee601..52be30168 100644 --- a/scripts/event-delivery-fault-proxy.py +++ b/scripts/event-delivery-fault-proxy.py @@ -157,10 +157,14 @@ def data_handler(state, upstream, timeout): wallet_endpoint = "/internal/finance/v1/wallet-command-events" wallet_passthrough = (state.fee_source_id is not None and endpoint == "/internal/finance/v1/business-fee-source-events") + # Firstleg 的同步只读客户端与装柜事件共用 LASTLEG_SERVICE_URL。 + loading_reads = state.event_type == "FirstLegLoadingPlanCompleted" and endpoint == "/internal/lastleg/events" + workflow_endpoint = "/internal/lastleg/v1/order-workflow-facts" class DataHandler(QuietHandler): def do_POST(self): - passthrough = wallet_passthrough and self.path == wallet_endpoint + passthrough = (wallet_passthrough and self.path == wallet_endpoint + or loading_reads and self.path == workflow_endpoint) if self.path != endpoint and not passthrough: self.safe_error(404, "UNEXPECTED_EVENT_ENDPOINT") return @@ -172,12 +176,22 @@ def data_handler(state, upstream, timeout): state.record(identity, matched, attempt, None, error_code) self.safe_error(409, error_code) return + destination = urllib.parse.urlunsplit((parts.scheme, parts.netloc, self.path, "", "")) if passthrough else upstream + self.forward(body, identity, matched, attempt, mode, destination, "POST") + + def do_GET(self): + if not loading_reads or not re.fullmatch(r"/internal/lastleg/appointment-work/containers/[1-9][0-9]*/route", self.path): + self.safe_error(404, "UNEXPECTED_EVENT_ENDPOINT") + return + destination = urllib.parse.urlunsplit((parts.scheme, parts.netloc, self.path, "", "")) + self.forward(None, None, False, 0, "PASS", destination, "GET") + + def forward(self, body, identity, matched, attempt, mode, destination, method): # Forward the exact body and end-to-end headers. Never persist auth headers. connection_headers = set(self.headers.get("Connection", "").lower().replace(" ", "").split(",")) headers = {name: value for name, value in self.headers.items() if name.lower() not in HOP_HEADERS | connection_headers} - destination = urllib.parse.urlunsplit((parts.scheme, parts.netloc, wallet_endpoint, "", "")) if passthrough else upstream - request = urllib.request.Request(destination, data=body, headers=headers, method="POST") + request = urllib.request.Request(destination, data=body, headers=headers, method=method) try: # Redirects are forbidden: a redirect could expose a service identity token. class NoRedirect(urllib.request.HTTPRedirectHandler): diff --git a/scripts/event-delivery-fault-proxy.test.mjs b/scripts/event-delivery-fault-proxy.test.mjs index 9a4386941..0324b7580 100644 --- a/scripts/event-delivery-fault-proxy.test.mjs +++ b/scripts/event-delivery-fault-proxy.test.mjs @@ -27,13 +27,17 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'], const chunks = [] for await (const chunk of request) chunks.push(chunk) const raw = Buffer.concat(chunks).toString() - const envelope = JSON.parse(raw) + const envelope = raw ? JSON.parse(raw) : {} calls.push({ raw, authorization: request.headers.authorization, eventId: envelope.eventId, path: request.url }) const outcome = applied.has(envelope.eventId) ? 'DUPLICATE' : 'APPLIED' applied.add(envelope.eventId) const body = JSON.stringify({ code: '200', data: { eventId: invalidAck ? otherId : envelope.eventId, payloadHash: envelope.payloadHash, outcome } }) response.writeHead(200, { 'Content-Type': 'application/json', 'X-Consumer-Evidence': 'actual-upstream' }) + if (request.url === '/internal/lastleg/v1/order-workflow-facts' || request.method === 'GET') { + response.end(JSON.stringify({ code: '200', data: { method: request.method, raw, path: request.url } })) + return + } response.end(body) }) await new Promise(resolve => consumer.listen(0, '127.0.0.1', resolve)) @@ -70,6 +74,7 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'], const dataUrl = `http://127.0.0.1:${ready.dataPort}${endpoint}` const controlUrl = `http://127.0.0.1:${ready.controlPort}` return { calls, applied, log, invalidAck: value => { invalidAck = value }, + get: path => fetch(new URL(path, dataUrl), { headers: { Authorization: `Bearer ${secret}` } }), send: async (envelope, path = endpoint) => { const sent = JSON.stringify(envelope) const response = await fetch(new URL(path, dataUrl), { method: 'POST', body: sent, @@ -94,6 +99,35 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'], } } +test('loading fault preserves exact synchronous read requests and only injects the selected event', async t => { + const eventType = 'FirstLegLoadingPlanCompleted' + const runtime = await readFile(new URL('./test-event-fault-runtime.py', import.meta.url), 'utf8') + assert.ok(runtime.includes(`['--event-type', '${eventType}', '--loading-plan-id'`)) + const proxy = await fixture(['--loading-plan-id', '2100000000000000001'], eventType, '/internal/lastleg/events') + t.after(() => proxy.stop()) + await proxy.control('/mode', { mode: 'FAULT' }) + const workflowPath = '/internal/lastleg/v1/order-workflow-facts' + const read = await proxy.send({ customerId: '101', orderIds: ['201'] }, workflowPath) + assert.equal(read.status, 200) + assert.equal(JSON.parse(read.raw).data.raw, read.sent) + const routePath = '/internal/lastleg/appointment-work/containers/301/route' + const route = await proxy.get(routePath) + assert.equal(route.status, 200) + assert.equal((await route.json()).data.path, routePath) + assert.ok(proxy.calls.every(call => call.authorization === `Bearer ${secret}`)) + assert.equal((await (await proxy.control('/status')).json()).attempt, 0) + assert.equal((await proxy.send({}, '/internal/lastleg/v1/unknown')).status, 404) + assert.equal((await proxy.get('/internal/lastleg/appointment-work/containers/301/route?other=1')).status, 404) + assert.equal((await proxy.send({}, routePath)).status, 404) + assert.equal((await proxy.get('/internal/lastleg/events')).status, 404) + assert.equal(proxy.calls.length, 2) + assert.equal((await proxy.send({ ...event(), eventType })).status, 503) + const snapshot = await (await proxy.control('/status')).json() + assert.equal(snapshot.attempt, 1) + assert.equal(snapshot.records.length, 1) + assert.equal(snapshot.records[0].eventId, targetId) +}) + test('fee fault preserves wallet endpoint bytes and auth without selecting its body', async t => { const proxy = await fixture(['--fee-source-id', '2100000000000000001', '--source-version', '1'], 'BusinessFeeSourceChanged', '/internal/finance/v1/business-fee-source-events') diff --git a/scripts/test-env-business-reset-policy.json b/scripts/test-env-business-reset-policy.json index ecf32938e..39ac26bc1 100644 --- a/scripts/test-env-business-reset-policy.json +++ b/scripts/test-env-business-reset-policy.json @@ -11,7 +11,7 @@ "numberSequences": "KEEP" }, "sourceSha256": { - "deploy/sql/头程数据库统一增量-MySQL8.sql": "20f8f8129dad86957872d68fdea81967b72e5bdc7f1adef612677cc59e091bf4", + "deploy/sql/头程数据库统一增量-MySQL8.sql": "f8f3c680b170296e5911076a3109f8043176cf9f845db75431ba2875b2c3eaeb", "deploy/sql/海外区域统一增量-MySQL8.sql": "db307236f5d0275c214db49888fe0d16d8a954802eb50ad5de52ed33ccfab4a7" }, "pendingReview": [