fix: validate transfer rejection and align dispatch HTTP contracts

This commit is contained in:
2026-09-14 13:46:32 +08:00
parent a2ea0ba9e6
commit d8e2dc59ae
9 changed files with 120 additions and 13 deletions
@@ -52,6 +52,7 @@ public final class WarehouseTransferRequests {
}
}
/** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */
public record Review(@NotBlank @Size(max = 32) String decision,
@Size(max = 1000) String remark) {}
@@ -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);
}
@@ -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");
+2
View File
@@ -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`,仍自动展开本轮前置并创建测试资源;不借历史柜。
@@ -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/)
})
@@ -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,
@@ -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/)
})
@@ -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)
@@ -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)