fix: validate transfer rejection and align dispatch HTTP contracts
This commit is contained in:
+1
@@ -52,6 +52,7 @@ public final class WarehouseTransferRequests {
|
||||
}
|
||||
}
|
||||
|
||||
/** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */
|
||||
public record Review(@NotBlank @Size(max = 32) String decision,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
|
||||
+5
-1
@@ -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);
|
||||
}
|
||||
|
||||
+36
-1
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user