fix(phase4): enforce customer and pallet versions
This commit is contained in:
+2
-1
@@ -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 }
|
||||
|
||||
+6
-6
@@ -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)));
|
||||
|
||||
+1
-1
@@ -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")));
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+10
-2
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+7
-1
@@ -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) {
|
||||
|
||||
+29
-3
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user