From 95b72afdcc48816ec7deac2ab07a23654cd43285 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 15:14:18 +0800 Subject: [PATCH] fix(phase4): enforce customer and pallet versions --- .../RelabelApplicationController.java | 3 +- .../lastleg/relabel/dto/RelabelRequests.java | 1 - .../openapi/overseas-business-v1.yaml | 5 +-- .../RelabelApplicationControllerTest.java | 12 +++---- .../RelabelApplicationServiceTest.java | 2 +- .../TaillegRealConsumerAcceptanceTest.java | 2 +- ...hase4-overseas-warehouse-contract.test.mjs | 13 +++++--- .../phase4-overseas-warehouse.mjs | 19 +++++++---- .../baiyue/warehouse/dto/PalletRequests.java | 12 +++++-- .../WarehouseResourceRepository.java | 4 +-- .../service/WarehouseResourceService.java | 8 ++++- .../service/WarehouseResourceServiceTest.java | 32 +++++++++++++++++-- 12 files changed, 82 insertions(+), 31 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 2638bbc2e..fa5482e72 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -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 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 + "')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index 60b8cdc78..32ddaea7c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -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, diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 62a66330b..eae0eca93 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java index 7e75808d2..5cdcbc004 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java @@ -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))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index a4262de7f..97a8dad24 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -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"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index 15a3694c9..ff1840e5f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -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)))); diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 149546c51..e25c7dd6a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -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, /公共托盘旧版本编辑返回冲突/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 789b88307..39b76d93e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -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, diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java index b5c6d8491..d209bfb06 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -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 { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index cdd5dc0f0..bfe2400f8 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -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) { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index 68c90694c..a4355d2d2 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -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) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index 005815ef1..b3e47eb2b 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -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 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);