From 45018987effcb338a602b348a8dc8a9f6ffe6c28 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 18:00:08 +0800 Subject: [PATCH] fix(return): replay concurrent merges and extend integrity acceptance --- .../mapper/ReturnInventoryCartonMapper.java | 3 + .../ReturnInventoryOperationMapper.java | 3 + .../service/ReturnInventoryService.java | 16 +- .../ReturnCurrentReadContractTest.java | 8 + .../service/ReturnInventoryServiceTest.java | 58 +++++++ scripts/admin-test-env-modules/README.md | 2 + ...eturn-boundary-scenarios-contract.test.mjs | 26 +++- .../return-boundary-scenarios.mjs | 141 +++++++++++++++++- 8 files changed, 246 insertions(+), 11 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java index c2ddec712..17807822d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java @@ -32,6 +32,9 @@ public interface ReturnInventoryCartonMapper extends BaseMapper { @Select("SELECT * FROM ll_return_inventory_operation WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1") ReturnInventoryOperation findKey(@Param("operatorId") String operatorId, @Param("key") String key); + + @Select("SELECT * FROM ll_return_inventory_operation WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE") + ReturnInventoryOperation lockKey(@Param("operatorId") String operatorId, @Param("key") String key); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index ea67379c5..02460fc85 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -224,7 +224,8 @@ public class ReturnInventoryService { operation.setRequestHash(requestHash); operation.setOperationType("MERGE"); operation.setCreatedAt(now); operation.setUpdatedAt(now); try { operations.insert(operation); } catch (DuplicateKeyException concurrent) { - ReturnInventoryOperation existing = operations.findKey(identity.operatorKey(), key); + // The duplicate insert waited for the winner; the earlier snapshot cannot see its commit. + ReturnInventoryOperation existing = operations.lockKey(identity.operatorKey(), key); if (existing != null) return replayMerge(existing, requestHash); throw concurrent; } @@ -342,14 +343,19 @@ public class ReturnInventoryService { private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) { if (!requestHash.equals(operation.getRequestHash())) throw conflict("幂等键已用于不同的合箱请求"); if (operation.getResultCartonId() == null) throw conflict("合箱请求正在处理"); - ReturnInventoryCarton carton = cartons.selectById(operation.getResultCartonId()); + // The result and its label must also bypass the snapshot established before the insert race. + ReturnInventoryCarton carton = cartons.lockId(operation.getResultCartonId()); if (carton == null) throw new IllegalStateException("合箱结果箱不存在"); - List items = cartonItems.listCarton(carton.getId()); - return new ReturnInventoryResponses.MergeResult(carton.getCartonMark(), items.get(0).getSkuCode(), carton.getTotalQuantity(), label(carton)); + List items = cartonItems.lockCarton(carton.getId()); + return new ReturnInventoryResponses.MergeResult(carton.getCartonMark(), items.get(0).getSkuCode(), carton.getTotalQuantity(), label(carton, items)); } private ReturnInventoryResponses.Label label(ReturnInventoryCarton carton) { - List items = cartonItems.listCarton(carton.getId()).stream() + return label(carton, cartonItems.listCarton(carton.getId())); + } + + private ReturnInventoryResponses.Label label(ReturnInventoryCarton carton, List cartonLines) { + List items = cartonLines.stream() .map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).toList(); return new ReturnInventoryResponses.Label(carton.getCartonMark(), carton.getOverseasWarehouseId(), carton.getCustomerId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), items, LocalDateTime.now(), printableHtml(carton, items)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCurrentReadContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCurrentReadContractTest.java index f13ab8df9..eb1395094 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCurrentReadContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCurrentReadContractTest.java @@ -2,6 +2,8 @@ package com.baiyue.lastleg.returnreceipt.service; import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryOperationMapper; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -14,5 +16,11 @@ class ReturnCurrentReadContractTest { assertTrue(receiptSql.endsWith("FOR UPDATE")); assertTrue(itemSql.endsWith("FOR UPDATE")); assertTrue(itemSql.contains("ORDER BY sku_code")); + String operationSql = String.join(" ", ReturnInventoryOperationMapper.class.getMethod("lockKey", String.class, String.class).getAnnotation(Select.class).value()); + String cartonSql = String.join(" ", ReturnInventoryCartonMapper.class.getMethod("lockId", long.class).getAnnotation(Select.class).value()); + assertTrue(operationSql.contains("operator_id=#{operatorId} AND idempotency_key=#{key}")); + assertTrue(operationSql.endsWith("FOR UPDATE")); + assertTrue(cartonSql.contains("WHERE id=#{id}")); + assertTrue(cartonSql.endsWith("FOR UPDATE")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index 2fd7111d3..0847e016d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests; import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation; import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper; @@ -22,6 +23,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.dao.DuplicateKeyException; import java.math.BigDecimal; import java.util.List; @@ -32,6 +34,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.ArgumentMatchers.any; import static org.junit.jupiter.api.Assertions.assertThrows; import org.springframework.web.server.ResponseStatusException; @@ -195,6 +198,61 @@ class ReturnInventoryServiceTest { verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 0L); } + @Test + void sameKeyInsertRaceReplaysCommittedResultAndLabelUsingOnlyCurrentReads() { + var request = mergeRequest(1); + var winner = winningOperation(request); + when(operations.insert(any(ReturnInventoryOperation.class))).thenThrow(new DuplicateKeyException("same key")); + when(operations.lockKey("operator-2", "concurrent-key")).thenReturn(winner); + ReturnInventoryCarton target = new ReturnInventoryCarton(); + target.setId(31L); target.setCartonMark("RC-WINNER"); target.setTotalQuantity(1L); target.setSkuTypes(1); + when(cartons.lockId(31L)).thenReturn(target); + when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 1L))); + + var result = service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), request); + + assertThat(result.cartonMark()).isEqualTo("RC-WINNER"); + assertThat(result.totalQuantity()).isEqualTo(1L); + assertThat(result.label().items()).hasSize(1); + assertThat(result.label().items().get(0).quantity()).isEqualTo(1L); + assertThat(result.label().printableHtml()).contains("RC-WINNER", "SKU-A"); + verify(operations).findKey("operator-2", "concurrent-key"); + verify(operations).lockKey("operator-2", "concurrent-key"); + verify(cartons, never()).selectById(anyLong()); + verify(cartonItems, never()).listCarton(anyLong()); + verify(cartons, never()).insert(any(ReturnInventoryCarton.class)); + verify(cartonItems, never()).insert(any(ReturnInventoryCartonItem.class)); + verify(operations, never()).updateById(any(ReturnInventoryOperation.class)); + verifyNoInteractions(orders, receipts, balances, numbers); + } + + @Test + void sameKeyInsertRaceWithDifferentPayloadIsConflictWithoutInventoryWrites() { + when(operations.insert(any(ReturnInventoryOperation.class))).thenThrow(new DuplicateKeyException("same key")); + when(operations.lockKey("operator-2", "concurrent-key")).thenReturn(winningOperation(mergeRequest(1))); + var error = assertThrows(ResponseStatusException.class, () -> service.merge( + new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2))); + assertThat(error.getStatusCode().value()).isEqualTo(409); + verifyNoInteractions(cartons, cartonItems, orders, receipts, balances, numbers); + } + + @Test + void unrelatedDuplicateInsertFailureIsNotMistakenForSuccessfulReplay() { + var duplicate = new DuplicateKeyException("different unique constraint"); + when(operations.insert(any(ReturnInventoryOperation.class))).thenThrow(duplicate); + assertThat(assertThrows(DuplicateKeyException.class, () -> service.merge( + new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(1)))).isSameAs(duplicate); + verify(operations).lockKey("operator-2", "concurrent-key"); + verifyNoInteractions(cartons, cartonItems, orders, receipts, balances, numbers); + } + + private ReturnInventoryOperation winningOperation(ReturnInventoryRequests.Merge request) { + ReturnInventoryOperation operation = new ReturnInventoryOperation(); + operation.setResultCartonId(31L); + operation.setRequestHash(ReflectionTestUtils.invokeMethod(service, "hash", request)); + return operation; + } + private ReturnInventoryCarton prepareConcurrentSource() { ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(21L); ReturnInventoryCarton snapshot = new ReturnInventoryCarton(); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index e26ce9cc2..95ef9ae91 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -10,6 +10,8 @@ 扩展离线检查:`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 验收;运行报告中的每个失败均保留阻断。 +同一退货扩展还覆盖五组完整性检查:混合明细中前项合法、后项超量/不存在时签收与合箱整笔回滚;同一操作员同键并发合箱及后续重试只生成一个目标箱;不同客户及仓库使用同一 SKU 时的候选、列表和库存隔离;客户/平台/PDA/无登录身份不得进入未授权退货入口;两批签收流水逐行核对客户、仓库、SKU、签收业务号及增量,并在重放和超收失败后回查不变。流水由现有接口从签收明细重建,不代表独立持久审计账本,也不验证后续换标或出库。并发合箱固定三轮、每轮两个请求,其余造数和校验顺序执行。 + `phase4-overseas-return-followups.mjs` 是独立可选后续模块,只在显式 `--modules phase4-overseas-return-followups` 时依赖并运行退货前置,再关联换标单及执行退货出库;不计入退货专项通过结论。仅复用同次运行、同 marker 的退货资源,凭据只在内存中,finally 停用其独立临时 PDA。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;HTTP 必须有目标环境写入授权。 退货专项复跑命令(在后端根目录执行,每次使用新的报告名;不要与其他环境混用): 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 bf022b3a8..9c3e28e02 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 @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' -import { rejected, classifyRace, stableState, verifyNoMutation } from './return-boundary-scenarios.mjs' +import { rejected, classifyRace, stableState, verifyNoMutation, exactReceiptLedger } from './return-boundary-scenarios.mjs' import { run as createMerchant } from './merchant-qualification-fixture.mjs' const response = (code = '200', http = 200) => ({ http, body: { code }, requestId: 'request', traceId: 'trace' }) @@ -98,3 +98,27 @@ test('第二客户准备运行真实fixture编排但不替换主客户内存与 names.forEach((name, index) => { if (saved[index] === undefined) delete process.env[name]; else process.env[name] = saved[index] }) } }) + +test('流水断言逐行核对归属和业务引用,不能仅凭合计相等通过', () => { + const a = { customerId: 'A', overseasWarehouseId: 'W1', skuCode: 'SKU', movementType: 'RECEIPT', referenceNo: 'RR-1', totalDelta: 2, availableDelta: 2 } + const b = { ...a, referenceNo: 'RR-2', totalDelta: 1, availableDelta: 1 } + assert.equal(exactReceiptLedger({ total: 2, records: [b, a] }, [a, b]), true) + for (const change of [{ customerId: 'B' }, { overseasWarehouseId: 'W2' }, { skuCode: 'OTHER' }, + { referenceNo: 'RR-1' }, { movementType: 'MERGE' }, { totalDelta: 2 }, { availableDelta: 0 }]) { + assert.equal(exactReceiptLedger({ total: 2, records: [a, { ...b, ...change }] }, [a, b]), false) + } + assert.equal(exactReceiptLedger({ total: 3, records: [a, b] }, [a, b]), false) + assert.equal(exactReceiptLedger({ total: 0, records: [] }, []), true) + assert.equal(exactReceiptLedger(undefined, []), false) +}) + +test('高价值扩展覆盖混合回滚、同键合箱、同码隔离、权限和流水且仍逐次回查', async () => { + const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8') + for (const name of ['混合明细整笔回滚', '同键并发合箱', '相同SKU跨客户跨仓隔离', '身份入口权限', '签收流水与余额核对']) { + assert.ok(source.includes(`scenario('${name}'`), name) + } + for (const evidence of ['后项超收', '后项未预报', '后项超库存', '后项不存在', 'additionalSummaries', + '同键并发合箱复用同一目标箱', '同码A签收不改变B客户或异仓数据', '无登录', '超收失败不留下流水']) assert.ok(source.includes(evidence), evidence) + assert.match(source, /snapshot: withLedger[\s\S]*签收重放不重复产生流水/) + assert.match(source, /sameKeyResponses/) +}) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs index 619f42abe..286fa4166 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -7,6 +7,7 @@ const ORDERS = '/api/merchant/v1/last-leg/return-orders' const INVENTORY = '/api/merchant/v1/last-leg/return-inventory' const RECEIPTS = '/api/pda/v1/last-leg/return-receipts' const MERGE = '/api/pda/v1/last-leg/return-inventory/cartons/merge' +const PLATFORM_INVENTORY = '/api/platform/v1/last-leg/return-inventory' const rows = data => Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] const ok = response => response?.http === 200 && String(response?.body?.code) === '200' export function rejected(response, statuses) { @@ -38,11 +39,20 @@ export async function verifyNoMutation({ snapshot, action, check, name, expected return response } +export function exactReceiptLedger(data, expected) { + const project = row => ({ customerId: String(row.customerId), overseasWarehouseId: String(row.overseasWarehouseId), + skuCode: row.skuCode, movementType: row.movementType, referenceNo: row.referenceNo, + totalDelta: Number(row.totalDelta), availableDelta: Number(row.availableDelta) }) + return Number(data?.total) === expected.length && rows(data).length === expected.length + && JSON.stringify(stableState(rows(data).map(project))) === JSON.stringify(stableState(expected.map(project))) +} + export async function runReturnBoundaries(context, { merchantToken, warehouse, picker, access }) { let sequence = 0 const prefix = `RBX-${context.marker.replace(/[^A-Z0-9]/gi, '').slice(-18)}` const key = name => `${prefix}-${name}-${++sequence}` const extraPickers = [] + let foreignPicker let failures = 0 const resources = context.createdResources.returnBoundaryScenarios = { returnNos: [], cartonMarks: [], cases: [], concurrencyRounds: 3 } function check(name, response, passed, evidence = '') { @@ -57,14 +67,15 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p return response.body.data } const url = (path, values) => `${path}?${new URLSearchParams(values)}` - async function newOrder(name, sku, quantity, token = merchantToken, warehouseId = warehouse.id) { + async function newOrder(name, sku, quantity, token = merchantToken, warehouseId = warehouse.id, + items = [{ skuCode: sku, productNameZh: '边界验收商品', expectedQuantity: quantity }]) { const data = await good(token, 'POST', `准备${name}退货单`, ORDERS, { overseasWarehouseId: warehouseId, warehouseContactName: '退货边界验收', warehouseContactPhone: '00000000000', customerReferenceNo: `${prefix}-${name}`, needRelabel: false, idempotencyKey: key('ORDER'), - items: [{ skuCode: sku, productNameZh: '边界验收商品', expectedQuantity: quantity }], + items, }) resources.returnNos.push(data.order.returnNo) - return { returnNo: data.order.returnNo, sku, quantity, token, warehouseId } + return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId } } function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) { return { returnNo: order.returnNo, startedBy: 'RETURN_NO', items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey } @@ -88,7 +99,10 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const items = [] for (const carton of rows(cartons)) items.push(await good(order.token, 'GET', '读取箱内SKU快照', `${INVENTORY}/cartons/${carton.cartonMark}`)) const summary = await good(order.token, 'GET', '读取SKU数量及所在箱数快照', url(`${INVENTORY}/summary`, { skuCode: order.sku, overseasWarehouseId: order.warehouseId, page: 1, size: 200 })) - result.push({ detail, cartons, items, summary }) + const additionalSummaries = [] + for (const sku of order.skus.filter(sku => sku !== order.sku)) additionalSummaries.push(await good(order.token, 'GET', + '读取其他SKU库存快照', url(`${INVENTORY}/summary`, { skuCode: sku, overseasWarehouseId: order.warehouseId, page: 1, size: 200 }))) + result.push({ detail, cartons, items, summary, additionalSummaries }) } return result } @@ -150,7 +164,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p await scenario('跨仓隔离', async () => { if (!otherWarehouse) throw new Error('缺少第二仓,不能跳过跨仓验证') - const foreignPicker = await createPicker(context, 94, String(otherWarehouse.id), access) + foreignPicker = await createPicker(context, 94, String(otherWarehouse.id), access) if (foreignPicker?.membershipId) extraPickers.push([foreignPicker, 94]) if (!foreignPicker?.token) throw new Error('第二仓操作员未就绪') const order = await newOrder('异仓', `${prefix}-WAREHOUSE`, 1, merchantToken, String(otherWarehouse.id)) @@ -242,6 +256,123 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p check('全量合箱后仍五件但仅一个可用箱', null, rows(summary).some(row => row.skuCode === sku && Number(row.availableQuantity) === 5 && Number(row.cartonQuantity) === 1)) }) + await scenario('混合明细整笔回滚', async () => { + const skuA = `${prefix}-ATOMIC-A`, skuB = `${prefix}-ATOMIC-B` + const order = await newOrder('混合明细', skuA, 2, merchantToken, warehouse.id, + [skuA, skuB].map(skuCode => ({ skuCode, expectedQuantity: 2 }))) + const makeReceipt = (secondSku, quantity) => ({ ...receiptBody(order, 1), items: [ + { skuCode: skuA, receivedQuantity: 1 }, { skuCode: secondSku, receivedQuantity: quantity }] }) + 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 good(picker.token, 'POST', '混合明细正常签收准备合箱', `${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]) + } + const [state] = await snapshot([order]) + check('混合失败后两SKU均保持两件且仅一个来源箱', null, Number(state.cartons.total) === 1 + && [state.summary, ...state.additionalSummaries].every(page => rows(page).length === 1 && Number(rows(page)[0].availableQuantity) === 2)) + }) + + await scenario('同键并发合箱', async () => { + for (let round = 1; round <= resources.concurrencyRounds; round += 1) { + const order = await newOrder(`同键合箱${round}`, `${prefix}-MERGE-KEY-${round}`, 2) + await receive(order) + const body = mergeBody([order], order.sku, 1) + const responses = await Promise.all([1, 2].map(() => request(picker.token, 'POST', MERGE, body))) + for (const [index, response] of responses.entries()) check(`同键并发合箱请求${index + 1}成功`, response, ok(response)) + check('同键并发合箱复用同一目标箱', responses[0], responses.every(ok) + && Boolean(responses[0].body.data?.cartonMark) && responses[0].body.data.cartonMark === responses[1].body.data.cartonMark) + const [state] = await snapshot([order]) + check('同键并发合箱只扣一次且原箱新箱各一件', null, Number(state.cartons.total) === 2 + && rows(state.cartons).every(row => Number(row.totalQuantity) === 1 && row.status === 'AVAILABLE') + && rows(state.summary).some(row => row.skuCode === order.sku && Number(row.availableQuantity) === 2 && Number(row.cartonQuantity) === 2)) + const original = responses.find(ok)?.body.data?.cartonMark + const replay = await request(picker.token, 'POST', MERGE, body) + check('同键并发合箱后重试仍返回原箱', replay, ok(replay) && Boolean(original) && replay.body.data?.cartonMark === original) + check('同键合箱重放后全部数据不变', replay, JSON.stringify(stableState([state])) === JSON.stringify(stableState(await snapshot([order])))) + } + }) + + await scenario('相同SKU跨客户跨仓隔离', async () => { + if (!foreignPicker?.token || !otherWarehouse) throw new Error('第二仓操作员未就绪') + const sku = `${prefix}-SHARED-SKU` + const a = await newOrder('同码客户A本仓', sku, 2) + const b = await newOrder('同码客户B本仓', sku, 3, tokenB) + const c = await newOrder('同码客户A异仓', sku, 5, merchantToken, String(otherWarehouse.id)) + for (const [operator, expected] of [[picker, [a, b]], [foreignPicker, [c]]]) { + const found = await good(operator.token, 'POST', '查询同码不同归属的PDA候选', `${RECEIPTS}/sku-candidates`, { skuCode: sku }) + check('PDA同码候选按仓隔离且各单数量独立', null, found.candidates.length === expected.length + && expected.every(order => found.candidates.some(row => row.returnNo === order.returnNo + && Number(row.skuExpectedQuantity) === order.quantity && Number(row.skuReceivedQuantity) === 0))) + } + const beforeOthers = await snapshot([b, c]) + await receive(a) + check('同码A签收不改变B客户或异仓数据', null, + JSON.stringify(stableState(beforeOthers)) === JSON.stringify(stableState(await snapshot([b, c])))) + await receive(b); await receive(c, foreignPicker) + for (const order of [a, b, c]) { + const data = await good(order.token, 'GET', '查询同码客户仓库维度库存', url(`${INVENTORY}/summary`, { + skuCode: sku, overseasWarehouseId: order.warehouseId, page: 1, size: 20 })) + check('同码库存只返回当前客户当前仓的准确数量', null, Number(data.total) === 1 && rows(data).length === 1 + && rows(data)[0].skuCode === sku && String(rows(data)[0].overseasWarehouseId) === String(order.warehouseId) + && Number(rows(data)[0].availableQuantity) === order.quantity && Number(rows(data)[0].cartonQuantity) === 1) + } + for (const [token, expected] of [[merchantToken, [a, c]], [tokenB, [b]]]) { + const data = await good(token, 'GET', '客户按同码查询自己全部退货单', url(ORDERS, { skuCode: sku, page: 1, size: 20 })) + check('客户同码列表不夹入其他客户单据', null, Number(data.total) === expected.length && rows(data).length === expected.length + && expected.every(order => rows(data).some(row => row.returnNo === order.returnNo))) + } + await unchanged('相同SKU也不能跨客户合箱', [a, b, c], () => request(picker.token, 'POST', MERGE, mergeBody([a, b], sku, 1)), [409]) + await unchanged('相同SKU也不能跨仓合箱', [a, b, c], () => request(picker.token, 'POST', MERGE, mergeBody([a, c], sku, 1)), [404]) + }) + + await scenario('身份入口权限', async () => { + const order = await newOrder('权限边界', `${prefix}-AUTH`, 1) + for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]], ['平台管理员', context.overseasToken, [403]]]) { + await unchanged(`${name}不能调用PDA签收`, [order], () => request(token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order)), statuses) + } + await receive(order) + for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]]]) { + 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`]) { + 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]) + } + } + }) + + await scenario('签收流水与余额核对', async () => { + const order = await newOrder('流水核对', `${prefix}-LEDGER`, 3) + const customerId = context.context.merchantQualificationFixture.tenantId + const ledger = () => good(context.overseasToken, 'GET', '读取本轮签收流水', url(`${PLATFORM_INVENTORY}/movements`, { + customerId, overseasWarehouseId: warehouse.id, skuCode: order.sku, page: 1, size: 200 })) + check('未签收前本轮SKU无流水', null, exactReceiptLedger(await ledger(), [])) + const body = receiptBody(order, 2) + const signed = await good(picker.token, 'POST', '流水场景首次实收两件', `${RECEIPTS}/confirm`, body) + const entry = { customerId, overseasWarehouseId: warehouse.id, skuCode: order.sku, + movementType: 'RECEIPT', referenceNo: signed.receiptNo, totalDelta: 2, availableDelta: 2 } + check('首次签收只生成一条两件流水且引用签收单', null, exactReceiptLedger(await ledger(), [entry])) + const withLedger = async () => ({ inventory: await snapshot([order]), ledger: await ledger() }) + await verifyNoMutation({ snapshot: withLedger, action: () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, body), + 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 final = await good(picker.token, 'POST', '流水场景第二批实收一件', `${RECEIPTS}/confirm`, receiptBody(order, 1)) + const data = await ledger() + check('两批签收流水业务引用唯一且分别两件一件', null, exactReceiptLedger(data, + [entry, { ...entry, referenceNo: final.receiptNo, totalDelta: 1, availableDelta: 1 }]) && signed.receiptNo !== final.receiptNo) + const [state] = await snapshot([order]) + check('流水增量合计等于订单实收和SKU余额', null, rows(data).reduce((sum, row) => sum + Number(row.totalDelta), 0) === 3 + && rows(data).reduce((sum, row) => sum + Number(row.availableDelta), 0) === 3 + && Number(state.detail.order.receivedQuantity) === 3 && rows(state.summary).some(row => Number(row.availableQuantity) === 3 && Number(row.totalQuantity) === 3)) + }) + await scenario('多候选与输入边界', async () => { const sku = `${prefix}-CANDIDATE` const a = await newOrder('候选A', sku, 1)