Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
15 changed files with 273 additions and 6 deletions
Showing only changes of commit 1a76602872 - Show all commits
@@ -10,6 +10,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.service.CartonMeasurementService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundMeasurementService;
@@ -23,10 +24,13 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
/** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */
@Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。")
@@ -95,6 +99,26 @@ public class PlatformInboundMeasurementController {
tenantId, inboundId, itemId, request.measurementVersion(), request.confirmedPackageCount()));
}
@BusinessAudit(event = "INBOUND_CARTON_SCAN", resourceType = "INBOUND",
resourceId = "#inboundId", description = "平台模拟设备单箱扫描")
@Operation(summary = "平台模拟单箱设备扫描",
description = "联调入口;data 中传箱号和设备测量值,image 传当前箱图片,复用正式单箱覆盖规则。")
@PostMapping(value = "/measurement/carton-scans", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<CartonMeasurementResult> simulateCartonScan(
@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);
return ApiResponse.success(cartonMeasurements.upsertForInbound(
tenantId, user.userId(), inboundId, command));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND",
resourceId = "#inboundId", description = "登记称重图片 OCR 原值")
@Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。")
@@ -13,6 +13,7 @@ import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -62,14 +63,25 @@ public final class InboundMeasurementRequests {
) {
}
/** 平台联调使用;最终设备协议在外部适配层转换为相同领域命令。 */
public record CartonScanRequest(
@NotBlank @Size(max = 128) String boxCode,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal length,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal width,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal height,
@DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal weight,
@NotBlank @Size(max = 64) String machineNo,
@NotNull LocalDateTime measuredAt
) {
}
public record CreateWeightResultRequest(
@NotNull @Positive Long fileId,
@Size(max = 64) String ocrProvider,
@Size(max = 1000) String ocrRawText,
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal ocrWeightValue,
@Size(max = 8) String ocrWeightUnit,
@DecimalMin("0.00000") @DecimalMax("1.00000")
@Digits(integer = 1, fraction = 5) BigDecimal confidence
@DecimalMin("0.00000") @DecimalMax("1.00000") BigDecimal confidence
) {
}
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.InboundCartonLabelMapper;
@@ -36,6 +37,15 @@ public class InboundCartonLabelRepository {
.eq(InboundCartonLabelEntity::getBoxCode, boxCode));
}
public boolean initializeOriginalPackageCount(long tenantId, long inboundId, int packageCount) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId)
.isNull(InboundOrderEntity::getTotalPackageCount)
.set(InboundOrderEntity::getTotalPackageCount, packageCount)
.setSql("version = version + 1")) == 1;
}
public void insert(InboundCartonLabelEntity label) {
labels.insert(label);
}
@@ -59,9 +59,25 @@ public class CartonMeasurementService {
}
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
return upsert(tenantId, operatorId, null, command);
}
public CartonMeasurementResult upsertForInbound(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);
}
private CartonMeasurementResult upsert(long tenantId, long operatorId, Long expectedInboundId,
CartonMeasurementCommand command) {
validateCommand(command);
String boxCode = command.boxCode().trim();
CartonLabelBindingResolver.Binding binding = requireBinding(tenantId, boxCode);
if (expectedInboundId != null && binding.inboundId() != expectedInboundId) {
throw new BizException(ResultCode.NOT_FOUND, "箱号不属于当前入库单");
}
InboundOrderEntity inbound = requireDeviceWritableInbound(tenantId, binding.inboundId());
MeasurementImageClient.UploadResult uploaded = uploadImage(
tenantId, operatorId, inbound.getId(), command.image());
@@ -41,7 +41,11 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
List<InboundCartonLabelEntity> existing = new ArrayList<>(repository.findByInbound(tenantId, inboundId));
if (!existing.isEmpty()) return batch(inbound, requireConsistent(existing));
if (!existing.isEmpty()) {
List<InboundCartonLabelEntity> consistent = requireConsistent(existing);
freezeOriginalPackageCount(inbound, consistent.get(0).getTotalCartonCount());
return batch(inbound, consistent);
}
if (!ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号");
}
@@ -51,6 +55,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
if (total < 1 || total > MAX_LABELS) {
throw new BizException(ResultCode.VALIDATION_FAILED, "唛头箱数必须为1至2000箱");
}
freezeOriginalPackageCount(inbound, total);
requirePrintableInboundCode(inbound.getInboundCode(), total);
int cartonIndex = 0;
@@ -146,6 +151,20 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
}
}
private void freezeOriginalPackageCount(InboundOrderEntity inbound, int total) {
Integer original = inbound.getTotalPackageCount();
if (original != null && original != total) {
throw new BizException(ResultCode.CONFLICT, "订单总箱数与已冻结箱号数量不一致");
}
if (original == null) {
if (!repository.initializeOriginalPackageCount(inbound.getTenantId(), inbound.getId(), total)) {
throw new BizException(ResultCode.CONFLICT, "订单总箱数已变化,请刷新后重试");
}
inbound.setTotalPackageCount(total);
inbound.setVersion(inbound.getVersion() == null ? 1 : inbound.getVersion() + 1);
}
}
private InboundCartonLabelEntity label(InboundOrderEntity inbound,
OrderCargoClient.CargoLabelBinding cargo,
int total, int index, LocalDateTime now) {
@@ -138,7 +138,7 @@ public class InboundMeasurementService {
weight.setOcrRawText(request.ocrRawText());
weight.setOcrWeightValue(request.ocrWeightValue());
weight.setOcrWeightUnit(unit);
weight.setConfidence(request.confidence());
weight.setConfidence(normalizeConfidence(request.confidence()));
weight.setRecognitionStatus(isPositive(request.ocrWeightValue()) && isSupportedUnit(unit)
? WEIGHT_PENDING_CONFIRM : WEIGHT_NEEDS_MANUAL);
weight.setCreatedBy(operatorId);
@@ -356,6 +356,17 @@ public class InboundMeasurementService {
return unit.trim().toUpperCase(Locale.ROOT);
}
private BigDecimal normalizeConfidence(BigDecimal confidence) {
if (confidence == null) {
return null;
}
if (confidence.compareTo(BigDecimal.ZERO) < 0
|| confidence.compareTo(BigDecimal.ONE) > 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 置信度必须在 0 到 1 之间");
}
return confidence.setScale(5, RoundingMode.HALF_UP);
}
private boolean isSupportedUnit(String unit) {
if (!StringUtils.hasText(unit)) {
return false;
@@ -192,6 +192,38 @@ paths:
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
'403': {description: 无查看权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:
post:
summary: 平台模拟单箱设备扫描
description: >-
需要 platform:firstleg:inbound:measure:edit 权限;仅用于平台联调和代设备录入,
复用正式单箱覆盖规则,不代表最终第三方设备协议或鉴权方式。
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/inbounds/{inboundId}/measurements/approve:
post:
summary: 审核通过测量
@@ -45,6 +45,7 @@ class InboundOrderControllerContractTest {
.contains("/api/platform/inbounds:")
.contains("/api/platform/inbounds/{inboundId}:")
.contains("/api/platform/inbounds/{inboundId}/carton-label-pdf:")
.contains("/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:")
.contains("required: false")
.contains("InboundOrderSummary:")
.contains("required: [id, tenantId]")
@@ -0,0 +1,91 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.service.CartonMeasurementService;
import com.baiyue.firstleg.service.InboundMeasurementService;
import com.baiyue.firstleg.service.MeasurementSubmissionService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.mock.web.MockPart;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class PlatformCartonScanControllerTest {
@Test
void bindsJsonDataAndImageToTheFormalCartonMeasurementCommand() 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.upsertForInbound(eq(10001L), eq(90001L), eq(70001L), any()))
.thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1", 1,
new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"),
new BigDecimal("12.5"), 99001L, "SIM-01",
LocalDateTime.parse("2026-08-07T10:30: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":"SIM-01","measuredAt":"2026-08-07T10:30: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/carton-scans", 70001L)
.file(image).part(data).param("tenantId", "10001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.data.boxCode").value("RK-001#5-1"));
ArgumentCaptor<CartonMeasurementCommand> command = ArgumentCaptor.forClass(CartonMeasurementCommand.class);
verify(cartonMeasurements).upsertForInbound(
eq(10001L), eq(90001L), eq(70001L), command.capture());
assertThat(command.getValue().machineNo()).isEqualTo("SIM-01");
assertThat(command.getValue().image().getOriginalFilename()).isEqualTo("carton.jpg");
}
private HandlerMethodArgumentResolver authenticationPrincipal(AuthenticatedUser user) {
return new HandlerMethodArgumentResolver() {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
org.springframework.web.bind.support.WebDataBinderFactory binderFactory) {
return user;
}
};
}
}
@@ -5,6 +5,7 @@ import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method;
@@ -25,6 +26,9 @@ class PlatformInboundMeasurementControllerContractTest {
assertAuthority("updateConfirmedPackageCount", "hasAuthority('platform:firstleg:inbound:measure:edit')",
AuthenticatedUser.class, long.class, long.class, long.class,
InboundMeasurementRequests.ConfirmedPackageCountRequest.class);
assertAuthority("simulateCartonScan", "hasAuthority('platform:firstleg:inbound:measure:edit')",
AuthenticatedUser.class, long.class, long.class,
InboundMeasurementRequests.CartonScanRequest.class, MultipartFile.class);
assertAuthority("createWeightResult", "hasAuthority('platform:firstleg:inbound:weight:edit')",
AuthenticatedUser.class, long.class, long.class,
InboundMeasurementRequests.CreateWeightResultRequest.class);
@@ -111,6 +111,15 @@ class CartonMeasurementServiceTest {
verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any());
}
@Test
void platformSimulationRejectsBoxCodeFromAnotherInboundBeforeUploadingImage() {
assertThatThrownBy(() -> service.upsertForInbound(10001L, 90001L, 70002L, command()))
.isInstanceOf(BizException.class)
.hasMessageContaining("箱号不属于当前入库单");
verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any());
}
@Test
void confirmedPackageCountUsesMeasurementOptimisticVersion() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -47,6 +47,7 @@ class InboundCartonLabelServiceTest {
var first = cargo(80001L, 1, 3, "60", "40", "30");
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
.thenReturn(ApiResponse.success(List.of(second, first)));
when(repository.initializeOriginalPackageCount(10001L, 70001L, 5)).thenReturn(true);
List<InboundCartonLabelEntity> inserted = new ArrayList<>();
org.mockito.Mockito.doAnswer(invocation -> {
inserted.add(invocation.getArgument(0));
@@ -63,12 +64,15 @@ class InboundCartonLabelServiceTest {
.containsExactly(80001L, 80001L, 80001L, 80002L, 80002L);
assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm");
assertThat(batch.labels().get(0).cartonText()).isEqualTo("CTNS:5-1");
assertThat(inbound.getTotalPackageCount()).isEqualTo(5);
assertThat(inserted).hasSize(5);
verify(repository).initializeOriginalPackageCount(10001L, 70001L, 5);
}
@Test
void repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation() {
InboundOrderEntity inbound = inbound();
inbound.setTotalPackageCount(5);
List<InboundCartonLabelEntity> existing = labels(inbound, 5);
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findByInbound(10001L, 70001L)).thenReturn(existing);
@@ -82,6 +86,22 @@ class InboundCartonLabelServiceTest {
verify(repository, never()).insert(any());
}
@Test
void refusesFrozenMappingWhenOriginalPackageCountDoesNotMatch() {
InboundOrderEntity inbound = inbound();
inbound.setTotalPackageCount(4);
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findByInbound(10001L, 70001L)).thenReturn(labels(inbound, 5));
assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L))
.isInstanceOf(BizException.class)
.hasMessageContaining("订单总箱数与已冻结箱号数量不一致");
verify(repository, never()).initializeOriginalPackageCount(
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyInt());
}
@Test
void resolvesBoxCodeInsideCustomerBoundary() {
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
@@ -81,6 +81,24 @@ class InboundMeasurementServiceTest {
verify(repository, never()).updateInbound(any());
}
@Test
void creatingWeightResultNormalizesProviderConfidenceAtBusinessBoundary() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
InboundMeasurementResponses.WeightResult result = service.createWeightResult(
1L, 10L, 9L,
new InboundMeasurementRequests.CreateWeightResultRequest(
120L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG",
new BigDecimal("0.5097191334")));
ArgumentCaptor<InboundWeightResultEntity> saved =
ArgumentCaptor.forClass(InboundWeightResultEntity.class);
verify(repository).insertWeight(saved.capture());
assertThat(saved.getValue().getConfidence()).isEqualByComparingTo("0.50972");
assertThat(result.confidence()).isEqualByComparingTo("0.50972");
}
@Test
void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -143,7 +143,7 @@ public class WeightOcrBatchService {
if (candidate == null || candidate.text() == null) {
continue;
}
String text = candidate.text().strip().replaceAll("[\\p{Cc}\\p{Cf}]+", " ");
String text = candidate.text().replaceAll("[\\p{Cc}\\p{Cf}]+", " ").strip();
if (text.isBlank()) {
continue;
}
@@ -67,7 +67,7 @@ class WeightOcrBatchServiceTest {
assertThat(result.recognized()).isEqualTo(1);
assertThat(result.results().get(0).status()).isEqualTo(Status.RECOGNIZED);
assertThat(result.results().get(0).confidence()).isEqualByComparingTo("0.50972");
assertThat(result.results().get(0).ocrRawText()).isEqualTo("12.35\nKG ");
assertThat(result.results().get(0).ocrRawText()).isEqualTo("12.35\nKG");
Path batchOutput = outputRoot.resolve(result.outputDirectory());
assertThat(batchOutput.resolve("cropped/weight.jpg")).isRegularFile();
assertThat(batchOutput.resolve("raw/weight.json")).isRegularFile();