fix(phase4): enforce customer and pallet versions

This commit is contained in:
2026-08-24 15:14:18 +08:00
parent e773816c98
commit 95b72afdcc
12 changed files with 82 additions and 31 deletions
@@ -25,9 +25,10 @@ public class RelabelApplicationController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
@Operation(summary = "Create a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_CREATE)
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId,
@Valid @RequestBody RelabelRequests.Create request) {
requirePlatform(user);
return ApiResponse.success(service.create(requiredCustomer(request.customerId()), operator(user), request));
return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request));
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
@@ -16,7 +16,6 @@ public final class RelabelRequests {
private RelabelRequests() {}
public record Create(
@Size(max = 32) String customerId,
@NotBlank @Size(max = 128) String customerOrderNo,
@NotBlank @Size(max = 128) String overseasWarehouseId,
@NotNull LocalDateTime labelDeadline,
@@ -317,6 +317,8 @@ paths:
summary: 创建海外换标单
description: 权限 platform:overseas-warehouse:relabel-order:create;明细与换标文件按请求快照保存,使用 idempotencyKey 幂等。
security: [{ bearerAuth: [] }]
parameters:
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 幂等键冲突 } }
/api/platform/v1/last-leg/relabel-applications/{relabelNo}:
@@ -466,9 +468,8 @@ components:
RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] }
RelabelCreate:
type: object
required: [customerId, customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items]
required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items]
properties:
customerId: { type: string, maxLength: 32, description: Target customer ID required by the platform create endpoint }
customerOrderNo: { type: string, maxLength: 128 }
overseasWarehouseId: { type: string, maxLength: 128 }
labelDeadline: { type: string, format: date-time }
@@ -21,21 +21,21 @@ class RelabelApplicationControllerTest {
@Test
void platformCreateUsesExplicitTargetCustomer() {
RelabelRequests.Create request = request("190000000000000001");
RelabelRequests.Create request = request();
controller.create(platform(), request);
controller.create(platform(), "190000000000000001", request);
verify(service).create("190000000000000001", "7", request);
}
@Test
void platformCreateRejectsMissingOrPlatformCustomer() {
assertThrows(ResponseStatusException.class, () -> controller.create(platform(), request(null)));
assertThrows(ResponseStatusException.class, () -> controller.create(platform(), request("0")));
assertThrows(ResponseStatusException.class, () -> controller.create(platform(), null, request()));
assertThrows(ResponseStatusException.class, () -> controller.create(platform(), "0", request()));
}
private RelabelRequests.Create request(String customerId) {
return new RelabelRequests.Create(customerId, "CUSTOMER-1", "WH-1",
private RelabelRequests.Create request() {
return new RelabelRequests.Create("CUSTOMER-1", "WH-1",
LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", null, "key-1",
List.of(new RelabelRequests.Item("SKU-A", null, null, null, "Product", 1, 1,
"SHIP-1", null, "SKU-B", "Replace", null, null, null)));
@@ -212,7 +212,7 @@ class RelabelApplicationServiceTest {
}
private static RelabelRequests.Create request(String key, int total, int notified) {
return new RelabelRequests.Create(null, "CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0),
return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0),
"Li", "13800000000", "remark", key, List.of(new RelabelRequests.Item("SKU-A", "img-1",
"image.png", "https://files/image.png", "Chinese name", total, notified, "SHP-1", "TRACK-1",
"SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf")));
@@ -92,7 +92,7 @@ class TaillegRealConsumerAcceptanceTest {
if (!exists("ll_relabel_application", MARKER + "-RELABEL-KEY")) {
relabel.create(CUSTOMER, "real-acceptance", new RelabelRequests.Create(
null, MARKER + "-RELABEL", "92", RELABEL_DEADLINE, "验收联系人", "13800000000",
MARKER + "-RELABEL", "92", RELABEL_DEADLINE, "验收联系人", "13800000000",
MARKER, MARKER + "-RELABEL-KEY", List.of(new RelabelRequests.Item("SKU-REAL-A", null, null,
null, "真实验收商品", 3, 3, MARKER + "-SHIP", MARKER + "-TRACK", "SKU-REAL-A-NEW",
"换标验收", null, null, null))));
@@ -9,7 +9,7 @@ import { run } from './phase4-overseas-warehouse.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), 'utf8')
function response(data) { return { http: 200, body: { code: '200', message: 'success', data }, requestId: 'request-test', traceId: 'trace-test' } }
function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } }
function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } }
function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) {
const calls = []; const steps = []; const gaps = []; let createdPallet = null
@@ -20,8 +20,11 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
calls.push({ channel: 'overseas', method, path: requestPath, body })
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', ...body }; return response('71001') }
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { createdPallet = { ...createdPallet, ...body }; return response(null) }
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') }
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) {
if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409')
createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return response(null)
}
if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }]))
if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] })
if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }]))
@@ -64,7 +67,7 @@ test('既有仓库场景只写本轮托盘生命周期', () => {
assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1)
assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/')))
const writes = context.calls.filter((call) => call.method !== 'GET')
assert.deepEqual(writes.map((call) => call.method), ['POST', 'PUT', 'PUT'])
assert.deepEqual(writes.map((call) => call.method), ['POST', 'PUT', 'PUT', 'PUT'])
assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/pallets')))
assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
})
@@ -110,5 +113,5 @@ test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径',
assert.match(source, /longId\(plan\.sourceOrderId\)/)
assert.match(source, /id\(pallet\.customerId\) === '0'/)
assert.match(source, /换标创建与状态生命周期/)
assert.match(source, /旧版本并发冲突/)
assert.match(source, /公共托盘旧版本编辑返回冲突/)
})
@@ -37,8 +37,9 @@ async function overseasGet(context, name, path) {
}
async function overseasMutation(context, method, name, path, body) {
const response = await context.requestOverseas(method, path, body)
assertEnvelope(context, name, response)
return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) }
const passed = response?.http === 200 && String(response?.body?.code) === '200'
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
return { response, data: passed ? response.body?.data : null }
}
async function overseasCsv(context, name, path) {
const response = await context.requestOverseasBinary('GET', path)
@@ -230,7 +231,7 @@ async function verifyPalletLifecycle(context, warehouseId) {
const updated = await overseasMutation(context, 'PUT', '编辑本轮公共托盘', detailPath, {
palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'IDLE',
locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`,
locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`, version: Number(detail.data?.version),
})
context.assert(MODULE, '公共托盘编辑成功', updated.response, updated.data === null,
`palletId=${palletId}; ${trace(updated.response)}`, OPTIONS)
@@ -239,14 +240,20 @@ async function verifyPalletLifecycle(context, warehouseId) {
id(after.data?.id) === palletId && Number(after.data?.capacityCbm) === 2.5
&& Number(after.data?.unitPrice) === 1 && after.data?.remark === `${context.marker}-edited`,
`capacity=${after.data?.capacityCbm ?? 'missing'}, unitPrice=${after.data?.unitPrice ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS)
coverageGap(context, '公共托盘旧版本并发冲突',
'当前 Pallet SaveRequest/PUT 未提供 version 或 expectedVersion,无法构造稳定旧版本冲突且禁止伪造冲突请求', 'missing-version-contract')
const stale = await context.requestOverseas('PUT', detailPath, {
palletCode, palletType: '木托盘', capacityCbm: 9, unitPrice: 9, status: 'IDLE',
remark: 'STALE-DO-NOT-APPLY', version: Number(detail.data?.version),
})
context.addStep(MODULE, '公共托盘旧版本编辑返回冲突', stale,
stale?.http === 409 || (stale?.http === 200 && String(stale?.body?.code).endsWith('409')), trace(stale), OPTIONS)
} finally {
if (palletId) {
const retired = await overseasMutation(context, 'PUT', '报废本轮公共托盘',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, {
palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'SCRAPPED',
remark: `${context.marker}-acceptance-retired`,
remark: `${context.marker}-acceptance-retired`, version: Number((await overseasGet(context,
'读取公共托盘报废前最新版本',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version),
})
context.createdResources.phase4OverseasWarehouse.terminalStatus = retired.data === null ? 'SCRAPPED' : null
context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, retired.data === null,
@@ -23,11 +23,19 @@ public final class PalletRequests {
@NotBlank String status,
Long locationId,
@Size(max = 500) String remark,
@Size(max = 128) String fbaWarehouseCode
@Size(max = 128) String fbaWarehouseCode,
@Min(0) Integer version
) {
public SaveRequest(String palletCode, String palletType, BigDecimal capacityCbm,
BigDecimal unitPrice, String status, Long locationId, String remark) {
this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, null);
this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, null, null);
}
public SaveRequest(String palletCode, String palletType, BigDecimal capacityCbm,
BigDecimal unitPrice, String status, Long locationId, String remark,
String fbaWarehouseCode) {
this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark,
fbaWarehouseCode, null);
}
public SaveRequest {
@@ -124,8 +124,8 @@ public class WarehouseResourceRepository {
pallets.insert(entity);
}
public void updatePallet(WarehousePalletEntity entity) {
pallets.updateById(entity);
public int updatePallet(WarehousePalletEntity entity) {
return pallets.updateById(entity);
}
public WarehousePalletEntity findPallet(long customerId, long warehouseId, long id) {
@@ -152,6 +152,9 @@ public class WarehouseResourceService {
@Transactional
public void updatePallet(long customerId, long warehouseId, long id, PalletRequests.SaveRequest request) {
WarehousePalletEntity existing = requirePallet(customerId, warehouseId, id);
if (request.version() == null || !request.version().equals(existing.getVersion())) {
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请刷新后重试");
}
if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) {
throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused");
}
@@ -161,7 +164,10 @@ public class WarehouseResourceService {
validatePallet(request, palletCode, locationId, customerId, warehouseId, id);
WarehousePalletEntity entity = palletEntity(customerId, warehouseId, request, palletCode, locationId);
entity.setId(existing.getId());
repository.updatePallet(entity);
entity.setVersion(request.version());
if (repository.updatePallet(entity) != 1) {
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请刷新后重试");
}
}
public WarehousePalletEntity pallet(long customerId, long warehouseId, long id) {
@@ -259,10 +259,11 @@ class WarehouseResourceServiceTest {
existing.setId(40002L);
existing.setPalletCode("PLT-OLD");
existing.setStatus("SCRAPPED");
existing.setVersion(0);
when(repository.findPallet(TENANT, WAREHOUSE, 40002L)).thenReturn(existing);
assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40002L,
new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null)))
new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null, null, 0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("cannot be reused");
}
@@ -274,17 +275,20 @@ class WarehouseResourceServiceTest {
existing.setId(40003L);
existing.setPalletCode("PLT-OLD");
existing.setStatus("IDLE");
existing.setVersion(2);
when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(existing);
when(repository.updatePallet(org.mockito.ArgumentMatchers.any())).thenReturn(1);
service.updatePallet(TENANT, WAREHOUSE, 40003L,
new PalletRequests.SaveRequest("PLT-NEW", "WOOD", new BigDecimal("2.5000"),
new BigDecimal("25.00"), "IDLE", null, "updated", "ONT8"));
new BigDecimal("25.00"), "IDLE", null, "updated", "ONT8", 2));
ArgumentCaptor<WarehousePalletEntity> captor = ArgumentCaptor.forClass(WarehousePalletEntity.class);
verify(repository).palletCodeExists(TENANT, WAREHOUSE, "PLT-NEW", 40003L);
verify(repository).updatePallet(captor.capture());
assertThat(captor.getValue().getPalletCode()).isEqualTo("PLT-NEW");
assertThat(captor.getValue().getFbaWarehouseCode()).isEqualTo("ONT8");
assertThat(captor.getValue().getVersion()).isEqualTo(2);
}
@Test
@@ -294,17 +298,39 @@ class WarehouseResourceServiceTest {
existing.setId(40004L);
existing.setPalletCode("PLT-OLD");
existing.setStatus("IDLE");
existing.setVersion(0);
when(repository.findPallet(TENANT, WAREHOUSE, 40004L)).thenReturn(existing);
when(repository.palletCodeExists(TENANT, WAREHOUSE, "PLT-DUP", 40004L)).thenReturn(true);
assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40004L,
new PalletRequests.SaveRequest("PLT-DUP", "WOOD", BigDecimal.ONE,
BigDecimal.ONE, "IDLE", null, null, "ONT8")))
BigDecimal.ONE, "IDLE", null, null, "ONT8", 0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("托盘编码已存在");
verify(repository, never()).updatePallet(org.mockito.ArgumentMatchers.any());
}
@Test
void updatePalletRejectsStaleVersionBeforeMutation() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
WarehousePalletEntity existing = new WarehousePalletEntity();
existing.setId(40005L);
existing.setPalletCode("PLT-OLD");
existing.setStatus("IDLE");
existing.setVersion(3);
when(repository.findPallet(TENANT, WAREHOUSE, 40005L)).thenReturn(existing);
assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40005L,
new PalletRequests.SaveRequest("PLT-STALE", "WOOD", BigDecimal.ONE,
BigDecimal.ONE, "IDLE", null, null, "ONT8", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("版本已变化");
verify(repository, never()).palletCodeExists(org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.any());
verify(repository, never()).updatePallet(org.mockito.ArgumentMatchers.any());
}
private static WarehouseEntity warehouse() {
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setId(WAREHOUSE);