This commit is contained in:
2026-08-12 14:37:45 +08:00
parent d183ad6330
commit fcb7c11af4
7 changed files with 162 additions and 13 deletions
@@ -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);
@@ -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<CartonMeasurementResult> 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 原值。")
@@ -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,
@@ -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());
@@ -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 重量
@@ -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
@@ -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