From fcb7c11af44c0b345e54f5ec86a89bf7b6583bae Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 14:37:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../finance/FinanceServiceValidationTest.java | 33 +++++++++++++++++ .../PlatformInboundMeasurementController.java | 20 ++++++++++ .../dto/InboundMeasurementRequests.java | 2 +- .../service/CartonMeasurementService.java | 37 +++++++++++++------ .../main/resources/openapi/firstleg-v1.yaml | 32 ++++++++++++++++ .../PlatformCartonScanControllerTest.java | 33 +++++++++++++++++ .../service/CartonMeasurementServiceTest.java | 18 +++++++++ 7 files changed, 162 insertions(+), 13 deletions(-) diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index aff765540..3a8be9b06 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -179,6 +179,39 @@ class FinanceServiceValidationTest { assertThrows(RuntimeException.class, () -> service.createCost(9L, 2L, request, "k5")); } + @Test + void acceptsAuthoritativeContainerSnapshotBeforeFeeSourceArrival() { + JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class + ? 1 : RETURNS_DEFAULTS.answer(invocation)); + FinanceFileClient files = mock(FinanceFileClient.class); + when(files.metadata("internal-token", 9L, 9L, 77L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "cost.jpg", "sha", "image/jpeg", 9L))); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(2L), eq("COST_CREATE"), eq("container-cost-key"))) + .thenReturn(List.of()); + when(jdbc.queryForMap(startsWith("SELECT * FROM fin_cost WHERE id="), eq(701L))) + .thenReturn(java.util.Map.of("id", 701L)); + doAnswer(invocation -> { + org.springframework.jdbc.support.KeyHolder holder = invocation.getArgument(1); + holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L)); + return 1; + }).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class)); + FinanceService service = new FinanceService(jdbc, new ObjectMapper()); + ReflectionTestUtils.setField(service, "fileClient", files); + ReflectionTestUtils.setField(service, "fileToken", "internal-token"); + var request = new FinanceService.CostRequest("C-3", "CONTAINER_SHARED", null, null, 88L, + null, null, "MANUAL", "整柜成本", new BigDecimal("300.00"), List.of("77"), null); + var snapshot = List.of(new FinanceService.ContainerOrderVolume( + 99L, 1001L, 88L, 9L, new BigDecimal("1.25"))); + + service.createCost(9L, 2L, request, snapshot, "container-cost-key"); + + verify(jdbc, never()).queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Number.class), any(), any()); + verify(jdbc).update(startsWith("INSERT INTO fin_cost_allocation"), eq(9L), eq(701L), eq(99L), + eq(1001L), eq(new BigDecimal("1.25")), eq(new BigDecimal("1.25")), + any(BigDecimal.class), any(BigDecimal.class), eq(0)); + } + @Test void returnsBillRevisionHistoryOnlyForOwningCustomer() { JdbcTemplate jdbc = mock(JdbcTemplate.class); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index 1b9b672ce..68b8caf13 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -120,6 +120,26 @@ public class PlatformInboundMeasurementController { tenantId, user.userId(), inboundId, command)); } + @BusinessAudit(event = "INBOUND_CARTON_MANUAL_ENTRY", resourceType = "INBOUND", + resourceId = "#inboundId", description = "平台人工录入单箱测量") + @Operation(summary = "平台人工录入单箱测量", + description = "data 中传箱号和人工测量值,image 传当前箱图片,复用正式单箱覆盖规则。") + @PostMapping(value = "/measurement/manual-cartons", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')") + public ApiResponse createManualCarton( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long inboundId, + @Valid @RequestPart("data") InboundMeasurementRequests.CartonScanRequest request, + @RequestPart("image") MultipartFile image) { + requirePlatformTenant(user, tenantId); + CartonMeasurementCommand command = new CartonMeasurementCommand( + request.boxCode(), request.length(), request.width(), request.height(), request.weight(), + request.machineNo(), request.measuredAt(), image, request.scannedShipmentId()); + return ApiResponse.success(cartonMeasurements.upsertManualForInbound( + tenantId, user.userId(), inboundId, command)); + } + @BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND", resourceId = "#inboundId", description = "登记称重图片 OCR 原值") @Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index 6721a62d5..38bebfe18 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -64,7 +64,7 @@ public final class InboundMeasurementRequests { ) { } - /** 平台联调使用;最终设备协议在外部适配层转换为相同领域命令。 */ + /** 平台设备联调及人工录入使用;设备协议在外部适配层转换为相同领域命令。 */ public record CartonScanRequest( @NotBlank @Size(max = 128) String boxCode, @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal length, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 5b5ef2a9b..1e67be5d1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -60,7 +60,8 @@ public class CartonMeasurementService { } public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) { - return upsert(tenantId, operatorId, null, command); + return upsert(tenantId, operatorId, null, command, + MeasurementBatchService.SOURCE_THIRD_PARTY, "DEVICE"); } public CartonMeasurementResult upsertForInbound(long tenantId, long operatorId, long inboundId, @@ -68,11 +69,22 @@ public class CartonMeasurementService { if (inboundId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于0"); } - return upsert(tenantId, operatorId, inboundId, command); + return upsert(tenantId, operatorId, inboundId, command, + MeasurementBatchService.SOURCE_THIRD_PARTY, "DEVICE"); + } + + public CartonMeasurementResult upsertManualForInbound(long tenantId, long operatorId, long inboundId, + CartonMeasurementCommand command) { + if (inboundId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于0"); + } + return upsert(tenantId, operatorId, inboundId, command, + MeasurementBatchService.SOURCE_MANUAL, "PLATFORM"); } private CartonMeasurementResult upsert(long tenantId, long operatorId, Long expectedInboundId, - CartonMeasurementCommand command) { + CartonMeasurementCommand command, + String sourceType, String sourceCode) { validateCommand(command); String boxCode = command.boxCode().trim(); CartonLabelBindingResolver.Binding binding = requireBinding(tenantId, boxCode); @@ -85,7 +97,7 @@ public class CartonMeasurementService { UpsertOutcome outcome; try { outcome = transactionTemplate.execute(status -> persist( - tenantId, operatorId, binding, boxCode, command, uploaded.fileId())); + tenantId, operatorId, binding, boxCode, command, uploaded.fileId(), sourceType, sourceCode)); } catch (RuntimeException exception) { cleanupImage(tenantId, operatorId, inbound.getId(), uploaded.fileId(), "回传落库失败补偿"); throw exception; @@ -150,8 +162,8 @@ public class CartonMeasurementService { private UpsertOutcome persist(long tenantId, long operatorId, CartonLabelBindingResolver.Binding binding, - String boxCode, - CartonMeasurementCommand command, long imageFileId) { + String boxCode, CartonMeasurementCommand command, long imageFileId, + String sourceType, String sourceCode) { InboundOrderEntity inbound = repository.lockInbound(tenantId, binding.inboundId()); if (inbound == null) { throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); @@ -166,7 +178,7 @@ public class CartonMeasurementService { if (created) { measurement = repository.findLatestEditableMeasurement(tenantId, inbound.getId()); if (measurement == null) { - measurement = newMeasurement(tenantId, operatorId, inbound.getId()); + measurement = newMeasurement(tenantId, operatorId, inbound.getId(), sourceType, sourceCode); repository.insertMeasurement(measurement); } item = new InboundMeasurementItemEntity(); @@ -208,8 +220,8 @@ public class CartonMeasurementService { } measurement.setStatus(MeasurementBatchService.STATUS_RECEIVED); - measurement.setSourceType(MeasurementBatchService.SOURCE_THIRD_PARTY); - measurement.setSourceCode("DEVICE"); + measurement.setSourceType(sourceType); + measurement.setSourceCode(sourceCode); measurement.setDeviceCode(command.machineNo().trim()); measurement.setMeasuredAt(command.measuredAt()); measurement.setUpdatedAt(LocalDateTime.now()); @@ -220,13 +232,14 @@ public class CartonMeasurementService { return new UpsertOutcome(result(measurement, item, created), oldImageFileId); } - private InboundMeasurementEntity newMeasurement(long tenantId, long operatorId, long inboundId) { + private InboundMeasurementEntity newMeasurement(long tenantId, long operatorId, long inboundId, + String sourceType, String sourceCode) { InboundMeasurementEntity measurement = new InboundMeasurementEntity(); measurement.setId(IdWorker.getId()); measurement.setTenantId(tenantId); measurement.setInboundId(inboundId); - measurement.setSourceType(MeasurementBatchService.SOURCE_THIRD_PARTY); - measurement.setSourceCode("DEVICE"); + measurement.setSourceType(sourceType); + measurement.setSourceCode(sourceCode); measurement.setStatus(MeasurementBatchService.STATUS_RECEIVED); measurement.setCreatedBy(operatorId); measurement.setCreatedAt(LocalDateTime.now()); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index cfb47e708..e01348eb0 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -427,6 +427,38 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 无测量编辑权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/manual-cartons: + post: + summary: 平台人工录入单箱测量 + description: >- + 需要 platform:firstleg:inbound:measure:edit 权限;人工录入独立记录为 MANUAL/PLATFORM, + 复用正式单箱绑定、图片、计算和覆盖规则,不影响设备扫描入口。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [data, image] + properties: + data: + type: object + required: [boxCode, length, width, height, machineNo, measuredAt] + properties: + boxCode: {type: string, maxLength: 128} + length: {type: number, format: double, minimum: 0.001} + width: {type: number, format: double, minimum: 0.001} + height: {type: number, format: double, minimum: 0.001} + weight: {type: number, format: double, minimum: 0.001, nullable: true} + machineNo: {type: string, maxLength: 64} + measuredAt: {type: string, format: date-time} + image: {type: string, format: binary} + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 无测量编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}/confirm: post: summary: 确认 OCR 重量 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java index dc7af145c..04835ee63 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java @@ -76,6 +76,39 @@ class PlatformCartonScanControllerTest { assertThat(command.getValue().image().getOriginalFilename()).isEqualTo("carton.jpg"); } + @Test + void manualCartonRouteUsesTheDedicatedManualServiceMethod() throws Exception { + CartonMeasurementService cartonMeasurements = mock(CartonMeasurementService.class); + PlatformInboundMeasurementController controller = new PlatformInboundMeasurementController(); + ReflectionTestUtils.setField(controller, "service", mock(InboundMeasurementService.class)); + ReflectionTestUtils.setField(controller, "submission", mock(MeasurementSubmissionService.class)); + ReflectionTestUtils.setField(controller, "cartonMeasurements", cartonMeasurements); + AuthenticatedUser user = new AuthenticatedUser(90001L, 0L, 0L, null, + "platform_admin", "PLATFORM", "WEB", Set.of("platform:firstleg:inbound:measure:edit")); + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(authenticationPrincipal(user)) + .build(); + when(cartonMeasurements.upsertManualForInbound(eq(10001L), eq(90001L), eq(70001L), any())) + .thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1", + null, 1, new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"), + new BigDecimal("12.5"), 99001L, "MANUAL-ADMIN", + LocalDateTime.parse("2026-08-12T14:00:00"), true)); + MockPart data = new MockPart("data", "data", (""" + {"boxCode":"RK-001#5-1","length":60.1,"width":40.2,"height":30.3, + "weight":12.5,"machineNo":"MANUAL-ADMIN","measuredAt":"2026-08-12T14:00:00"} + """).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + data.getHeaders().setContentType(MediaType.APPLICATION_JSON); + MockMultipartFile image = new MockMultipartFile( + "image", "carton.jpg", MediaType.IMAGE_JPEG_VALUE, new byte[] {1, 2, 3}); + + mvc.perform(multipart("/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/manual-cartons", 70001L) + .file(image).part(data).param("tenantId", "10001")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")); + + verify(cartonMeasurements).upsertManualForInbound(eq(10001L), eq(90001L), eq(70001L), any()); + } + private HandlerMethodArgumentResolver authenticationPrincipal(AuthenticatedUser user) { return new HandlerMethodArgumentResolver() { @Override diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java index 75a0a891b..189441602 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java @@ -93,6 +93,24 @@ class CartonMeasurementServiceTest { assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("64.000"); assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("54.000"); assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("54.000"); + assertThat(measurement.getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_THIRD_PARTY); + assertThat(measurement.getSourceCode()).isEqualTo("DEVICE"); + } + + @Test + void manualEntryCreatesManualMeasurementWithoutChangingDeviceEntrySemantics() { + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); + when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); + when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); + when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get())); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + CartonMeasurementResult result = service.upsertManualForInbound( + 10001L, 90001L, 70001L, acceptanceCommand()); + + assertThat(result.created()).isTrue(); + assertThat(measurement.getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_MANUAL); + assertThat(measurement.getSourceCode()).isEqualTo("PLATFORM"); } @Test