优化
This commit is contained in:
@@ -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:
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
|
||||
+8
-1
@@ -44,6 +44,7 @@ class OverseasPhase4PermissionSqlContractTest {
|
||||
private static final List<String> 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);
|
||||
}
|
||||
|
||||
+2
-4
@@ -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("幂等键已用于不同的签收请求");
|
||||
|
||||
+6
-6
@@ -366,8 +366,7 @@ class ReturnReceiptServiceTest {
|
||||
var first = service.confirm(identity, request);
|
||||
ArgumentCaptor<ReturnReceipt> 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<ReturnReceipt> 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<ReturnOrderItem> 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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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、签收业务号及增量,并在重放和超收失败后回查不变。流水由现有接口从签收明细重建,不代表独立持久审计账本,也不验证后续换标或出库。并发合箱固定三轮、每轮两个请求,其余造数和校验顺序执行。
|
||||
|
||||
|
||||
@@ -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, /已完成退货单不再进入待签收流程/)
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
&& /<svg\b/.test(label.data?.printableHtml || '')
|
||||
&& label.data?.printableHtml?.includes(merged.data.cartonMark), trace(label.response), OPTIONS)
|
||||
const mergeReplay = 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)
|
||||
context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response,
|
||||
success(mergeReplay.response) && nonBlank(merged.data?.cartonMark)
|
||||
&& id(mergeReplay.data?.cartonMark) === id(merged.data?.cartonMark), trace(mergeReplay.response), OPTIONS)
|
||||
await reject('合箱同键异载荷返回冲突', conflict, () => 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,
|
||||
|
||||
@@ -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'\]/)
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"services": 13
|
||||
},
|
||||
"openapi": {
|
||||
"documents": 69,
|
||||
"documents": 72,
|
||||
"coveredServices": 11,
|
||||
"codeFirstServices": 2
|
||||
},
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user