From d8e2dc59ae4f90723a044088cb9621bd781d150d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 14 Sep 2026 13:46:32 +0800 Subject: [PATCH] fix: validate transfer rejection and align dispatch HTTP contracts --- .../dto/WarehouseTransferRequests.java | 1 + .../service/WarehouseTransferService.java | 6 ++- .../service/WarehouseTransferServiceTest.java | 37 ++++++++++++++++++- scripts/admin-test-env-modules/README.md | 2 + ...phase4-overseas-dispatch-contract.test.mjs | 17 ++++++++- .../phase4-overseas-dispatch.mjs | 26 +++++++++---- ...se5-overseas-fulfillment-contract.test.mjs | 12 ++++++ .../phase5-overseas-fulfillment.mjs | 30 +++++++++++++++ .../warehouse-transfer-review.mjs | 2 +- 9 files changed, 120 insertions(+), 13 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index 2c0c34867..db1373ca1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -52,6 +52,7 @@ public final class WarehouseTransferRequests { } } + /** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */ public record Review(@NotBlank @Size(max = 32) String decision, @Size(max = 1000) String remark) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index d2490202b..b7f826c1d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -358,6 +358,9 @@ public class WarehouseTransferService { case "REJECT", REJECTED -> REJECTED; default -> throw badRequest("Review decision must be APPROVE or REJECT"); }; + if (REJECTED.equals(next) && !StringUtils.hasText(request.remark())) { + throw badRequest("驳回原因不能为空"); + } WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); if (!PENDING_REVIEW.equals(transfer.getStatus())) { @@ -384,7 +387,8 @@ public class WarehouseTransferService { order.setUpdatedAt(now); orders.updateById(order); if (!java.util.Objects.equals(before, orderStatus)) { insertOrderHistory(customerId, order.getId(), before, orderStatus, transferNo, - "Warehouse transfer reviewed: " + next, now); + "Warehouse transfer reviewed: " + next + + (StringUtils.hasText(request.remark()) ? "; " + request.remark().trim() : ""), now); } return detail(customerId, transferNo); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 6f3fbaea6..c09a5086c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -222,7 +222,7 @@ class WarehouseTransferServiceTest { when(sourceOrders.selectList(any())).thenReturn(List.of()); var result = service.review("TENANT-A", "WTR-1", "USER-1", - new WarehouseTransferRequests.Review("APPROVE", "approved")); + new WarehouseTransferRequests.Review("APPROVE", null)); assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus()); assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus()); @@ -233,6 +233,41 @@ class WarehouseTransferServiceTest { verifyNoInteractions(inventory); } + @Test + void rejectionRequiresNonBlankReasonBeforeAnyStateOrInventoryChange() { + for (String decision : List.of("REJECT", "REJECTED")) { + for (String reason : new String[] { null, "", " \t\n" }) { + var error = assertThrows(ResponseStatusException.class, () -> service.review( + "TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Review(decision, reason))); + assertEquals(400, error.getStatusCode().value()); + assertEquals("驳回原因不能为空", error.getReason()); + } + } + verifyNoInteractions(transfers, orders, orderHistories, inventory); + } + + @Test + void rejectionWithReasonCancelsLinkedOrderAndPreservesReasonInHistory() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); + LastlegOrder tailOrder = historicalOrder(); + tailOrder.setId(31L); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.review("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Review("REJECT", " 地址不完整 ")); + assertEquals("REJECTED", result.status()); + assertEquals("CANCELLED", tailOrder.getCurrentStatus()); + var history = ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderStatusHistory.class); + verify(orderHistories).insert(history.capture()); + assertTrue(history.getValue().getRemark().endsWith("; 地址不完整")); + verifyNoInteractions(inventory); + } + @Test void approvedTransferAllocatesExactSourceInboundPalletWithoutMovingInventory() { WarehouseTransferOrder transfer = transfer("APPROVED"); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 58cbc394a..ec940ace6 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -41,6 +41,8 @@ export async function run(context) { - `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。 - `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。 - 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。 +- 排车详情按 `DispatchResponse.Item` 核对托盘与箱数,不读取 DTO 未提供的箱唛字段;`phase5-overseas-fulfillment` 将同一换标排车转为待出库单,通过 `OutboundDetail.pallets.cartonMarks` 验证全部新箱唛、排除原箱唛和重复箱。该测试生成的待出库单及派送单保留为本轮证据,不推进人工退货样本。 +- 出库前同操作员、同草稿允许重复预排并更新原排车;脚本核对排车身份与业务数量不漂移,不要求重新生成的明细行 ID 不变。其他草稿抢锁仍必须冲突,生成出库单后仍验证托盘编辑被拒绝。 货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs index bca3ffc74..76995bfb6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' +import { dispatchBusinessSnapshot } from './phase4-overseas-dispatch.mjs' const moduleUrl = new URL('./phase4-overseas-dispatch.mjs', import.meta.url) const source = await readFile(fileURLToPath(moduleUrl), 'utf8') @@ -27,11 +28,11 @@ test('module uses marker-owned completed PDA pallets and never borrows historica assert.doesNotMatch(source, /candidates\[0\]/) }) -test('module proves draft idempotency, lock isolation, stale replay conflict and readback', () => { +test('module proves draft idempotency, lock isolation, editable pre-arrange replay and readback', () => { assert.match(source, /相同草稿快照重复保存/) assert.match(source, /同草稿重复锁定托盘幂等成功/) assert.match(source, /其他草稿锁定同一托盘返回A0409/) - assert.match(source, /已转换正式锁的旧草稿载荷再次预排返回A0409/) + assert.match(source, /出库前同草稿重复预排复用原排车且业务快照不漂移/) assert.match(source, /重复释放草稿幂等成功/) assert.match(source, /排车详情按记录自身客户范围加载托盘明细/) assert.match(source, /response\?\.http === 409.*codeOf\(response\) === 'A0409'/s) @@ -65,3 +66,15 @@ test('module prefers marker-owned relabel pallets and proves only new FBA carton assert.doesNotMatch(branch, /\breturn\b/) assert.match(source, /Object.assign\(relabel, \{/) }) + +test('dispatch replay snapshot ignores regenerated item ids but detects business drift', () => { + const a = { id: '1', dispatchNo: 'DS-1', status: 'PENDING_DEPARTURE', packageCount: 2, + palletCount: 1, items: [{ id: '10', planPalletId: '20', warehousePalletId: '30', packageCount: 2 }] } + const replay = { ...a, items: [{ ...a.items[0], id: '11' }] } + assert.equal(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(replay)) + for (const changed of [{ ...replay, id: '2' }, { ...replay, packageCount: 4 }, + { ...replay, items: [{ ...a.items[0], warehousePalletId: '31' }] }]) { + assert.notEqual(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(changed)) + } + assert.doesNotMatch(source, /item\?\.cartonMarks/) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index 69b60de5c..59e07045c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -9,6 +9,15 @@ function id(value) { return value == null ? '' : String(value) } function records(value) { return Array.isArray(value?.records) ? value.records : [] } function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } function success(response) { return response?.http === 200 && codeOf(response) === '200' } +export function dispatchBusinessSnapshot(value) { + return JSON.stringify({ id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status, + packageCount: value?.packageCount, palletCount: value?.palletCount, + items: (value?.items || []).map(item => ({ planPalletId: id(item.planPalletId), + warehousePalletId: id(item.warehousePalletId), sourceStage: item.sourceStage, + packageCount: item.packageCount, inboundCode: item.inboundCode })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + }) +} function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` @@ -76,12 +85,11 @@ async function runRelabelDispatch(context, relabel) { relabelNo: relabel.relabelNo, planPalletId: null, }) const detail = await api(context, 'GET', '回查本轮换标排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`) - const detailMarks = (detail.data?.items || []).flatMap((item) => String(item?.cartonMarks || '') - .split(',').map((mark) => mark.trim()).filter(Boolean)) - context.assert(MODULE, '排车详情冻结全部换标后箱唛且不混入原箱唛', detail.response, - relabel.relabelCartonMarks.every((mark) => detailMarks.includes(mark)) - && relabel.sourceCartonMarks.every((mark) => !detailMarks.includes(mark)), - `frozenMarks=${detailMarks.join(',')}; ${trace(detail.response)}`, OPTIONS) + // DispatchResponse.Item omits cartonMarks. Phase5 verifies the persisted marks + // through OutboundDetail.pallets after transferring this exact dispatch. + context.assert(MODULE, '排车详情保留本轮换标托盘与箱数快照', detail.response, + success(detail.response) && dispatchBusinessSnapshot(detail.data) === dispatchBusinessSnapshot(arranged.data), + trace(detail.response), OPTIONS) } export async function run(context) { @@ -233,8 +241,10 @@ export async function run(context) { context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null const duplicateArrange = await context.requestOverseas('POST', `${BASE}/pre-arrange`, preArrangeBody) - context.addStep(MODULE, '已转换正式锁的旧草稿载荷再次预排返回A0409', duplicateArrange, - conflict(duplicateArrange), trace(duplicateArrange), OPTIONS) + context.addStep(MODULE, '出库前同草稿重复预排复用原排车且业务快照不漂移', duplicateArrange, + success(duplicateArrange) + && dispatchBusinessSnapshot(duplicateArrange.body?.data) === dispatchBusinessSnapshot(arranged.data), + trace(duplicateArrange), OPTIONS) const pageResult = await api(context, 'GET', '按排车单号回查跨客户分页', query(BASE, { page: 1, size: 20, dispatchNo, diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index ca6fb026f..40a0dbe67 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,4 +1,5 @@ import test from 'node:test' +import { relabelOutboundMarksMatch } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -84,3 +85,14 @@ test('phase-five writes carry idempotency and record deployment blockers instead assert.match(source, /addCoverageGap\(MODULE, SCENARIO/) assert.match(source, /deploymentBlocker: true/) }) +test('relabel marks are checked on the authoritative outbound DTO without weakening identity checks', () => { + const relabel = { relabelCartonMarks: ['NEW-1', 'NEW-2'], sourceCartonMarks: ['OLD-1', 'OLD-2'] } + const detail = { outbound: { packageCount: 2 }, pallets: [{ cartonMarks: 'NEW-1,NEW-2' }] } + assert.equal(relabelOutboundMarksMatch(detail, relabel), true) + for (const cartonMarks of ['', 'NEW-1', 'NEW-1,NEW-1', 'NEW-1,OLD-2', 'NEW-1,NEW-2,OTHER']) { + assert.equal(relabelOutboundMarksMatch({ ...detail, pallets: [{ cartonMarks }] }, relabel), false) + } + assert.equal(relabelOutboundMarksMatch({ ...detail, outbound: { packageCount: 3 } }, relabel), false) + assert.match(source, /detail\.data\?\.outbound\?\.dispatchNo === relabel\.dispatchNo/) + assert.match(source, /relabel\?\.marker !== context\.marker/) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 7508362e8..4f03048f4 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -48,6 +48,35 @@ async function merchant(context, token, method, name, path, body = undefined) { return { response, data } } +export function relabelOutboundMarksMatch(detail, relabel) { + const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '') + .split(',').map(mark => mark.trim()).filter(Boolean)) + const expected = relabel?.relabelCartonMarks || [] + return expected.length > 0 && marks.length === expected.length + && new Set(marks).size === marks.length && expected.every(mark => marks.includes(mark)) + && (relabel.sourceCartonMarks || []).every(mark => !marks.includes(mark)) + && Number(detail?.outbound?.packageCount) === expected.length +} + +async function verifyRelabelDispatchOutbound(context, relabel, transferBody) { + if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) { + blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据') + return + } + const transferred = await platform(context, 'POST', '本轮换标排车转派送并生成出库快照', + `${DISPATCH_BASE}/${encodeURIComponent(relabel.dispatchNo)}/transfer-delivery`, + { ...transferBody, idempotencyKey: key(context, 'RELABEL-TRANSFER') }) + const outboundNo = transferred.data?.outboundNo + if (!success(transferred.response) || !nonBlank(outboundNo)) return + const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛', + query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 200 })) + context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response, + success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo + && detail.data?.outbound?.dispatchNo === relabel.dispatchNo + && relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS) + Object.assign(relabel, { outboundNo, deliveryNo: transferred.data?.deliveryNo }) +} + async function loginMerchant(context, customerId) { const merchantFixture = context.context?.merchantQualificationFixture const credentials = nonBlank(merchantFixture?.username) && nonBlank(merchantFixture?.password) @@ -187,6 +216,7 @@ export async function run(context) { fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }], } + await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, transferBody) const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) const outboundNo = id(transferred.data?.outboundNo) diff --git a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs index 0e503e9c7..81f734281 100644 --- a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs +++ b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs @@ -74,7 +74,7 @@ export async function verifyWarehouseTransferReview(context, token, customerId, context.addStep(MODULE, '创建空驳回原因独立反向票', reasonFixture, ok(reasonFixture) && Boolean(reasonNo), evidence(reasonFixture), OPTIONS) if (!ok(reasonFixture) || !reasonNo) return false const emptyReason = await call('POST', reviewPath(reasonNo), { decision: 'REJECT', remark: '' }) - context.addStep(MODULE, '空驳回原因应拒绝(当前服务端缺口)', emptyReason, rejected(emptyReason, 400), evidence(emptyReason), OPTIONS) + context.addStep(MODULE, '空驳回原因应拒绝', emptyReason, rejected(emptyReason, 400), evidence(emptyReason), OPTIONS) const reasonReadback = await call('GET', scoped(reasonNo)) context.addStep(MODULE, '空原因拒绝后仍待审核', reasonReadback, ok(reasonReadback) && reasonReadback.body.data?.status === 'PENDING_REVIEW', evidence(reasonReadback), OPTIONS)