From 497544e9fd782935350629266ea81af94e44d307 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 5 Nov 2026 16:30:27 +0800 Subject: [PATCH] fix: restore customer visit uploads and guard fee publication --- .../file/service/BusinessFileCategory.java | 4 +- .../file/service/BusinessFileStorage.java | 17 ++ .../CustomerVisitBusinessFileTest.java | 161 ++++++++++++++++++ .../PlatformFbaPricingController.java | 3 +- .../repository/FbaPricingRepository.java | 16 +- .../firstleg/service/FbaPricingService.java | 25 ++- .../LoadingCompletedOutboxService.java | 19 +-- .../resources/openapi/fba-pricing-v1.yaml | 4 +- .../FbaPricingFeeTransitionTest.java | 72 +++++++- .../service/FbaPricingServiceTest.java | 38 ++++- .../LoadingCompletedOutboxServiceTest.java | 55 ++++++ 11 files changed, 369 insertions(+), 45 deletions(-) create mode 100644 file/src/test/java/com/baiyue/file/service/CustomerVisitBusinessFileTest.java diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 96dc4e0fd..b777e60a2 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -49,6 +49,7 @@ enum BusinessFileCategory { OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_CUSTOMER_VISIT(true, FormatPolicy.CUSTOMER_VISIT, Limits.CUSTOMER_VISIT, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), @@ -135,11 +136,12 @@ enum BusinessFileCategory { return Long.toString(value); } - enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE } + enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE, CUSTOMER_VISIT } private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO } private static final class Limits { private static final long STANDARD = 10L * 1024 * 1024; private static final long CUSTOMS = 50L * 1024 * 1024; private static final long ORDER_IMPORT = 5L * 1024 * 1024; + private static final long CUSTOMER_VISIT = 20L * 1024 * 1024; } } diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index 2b78410e0..924c8aee1 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -18,6 +18,7 @@ import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.UUID; @Component @@ -27,6 +28,8 @@ public class BusinessFileStorage { private static final Map TYPES = Map.of( "text/plain", ".txt", "application/pdf", ".pdf", "image/jpeg", ".jpg", "image/png", ".png"); + private static final Set CUSTOMER_VISIT_EXTENSIONS = Set.of( + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "webp"); @Value("${baiyue.file.storage-root}") private Path configuredRoot; @@ -46,6 +49,7 @@ public class BusinessFileStorage { return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy()); } + /** 按业务类别检查大小和格式,保存真实文件并返回存储结果。 */ private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize, BusinessFileCategory.FormatPolicy formatPolicy) { if (file == null || file.isEmpty()) throw bad("附件不能为空"); @@ -55,6 +59,8 @@ public class BusinessFileStorage { String extension; if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) { extension = ""; + } else if (formatPolicy == BusinessFileCategory.FormatPolicy.CUSTOMER_VISIT) { + extension = customerVisitExtension(file); } else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) { if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF"); verifyContent(file, type); @@ -85,6 +91,17 @@ public class BusinessFileStorage { } } + /** 与回访入口保持相同扩展名约束,接受浏览器未提供 MIME 的 Office 资料。 */ + private static String customerVisitExtension(MultipartFile file) { + String name = safeName(file); + int dot = name.lastIndexOf('.'); + String extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT); + if (!CUSTOMER_VISIT_EXTENSIONS.contains(extension)) { + throw bad("仅支持PDF、Office、JPG、PNG和WEBP文件"); + } + return "." + extension; + } + private static void verifyXlsx(MultipartFile file) { try (InputStream input = file.getInputStream()) { byte[] prefix = input.readNBytes(4); diff --git a/file/src/test/java/com/baiyue/file/service/CustomerVisitBusinessFileTest.java b/file/src/test/java/com/baiyue/file/service/CustomerVisitBusinessFileTest.java new file mode 100644 index 000000000..0ecd8cee9 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/CustomerVisitBusinessFileTest.java @@ -0,0 +1,161 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class CustomerVisitBusinessFileTest { + private static final String TYPE = "OVERSEAS_CUSTOMER_VISIT"; + private static final long MAX_SIZE = 20L * 1024 * 1024; + @TempDir Path root; + private BusinessFileStorage storage; + private FileObjectMapper files; + private InternalBusinessFileService service; + + /** 初始化文件元数据映射,供真实归属查询使用。 */ + @BeforeAll + static void initializeMapping() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "visit-files"), + FileObjectEntity.class); + } + + /** 使用真实本地存储,隔离数据库写入。 */ + @BeforeEach + void setUp() { + storage = new BusinessFileStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + storage.initializeRoot(); + files = mock(FileObjectMapper.class); + service = new InternalBusinessFileService(); + ReflectionTestUtils.setField(service, "files", files); + ReflectionTestUtils.setField(service, "storage", storage); + } + + /** 页面允许的全部回访资料均可保存,并保持客户及回访记录的精确归属。 */ + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "pdf|application/pdf", "doc|application/msword", "docx|application/octet-stream", + "xls|application/vnd.ms-excel", "xlsx|application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt|application/vnd.ms-powerpoint", "pptx|application/vnd.openxmlformats-officedocument.presentationml.presentation", + "jpg|image/jpeg", "jpeg|image/jpeg", "png|image/png", "webp|image/webp" + }) + void uploadsAllSupportedFormatsWithCustomerAndVisitOwnership(String extension, String contentType) { + var file = new MockMultipartFile("file", "回访资料." + extension, contentType, new byte[]{1, 2, 3}); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> { + FileObjectEntity row = call.getArgument(0); + row.setId(801L); + return 1; + }); + + var metadata = service.upload(1001L, TYPE, "71", 9L, file); + + assertThat(metadata.ownerScope()).isEqualTo("CUSTOMER"); + assertThat(metadata.tenantId()).isEqualTo(1001L); + assertThat(metadata.businessType()).isEqualTo(TYPE); + assertThat(metadata.businessId()).isEqualTo("71"); + assertThat(metadata.fileId()).isEqualTo(801L); + assertThat(metadata.fileName()).isEqualTo(file.getOriginalFilename()); + assertThat(metadata.contentType()).isEqualTo(contentType); + assertThat(metadata.sizeBytes()).isEqualTo(3L); + var persisted = ArgumentCaptor.forClass(FileObjectEntity.class); + verify(files).insert(persisted.capture()); + assertThat(storage.read(persisted.getValue().getObjectKey())).containsExactly(1, 2, 3); + } + + /** MIME 缺失或扩展名大写不应阻断有效的 Office 回访资料。 */ + @Test + void acceptsOfficeFileWithoutBrowserMimeType() { + var file = new MockMultipartFile("file", "回访记录.DOCX", null, new byte[]{1}); + var stored = storage.store(1001L, "71", file, BusinessFileCategory.require(TYPE)); + assertThat(stored.contentType()).isEqualTo("application/octet-stream"); + assertThat(stored.objectKey()).endsWith(".docx"); + } + + /** 回访资料单独使用 20MiB 限制,不放宽其他业务附件的限制。 */ + @Test + void enforcesNonemptyAndTwentyMibBoundary() { + var category = BusinessFileCategory.require(TYPE); + assertThatThrownBy(() -> storage.store(1001L, "71", new MockMultipartFile("file", new byte[0]), category)) + .isInstanceOf(BizException.class).hasMessageContaining("不能为空"); + var oversized = mock(MultipartFile.class); + when(oversized.isEmpty()).thenReturn(false); + when(oversized.getSize()).thenReturn(MAX_SIZE + 1); + assertThatThrownBy(() -> storage.store(1001L, "71", oversized, category)) + .isInstanceOf(BizException.class).hasMessageContaining("20MB"); + var boundary = new MockMultipartFile("file", "回访.doc", "application/msword", new byte[(int) MAX_SIZE]); + assertThat(storage.store(1001L, "71", boundary, category).size()).isEqualTo(MAX_SIZE); + assertThat(BusinessFileCategory.CUSTOMS_DOCUMENT.maxSizeBytes()).isEqualTo(10L * 1024 * 1024); + } + + /** 不接受回访入口约定之外的文件或双扩展名可执行文件。 */ + @ParameterizedTest + @ValueSource(strings = {"readme.txt", "archive.zip", "visit.pdf.exe", "visit"}) + void rejectsUnsupportedFormatsBeforeMetadataIsWritten(String name) { + var file = new MockMultipartFile("file", name, "application/octet-stream", new byte[]{1}); + assertThatThrownBy(() -> service.upload(1001L, TYPE, "71", 9L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("仅支持"); + verifyNoInteractions(files); + } + + /** 回访资料必须归属明确客户和有效回访记录。 */ + @Test + void rejectsPlatformScopeAndInvalidVisitId() { + var file = new MockMultipartFile("file", "visit.pdf", "application/pdf", new byte[]{1}); + assertThatThrownBy(() -> service.upload(0L, TYPE, "71", 9L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("必须归属明确客户"); + assertThatThrownBy(() -> service.upload(1001L, TYPE, "0", 9L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("业务ID必须有效"); + verifyNoInteractions(files); + } + + /** 保存回访时按客户、类型、回访记录和文件 ID 校验,不能串用其他资料。 */ + @Test + void metadataRequiresEveryOwnershipKey() { + var value = new FileObjectEntity(); + value.setId(801L); + value.setTenantId(1001L); + value.setBusinessType(TYPE); + value.setBusinessId("71"); + when(files.selectOne(any())).thenAnswer(call -> { + LambdaQueryWrapper query = call.getArgument(0); + assertThat(query.getSqlSegment()).contains("tenant_id", "business_type", "business_id", "id", "status", "deleted"); + return query.getParamNameValuePairs().values().containsAll( + java.util.List.of(1001L, TYPE, "71", 801L, "ACTIVE", false)) ? value : null; + }); + + assertThat(service.metadata(1001L, TYPE, "71", 801L).fileId()).isEqualTo(801L); + assertThatThrownBy(() -> service.metadata(1002L, TYPE, "71", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(1001L, TYPE, "72", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(1001L, "OVERSEAS_EXCEPTION_EVIDENCE", "71", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(1001L, TYPE, "71", 802L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java index 6f9dd8e5d..7e18a893a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java @@ -87,11 +87,12 @@ public class PlatformFbaPricingController { return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey)); } + /** 按费用发送权限发布已确认费用,审核门禁由服务端校验且不推进履约状态。 */ @PostMapping("/send") @PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee'," + " 'firstleg:inbound:send-fee')") @BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId", - description = "发送FBA费用快照并进入待托盘状态") + description = "发送FBA费用快照") public ApiResponse send( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 4949dee6f..ac879b1fc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -331,6 +331,7 @@ public class FbaPricingRepository { return inbounds.update(null, update); } + /** 保存试算及计费量,保留测量、审核与仓库作业的主状态。 */ public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode, java.math.BigDecimal selectedWeightKg, java.math.BigDecimal selectedVolumeCbm, @@ -341,10 +342,6 @@ public class FbaPricingRepository { .eq(InboundPricingEntity::getProcessStatus, processStatus) .in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT")) .set(InboundPricingEntity::getFeeStatus, "CALCULATED"); - if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED") - .contains(processStatus)) { - update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING"); - } applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable); return inbounds.update(null, update); } @@ -383,12 +380,14 @@ public class FbaPricingRepository { .set(InboundFeeEntity::getConfirmedAt, now)); } - /** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */ + /** 仅确认已通过测量审核的费用,并保留当前履约状态。 */ public int markInboundConfirmed(long tenantId, long inboundId) { return inbounds.update(null, new LambdaUpdateWrapper() .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) .in(InboundPricingEntity::getProcessStatus, - List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")) + List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", + "LOADING", "LOADED", "DELIVERED")) + .eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED") .eq(InboundPricingEntity::getFeeStatus, "CALCULATED") .set(InboundPricingEntity::getFeeStatus, "CONFIRMED")); } @@ -436,15 +435,14 @@ public class FbaPricingRepository { .set(InboundPricingEntity::getFeeStatus, "VOIDED")); } + /** 发送仅更新费用状态,并原子校验测量已审核及履约状态未变化。 */ public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) { LambdaUpdateWrapper update = new LambdaUpdateWrapper() .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) .eq(InboundPricingEntity::getProcessStatus, processStatus) + .eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED") .eq(InboundPricingEntity::getFeeStatus, "CONFIRMED") .set(InboundPricingEntity::getFeeStatus, "SENT"); - if ("FEE_CONFIRMING".equals(processStatus)) { - update.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET"); - } return inbounds.update(null, update); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 55a675e00..1b348b82c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -196,9 +196,8 @@ public class FbaPricingService { } feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now); complete(token, fee.getId(), now); - String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus()) - ? inbound.getProcessStatus() : "FEE_CONFIRMING"; - return response(inbound, fee, rate.getUnitPrice(), nextStatus); + // 费用是独立状态,提前试算不得覆盖待测量或待审核等履约进度。 + return response(inbound, fee, rate.getUnitPrice(), inbound.getProcessStatus()); } /** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */ @@ -290,9 +289,7 @@ public class FbaPricingService { weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存"); feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now); complete(token, fee.getId(), now); - String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus()) - ? inbound.getProcessStatus() : "FEE_CONFIRMING"; - return response(inbound, fee, request.unitPrice(), nextStatus); + return response(inbound, fee, request.unitPrice(), inbound.getProcessStatus()); } /** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */ @@ -577,10 +574,14 @@ public class FbaPricingService { public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status, int recalculatedCount) { } + /** 测量审核通过后才允许确认费用,避免提前冻结费用阻断审核后的重新计价。 */ @Transactional public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId, String idempotencyKey) { InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); + if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) { + throw conflict("测量数据尚未审核通过,请先完成测量审核后再确认费用"); + } if (!feeRevisionStatusSupported(inbound.getProcessStatus())) { throw conflict("当前入库单状态不允许确认费用"); } @@ -599,10 +600,14 @@ public class FbaPricingService { return response(tenantId, current.getId(), null); } + /** 仅发布已通过测量审核的费用,发送不改变测量与仓库作业进度。 */ @Transactional public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId, String idempotencyKey) { InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); + if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) { + throw conflict("测量数据尚未审核通过,请先完成测量审核后再发送客户"); + } if (!feeRevisionStatusSupported(inbound.getProcessStatus())) { throw conflict("当前入库单状态不允许发送费用"); } @@ -611,8 +616,7 @@ public class FbaPricingService { String hash = hash("SEND", inboundId, current.getId().toString()); InboundPricingIdempotencyEntity token = beginIdempotency( tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash); - String nextProcessStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus()) - ? inbound.getProcessStatus() : "WAIT_PALLET"; + String nextProcessStatus = inbound.getProcessStatus(); if ("COMPLETED".equals(token.getStatus())) { feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(), operatorId, null, LocalDateTime.now(clock)); @@ -812,11 +816,6 @@ public class FbaPricingService { return inbound; } - private static boolean preserveProcessStatusDuringPricing(String processStatus) { - return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED") - .contains(processStatus); - } - private static boolean feeRevisionStatusSupported(String processStatus) { return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index c2f20cd65..b0696645d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -169,24 +169,23 @@ public class LoadingCompletedOutboxService { traceId, eventKey, null, payloadNode, hash).requireValid(); } + /** 国内同类照片可追加,交接按每类最新有效照片冻结一张对照图。 */ private List loadingEvidence(Long containerBookingId) { if (containerBookingId == null || containerBookingId <= 0) { throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片"); } - List result = cabinetEvidenceMapper - .byContainer(containerBookingId).stream() + Map latestByType = new java.util.TreeMap<>(); + // Mapper 按类型、证据 ID 升序返回;同类后到的有效照片覆盖前一张,原始证据全部保留。 + cabinetEvidenceMapper.byContainer(containerBookingId).stream() .filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus())) - .map(value -> new FirstLegLoadingPlanCompletedV3.LoadingEvidence( - value.getEvidenceType(), value.getFileId(), value.getFileName())) - .sorted(Comparator.comparing(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)) - .toList(); - Set types = result.stream().map(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType) - .collect(java.util.stream.Collectors.toSet()); - if (result.size() != 5 || !types.equals(Set.of( + .forEach(value -> latestByType.put(value.getEvidenceType(), + new FirstLegLoadingPlanCompletedV3.LoadingEvidence( + value.getEvidenceType(), value.getFileId(), value.getFileName()))); + if (!latestByType.keySet().equals(Set.of( "PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) { throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件"); } - return result; + return List.copyOf(latestByType.values()); } private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows, diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index d34d5cfc1..b6fc00a9b 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -171,13 +171,13 @@ paths: /api/platform/v1/inbounds/{inboundId}/fees/confirm: post: summary: 确认最新费用快照 - description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。 + description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。测量审核通过后才可确认费用;未通过时拒绝且不冻结费用。确认只更新费用状态,不改变履约主状态。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/send: post: summary: 发送已确认费用快照 - description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee。人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。 + description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee,无需额外的测量查看权限。服务端校验测量审核通过后才可发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox,不改变履约主状态。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/latest: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingFeeTransitionTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingFeeTransitionTest.java index 3a9f0ff00..476a6863f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingFeeTransitionTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingFeeTransitionTest.java @@ -37,7 +37,8 @@ class FbaPricingFeeTransitionTest { session.getConnection(); sql = new JdbcTemplate(ds); sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," + - "process_status VARCHAR(32),fee_status VARCHAR(32),deleted BOOLEAN DEFAULT FALSE)"); + "process_status VARCHAR(32),fee_status VARCHAR(32),measure_review_status VARCHAR(32) DEFAULT 'APPROVED'," + + "chargeable_weight_kg DECIMAL(20,6),chargeable_volume_cbm DECIMAL(20,6),deleted BOOLEAN DEFAULT FALSE)"); repository = new FbaPricingRepository(); ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class)); } @@ -50,7 +51,8 @@ class FbaPricingFeeTransitionTest { /** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */ @ParameterizedTest - @ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"}) + @ValueSource(strings = {"WAIT_PRICING", + "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"}) void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) { insertInbound(processStatus, "CALCULATED"); @@ -58,7 +60,7 @@ class FbaPricingFeeTransitionTest { assertState(processStatus, "CONFIRMED"); assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero(); assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1); - assertState("FEE_CONFIRMING".equals(processStatus) ? "WAIT_PALLET" : processStatus, "SENT"); + assertState(processStatus, "SENT"); assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero(); } @@ -71,12 +73,10 @@ class FbaPricingFeeTransitionTest { assertState("WAIT_PALLETIZE", feeStatus); } - /** 非确认阶段、其他客户和已删除入库单仍不可确认。 */ + /** 其他客户和已删除入库单仍不可确认。 */ @Test void preservesProcessCustomerAndDeletionGuards() { - insertInbound("WAIT_MEASURE", "CALCULATED"); - assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero(); - sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88"); + insertInbound("WAIT_PALLETIZE", "CALCULATED"); assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero(); sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88"); assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero(); @@ -91,6 +91,64 @@ class FbaPricingFeeTransitionTest { assertState("WAIT_SPLIT", "CONFIRMED"); } + /** 提前试算保留测量进度,但审核前不能确认和冻结费用。 */ + @ParameterizedTest + @ValueSource(strings = {"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_SPLIT"}) + void calculationPreservesMeasurementWorkflow(String processStatus) { + insertInbound(processStatus, "UNPRICED"); + boolean approved = "WAIT_SPLIT".equals(processStatus); + sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", + approved ? "APPROVED" : "PENDING"); + assertThat(repository.markInboundCalculated(10001L, 88L, processStatus, "KG", + new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"), + new java.math.BigDecimal("616.13"))).isEqualTo(1); + assertState(processStatus, "CALCULATED"); + assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(approved ? 1 : 0); + assertState(processStatus, approved ? "CONFIRMED" : "CALCULATED"); + } + + /** 即使主状态已经升级,确认也须原子校验实际测量审核结果。 */ + @ParameterizedTest + @ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"}) + void rejectsConfirmationWhenMeasurementIsNotApproved(String reviewStatus) { + insertInbound("WAIT_SPLIT", "CALCULATED"); + sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus); + assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero(); + assertState("WAIT_SPLIT", "CALCULATED"); + } + + /** 提前试算被拒绝确认后,审核通过仍可按正常费用链重新试算、确认和发送。 */ + @Test + void pendingCalculationCanBeRepricedAndPublishedAfterMeasurementApproval() { + insertInbound("MEASURE_PENDING", "UNPRICED"); + sql.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=88"); + assertThat(repository.markInboundCalculated(10001L, 88L, "MEASURE_PENDING", "KG", + new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"), + new java.math.BigDecimal("616.13"))).isEqualTo(1); + assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero(); + assertState("MEASURE_PENDING", "CALCULATED"); + + sql.update("UPDATE fl_inbound_order SET process_status='WAIT_SPLIT',measure_review_status='APPROVED' WHERE id=88"); + assertThat(repository.markInboundCalculated(10001L, 88L, "WAIT_SPLIT", "KG", + new java.math.BigDecimal("580"), new java.math.BigDecimal("4"), + new java.math.BigDecimal("668"))).isEqualTo(1); + assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1); + assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_SPLIT")).isEqualTo(1); + assertState("WAIT_SPLIT", "SENT"); + assertThat(sql.queryForObject("SELECT chargeable_weight_kg FROM fl_inbound_order WHERE id=88", + java.math.BigDecimal.class)).isEqualByComparingTo("668"); + } + + /** 即使确认费用已完成,测量审核未通过也不能在持久化阶段发送。 */ + @ParameterizedTest + @ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"}) + void rejectsSendingUnapprovedMeasurement(String reviewStatus) { + insertInbound("MEASURE_PENDING", "CONFIRMED"); + sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus); + assertThat(repository.markInboundFeeSent(10001L, 88L, "MEASURE_PENDING")).isZero(); + assertState("MEASURE_PENDING", "CONFIRMED"); + } + /** 准备单个客户的入库单,不调用共享环境。 */ private void insertInbound(String processStatus, String feeStatus) { sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 0c160535b..4fc048ede 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -165,6 +165,7 @@ class FbaPricingServiceTest { } @Test + /** 试算生成费用但保留原履约进度。 */ @DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商") void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() { readyRepository(); @@ -186,7 +187,7 @@ class FbaPricingServiceTest { assertThat(response.conversionFactor()).isEqualByComparingTo("167"); assertThat(response.roundingScale()).isEqualTo(6); assertThat(response.roundingMode()).isEqualTo("HALF_UP"); - assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING"); + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PRICING"); ArgumentCaptor fee = ArgumentCaptor.forClass(InboundFeeEntity.class); verify(repository).insertFee(fee.capture()); assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); @@ -630,7 +631,7 @@ class FbaPricingServiceTest { /** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */ @ParameterizedTest - @CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"}) + @CsvSource({"FEE_CONFIRMING,FEE_CONFIRMING", "WAIT_PALLETIZE,WAIT_PALLETIZE"}) @DisplayName("重复发送只返回首次结果且不会重复写Outbox") void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) { InboundPricingEntity inbound = inbound("10", "1"); @@ -687,6 +688,39 @@ class FbaPricingServiceTest { eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW)); } + /** 测量未审核前拒绝单独确认,不留下阻断审核后初算的已确认费用。 */ + @ParameterizedTest + @ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"}) + void rejectsConfirmingBeforeMeasurementApproval(String reviewStatus) { + InboundPricingEntity inbound = inbound("570.500", "3.6894"); + inbound.setProcessStatus("MEASURE_PENDING"); + inbound.setMeasureReviewStatus(reviewStatus); + inbound.setFeeStatus("CALCULATED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + + assertThatThrownBy(() -> service.confirm(TENANT, OPERATOR, INBOUND, "confirm-pending")) + .isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核"); + verify(repository, never()).confirmFee(anyLong(), anyLong(), anyLong(), any()); + verify(repository, never()).markInboundConfirmed(anyLong(), anyLong()); + verify(repository, never()).insertIdempotency(any()); + } + + /** 测量未审核前拒绝发布,不能产生已发送快照或财务事件。 */ + @ParameterizedTest + @ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"}) + void rejectsSendingBeforeMeasurementApproval(String reviewStatus) { + InboundPricingEntity inbound = inbound("570.500", "3.6894"); + inbound.setProcessStatus("MEASURE_PENDING"); + inbound.setMeasureReviewStatus(reviewStatus); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + + assertThatThrownBy(() -> service.send(TENANT, OPERATOR, INBOUND, "send-pending")) + .isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核"); + verify(repository, never()).sendFee(anyLong(), anyLong(), anyLong(), any()); + verify(repository, never()).insertIdempotency(any()); + verify(financeOutbox, never()).publishInbound(anyString(), anyLong(), anyLong(), anyLong(), anyInt(), any()); + } + @Test @DisplayName("同一幂等键用于不同试算时点会冲突") void rejectsReusedIdempotencyKeyWithDifferentRequest() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index ad49ea843..4e5a085ae 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -101,6 +101,61 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.eventVersion()).isEqualTo(3); } + /** 同类追加照片不阻断预检和发布,每类冻结最新有效照片并保持重试快照稳定。 */ + @Test + void freezesLatestActivePhotoPerTypeDuringPreflightAndPublication() throws Exception { + Row value = row(11, 100, 101); + value.setPlannedPackageCount(1); + value.setPickedPackageCount(1); + when(snapshots.previewRows(90L)).thenReturn(List.of(value)); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_3", 7); + voided.setStatus("VOID"); + CabinetLoadingEvidenceEntity inactive = evidence("PHOTO_STEP_3", 8); + inactive.setActiveFlag(null); + // 与 Mapper 的 evidence_type、id 升序一致,后到的作废或失效照片不能替代有效照片。 + when(cabinetEvidence.byContainer(91L)).thenReturn(List.of( + evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2), + evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), voided, inactive, + evidence("PHOTO_STEP_4", 4), evidence("SEAL", 5))); + + service.requirePublishable(90L); + verify(outbox, org.mockito.Mockito.never()).insert( + org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); + service.publish(90L); + service.publish(90L); + + ArgumentCaptor captured = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox, org.mockito.Mockito.times(2)).insert(captured.capture()); + EventEnvelope first = mapper.readValue(captured.getAllValues().get(0).getPayloadJson(), EventEnvelope.class); + EventEnvelope replay = mapper.readValue(captured.getAllValues().get(1).getPayloadJson(), EventEnvelope.class); + assertThat(first.payload().path("loadingEvidence")).hasSize(5); + assertThat(first.payload().at("/loadingEvidence/2/evidenceType").asText()).isEqualTo("PHOTO_STEP_3"); + assertThat(first.payload().at("/loadingEvidence/2/fileId").asLong()).isEqualTo(6L); + assertThat(replay.payloadHash()).isEqualTo(first.payloadHash()); + } + + /** 多张同类照片不能替代缺失类别,作废照片仍不计入五类完整性。 */ + @Test + void rejectsMissingPhotoTypeEvenWhenMoreThanFivePhotosRemain() { + Row value = row(11, 100, 101); + value.setPlannedPackageCount(1); + value.setPickedPackageCount(1); + when(snapshots.previewRows(90L)).thenReturn(List.of(value)); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_4", 4); + voided.setStatus("VOID"); + when(cabinetEvidence.byContainer(91L)).thenReturn(List.of( + evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2), + evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), evidence("PHOTO_STEP_3", 7), + voided, evidence("SEAL", 5))); + + assertThatThrownBy(() -> service.requirePublishable(90L)).hasMessageContaining("五类照片不完整"); + assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("五类照片不完整"); + verify(outbox, org.mockito.Mockito.never()).insert( + org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); + } + /** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */ @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})