diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index e893dee83..28e223b91 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -93,7 +93,7 @@ paths: description: 分页查询商户企业客户,需要 `platform:customer:list` 权限;联系电话和邮箱仅返回脱敏摘要。 parameters: - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 1000, default: 20}} - {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索} - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}} responses: diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index b2ed7fb78..33b1c9c2a 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -30,7 +30,7 @@ class PlatformTenantControllerContractTest { method.getAnnotation(PreAuthorize.class).value()); assertNotNull(annotation(method, 0, Min.class)); assertNotNull(annotation(method, 1, Min.class)); - assertEquals(200, annotation(method, 1, Max.class).value()); + assertEquals(1000, annotation(method, 1, Max.class).value()); assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED", annotation(method, 3, Pattern.class).regexp()); diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 478209543..c7cdc23e9 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -44,6 +44,7 @@ class OverseasPhase4PermissionSqlContractTest { private static final List REQUIRED = List.of( "platform:overseas-warehouse:last-mile-order:list", "platform:overseas-warehouse:discard:list", + "platform:overseas-warehouse:discard:approve", "platform:overseas-warehouse:warehouse-transfer:approve", "platform:overseas-warehouse:inbound-order:list", "platform:overseas-warehouse:inbound-order:fba-appointment", @@ -76,7 +77,7 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(70).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(71).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains( "(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'"); assertThat(sql).contains("CALL assert_warehouse_transfer_approval_permission()", @@ -157,6 +158,12 @@ class OverseasPhase4PermissionSqlContractTest { // Vehicle care already declares this guard directly on its finance-submit facade. serviceCatalog.addAll(codes(repositoryFile( "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java"))); + // Cargo discard declares its approval guard in the service rather than OverseasPermissions. + serviceCatalog.addAll(codes(repositoryFile( + "lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java"))); + assertThat(repositoryFile( + "lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java")) + .contains("user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)"); assertThat(serviceCatalog).containsExactlyInAnyOrderElementsOf(moduleCatalog); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index 6cc9a219a..bf5b4e3ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -501,8 +501,6 @@ public class ReturnReceiptService { public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) { String key = text(request.idempotencyKey()); String requestHash = hash(request); - ReturnReceipt replay = receipts.findKey(identity.operatorKey(), key); - if (replay != null) return replayReceipt(replay, requestHash); String startedBy = norm(request.startedBy()); if (!Set.of("RETURN_NO", "SKU").contains(startedBy)) throw badRequest("startedBy 必须为 RETURN_NO 或 SKU"); String trackingNo = text(request.trackingNo()); @@ -513,8 +511,8 @@ public class ReturnReceiptService { if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) { throw notFound("当前绑定仓没有该退货单"); } - // Waiting for the order lock may allow the same request to commit. Use a - // current read, not the repeatable-read snapshot established by findKey. + // Lock the order before any consistent read: a snapshot opened before + // waiting would hide the winner's cartons when rendering replay labels. ReturnReceipt concurrentReplay = receipts.lockKey(identity.operatorKey(), key); if (concurrentReplay != null) { if (!requestHash.equals(concurrentReplay.getRequestHash())) throw conflict("幂等键已用于不同的签收请求"); 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 dc7cd8db5..019a0db3b 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 @@ -366,8 +366,7 @@ class ReturnReceiptServiceTest { var first = service.confirm(identity, request); ArgumentCaptor savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class); verify(receipts).insert(savedReceipt.capture()); - when(receipts.findKey("operator-1", "key-final")).thenReturn(savedReceipt.getValue()); - when(orders.selectById(1L)).thenReturn(order); + when(receipts.lockKey("operator-1", "key-final")).thenReturn(savedReceipt.getValue()); var replay = service.confirm(identity, request); assertTrue(first.fullyMatched()); @@ -383,7 +382,6 @@ class ReturnReceiptServiceTest { void rejectsOverReceiptBeforeCreatingReceiptInventoryOrBatch() { ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1); ReturnOrderItem line = item(11L, "SKU-A", 2, 1); - when(receipts.findKey("operator-1", "key-over")).thenReturn(null); when(orders.lockNo("RT-1")).thenReturn(order); when(orderItems.lockOrder(1L)).thenReturn(List.of(line)); @@ -398,7 +396,6 @@ class ReturnReceiptServiceTest { @Test void rejectsAnotherWarehousesOrderBeforeLockingSkuOrWritingInventory() { ReturnOrder order = order("RT-1", "11", ReturnReceiptService.PENDING_RECEIPT, 2, 0); - when(receipts.findKey("operator-1", "key-cross")).thenReturn(null); when(orders.lockNo("RT-1")).thenReturn(order); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity, @@ -419,7 +416,7 @@ class ReturnReceiptServiceTest { service.confirm(identity, original); ArgumentCaptor savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class); verify(receipts).insert(savedReceipt.capture()); - when(receipts.findKey("operator-1", "key-1")).thenReturn(savedReceipt.getValue()); + when(receipts.lockKey("operator-1", "key-1")).thenReturn(savedReceipt.getValue()); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity, confirm("RETURN_NO", "TRACK-CHANGED", "RC-1", "key-1", @@ -431,7 +428,6 @@ class ReturnReceiptServiceTest { } private void stubNewReceipt(ReturnOrder order, List items, String cartonMark) { - when(receipts.findKey(eq("operator-1"), anyString())).thenReturn(null); when(orders.lockNo("RT-1")).thenReturn(order); when(orderItems.lockOrder(1L)).thenReturn(items); when(orderItems.listOrder(1L)).thenReturn(items); @@ -455,6 +451,10 @@ class ReturnReceiptServiceTest { var result = service.confirm(identity, request); + verify(receipts, never()).findKey(anyString(), anyString()); + var reads = org.mockito.Mockito.inOrder(orders, receipts); + reads.verify(orders).lockNo("RT-1"); + reads.verify(receipts).lockKey("operator-1", "concurrent-key"); assertEquals("RR-COMMITTED", result.receiptNo()); assertEquals("RB-COMMITTED", result.receiptBatchNo()); assertEquals(1, result.receivedQuantity()); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 27fcb000d..ede6f17cc 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -16,6 +16,21 @@ const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 +export function transportFailureDetails(error) { + const causes = [] + const visited = new Set() + const symbol = value => typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,79}$/.test(value) ? value : null + function visit(value) { + if (!value || typeof value !== 'object' || visited.has(value) || causes.length >= 6) return + visited.add(value) + causes.push({ name: symbol(value.name), code: symbol(value.code), syscall: symbol(value.syscall) }) + visit(value.cause) + if (Array.isArray(value.errors)) for (const nested of value.errors) visit(nested) + } + visit(error) + return { causes } +} + export function planA0LocationIsolation(locations, requiredSlots = 1, maxMutations = A0_MAX_LOCATION_MUTATIONS) { const slots = Math.max(1, Number.isInteger(Number(requiredSlots)) ? Number(requiredSlots) : 1) @@ -643,7 +658,7 @@ export class AcceptanceRun { await new Promise((resolve) => setTimeout(resolve, attempt * 250)) continue } - return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null, transportError: transportFailureDetails(error) } } } throw new Error('unreachable request attempt state') @@ -675,7 +690,7 @@ export class AcceptanceRun { requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } } catch (error) { - return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null, transportError: transportFailureDetails(error) } } } @@ -718,6 +733,7 @@ export class AcceptanceRun { code: response?.body?.code === undefined ? null : String(response.body.code), message: response?.body?.message || response?.raw || null, requestId: response?.requestId || null, traceId: response?.traceId || null, + ...(response?.transportError ? { transportError: response.transportError } : {}), evidence, checkedAt: new Date().toISOString(), } this.steps.push(step) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index be4c01dda..84db159ec 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -88,17 +88,17 @@ `pda-loading-link-regression.mjs` 通过总入口 `--modules pda-loading-link-regression` 运行,复用 `dropship-pallet-matrix.mjs` 的国内正式订单、测量、封托、订舱、证据和完成生产链,仅执行国内关联回归。覆盖单票加入后全移除换柜完成、多票共托反复换三个旧柜后完成、逐托即时提交及同键重放,以及一单多柜中仍有实际分配的未开始柜导致 `OWNER_READY/A0409`。每次重新创建带 marker 的业务数据,通过公开 HTTP 回查旧柜有效分配数、PDA 终态和入仓状态;不写业务数据库伪造前置。零分配残留由真实整托移除链观察,报告保存 `createdResources.pdaLoadingLinkRegression`。负向柜保留为 `COMPLETION_FAILED`,成功柜保留为 `COMPLETED`,不自动清理;失败报告保留且不得算通过。使用显式测试环境文件及 `--output reports/<本轮名称>.json`,须有真实 HTTP 写入授权;不启动海外收货或出库。 -退货专项同时核验新建退货单、退货主单、签收单、批次和自动箱唛的完整业务编号:`RT/RR/RB/RC/RETU-` 加 25 位大写 Base36 UUID 编码;不再截取内部 ID 六位尾号。幂等重放仍核对同一已保存编号,手工指定箱唛不受系统生成格式约束。编号生成器另有完整 128 位编码往返、原截断碰撞和本地并行生成单测;本地生成压测不增加 HTTP 并发。 +退货专项核验退货单、退货主单、签收单、批次和合箱生成箱唛的完整业务编号:`RT/RR/RB/RC/RETU-` 加 25 位大写 Base36 UUID 编码;托盘签收生成的实物箱唛采用当前 `RC` 前缀及箱数/序号标签协议,必须从签收响应 `cartonMark` 读取,不能用 `palletCode` 替代。不再截取内部 ID 六位尾号。幂等重放仍核对同一已保存编号,手工指定箱唛不受系统生成格式约束。编号生成器另有完整 128 位编码往返、原截断碰撞和本地并行生成单测;本地生成压测不增加 HTTP 并发。 退货专项只运行 `--modules phase4-overseas-return-receipt`,自动准备本轮客户;覆盖非法/重复预报、退货单号和 SKU 查询、非法/未知 SKU 实收、超收及失败不落账、分批签收完成、部分签收禁止合箱、合箱非法箱规/数量/来源、幂等重放前后库存快照一致、逐箱分页无重复遗漏、客户 SKU 数量守恒及标签。通过显式测试环境文件运行总入口,JSON 路径由 `--output` 指定;会创建隔离测试数据,finally 停用临时 PDA,不删除业务记录。失败保留报告,先核对记录再决定是否新批次重跑。 -退货预报创建后为 `PENDING_REVIEW`。签收前必须通过客户详情确认归属,再经平台 `/return-orders/{returnNo}/review` 携带 `X-Customer-Id` 审核,客户回查 `PENDING_RECEIPT` 且实收为零;审核失败立即停止签收。`return-review-contract.test.mjs` 覆盖归属拒绝、审核权限失败和回查未收敛。主签收场景按单 SKU 分甲3件、乙2件两批,使用正式 PDA 退货托盘生成接口返回的 `palletCode`,不复用国内基础托盘;每批对应一个计费批次。最后一批先验证缺照片拒绝及库存不变,再上传明确为合成测试图片的 PNG 并完成签收;不把图片称为真实现场照片。边界扩展与换标后续仍需按新协议独立验证,主签收通过不能证明全部退货链通过。 +退货预报创建后为 `PENDING_REVIEW`。签收前必须通过客户详情确认归属,再经平台 `/return-orders/{returnNo}/review` 携带 `X-Customer-Id` 审核,客户回查 `PENDING_RECEIPT` 且实收为零;审核失败立即停止签收。`return-review-contract.test.mjs` 覆盖归属拒绝、审核权限失败和回查未收敛。主签收场景按单 SKU 分甲3件、乙2件两批,使用正式 PDA 退货托盘生成接口返回的 `palletCode`,不复用国内基础托盘;每批对应一个计费批次。按 `PALLET` 提交每 SKU 箱数和托盘长宽高、重量,总箱数由服务端汇总;主场景验证不上传可选照片仍能完成。边界场景继续验证合成 PNG 上传及合法多 SKU 同托签收,不把图片称为真实现场照片。边界扩展与换标后续仍需按新协议独立验证,主签收通过不能证明全部退货链通过。 `return-boundary-scenarios.mjs` 是退货专项自动调用的内部扩展,不单独注册入口:本轮新建第二隔离客户,复用两个有效海外仓,覆盖跨客户单据/箱/标签/列表隔离、跨仓 PDA 隔离、两人争抢最后一件的签收及合箱、同键并发签收与成功后重试、多单同 SKU 全量合箱和实际来源追溯、耗尽箱清零、多单 SKU 候选和输入边界。异常写入逐次前后回查订单、签收批次、箱及 SKU;同键重试只模拟响应丢失后的客户端行为,不代表网络故障注入。每组并发严格为两个请求,不启动批量 worker;缺少第二仓或操作员属于失败,不能跳过后宣称通过。finally 停用新增 PDA,保留测试客户和业务记录;不触发部署、SQL、换标、出库或二次合箱。真实运行须明确授权目标环境及此小规模并发边界。`merchant-qualification-fixture.mjs` 的内部参数 `publishEnvironment: false` 只将第二客户凭据留在独立内存上下文,不覆盖主客户环境凭据,默认主 fixture 行为不变。 扩展离线检查:`node --test scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/script-documentation-contract.test.mjs`。这些校验验证脚本编排和断言,不代替真实 HTTP 验收;运行报告中的每个失败均保留阻断。 -边界扩展创建的每张退货单也必须先审核,并由绑定该仓的 PDA 经正式接口生成本轮退货实收托盘、上传合成打托测试图片;客户身份通过本轮登录会话映射,禁止用主客户替代第二客户。签收请求传 `palletCode`,成功响应核对同一托盘而非已移除的 `cartonMark` 字段;流水分两批时分别使用两个正式托盘。混合明细场景先验证两种 SKU 同批拒绝且不落账,再分别签收为两个来源库存箱以继续合箱回滚验证。三轮双请求并发范围保持不变。 +边界扩展创建的每张退货单也必须先审核,再由绑定仓 PDA 按 `PARCEL` 实收、上传可选合成照片;客户身份由本轮登录会话映射。散件签收使用独立包裹 `unitCode`,库存定位读取响应 `cartonMark`,所有箱码路径必须编码。合法多 SKU 同包裹生成独立散件库存,并经散件合箱接口核对并发、回滚和流水;主场景的 `PALLET` 实物箱通过 `merge-boxes` 和逐箱 `sourceDeductions` 核对合并、重放及数量守恒。三轮双请求并发范围保持不变。 同一退货扩展还覆盖五组完整性检查:混合明细中前项合法、后项超量/不存在时签收与合箱整笔回滚;同一操作员同键并发合箱及后续重试只生成一个目标箱;不同客户及仓库使用同一 SKU 时的候选、列表和库存隔离;客户/平台/PDA/无登录身份不得进入未授权退货入口;两批签收流水逐行核对客户、仓库、SKU、签收业务号及增量,并在重放和超收失败后回查不变。流水由现有接口从签收明细重建,不代表独立持久审计账本,也不验证后续换标或出库。并发合箱固定三轮、每轮两个请求,其余造数和校验顺序执行。 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 0d5799df5..b1216edc3 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 @@ -1,3 +1,4 @@ +import { palletReceiptBody } from './return-boundary-scenarios.mjs' import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' @@ -8,6 +9,19 @@ import { completeReturnRelabel, outboundRelabelledReturn, verifyReturnOutbound } 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('both pallet receipt batches supply SKU box counts and measured pallet fields', () => { + for (const [sku, quantity, startedBy] of [['A', 3, 'RETURN_NO'], ['B', 2, 'SKU']]) { + const body = palletReceiptBody('RT-1', `P-${sku}`, sku, quantity, `KEY-${sku}`, startedBy) + assert.equal(body.unitType, 'PALLET') + assert.equal(body.unitCode, `P-${sku}`) + assert.equal(body.palletCode, body.unitCode) + assert.equal(body.startedBy, startedBy) + assert.deepEqual(body.items, [{ skuCode: sku, receivedQuantity: quantity, boxCount: 1 }]) + for (const field of ['length', 'width', 'height', 'weight']) assert.ok(body[field] > 0) + assert.equal(body.totalBoxCount, undefined, 'server derives the total from SKU box counts') + } +}) + test('relabelled outbound refuses another relabel application before creating an outbound', async () => { const calls = [] const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, @@ -214,12 +228,10 @@ test('return receipt acceptance covers both lookup paths, partial/final receipt assert.match(source, /\$\{PDA_RECEIPTS\}\/start/) assert.match(source, /\$\{PDA_RECEIPTS\}\/sku-candidates/) assert.match(source, /PDA首次单SKU签收甲3件/) - assert.match(source, /PDA按单SKU完成乙2件签收/) - assert.match(source, /palletCode: cartonA/) - assert.match(source, /最后一批缺少打托照片拒绝且库存不变/) - assert.match(source, /photos\/files/) - assert.match(source, /requestMultipart\('POST',[^\n]+photoForm, picker\.token\)/) - assert.doesNotMatch(source, /photoForm, \{\}, picker\.token/) + assert.match(source, /PDA无照片按单SKU完成乙2件签收/) + assert.match(source, /palletReceiptBody\(returnNo, palletCodeA, skuA, 3,/) + assert.match(source, /const cartonA = id\(first.data\?\.cartonMark\)/) + assert.match(source, /const cartonB = id\(second.data\?\.cartonMark\)/) assert.match(source, /不存在SKU不是业务错误/) assert.match(source, /已完成退货单不再进入待签收流程/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 016f68832..f45458c3c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -1,5 +1,5 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' -import { runReturnBoundaries, verifyNoMutation } from './return-boundary-scenarios.mjs' +import { runReturnBoundaries, verifyNoMutation, palletReceiptBody } from './return-boundary-scenarios.mjs' const MODULE = 'phase4-overseas-return-receipt' const SCENARIO = 'phase4-overseas-return-receipt-and-billing' @@ -96,6 +96,7 @@ function itemMap(items) { return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item])) } + export async function approveReturnFixture(context, merchantToken, created) { const expectedCustomer = id(context.context?.merchantQualificationFixture?.tenantId) const order = created?.order @@ -235,16 +236,18 @@ export async function run(context) { const palletB = await overseas(context, picker.token, 'POST', 'PDA生成本轮SKU乙签收托盘', `${PDA_RECEIPTS}/pallets`, { returnNo, skuCode: skuB }) if (!success(palletA.response) || !nonBlank(palletA.data?.palletCode) || !success(palletB.response) || !nonBlank(palletB.data?.palletCode)) throw new Error('签收托盘准备失败') - const cartonA = palletA.data.palletCode + const palletCodeA = palletA.data.palletCode const firstBody = { - returnNo, trackingNo: `TRACK-${suffix}`, startedBy: 'RETURN_NO', palletCode: cartonA, - items: [{ skuCode: skuA, receivedQuantity: 3 }], idempotencyKey: key(context, 'RECEIPT-1'), + ...palletReceiptBody(returnNo, palletCodeA, skuA, 3, key(context, 'RECEIPT-1'), 'RETURN_NO'), + trackingNo: `TRACK-${suffix}`, } const first = await overseas(context, picker.token, 'POST', 'PDA首次单SKU签收甲3件', `${PDA_RECEIPTS}/confirm`, firstBody) if (!success(first.response)) throw new Error('首次签收失败,停止后续库存场景') + const cartonA = id(first.data?.cartonMark) + context.assert(MODULE, '托盘签收返回独立实物箱码', first.response, nonBlank(cartonA) && cartonA !== palletCodeA, trace(first.response), OPTIONS) context.assert(MODULE, '首次签收形成唯一箱和仓储计费批次', first.response, first.data?.orderStatus === 'PARTIALLY_RECEIVED' && first.data?.fullyMatched === false - && Number(first.data?.receivedQuantity) === 3 && first.data?.palletCode === cartonA + && Number(first.data?.receivedQuantity) === 3 && first.data?.palletCode === palletCodeA && 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) @@ -254,7 +257,8 @@ export async function run(context) { context.assert(MODULE, '签收重放不重复创建签收单、箱或计费批次', firstReplay.response, id(firstReplay.data?.receiptNo) === firstReceiptNo && id(firstReplay.data?.receiptBatchNo) === firstBatchNo - && id(firstReplay.data?.palletCode) === cartonA, + && id(firstReplay.data?.palletCode) === palletCodeA + && id(firstReplay.data?.cartonMark) === cartonA, trace(firstReplay.response), OPTIONS) await reject('签收同键异载荷返回冲突', conflict, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`, @@ -262,8 +266,7 @@ export async function run(context) { await reject('超收被拒绝且不得生成费用批次', conflict, async () => { const body = { - returnNo, startedBy: 'SKU', palletCode: cartonA, items: [{ skuCode: skuA, receivedQuantity: 2 }], - idempotencyKey: key(context, 'OVER-RECEIPT'), + ...palletReceiptBody(returnNo, palletCodeA, skuA, 2, key(context, 'OVER-RECEIPT'), 'SKU'), } let response = null for (let attempt = 1; attempt <= 3; attempt += 1) { @@ -275,11 +278,12 @@ export async function run(context) { }) for (const [name, changes, expected] of [ - ['零实收数量', { items: [{ skuCode: skuA, receivedQuantity: 0 }] }, validationFailure], - ['负实收数量', { items: [{ skuCode: skuA, receivedQuantity: -1 }] }, validationFailure], + ['零实收数量', { items: [{ skuCode: skuA, receivedQuantity: 0, boxCount: 1 }] }, validationFailure], + ['负实收数量', { items: [{ skuCode: skuA, receivedQuantity: -1, boxCount: 1 }] }, validationFailure], + ['零SKU箱数', { items: [{ skuCode: skuB, receivedQuantity: 1, boxCount: 0 }] }, validationFailure], ['空实收明细', { items: [] }, validationFailure], ['非法签收方式', { startedBy: 'INVALID' }, validationFailure], - ['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1 }] }, conflict], + ['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1, boxCount: 1 }] }, conflict], ]) { await reject(`拒绝${name}`, expected, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, picker.token, {})) } @@ -298,26 +302,9 @@ export async function run(context) { `receipts=${afterPartial.data?.receipts?.length ?? 'missing'}`, OPTIONS) const secondBody = { - returnNo, startedBy: 'SKU', palletCode: palletB.data.palletCode, - items: [{ skuCode: skuB, receivedQuantity: 2 }], - idempotencyKey: key(context, 'RECEIPT-2'), + ...palletReceiptBody(returnNo, palletB.data.palletCode, skuB, 2, key(context, 'RECEIPT-2'), 'SKU'), } - await reject('最后一批缺少打托照片拒绝且库存不变', conflict, async () => { - let response = null - for (let attempt = 1; attempt <= 3; attempt += 1) { - response = await context.request('POST', `${PDA_RECEIPTS}/confirm`, secondBody, picker.token, {}) - if (response?.http !== 0 || attempt === 3) break - await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) - } - return response - }) - const photoForm = new FormData() - // Synthetic HTTP fixture, not a photograph of real warehouse activity. - photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'return-receipt-test.png') - const photo = await context.requestMultipart('POST', `${PDA_RECEIPTS}/${encodeURIComponent(returnNo)}/photos/files`, photoForm, picker.token) - add(context, '上传本轮合成打托测试照片', photo, success(photo)) - if (!success(photo) || !photo.body?.data?.fileId) throw new Error('打托照片上传失败') - const second = await overseas(context, picker.token, 'POST', 'PDA按单SKU完成乙2件签收', `${PDA_RECEIPTS}/confirm`, secondBody) + const second = await overseas(context, picker.token, 'POST', 'PDA无照片按单SKU完成乙2件签收', `${PDA_RECEIPTS}/confirm`, secondBody) if (!success(second.response)) throw new Error('最终签收失败,停止合箱和换标后续') context.assert(MODULE, '最终签收自动核平并生成第二个费用批次', second.response, second.data?.orderStatus === 'COMPLETED' && second.data?.fullyMatched === true @@ -327,7 +314,7 @@ export async function run(context) { `billingBatch=${id(second.data?.receiptBatchNo) || 'missing'}`, OPTIONS) const secondReceiptNo = id(second.data?.receiptNo) const secondBatchNo = id(second.data?.receiptBatchNo) - const cartonB = id(second.data?.palletCode) + 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) @@ -400,18 +387,18 @@ export async function run(context) { 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, + context.assert(MODULE, '实物箱库存不混入散SKU合箱候选', 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, + && !packingSkus.has(skuA) && !packingSkus.has(skuB), trace(packing.response), OPTIONS) const mergeBody = { - returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }, { skuCode: skuB, quantity: 1 }], + sourceCartonMarks: [cartonA, cartonB], + sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 1 }, { cartonMark: cartonB, 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) + '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', mergeBody) if (success(merged.response)) { context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response, nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2 @@ -443,26 +430,26 @@ export async function run(context) { && / context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, weight: 3 }, picker.token, {})) + await reject('合箱同键异载荷返回冲突', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', { ...mergeBody, weight: 3 }, picker.token, {})) const beforeInvalid = await overseas(context, merchantToken, 'GET', '冻结合箱异常检查前库存快照', query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })) for (const [name, changes, expected] of [ ['零箱长', { length: 0 }, validationFailure], ['负重量', { weight: -1 }, validationFailure], - ['空合箱明细', { items: [] }, validationFailure], - ['零合箱数量', { items: [{ skuCode: skuA, quantity: 0 }] }, validationFailure], - ['超可用库存', { items: [{ skuCode: skuA, quantity: 999 }] }, conflict], - ['不存在来源单', { returnNos: [`MISSING-${suffix}`] }, notFound], + ['空来源箱', { sourceCartonMarks: [] }, validationFailure], + ['零合箱数量', { sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 0 }] }, validationFailure], + ['超可用库存', { sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 999 }] }, conflict], + ['不存在来源箱', { sourceCartonMarks: [`MISSING-${suffix}`] }, conflict], ]) { - await reject(`合箱拒绝${name}`, expected, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, picker.token, {})) + await reject(`合箱拒绝${name}`, expected, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, picker.token, {})) } await overseas(context, picker.token, 'POST', '异常检查后再重放原合箱请求', - '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody) + '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', mergeBody) const afterInvalid = await overseas(context, merchantToken, 'GET', '回查异常及重放后的全部箱库存', query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })) context.assert(MODULE, '失败与幂等重放不改变箱数数量状态及来源', afterInvalid.response, diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs index da1505fc2..e549cbfc0 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs @@ -132,14 +132,14 @@ test('高价值扩展覆盖混合回滚、同键合箱、同码隔离、权限 assert.match(source, /sameKeyResponses/) }) -test('边界fixture遵守审核、单SKU托盘和完工照片新契约', async () => { +test('边界fixture遵守审核、托盘实收和可选照片新契约', async () => { const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8') assert.match(source, /customerByToken\.set\(tokenB, String\(fixtureB\.tenantId\)\)/) assert.match(source, /'X-Customer-Id': customerId/) assert.match(source, /status !== 'PENDING_REVIEW'/) - assert.match(source, /palletCode: order\.palletCode/) + assert.match(source, /unitType: 'PARCEL', unitCode: order.unitCode/) assert.match(source, /requestMultipart\('POST',[^\n]+form, operator\.token\)/) - assert.match(source, /两个合法SKU同批签收仍整笔拒绝/) - assert.match(source, /palletCode: nextPallet\.palletCode/) + assert.match(source, /两个合法SKU同包裹签收生成独立散件库存/) + assert.match(source, /unitCode: `\$\{order.unitCode\}-SECOND`/) assert.doesNotMatch(source, /\['receiptNo', 'receiptBatchNo', 'cartonMark'\]/) }) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs index 514596acd..b7a938f18 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -47,6 +47,14 @@ export function exactReceiptLedger(data, expected) { && JSON.stringify(stableState(rows(data).map(project))) === JSON.stringify(stableState(expected.map(project))) } +export function palletReceiptBody(returnNo, palletCode, skuCode, receivedQuantity, idempotencyKey, startedBy) { + return { + returnNo, palletCode, unitType: 'PALLET', unitCode: palletCode, startedBy, + length: 120, width: 100, height: 80, weight: 10, + items: [{ skuCode, receivedQuantity, boxCount: 1 }], idempotencyKey, + } +} + export async function runReturnBoundaries(context, { merchantToken, warehouse, picker, access }) { let sequence = 0 const prefix = `RBX-${context.marker.replace(/[^A-Z0-9]/gi, '').slice(-18)}` @@ -92,26 +100,24 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p if (!ok(reviewed) || reviewed.body.data?.order?.status !== 'PENDING_RECEIPT') throw new Error('边界退货审核失败') const operator = String(warehouseId) === String(warehouse.id) ? picker : foreignPicker if (!operator?.token) throw new Error('缺少本仓签收操作员') - const pallet = await good(operator.token, 'POST', '准备边界退货实收托盘', `${RECEIPTS}/pallets`, { returnNo: data.order.returnNo, skuCode: sku }) - if (!pallet.palletCode) throw new Error('退货托盘码缺失') const form = new FormData() form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-boundary.png') const photo = await context.requestMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token) check('边界签收准备合成打托照片', photo, ok(photo) && Boolean(photo.body.data?.fileId)) if (!ok(photo) || !photo.body.data?.fileId) throw new Error('边界照片上传失败') - return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, palletCode: pallet.palletCode } + return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, unitCode: `PKG-${data.order.returnNo}` } } function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) { - return { returnNo: order.returnNo, startedBy: 'RETURN_NO', palletCode: order.palletCode, items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey } + return { returnNo: order.returnNo, startedBy: 'RETURN_NO', unitType: 'PARCEL', unitCode: order.unitCode, items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey } } async function receive(order, operator = picker) { const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order)) - check('签收单批次采用独立完整编号且绑定本轮托盘', null, + check('签收单批次采用独立完整编号且绑定本轮包裹', null, /^RR-[0-9A-Z]{25}$/.test(data.receiptNo) && /^RB-[0-9A-Z]{25}$/.test(data.receiptBatchNo) - && data.palletCode === order.palletCode) - order.cartonMark = data.palletCode + && data.unitType === 'PARCEL' && data.unitCode === order.unitCode && !data.palletCode) + order.cartonMark = data.cartonMark order.batch = data.receiptBatchNo - resources.cartonMarks.push(data.palletCode) + resources.cartonMarks.push(data.cartonMark) return data } function mergeBody(orders, sku, quantity, idempotencyKey = key('MERGE')) { @@ -124,7 +130,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const detail = await good(order.token, 'GET', '读取订单数量与签收批次快照', `${ORDERS}/${order.returnNo}`) const cartons = await good(order.token, 'GET', '读取全部状态箱快照', url(`${INVENTORY}/cartons`, { returnNo: order.returnNo, page: 1, size: 200 })) const items = [] - for (const carton of rows(cartons)) items.push(await good(order.token, 'GET', '读取箱内SKU快照', `${INVENTORY}/cartons/${carton.cartonMark}`)) + for (const carton of rows(cartons)) items.push(await good(order.token, 'GET', '读取箱内SKU快照', `${INVENTORY}/cartons/${encodeURIComponent(carton.cartonMark)}`)) const summary = await good(order.token, 'GET', '读取SKU数量及所在箱数快照', url(`${INVENTORY}/summary`, { skuCode: order.sku, overseasWarehouseId: order.warehouseId, page: 1, size: 200 })) const additionalSummaries = [] for (const sku of order.skus.filter(sku => sku !== order.sku)) additionalSummaries.push(await good(order.token, 'GET', @@ -176,8 +182,8 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p await receive(a); await receive(b) for (const [name, path] of [ ['退货单详情', `${ORDERS}/${b.returnNo}`], - ['箱明细', `${INVENTORY}/cartons/${b.cartonMark}`], - ['箱标签', `${INVENTORY}/cartons/${b.cartonMark}/label`], + ['箱明细', `${INVENTORY}/cartons/${encodeURIComponent(b.cartonMark)}`], + ['箱标签', `${INVENTORY}/cartons/${encodeURIComponent(b.cartonMark)}/label`], ]) await unchanged(`客户A禁止读取客户B${name}`, [a, b], () => request(merchantToken, 'GET', path), [403, 404]) for (const [name, path, filter] of [ ['退货单列表', ORDERS, { returnNo: b.returnNo }], @@ -201,7 +207,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const candidates = await good(picker.token, 'POST', '本仓查询异仓SKU候选', `${RECEIPTS}/sku-candidates`, { skuCode: order.sku }) check('SKU候选不串仓', null, Array.isArray(candidates.candidates) && candidates.candidates.length === 0) await receive(order, foreignPicker) - await unchanged('非绑定仓禁止读取箱标签', [order], () => request(picker.token, 'GET', `/api/pda/v1/last-leg/return-inventory/cartons/${order.cartonMark}/label`), [404]) + await unchanged('非绑定仓禁止读取箱标签', [order], () => request(picker.token, 'GET', `/api/pda/v1/last-leg/return-inventory/cartons/${encodeURIComponent(order.cartonMark)}/label`), [404]) await unchanged('非绑定仓禁止合箱', [order], () => request(picker.token, 'POST', MERGE, mergeBody([order], order.sku, 1)), [404]) }) @@ -228,7 +234,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const retry = await request(picker.token, 'POST', `${RECEIPTS}/confirm`, body) const accepted = sameKeyResponses.find(ok)?.body.data check('模拟成功响应丢失后原键重试复用原签收', retry, ok(retry) && accepted - && ['receiptNo', 'receiptBatchNo', 'palletCode'].every(field => retry.body.data[field] === accepted[field])) + && ['receiptNo', 'receiptBatchNo', 'unitCode', 'cartonMark'].every(field => retry.body.data[field] === accepted[field])) check('重试后签收和库存不重复', retry, JSON.stringify(stableState(beforeRetry)) === JSON.stringify(stableState(await snapshot([replayOrder])))) } }) @@ -269,16 +275,16 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p await receive(a); await receive(b); await receive(unused) const result = await good(picker.token, 'POST', '同客户多单全量合箱', MERGE, mergeBody([a, b, unused], sku, 5)) resources.cartonMarks.push(result.cartonMark) - const target = await good(merchantToken, 'GET', '回查多单目标箱', `${INVENTORY}/cartons/${result.cartonMark}`) + const target = await good(merchantToken, 'GET', '回查多单目标箱', `${INVENTORY}/cartons/${encodeURIComponent(result.cartonMark)}`) check('多单同SKU合并为一行五件', null, Number(target.totalQuantity) === 5 && target.items.length === 1 && Number(target.items[0].quantity) === 5) check('来源单和批次仅含实际消耗且去重', null, JSON.stringify(String(target.sourceReturnNo).split(',').sort()) === JSON.stringify([a.returnNo, b.returnNo].sort()) && JSON.stringify(String(target.sourceBatchNo).split(',').sort()) === JSON.stringify([a.batch, b.batch].sort())) for (const source of [a, b]) { - const carton = await good(merchantToken, 'GET', '回查已耗尽来源箱', `${INVENTORY}/cartons/${source.cartonMark}`) + const carton = await good(merchantToken, 'GET', '回查已耗尽来源箱', `${INVENTORY}/cartons/${encodeURIComponent(source.cartonMark)}`) check('耗尽来源箱为MERGED且件数SKU数清零', null, carton.status === 'MERGED' && Number(carton.totalQuantity) === 0 && Number(carton.skuTypes) === 0 && carton.items.length === 0) } - const untouched = await good(merchantToken, 'GET', '回查未取货来源箱', `${INVENTORY}/cartons/${unused.cartonMark}`) + const untouched = await good(merchantToken, 'GET', '回查未取货来源箱', `${INVENTORY}/cartons/${encodeURIComponent(unused.cartonMark)}`) check('选中但未取货来源保持可用一件', null, untouched.status === 'AVAILABLE' && Number(untouched.totalQuantity) === 1) const summary = await good(merchantToken, 'GET', '回查全量合箱SKU汇总', url(`${INVENTORY}/summary`, { skuCode: sku, page: 1, size: 20 })) check('全量合箱后仍五件但仅一个可用箱', null, rows(summary).some(row => row.skuCode === sku && Number(row.availableQuantity) === 5 && Number(row.cartonQuantity) === 1)) @@ -293,12 +299,8 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p for (const [name, secondSku, quantity] of [['后项超收', skuB, 3], ['后项未预报', `${prefix}-ATOMIC-UNKNOWN`, 1]]) { await unchanged(`混合签收${name}必须整笔失败`, [order], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, makeReceipt(secondSku, quantity)), [409]) } - await unchanged('两个合法SKU同批签收仍整笔拒绝', [order], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, { - ...receiptBody(order), items: [skuA, skuB].map(skuCode => ({ skuCode, receivedQuantity: 2 })) }), [409]) - await good(picker.token, 'POST', '甲SKU独立签收准备合箱', `${RECEIPTS}/confirm`, receiptBody(order)) - const skuBPallet = await good(picker.token, 'POST', '乙SKU独立托盘', `${RECEIPTS}/pallets`, { returnNo: order.returnNo, skuCode: skuB }) - await good(picker.token, 'POST', '乙SKU独立签收准备合箱', `${RECEIPTS}/confirm`, { - ...receiptBody(order), palletCode: skuBPallet.palletCode, items: [{ skuCode: skuB, receivedQuantity: 2 }] }) + await good(picker.token, 'POST', '两个合法SKU同包裹签收生成独立散件库存', `${RECEIPTS}/confirm`, { + ...receiptBody(order), items: [skuA, skuB].map(skuCode => ({ skuCode, receivedQuantity: 2 })) }) for (const [name, secondSku, quantity] of [['后项超库存', skuB, 3], ['后项不存在', `${prefix}-ATOMIC-UNKNOWN`, 1]]) { await unchanged(`混合合箱${name}必须整笔回滚`, [order], () => request(picker.token, 'POST', MERGE, { ...mergeBody([order], skuA, 1), items: [{ skuCode: skuA, quantity: 1 }, { skuCode: secondSku, quantity }] }), [409]) @@ -371,7 +373,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p await unchanged(`${name}不能调用PDA合箱`, [order], () => request(token, 'POST', MERGE, mergeBody([order], order.sku, 1)), statuses) } for (const [name, token] of [['客户', merchantToken], ['PDA', picker.token]]) { - for (const path of [PLATFORM_INVENTORY, `${PLATFORM_INVENTORY}/cartons/${order.cartonMark}`, `${PLATFORM_INVENTORY}/cartons/${order.cartonMark}/label`, `${PLATFORM_INVENTORY}/movements`]) { + for (const path of [PLATFORM_INVENTORY, `${PLATFORM_INVENTORY}/cartons/${encodeURIComponent(order.cartonMark)}`, `${PLATFORM_INVENTORY}/cartons/${encodeURIComponent(order.cartonMark)}/label`, `${PLATFORM_INVENTORY}/movements`]) { const validPath = path === PLATFORM_INVENTORY || path.endsWith('/movements') ? url(path, { page: 1, size: 20, customerId: context.context.merchantQualificationFixture.tenantId, skuCode: order.sku }) : path await unchanged(`${name}禁止读取平台退货入口${path.split('/').at(-1)}`, [order], () => request(token, 'GET', validPath), [403]) @@ -395,8 +397,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p check, name: '签收重放不重复产生流水', expected: result => ok(result) && result.body.data?.receiptNo === signed.receiptNo }) await verifyNoMutation({ snapshot: withLedger, action: () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order, 2)), check, name: '超收失败不留下流水', expected: result => rejected(result, [409]) }) - const nextPallet = await good(picker.token, 'POST', '流水场景第二批托盘', `${RECEIPTS}/pallets`, { returnNo: order.returnNo, skuCode: order.sku }) - const final = await good(picker.token, 'POST', '流水场景第二批实收一件', `${RECEIPTS}/confirm`, { ...receiptBody(order, 1), palletCode: nextPallet.palletCode }) + const final = await good(picker.token, 'POST', '流水场景第二批实收一件', `${RECEIPTS}/confirm`, { ...receiptBody(order, 1), unitCode: `${order.unitCode}-SECOND` }) const data = await ledger() check('两批签收流水业务引用唯一且分别两件一件', null, exactReceiptLedger(data, [entry, { ...entry, referenceNo: final.receiptNo, totalDelta: 1, availableDelta: 1 }]) && signed.receiptNo !== final.receiptNo) @@ -420,7 +421,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p && remaining.candidates[0].returnNo === b.returnNo && Number(remaining.candidates[0].skuExpectedQuantity) === 2 && Number(remaining.candidates[0].skuReceivedQuantity) === 0) for (const [name, patch] of [ - ['已存在箱号', { palletCode: a.cartonMark }], + ['已存在箱号', { unitType: 'BOX', unitCode: a.cartonMark, items: [{ skuCode: sku, receivedQuantity: 1, boxCount: 1 }] }], ['缺少幂等键', { idempotencyKey: '' }], ['超长快递单号', { trackingNo: 'X'.repeat(129) }], ]) await unchanged(`拒绝${name}`, [a, b], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, { ...receiptBody(b, 1), ...patch }), name === '已存在箱号' ? [409] : [400, 422]) diff --git a/scripts/admin-test-env-request-retry-contract.test.mjs b/scripts/admin-test-env-request-retry-contract.test.mjs index 8e02b0188..c26abb68d 100644 --- a/scripts/admin-test-env-request-retry-contract.test.mjs +++ b/scripts/admin-test-env-request-retry-contract.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { AcceptanceRun, transportFailureDetails } from './admin-test-env-acceptance.mjs' function runContext() { return new AcceptanceRun({ gateway: 'http://acceptance.invalid', overseasGateway: 'http://overseas.invalid' }) @@ -32,14 +32,29 @@ test('mutating HTTP requests never retry an ambiguous network failure', async () let attempts = 0 globalThis.fetch = async () => { attempts += 1 - throw new TypeError('fetch failed') + throw new TypeError('fetch failed', { cause: Object.assign(new Error('private host and token'), { code: 'ECONNRESET', syscall: 'read' }) }) } try { const response = await runContext().request('POST', '/api/orders', { value: 1 }) assert.equal(attempts, 1) assert.equal(response.http, 0) assert.equal(response.raw, 'fetch failed') + assert.equal(response.transportError.causes[1].code, 'ECONNRESET') + const run = runContext(); run.options.quiet = true + run.addStep('orders', 'conflict request', response, false) + assert.deepEqual(run.steps[0].transportError, response.transportError) + assert.ok(!JSON.stringify(run.steps).includes('private host and token')) } finally { globalThis.fetch = originalFetch } }) + +test('transport diagnostics retain nested connection codes without addresses or exception messages', () => { + const cause = Object.assign(new Error('secret endpoint'), { code: 'ETIMEDOUT', address: 'private', syscall: 'connect' }) + const error = new TypeError('fetch failed', { cause: new AggregateError([cause]) }) + cause.cause = error + const result = transportFailureDetails(error) + assert.equal(result.causes.length, 3) + assert.equal(result.causes[2].code, 'ETIMEDOUT') + assert.doesNotMatch(JSON.stringify(result), /secret|private|endpoint/) +}) diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index 986fb0fb1..625f13a9a 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -121,10 +121,18 @@ test('数据库迁移计划包含授权版本和事件可靠投递的正式目 commit: null, }) const targets = Object.fromEntries(plan.regions.flatMap((region) => region.schemas.map((schema) => [schema.schema, schema]))) - assert.equal(targets.baiyue_iam.target, '3') + assert.equal(targets.baiyue_iam.target, '4') + assert.equal(targets.baiyue_iam.migrations.at(-1).path, + 'migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql') assert.equal(targets.baiyue_firstleg.target, '7') assert.equal(targets.baiyue_finance.target, '2') - assert.equal(targets.baiyue_lastleg.target, '9') + assert.equal(targets.baiyue_lastleg.target, '13') + assert.deepEqual(targets.baiyue_lastleg.migrations.slice(-4).map(migration => migration.path), [ + 'migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql', + 'migrations/overseas/baiyue_lastleg/V11__align_capacity_pool_collation.sql', + 'migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql', + 'migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql', + ]) assert.equal(targets.baiyue_lastlag_warehouse.target, '2') assert.deepEqual(targets.baiyue_firstleg.migrations.slice(-3).map((migration) => migration.path), [ 'migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql', diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json index 2f78ffa34..4e698f3c7 100644 --- a/scripts/java-quality-baseline.json +++ b/scripts/java-quality-baseline.json @@ -11,7 +11,7 @@ "services": 13 }, "openapi": { - "documents": 69, + "documents": 72, "coveredServices": 11, "codeFirstServices": 2 }, diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 4715cccc6..1efe09648 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -65,7 +65,10 @@ test('repository freezes the current topology and non-blocking aggregate summary const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 20, codeModules: 19, services: 13 }) - assert.equal(summary.openapi.documents, 69) + assert.equal(summary.openapi.documents, 72) + for (const name of ['lastleg-exports', 'return-packing', 'fleet-capacity-offers']) { + assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`)) + } assert.ok(summary.openapi.files.includes('lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml')) assert.ok(summary.openapi.files.includes('lastleg-service/src/main/resources/openapi/fleet-drivers-v1.yaml')) assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/fleet-driver-license-v1.yaml')) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index fa71b2a14..dbfbb24ec 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -78,6 +78,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'file-service', 'iam-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), feign('file/src/main/java/com/baiyue/file/client/IamQualificationClient.java', 'file-service', 'iam-service', ['REQUEST'], 'WRITE', 'STATE_CONVERGENT', '资质登记/替换按客户与资质类型收敛,无通用幂等头'), + feign('file/src/main/java/com/baiyue/file/client/LastlegExportProviderClient.java', + 'file-service', 'lastleg-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 通过内部令牌按客户范围分批读取海外业务数据'), feign('file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java', 'file-service', 'order-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), feign('file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 38f2d7ce4..94cdf6b22 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -50,6 +50,7 @@ const expectedBackgroundEdges = [ 'file-service->finance-service', 'file-service->firstleg-service', 'file-service->iam-service', + 'file-service->lastleg-service', 'file-service->order-service', 'file-service->warehouse-service', 'finance-service->lastleg-service', @@ -102,7 +103,7 @@ test('all Feign clients have reviewed context, access and idempotency metadata', const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 71) + assert.equal(reviewed.length, 72) assert.deepEqual(await discoverFeignEdges(), reviewed) }) @@ -308,5 +309,6 @@ test('shared Feign timeout defaults and long-running export overrides do not dri assert.match(source, /read-timeout:\s*5000/) } const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8') - assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5) + assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 6) + assert.match(fileConfig, /lastlegExportProviderClient:\s+connect-timeout:\s*3000\s+read-timeout:\s*15000/) })