diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index f4478ba23..28bcf59f7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -142,7 +143,18 @@ class ReturnReceiptServiceTest { assertEquals(0, second.getReceivedQuantity()); assertEquals(ReturnReceiptService.PARTIALLY_RECEIVED, order.getStatus()); verify(inventory).createReceiptCarton(eq(order), any(ReturnReceipt.class), eq("RC-1"), any()); - verify(batches).insert(any(ReturnReceiptBatch.class)); + ArgumentCaptor savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class); + verify(receipts).insert(savedReceipt.capture()); + ArgumentCaptor savedBatch = ArgumentCaptor.forClass(ReturnReceiptBatch.class); + verify(batches).insert(savedBatch.capture()); + assertEquals(order.getId(), savedBatch.getValue().getReturnOrderId()); + assertEquals(savedReceipt.getValue().getId(), savedBatch.getValue().getReceiptId()); + assertEquals(order.getCustomerId(), savedBatch.getValue().getCustomerId()); + assertEquals(order.getOverseasWarehouseId(), savedBatch.getValue().getOverseasWarehouseId()); + assertEquals(result.receiptBatchNo(), savedBatch.getValue().getBatchNo()); + assertEquals("PENDING_PRICING", savedBatch.getValue().getStorageBillingStatus()); + assertNotNull(savedBatch.getValue().getStorageStartedAt()); + assertNotNull(savedBatch.getValue().getCreatedAt()); } @Test diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 6444347f4..4344c1e76 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', @@ -75,6 +75,7 @@ const MODULE_DEPENDENCIES = new Map([ ['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']], ['phase4-overseas-pda', ['phase4-tail-order-controls']], ['phase4-overseas-pda-operations', ['phase4-overseas-pda']], + ['phase4-overseas-return-receipt', ['merchant-qualification-fixture']], ['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']], ['phase4-overseas-client-services', ['phase4-overseas-pda-operations']], ['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']], @@ -333,6 +334,7 @@ export function parseArguments(argv) { const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' const requiresMarkerOwnedMerchantFixture = [ 'phase4-overseas-business', + 'phase4-overseas-return-receipt', 'phase4-overseas-client-services', 'phase5-overseas-fulfillment', 'phase4-overseas-finance-cross-region', diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index bce5f7498..526dd48b9 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -92,6 +92,7 @@ export async function run(context) { | 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 | | 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | | 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` | +| 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 | | 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 | | 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed | | 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | 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 new file mode 100644 index 000000000..f8b2a421d --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url) +const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url) + +test('full acceptance registers return receipt as a configured overseas module with an isolated merchant fixture', async () => { + const source = await readFile(acceptanceUrl, 'utf8') + assert.match(source, /CONFIGURED_MODULES[^\n]+phase4-overseas-return-receipt/) + assert.match(source, /\['phase4-overseas-return-receipt', \['merchant-qualification-fixture'\]\]/) + assert.match(source, /requiresMarkerOwnedMerchantFixture[\s\S]+phase4-overseas-return-receipt/) +}) + +test('return receipt acceptance covers both lookup paths, partial/final receipt and normal empty results', async () => { + const source = await readFile(moduleUrl, 'utf8') + assert.match(source, /PDA_RECEIPTS = '\/api\/pda\/v1\/last-leg\/return-receipts'/) + assert.match(source, /\$\{PDA_RECEIPTS\}\/start/) + assert.match(source, /\$\{PDA_RECEIPTS\}\/sku-candidates/) + assert.match(source, /PDA首次分批签收2件/) + assert.match(source, /PDA按SKU完成剩余3件签收/) + assert.match(source, /不存在SKU不是业务错误/) + assert.match(source, /已完成退货单不再进入待签收流程/) +}) + +test('return receipt acceptance proves idempotency, over-receipt isolation and exact inventory', async () => { + const source = await readFile(moduleUrl, 'utf8') + assert.match(source, /预报幂等重放复用同一退货单/) + assert.match(source, /签收重放不重复创建签收单、箱或计费批次/) + assert.match(source, /超收被拒绝且不得生成费用批次/) + assert.match(source, /订单5件核平且恰有两个唯一仓储计费批次/) + assert.match(source, /两个实收箱逐一引用对应计费批次且数量守恒/) +}) + +test('return receipt acceptance includes fee-batch evidence and return packing lifecycle', async () => { + const source = await readFile(moduleUrl, 'utf8') + assert.match(source, /billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt'/) + assert.match(source, /receiptBatchNos/) + assert.match(source, /sourceBatchNo/) + assert.match(source, /PDA查询完成退货单的可合箱库存/) + assert.match(source, /PDA将同客户退货库存合为新箱/) + assert.match(source, /合箱重放复用同一新箱且不重复扣减/) +}) + +test('HTTP acceptance uses injected clients and never bypasses APIs with database access', async () => { + const source = await readFile(moduleUrl, 'utf8') + assert.doesNotMatch(source, /process\.env|https?:\/\/|\bfetch\s*\(|\bmysql\b|\bSELECT\s|\bINSERT\s|\bUPDATE\s/i) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs new file mode 100644 index 000000000..3507217bf --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -0,0 +1,310 @@ +import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' + +const MODULE = 'phase4-overseas-return-receipt' +const SCENARIO = 'phase4-overseas-return-receipt-and-billing' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const RETURN_ORDERS = '/api/merchant/v1/last-leg/return-orders' +const PDA_RECEIPTS = '/api/pda/v1/last-leg/return-receipts' +const RETURN_INVENTORY = '/api/merchant/v1/last-leg/return-inventory' + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } +function code(response) { return String(response?.body?.code ?? '') } +function success(response) { return response?.http === 200 && code(response) === '200' } +function conflict(response) { + return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} +function notFound(response) { + return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} +function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [name, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value)) + } + return params.size ? `${path}?${params}` : path +} +function key(context, action) { + return `${context.marker}-RETURN-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 255) +} +function markerSuffix(context) { + return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() +} + +function add(context, name, response, passed, evidence = '') { + context.addStep(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS) +} + +async function overseas(context, token, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body, {}, token) + add(context, name, response, success(response)) + return { response, data: success(response) ? response.body?.data : null } +} + +async function resolveWarehouse(context) { + const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId) + const response = await context.request('GET', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') + const rows = records(response?.body?.data) + const fixedIds = new Set(['193000000000200003', '193000000000200004']) + const warehouse = rows.find((item) => id(item?.id) === existing) + || rows.find((item) => fixedIds.has(id(item?.id))) + || rows[0] + add(context, '读取可执行退货签收的海外仓', response, + success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`) + return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null +} + +async function loginMerchant(context) { + const fixture = context.context?.merchantQualificationFixture + if (fixture?.marker !== context.marker || !fixture?.username || !fixture?.password) { + add(context, '取得本轮客户账号', null, false, `marker=${fixture?.marker || 'missing'}`) + return null + } + const response = await context.request('POST', '/api/auth/login', { + loginId: fixture.username, password: fixture.password, + }, '') + const token = success(response) ? response.body?.data?.overseasAccessToken : null + add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`) + return nonBlank(token) ? token : null +} + +function itemMap(items) { + return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item])) +} + +export async function run(context) { + context.createdResources.phase4OverseasReturnReceipt = { + marker: context.marker, returnNo: null, receiptNos: [], receiptBatchNos: [], cartonMarks: [], + billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt', + } + const merchantToken = await loginMerchant(context) + const warehouse = await resolveWarehouse(context) + const access = await resolvePickerAccess(context) + if (!merchantToken || !warehouse || !access) return + const picker = await createPicker(context, 91, warehouse.id, access) + if (!picker?.token) { + add(context, '创建退货签收PDA操作员', null, false, '未取得绑定仓PDA令牌') + if (picker?.membershipId) await disablePicker(context, picker, 91) + return + } + + const suffix = markerSuffix(context) + const skuA = `RET-A-${suffix}`.slice(0, 128) + const skuB = `RET-B-${suffix}`.slice(0, 128) + const createBody = { + overseasWarehouseId: warehouse.id, + warehouseContactName: '全量验收海外仓', warehouseContactPhone: '0000000000', + customerReferenceNo: `REF-${suffix}`, needRelabel: false, remark: context.marker, + idempotencyKey: key(context, 'CREATE'), + items: [ + { skuCode: skuA, productNameZh: '退货验收商品A', expectedQuantity: 3 }, + { skuCode: skuB, productNameZh: '退货验收商品B', expectedQuantity: 2 }, + ], + } + + try { + const created = await overseas(context, merchantToken, 'POST', '客户创建双SKU退货预报', RETURN_ORDERS, createBody) + const returnNo = id(created.data?.order?.returnNo) + context.assert(MODULE, '退货预报初始数量和状态正确', created.response, + nonBlank(returnNo) && created.data?.order?.status === 'PENDING_RECEIPT' + && Number(created.data?.order?.expectedQuantity) === 5 + && Number(created.data?.order?.receivedQuantity) === 0 + && itemMap(created.data?.items).size === 2, + `returnNo=${returnNo || 'missing'}`, OPTIONS) + if (!returnNo) return + context.createdResources.phase4OverseasReturnReceipt.returnNo = returnNo + + const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody) + context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response, + id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS) + const createConflict = await context.requestOverseas('POST', RETURN_ORDERS, + { ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken) + add(context, '退货预报同键异载荷返回冲突', createConflict, conflict(createConflict)) + + const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选', + `${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` }) + context.assert(MODULE, '不存在SKU不是业务错误', emptySku.response, + success(emptySku.response) && Array.isArray(emptySku.data?.candidates) && emptySku.data.candidates.length === 0, + trace(emptySku.response), OPTIONS) + + const started = await overseas(context, picker.token, 'POST', 'PDA按退货单号开始签收', + `${PDA_RECEIPTS}/start`, { returnNo }) + context.assert(MODULE, '首次开始返回全部未收SKU', started.response, + started.data?.status === 'PENDING_RECEIPT' && itemMap(started.data?.expectedItems).size === 2, + trace(started.response), OPTIONS) + const skuCandidates = await overseas(context, picker.token, 'POST', 'PDA按SKU查询当前仓候选退货单', + `${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB }) + context.assert(MODULE, 'SKU候选包含本轮退货单及准确数量', skuCandidates.response, + skuCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo + && Number(candidate.skuExpectedQuantity) === 2 && Number(candidate.skuReceivedQuantity) === 0), + trace(skuCandidates.response), OPTIONS) + + const cartonA = `RC-${suffix}-A`.slice(0, 128) + const firstBody = { + returnNo, trackingNo: `TRACK-${suffix}`, startedBy: 'RETURN_NO', cartonMark: cartonA, + items: [{ skuCode: skuA, receivedQuantity: 2 }], idempotencyKey: key(context, 'RECEIPT-1'), + } + const first = await overseas(context, picker.token, 'POST', 'PDA首次分批签收2件', `${PDA_RECEIPTS}/confirm`, firstBody) + context.assert(MODULE, '首次签收形成唯一箱和仓储计费批次', first.response, + first.data?.orderStatus === 'PARTIALLY_RECEIVED' && first.data?.fullyMatched === false + && Number(first.data?.receivedQuantity) === 2 && first.data?.cartonMark === cartonA + && nonBlank(first.data?.receiptNo) && nonBlank(first.data?.receiptBatchNo), + `receiptNo=${id(first.data?.receiptNo) || 'missing'}, billingBatch=${id(first.data?.receiptBatchNo) || 'missing'}`, OPTIONS) + const firstReceiptNo = id(first.data?.receiptNo) + const firstBatchNo = id(first.data?.receiptBatchNo) + + const firstReplay = await overseas(context, picker.token, 'POST', '首次签收同键同载荷重放', `${PDA_RECEIPTS}/confirm`, firstBody) + context.assert(MODULE, '签收重放不重复创建签收单、箱或计费批次', firstReplay.response, + id(firstReplay.data?.receiptNo) === firstReceiptNo + && id(firstReplay.data?.receiptBatchNo) === firstBatchNo + && id(firstReplay.data?.cartonMark) === cartonA, + trace(firstReplay.response), OPTIONS) + const firstConflict = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { + ...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`, + }, {}, picker.token) + add(context, '签收同键异载荷返回冲突', firstConflict, conflict(firstConflict)) + + const overReceipt = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { + returnNo, startedBy: 'SKU', items: [{ skuCode: skuA, receivedQuantity: 2 }], + idempotencyKey: key(context, 'OVER-RECEIPT'), + }, {}, picker.token) + add(context, '超收被拒绝且不得生成费用批次', overReceipt, conflict(overReceipt)) + + const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态', + `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) + context.assert(MODULE, '超收失败后订单、签收和费用批次数量均未变化', afterPartial.response, + afterPartial.data?.order?.status === 'PARTIALLY_RECEIVED' + && Number(afterPartial.data?.order?.receivedQuantity) === 2 + && afterPartial.data?.receipts?.length === 1 + && id(afterPartial.data.receipts[0]?.receiptBatchNo) === firstBatchNo, + `receipts=${afterPartial.data?.receipts?.length ?? 'missing'}`, OPTIONS) + + const secondBody = { + returnNo, startedBy: 'SKU', + items: [{ skuCode: skuA, receivedQuantity: 1 }, { skuCode: skuB, receivedQuantity: 2 }], + idempotencyKey: key(context, 'RECEIPT-2'), + } + const second = await overseas(context, picker.token, 'POST', 'PDA按SKU完成剩余3件签收', `${PDA_RECEIPTS}/confirm`, secondBody) + context.assert(MODULE, '最终签收自动核平并生成第二个费用批次', second.response, + second.data?.orderStatus === 'COMPLETED' && second.data?.fullyMatched === true + && Number(second.data?.receivedQuantity) === 5 && nonBlank(second.data?.cartonMark) + && nonBlank(second.data?.receiptNo) && nonBlank(second.data?.receiptBatchNo) + && id(second.data?.receiptBatchNo) !== firstBatchNo, + `billingBatch=${id(second.data?.receiptBatchNo) || 'missing'}`, OPTIONS) + const secondReceiptNo = id(second.data?.receiptNo) + const secondBatchNo = id(second.data?.receiptBatchNo) + const cartonB = id(second.data?.cartonMark) + context.createdResources.phase4OverseasReturnReceipt.receiptNos.push(firstReceiptNo, secondReceiptNo) + context.createdResources.phase4OverseasReturnReceipt.receiptBatchNos.push(firstBatchNo, secondBatchNo) + context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB) + + const completedStart = await context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token) + add(context, '已完成退货单不再进入待签收流程', completedStart, notFound(completedStart)) + const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空', + `${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB }) + context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response, + !completedCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo), + trace(completedCandidates.response), OPTIONS) + + const detail = await overseas(context, merchantToken, 'GET', '客户回查完成退货单与两次签收', + `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) + const detailItems = itemMap(detail.data?.items) + const detailReceipts = Array.isArray(detail.data?.receipts) ? detail.data.receipts : [] + const detailBatches = new Set(detailReceipts.map((receipt) => id(receipt.receiptBatchNo))) + context.assert(MODULE, '订单5件核平且恰有两个唯一仓储计费批次', detail.response, + detail.data?.order?.status === 'COMPLETED' && Number(detail.data?.order?.receivedQuantity) === 5 + && Number(detailItems.get(skuA)?.receivedQuantity) === 3 + && Number(detailItems.get(skuB)?.receivedQuantity) === 2 + && detailReceipts.length === 2 && detailBatches.size === 2 + && detailBatches.has(firstBatchNo) && detailBatches.has(secondBatchNo), + `receipts=${detailReceipts.length}, billingBatches=${detailBatches.size}`, OPTIONS) + + const merchantInventory = await context.requestOverseas('GET', + query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }), + undefined, {}, merchantToken) + add(context, '客户权限可读取自身退货库存', merchantInventory, success(merchantInventory)) + + const customerId = id(context.context?.merchantQualificationFixture?.tenantId) + const inventory = await overseas(context, context.overseasToken, 'GET', '平台按客户和退货单回查全部退货箱库存', + query('/api/platform/v1/last-leg/return-inventory/cartons', { + page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, returnNo, + })) + const inventoryRows = records(inventory.data) + const sourceBatches = new Set(inventoryRows.map((row) => id(row.sourceBatchNo))) + context.assert(MODULE, '两个实收箱逐一引用对应计费批次且数量守恒', inventory.response, + inventoryRows.length === 2 + && inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0) === 5 + && sourceBatches.size === 2 && sourceBatches.has(firstBatchNo) && sourceBatches.has(secondBatchNo), + `cartons=${inventoryRows.length}, quantity=${inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0)}`, OPTIONS) + + const summary = await overseas(context, context.overseasToken, 'GET', '平台回查退货SKU汇总库存', + query('/api/platform/v1/last-leg/return-inventory', { + page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, + })) + const summaries = new Map(records(summary.data).filter((row) => [skuA, skuB].includes(row.skuCode)) + .map((row) => [row.skuCode, row])) + context.assert(MODULE, '退货SKU汇总数量与两个实收箱一致', summary.response, + Number(summaries.get(skuA)?.totalQuantity) === 3 + && Number(summaries.get(skuB)?.totalQuantity) === 2, + `skuA=${summaries.get(skuA)?.totalQuantity ?? 'missing'}, skuB=${summaries.get(skuB)?.totalQuantity ?? 'missing'}`, OPTIONS) + + for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 2 }, + { cartonMark: cartonB, batchNo: secondBatchNo, quantity: 3 }].entries()) { + const carton = await overseas(context, context.overseasToken, 'GET', `平台回查第${index + 1}个退货箱明细`, + `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`) + context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response, + carton.data?.sourceBatchNo === expected.batchNo + && Number(carton.data?.totalQuantity) === expected.quantity + && Array.isArray(carton.data?.items) && carton.data.items.length > 0, + `batch=${carton.data?.sourceBatchNo || 'missing'}, quantity=${carton.data?.totalQuantity ?? 'missing'}`, OPTIONS) + const label = await overseas(context, context.overseasToken, 'GET', `平台生成第${index + 1}个退货箱标签`, + `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}/label`) + context.assert(MODULE, `第${index + 1}个箱标签保留来源批次`, label.response, + label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo + && Number(label.data?.totalQuantity) === expected.quantity && nonBlank(label.data?.printableHtml), + trace(label.response), OPTIONS) + } + + const packing = await overseas(context, picker.token, 'GET', 'PDA查询完成退货单的可合箱库存', + query('/api/pda/v1/last-leg/return-inventory/packing/orders', { returnNo })) + const packingSkus = new Map((packing.data?.skus || []).map((row) => [row.skuCode, row])) + context.assert(MODULE, '退货合箱候选只含本轮完成单且库存准确', packing.response, + packing.data?.orders?.some?.((order) => order.returnNo === returnNo && order.status === 'COMPLETED') + && Number(packingSkus.get(skuA)?.availableQuantity) === 3 + && Number(packingSkus.get(skuB)?.availableQuantity) === 2, + trace(packing.response), OPTIONS) + + const mergeBody = { + returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }, { skuCode: skuB, quantity: 1 }], + length: 30, width: 20, height: 10, weight: 2, idempotencyKey: key(context, 'MERGE'), + } + const merged = await overseas(context, picker.token, 'POST', 'PDA将同客户退货库存合为新箱', + '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody) + if (success(merged.response)) { + context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response, + nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2 + && merged.data?.label?.sourceBatchNo === 'MERGED' + && Number(merged.data?.label?.items?.length) === 2 + && nonBlank(merged.data?.label?.printableHtml), + `cartonMark=${merged.data?.cartonMark || 'missing'}`, OPTIONS) + const mergeReplay = await overseas(context, picker.token, 'POST', '退货合箱同键同载荷重放', + '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody) + context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response, + success(mergeReplay.response) && nonBlank(merged.data?.cartonMark) + && id(mergeReplay.data?.cartonMark) === id(merged.data?.cartonMark), trace(mergeReplay.response), OPTIONS) + const mergeConflict = await context.requestOverseas('POST', + '/api/pda/v1/last-leg/return-inventory/cartons/merge', + { ...mergeBody, weight: 3 }, {}, picker.token) + add(context, '合箱同键异载荷返回冲突', mergeConflict, conflict(mergeConflict)) + } + } finally { + await disablePicker(context, picker, 91) + } +}