diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 18743f32c..d3a0836ac 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -847,6 +847,22 @@ CREATE TABLE IF NOT EXISTS wh_express_company ( KEY idx_wh_express_tenant_status (tenant_id, status, deleted) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共快递公司主数据,固定归属 tenant_id=0'; +-- 开发/测试环境公共快递基线:供订单履约快递选择和人工验收使用。 +INSERT INTO wh_express_company ( + id, tenant_id, company_code, company_name, service_phone, website, + tracking_url_template, status, remark, created_by, updated_by, deleted, version +) VALUES ( + 191000000000000101, 0, 'EXP-TEST', 'IT 测试快递', '400-000-0000', NULL, + NULL, 'ACTIVE', '统一初始化测试基线', 1000001, 1000001, 0, 0 +) +ON DUPLICATE KEY UPDATE + tenant_id = VALUES(tenant_id), + company_name = VALUES(company_name), + service_phone = VALUES(service_phone), + status = 'ACTIVE', + remark = VALUES(remark), + deleted = 0; + -- ============================================================================ -- 来源:firstleg\src\main\resources\db\firstleg.sql -- ============================================================================ @@ -5132,8 +5148,7 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + IF current_plan_status = 'COMPLETED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; END IF; IF current_plan_status = 'LOADING' AND ( @@ -5483,7 +5498,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -5498,7 +5513,8 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; - IF current_plan_status = 'COMPLETED' THEN + IF current_plan_status = 'COMPLETED' + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ @@ -14435,5 +14451,6 @@ ALTER TABLE baiyue_order.ord_order_cargo MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克);草稿可空,提交前必须大于0', MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米);草稿可空,提交前必须大于0'; +-- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。 SELECT 'PASS' AS migration_status, '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; diff --git a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java index 99b4c1f9f..046b1ac3a 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java @@ -1,3 +1,87 @@ package com.baiyue.file.controller; -import com.baiyue.common.core.api.*;import com.baiyue.common.core.exception.BizException;import com.baiyue.file.service.InternalBusinessFileService;import jakarta.annotation.Resource;import jakarta.validation.constraints.*;import org.springframework.beans.factory.annotation.Value;import org.springframework.http.MediaType;import org.springframework.web.bind.annotation.*;import org.springframework.web.multipart.MultipartFile; -@RestController @RequestMapping("/internal/file/v1/business-files") public class InternalBusinessFileController{@Resource InternalBusinessFileService service;@Value("${baiyue.security.internal-service-token}") String expected;private void check(String token){if(token==null||!java.security.MessageDigest.isEqual(token.getBytes(java.nio.charset.StandardCharsets.UTF_8),expected.getBytes(java.nio.charset.StandardCharsets.UTF_8)))throw new BizException(ResultCode.FORBIDDEN,"内部服务令牌无效");}@PostMapping(value="/{tenantId}/{businessType}/{businessId}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse upload(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestHeader("X-Operator-Id") long operatorId,@PathVariable @Positive long tenantId,@PathVariable @NotBlank String businessType,@PathVariable @NotBlank String businessId,@RequestPart("file") MultipartFile file){check(token);return ApiResponse.success(service.upload(tenantId,businessType,businessId,operatorId,file));}@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") public ApiResponse metadata(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@PathVariable @Positive long tenantId,@PathVariable @NotBlank String businessType,@PathVariable @NotBlank String businessId,@PathVariable @Positive long fileId){check(token);return ApiResponse.success(service.metadata(tenantId,businessType,businessId,fileId));}@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content") public org.springframework.http.ResponseEntity download(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@PathVariable @Positive long tenantId,@PathVariable @NotBlank String businessType,@PathVariable @NotBlank String businessId,@PathVariable @Positive long fileId){check(token);var value=service.download(tenantId,businessType,businessId,fileId);String name=value.fileName().replace("\r","").replace("\n","").replace("\"","");return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()).contentType(MediaType.parseMediaType(value.contentType())).header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=\""+name+"\"").contentLength(value.content().length).body(value.content());}} + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.InternalBusinessFileService; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/file/v1/business-files") +public class InternalBusinessFileController { + @Resource private InternalBusinessFileService service; + @Value("${baiyue.security.internal-service-token}") private String expected; + + @PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse upload( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable @NotBlank String businessType, + @PathVariable @NotBlank String businessId, + @RequestPart("file") MultipartFile file) { + check(token); + checkTenantId(tenantId); + return ApiResponse.success(service.upload(tenantId, businessType, businessId, operatorId, file)); + } + + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") + public ApiResponse metadata( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, + @PathVariable @NotBlank String businessType, + @PathVariable @NotBlank String businessId, + @PathVariable @Positive long fileId) { + check(token); + checkTenantId(tenantId); + return ApiResponse.success(service.metadata(tenantId, businessType, businessId, fileId)); + } + + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content") + public ResponseEntity download( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, + @PathVariable @NotBlank String businessType, + @PathVariable @NotBlank String businessId, + @PathVariable @Positive long fileId) { + check(token); + checkTenantId(tenantId); + var value = service.download(tenantId, businessType, businessId, fileId); + String name = value.fileName().replace("\r", "").replace("\n", "").replace("\"", ""); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(value.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + name + "\"") + .contentLength(value.content().length) + .body(value.content()); + } + + private void check(String token) { + if (token == null || !MessageDigest.isEqual( + token.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } + } + + private static void checkTenantId(long tenantId) { + if (tenantId < 0) throw new BizException(ResultCode.BAD_REQUEST, "客户作用域不能为负数"); + } +} diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index c57567dd7..090a04bf1 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -8,18 +8,110 @@ import com.baiyue.file.mapper.FileObjectMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; + import java.time.LocalDateTime; +import java.util.Set; @Service public class InternalBusinessFileService { - private static final java.util.Set BUSINESS_TYPES = java.util.Set.of( - "TRUCK_APPOINTMENT_ATTACHMENT", "CUSTOMS_DOCUMENT", "CUSTOMS_RECEIPT", "FINANCE_VOUCHER"); - @Resource FileObjectMapper files; @Resource BusinessFileStorage storage; - public Metadata upload(long tenantId,String businessType,String businessId,long operatorId,MultipartFile file){if(tenantId<=0||operatorId<=0)throw new BizException(ResultCode.BAD_REQUEST,"归属和操作人必须有效");if(!BUSINESS_TYPES.contains(businessType))throw new BizException(ResultCode.BAD_REQUEST,"不支持的附件业务类型");long parsedBusinessId;try{parsedBusinessId=Long.parseLong(businessId);}catch(NumberFormatException ex){throw new BizException(ResultCode.BAD_REQUEST,"业务ID格式无效");}if(parsedBusinessId<=0)throw new BizException(ResultCode.BAD_REQUEST,"业务ID必须有效");var stored=storage.storeLoading(tenantId,parsedBusinessId,file);FileObjectEntity value=new FileObjectEntity();value.setTenantId(tenantId);value.setBusinessType(businessType);value.setBusinessId(businessId);value.setOriginalName(stored.originalName());value.setObjectKey(stored.objectKey());value.setStorageProvider("LOCAL");value.setBucketName("business-files");value.setContentType(stored.contentType());value.setSizeBytes(stored.size());value.setSha256(stored.sha256());value.setStatus("ACTIVE");value.setCreatedBy(operatorId);value.setUpdatedBy(operatorId);value.setCreatedAt(LocalDateTime.now());value.setUpdatedAt(value.getCreatedAt());value.setDeleted(false);value.setVersion(0);if(files.insert(value)!=1)throw new BizException(ResultCode.INTERNAL_ERROR,"附件元数据保存失败");return metadata(value);} - public Metadata metadata(long tenantId,String businessType,String businessId,long fileId){return metadata(required(tenantId,businessType,businessId,fileId));} - public Download download(long tenantId,String businessType,String businessId,long fileId){FileObjectEntity value=required(tenantId,businessType,businessId,fileId);return new Download(value.getOriginalName(),value.getContentType(),storage.read(value.getObjectKey()));} - private FileObjectEntity required(long tenantId,String businessType,String businessId,long fileId){FileObjectEntity value=files.selectOne(new LambdaQueryWrapper().eq(FileObjectEntity::getTenantId,tenantId).eq(FileObjectEntity::getBusinessType,businessType).eq(FileObjectEntity::getBusinessId,businessId).eq(FileObjectEntity::getId,fileId).eq(FileObjectEntity::getStatus,"ACTIVE").eq(FileObjectEntity::getDeleted,false));if(value==null)throw new BizException(ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配");return value;} - private static Metadata metadata(FileObjectEntity value){return new Metadata(value.getId(),value.getTenantId(),value.getBusinessType(),value.getBusinessId(),value.getOriginalName(),value.getSha256(),value.getContentType(),value.getSizeBytes());} - public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String sha256,String contentType,Long sizeBytes){} - public record Download(String fileName,String contentType,byte[] content){} + private static final Set BUSINESS_TYPES = Set.of( + "TRUCK_APPOINTMENT_ATTACHMENT", + "CUSTOMS_DOCUMENT", + "CUSTOMS_RECEIPT", + "FINANCE_VOUCHER", + "CONTAINER_CLEARANCE_DOCUMENT", + "LOADING_CUSTOMS_DOCUMENT"); + + @Resource private FileObjectMapper files; + @Resource private BusinessFileStorage storage; + + public Metadata upload(long tenantId, String businessType, String businessId, long operatorId, + MultipartFile file) { + if (tenantId < 0 || operatorId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效"); + } + validateBusinessType(businessType); + long parsedBusinessId = parseBusinessId(businessId); + var stored = storage.storeLoading(tenantId, parsedBusinessId, file); + FileObjectEntity value = new FileObjectEntity(); + value.setTenantId(tenantId); + value.setBusinessType(businessType); + value.setBusinessId(businessId); + value.setOriginalName(stored.originalName()); + value.setObjectKey(stored.objectKey()); + value.setStorageProvider("LOCAL"); + value.setBucketName("business-files"); + value.setContentType(stored.contentType()); + value.setSizeBytes(stored.size()); + value.setSha256(stored.sha256()); + value.setStatus("ACTIVE"); + value.setCreatedBy(operatorId); + value.setUpdatedBy(operatorId); + value.setCreatedAt(LocalDateTime.now()); + value.setUpdatedAt(value.getCreatedAt()); + value.setDeleted(false); + value.setVersion(0); + if (files.insert(value) != 1) { + throw new BizException(ResultCode.INTERNAL_ERROR, "附件元数据保存失败"); + } + return metadata(value); + } + + public Metadata metadata(long tenantId, String businessType, String businessId, long fileId) { + return metadata(required(tenantId, businessType, businessId, fileId)); + } + + public Download download(long tenantId, String businessType, String businessId, long fileId) { + FileObjectEntity value = required(tenantId, businessType, businessId, fileId); + return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey())); + } + + private FileObjectEntity required(long tenantId, String businessType, String businessId, long fileId) { + validateScope(tenantId, businessType, businessId, fileId); + FileObjectEntity value = files.selectOne(new LambdaQueryWrapper() + .eq(FileObjectEntity::getTenantId, tenantId) + .eq(FileObjectEntity::getBusinessType, businessType) + .eq(FileObjectEntity::getBusinessId, businessId) + .eq(FileObjectEntity::getId, fileId) + .eq(FileObjectEntity::getStatus, "ACTIVE") + .eq(FileObjectEntity::getDeleted, false)); + if (value == null) { + throw new BizException(ResultCode.NOT_FOUND, "业务附件不存在或归属不匹配"); + } + return value; + } + + private static void validateScope(long tenantId, String businessType, String businessId, long fileId) { + if (tenantId < 0 || fileId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效"); + } + validateBusinessType(businessType); + parseBusinessId(businessId); + } + + private static void validateBusinessType(String businessType) { + if (!BUSINESS_TYPES.contains(businessType)) { + throw new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"); + } + } + + private static long parseBusinessId(String businessId) { + final long value; + try { + value = Long.parseLong(businessId); + } catch (NumberFormatException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "业务ID格式无效"); + } + if (value <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须有效"); + return value; + } + + private static Metadata metadata(FileObjectEntity value) { + return new Metadata(value.getId(), value.getTenantId(), value.getBusinessType(), value.getBusinessId(), + value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes()); + } + + public record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String sha256, String contentType, Long sizeBytes) {} + public record Download(String fileName, String contentType, byte[] content) {} } diff --git a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java new file mode 100644 index 000000000..96c7bb064 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java @@ -0,0 +1,58 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.InternalBusinessFileService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(InternalBusinessFileController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class InternalBusinessFileControllerTest { + @MockBean InternalBusinessFileService service; + @Resource InternalBusinessFileController controller; + + @Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() { + var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes()); + var metadata = new InternalBusinessFileService.Metadata(81L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", + "41", "clearance.pdf", "hash", "application/pdf", file.getSize()); + when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata); + when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata); + + assertThat(controller.upload("internal-token", 7L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", file).data()) + .isEqualTo(metadata); + assertThat(controller.metadata("internal-token", 0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L).data()) + .isEqualTo(metadata); + } + + @Test void zeroTenantDownloadReturnsOwnedContentWithoutCaching() { + when(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L)) + .thenReturn(new InternalBusinessFileService.Download("declaration.pdf", "application/pdf", new byte[]{1, 2})); + + var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + + assertThat(response.getBody()).containsExactly(1, 2); + assertThat(response.getHeaders().getCacheControl()).contains("no-store"); + verify(service).download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + } + + @Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() { + var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes()); + assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L, + "CONTAINER_CLEARANCE_DOCUMENT", "41", file)) + .isInstanceOf(BizException.class).hasMessageContaining("令牌无效"); + assertThatThrownBy(() -> controller.metadata("internal-token", -1L, + "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)) + .isInstanceOf(BizException.class).hasMessageContaining("不能为负数"); + verifyNoInteractions(service); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index cae7cc142..9d58a196a 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -35,6 +35,39 @@ class InternalBusinessFileServiceTest { assertThat(result.contentType()).isEqualTo("text/plain"); } + @Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() { + MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes()); + when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation -> + new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf", + "application/pdf", file.getSize(), "b".repeat(64), "customs.pdf")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1; + }); + + var clearance = service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file); + var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file); + + assertThat(clearance).extracting(InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41"); + assertThat(customs).extracting(InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51"); + verify(storage).storeLoading(0L, 41L, file); + verify(storage).storeLoading(0L, 51L, file); + } + + @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() { + MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()); + assertThatThrownBy(() -> service.upload(-1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("归属"); + assertThatThrownBy(() -> service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "0", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("业务ID"); + assertThatThrownBy(() -> service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 0L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("操作人"); + verifyNoInteractions(files, storage); + } + @Test void unsupportedBusinessTypeFailsClosed() { assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L, new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()))) @@ -55,4 +88,25 @@ class InternalBusinessFileServiceTest { assertThat(result.content()).isEqualTo("released".getBytes()); verify(storage).read(value.getObjectKey()); } + + @Test void platformMetadataAndDownloadRequireExactCompositeOwnership() { + FileObjectEntity value = new FileObjectEntity(); value.setId(91L); value.setTenantId(0L); + value.setBusinessType("LOADING_CUSTOMS_DOCUMENT"); value.setBusinessId("51"); + value.setOriginalName("declaration.pdf"); value.setObjectKey("business-files/0/51/declaration.pdf"); + value.setContentType("application/pdf"); value.setSha256("c".repeat(64)); value.setSizeBytes(8L); + when(files.selectOne(any())).thenReturn(value, value, null, null, null); + when(storage.read(value.getObjectKey())).thenReturn("document".getBytes()); + + assertThat(service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).fileName()) + .isEqualTo("declaration.pdf"); + assertThat(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).content()) + .isEqualTo("document".getBytes()); + assertThatThrownBy(() -> service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "51", 91L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "52", 91L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 92L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + verify(storage).read(value.getObjectKey()); + } } diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java b/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java index 5595c86b0..f577513c7 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java @@ -6,7 +6,11 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRe import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetailEnvelope; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementListEnvelope; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary; import com.baiyue.finance.service.FinanceSettlementService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; import org.slf4j.MDC; import org.springframework.security.access.AccessDeniedException; @@ -19,9 +23,11 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.Instant; +import java.util.List; import java.util.UUID; @RestController @@ -33,6 +39,24 @@ public class FinanceSettlementController { this.service = service; } + @GetMapping("/receivable-settlements") + @PreAuthorize("hasAuthority('platform:finance:settlement-ar:view')") + @Operation(summary = "查询正式应收结算单") + public SettlementListEnvelope receivableSettlements( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId) { + return success(service.listSettlements("RECEIVABLE", customerId)); + } + + @GetMapping("/payable-settlements") + @PreAuthorize("hasAuthority('platform:finance:settlement-ap:view')") + @Operation(summary = "查询正式应付结算单") + public SettlementListEnvelope payableSettlements( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId) { + return success(service.listSettlements("PAYABLE", customerId)); + } + @PostMapping("/receivable-settlements") @PreAuthorize("hasAuthority('platform:finance:settlement-ar:create')") public SettlementDetailEnvelope createReceivableSettlement( @@ -82,6 +106,14 @@ public class FinanceSettlementController { return new SettlementDetailEnvelope("200", "success", detail, requestId, traceId, Instant.now()); } + private static SettlementListEnvelope success(List summaries) { + String requestId = first(MDC.get("requestId"), MDC.get("request_id")); + if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString(); + String traceId = first(MDC.get("traceId"), MDC.get("trace_id")); + if (!StringUtils.hasText(traceId)) traceId = requestId; + return new SettlementListEnvelope("200", "success", summaries, requestId, traceId, Instant.now()); + } + private static String first(String first, String second) { return StringUtils.hasText(first) ? first : second; } diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index 2f8ba2211..71c79b0ec 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -70,24 +70,6 @@ public class PlatformFinanceController { return FinanceResponses.success(queryService.statements(customerId, false)); } - @GetMapping("/receivable-settlements") - @PreAuthorize("hasAuthority('platform:finance:bill:view')") - @Operation(summary = "结算单应收") - public ApiResponse receivableSettlements( - @Parameter(description = "可选客户筛选;省略时查询全部客户") - @RequestParam(required = false) Long customerId) { - return FinanceResponses.success(queryService.receivableSettlements(customerId, false)); - } - - @GetMapping("/payable-settlements") - @PreAuthorize("hasAuthority('platform:finance:cost:view')") - @Operation(summary = "结算单应付") - public ApiResponse payableSettlements( - @Parameter(description = "可选客户筛选;省略时查询全部客户") - @RequestParam(required = false) Long customerId) { - return FinanceResponses.success(queryService.payableSettlements(customerId)); - } - @PostMapping(value = "/vouchers", consumes = "multipart/form-data") @PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create')") public ApiResponse uploadVoucher(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java index 0aca23747..778994df8 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java @@ -42,6 +42,8 @@ public final class FinanceSettlementDtos { public record SettlementDetail(@JsonUnwrapped SettlementSummary summary, List lines, List transactions, List revisions) {} + public record SettlementListEnvelope(String code, String message, List data, + String requestId, String traceId, Instant timestamp) {} public record SettlementDetailEnvelope(String code, String message, SettlementDetail data, String requestId, String traceId, Instant timestamp) {} } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 4e84982b1..82029af88 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -193,6 +193,27 @@ public class FinanceSettlementService { return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions)); } + public List listSettlements(String direction, Long customerId) { + if (!Set.of("RECEIVABLE", "PAYABLE").contains(direction)) { + throw error("FIN_400", "结算单方向无效"); + } + if (customerId != null && customerId <= 0) { + throw error("FIN_400", "客户范围无效"); + } + String sql = "SELECT id,tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id," + + "supplier_name_snapshot,reconciliation_statement_id,business_period,currency_code,total_amount," + + "paid_amount,outstanding_amount,generated_date,due_date,payment_status,status,version " + + "FROM fin_settlement WHERE direction=?"; + List args = new ArrayList<>(); + args.add(direction); + if (customerId != null) { + sql += " AND tenant_id=?"; + args.add(customerId); + } + sql += " ORDER BY generated_date DESC,id DESC"; + return jdbc.queryForList(sql, args.toArray()).stream().map(this::summary).toList(); + } + @Transactional public SettlementDetail createSettlementPayment(long settlementId, long operatorId, String key, CreateSettlementPaymentRequest request) { diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java index eb5399a3b..40e04e3e2 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.finance; import com.baiyue.finance.controller.PlatformFinanceController; +import com.baiyue.finance.controller.FinanceSettlementController; import io.swagger.v3.oas.annotations.Parameter; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -29,8 +30,8 @@ class FinancePlatformListContractTest { Map expected = Map.of( "feePool", "hasAuthority('platform:finance:bill:view')", "statements", "hasAuthority('platform:finance:bill:view')", - "receivableSettlements", "hasAuthority('platform:finance:bill:view')", - "payableSettlements", "hasAuthority('platform:finance:cost:view')"); + "receivableSettlements", "hasAuthority('platform:finance:settlement-ar:view')", + "payableSettlements", "hasAuthority('platform:finance:settlement-ap:view')"); for (var entry : expected.entrySet()) { assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).isEqualTo(entry.getValue()); } @@ -46,7 +47,8 @@ class FinancePlatformListContractTest { private static Method find(String name) throws Exception { return switch (name) { case "feePool" -> PlatformFinanceController.class.getMethod(name, Long.class, String.class); - default -> PlatformFinanceController.class.getMethod(name, Long.class); + case "statements" -> PlatformFinanceController.class.getMethod(name, Long.class); + default -> FinanceSettlementController.class.getMethod(name, Long.class); }; } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java index 139989c5f..593e7dc57 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java @@ -4,11 +4,13 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.finance.client.FinanceFileClient; import com.baiyue.finance.client.FirstlegCostSnapshotClient; import com.baiyue.finance.controller.ClientFinanceController; +import com.baiyue.finance.controller.FinanceSettlementController; import com.baiyue.finance.controller.PlatformFinanceController; import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt; import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation; import com.baiyue.finance.service.FinanceQueryService; import com.baiyue.finance.service.FinanceService; +import com.baiyue.finance.service.FinanceSettlementService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -31,6 +33,7 @@ class FinanceReadApiBehaviorTest { @Mock FinanceQueryService queryService; @Mock FinanceFileClient fileClient; @Mock FirstlegCostSnapshotClient snapshotClient; + @Mock FinanceSettlementService settlementService; @Test void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception { @@ -63,34 +66,36 @@ class FinanceReadApiBehaviorTest { } @Test - void fivePlatformPageMethodsUseTypedQueryFacade() { + void fivePlatformPageMethodsUseTheirTypedQueryFacades() { var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService); + var settlementController = new FinanceSettlementController(settlementService); controller.customerAccount(11L); controller.statements(11L); - controller.receivableSettlements(11L); - controller.payableSettlements(11L); + settlementController.receivableSettlements(11L); + settlementController.payableSettlements(11L); verify(queryService).customerAccount(11L); verify(queryService).statements(11L, false); - verify(queryService).receivableSettlements(11L, false); - verify(queryService).payableSettlements(11L); + verify(settlementService).listSettlements("RECEIVABLE", 11L); + verify(settlementService).listSettlements("PAYABLE", 11L); } @Test void platformListsDefaultToAllCustomersAndKeepOptionalFilter() { var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService); + var settlementController = new FinanceSettlementController(settlementService); controller.feePool(null, null); controller.statements(null); - controller.receivableSettlements(null); - controller.payableSettlements(null); + settlementController.receivableSettlements(null); + settlementController.payableSettlements(null); controller.statements(31L); verify(queryService).feePool(null, null); verify(queryService).statements(null, false); - verify(queryService).receivableSettlements(null, false); - verify(queryService).payableSettlements(null); + verify(settlementService).listSettlements("RECEIVABLE", null); + verify(settlementService).listSettlements("PAYABLE", null); verify(queryService).statements(31L, false); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index dd0e29726..6687fa3d7 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -64,6 +64,44 @@ class FinanceSettlementBehaviorTest { user(Set.of("platform:finance:settlement-ar:view")), 88L)).isInstanceOf(AccessDeniedException.class); } + @Test + void controllerListsFormalSettlementsByDirection() { + FinanceSettlementController controller = new FinanceSettlementController(controllerService); + SettlementSummary receivable = detail("RECEIVABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 0).summary(); + when(controllerService.listSettlements("RECEIVABLE", 10L)).thenReturn(List.of(receivable)); + + var response = controller.receivableSettlements(10L); + + assertThat(response.code()).isEqualTo("200"); + assertThat(response.data()).containsExactly(receivable); + verify(controllerService).listSettlements("RECEIVABLE", 10L); + } + + @Test + void formalSettlementListUsesFinSettlementAndOptionalCustomerFilter() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + Map row = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 0); + when(jdbc.queryForList(anyString(), eq("PAYABLE"))).thenReturn(List.of(row)); + when(jdbc.queryForList(anyString(), eq("PAYABLE"), eq(10L))).thenReturn(List.of(row)); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + var all = service.listSettlements("PAYABLE", null); + var scoped = service.listSettlements("PAYABLE", 10L); + + assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L); + assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L); + ArgumentCaptor allSql = ArgumentCaptor.forClass(String.class); + ArgumentCaptor scopedSql = ArgumentCaptor.forClass(String.class); + verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE")); + verify(jdbc).queryForList(scopedSql.capture(), eq("PAYABLE"), eq(10L)); + assertThat(allSql.getValue()).contains("FROM fin_settlement WHERE direction=?") + .doesNotContain("FROM fin_bill", "FROM fin_cost", "tenant_id=?"); + assertThat(scopedSql.getValue()).contains("FROM fin_settlement WHERE direction=?", "tenant_id=?") + .doesNotContain("FROM fin_bill", "FROM fin_cost"); + } + @Test void arCreationRejectsNonCnyBeforeDatabaseAccess() { JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java index ca2dc02f8..f8fa2dffd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java @@ -4,12 +4,21 @@ import com.baiyue.common.core.api.ApiResponse; import org.springframework.cloud.openfeign.FeignClient; 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.RequestHeader; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; /** Validates that a referenced File object belongs to the exact loading business resource. */ @FeignClient(name = "file-service", contextId = "firstlegLoadingDocumentFileClient", url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files") public interface LoadingDocumentFileClient { + @PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = "multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, @PathVariable String businessType, + @PathVariable String businessId, @RequestPart("file") MultipartFile file); + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long tenantId, @PathVariable String businessType, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index f2bd8f8e6..539743f42 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -19,11 +19,15 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "货柜基础资料") @Validated @RestController @RequestMapping("/api/platform/v1/containers") @@ -87,6 +91,20 @@ public class PlatformContainerController { id, user.userId(), user.username(), key, request)); } + @PostMapping(value = "/{id}/clearance/document/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @Operation(summary = "直接上传或替换货柜目的港清关资料") + public ApiResponse uploadClearance( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @RequestHeader("Idempotency-Key") String key, + @RequestPart("file") MultipartFile file, + @RequestPart("expectedVersion") @Min(0) int expectedVersion, + @RequestPart(value = "operationReason", required = false) @Size(max = 500) String operationReason) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(loadingLists.uploadContainerClearance( + id, user.userId(), user.username(), key, expectedVersion, operationReason, file)); + } + @PostMapping("/{id}/clearance/complete") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @Operation(summary = "确认货柜目的港清关完成") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index ecf81f30e..7031f0115 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -19,7 +19,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -32,6 +35,8 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ @Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") @@ -116,6 +121,21 @@ public class PlatformLoadingListController { planId, warehouseId, user.userId(), user.username(), key, request)); } + @Operation(summary = "直接上传或替换装柜清单出口报关文档") + @PostMapping(value = "/loading-lists/{planId}/customs/document/upload", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + public ApiResponse uploadDeclaration( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key, + @RequestPart("file") MultipartFile file, + @RequestPart("expectedVersion") @Min(0) int expectedVersion, + @RequestPart(value = "operationReason", required = false) @Size(max = 500) String operationReason) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.uploadLoadingDeclaration(planId, warehouseId, + user.userId(), user.username(), key, expectedVersion, operationReason, file)); + } + @Operation(summary = "确认装柜清单出口报关完成") @PostMapping("/loading-lists/{planId}/customs/complete") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 80ced992c..38897b09a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; -import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.entity.ContainerDocumentEntity; +import com.baiyue.firstleg.repository.LoadingListRepository; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; import jakarta.annotation.Resource; @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component; @Component public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor { - @Resource private CustomsRepository repository; + @Resource private LoadingListRepository repository; @Override public String code() { return "CUSTOMS_CLEARANCE"; } @@ -18,13 +18,12 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), context.inboundId(), - context.containerBookingId()); + ContainerDocumentEntity value = repository.document(context.containerBookingId(), "CUSTOMS_DECLARATION"); if (value == null) return gate(false, "MISSING"); - return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus()); + return gate(value.getCompletedAt() != null, value.getCompletedAt() == null ? "UPLOADED" : "COMPLETED"); } private static GateItem gate(boolean passed, String actual) { - return new GateItem("CUSTOMS_CLEARANCE", "报关放行", passed, actual, "CLEARANCE_COMPLETED"); + return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java index f5414da4b..945bd89ff 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java @@ -18,12 +18,9 @@ public final class InboundProcessStatus { public static final String FEE_CONFIRMING = "FEE_CONFIRMING"; /** 费用已确认,正在执行托盘、库位占用和入库收口。 */ public static final String INBOUNDING = "INBOUNDING"; - /** 已入库完成,费用已发送给客户。 */ + /** 历史已入库终态;存量数据仍允许进入分柜。 */ public static final String INBOUNDED = "INBOUNDED"; - /** - * 历史兼容状态。二、三期已统一使用 {@link #INBOUNDED},不得再写入或对外返回此值。 - */ - @Deprecated(forRemoval = false) + /** 入库和费用发布完成,等待分柜。 */ public static final String WAIT_SPLIT = "WAIT_SPLIT"; /** 已进入装柜作业,PDA 可执行拣货。 */ public static final String LOADING = "LOADING"; @@ -36,7 +33,7 @@ public final class InboundProcessStatus { public static final Set VALUES = Set.of( WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED, - WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED, + WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, VOIDED ); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java index 5b77f89be..003165aa6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java @@ -14,7 +14,7 @@ public record UnifiedInboundQuery( Long tenantId, @Size(max = 64) String keyword, @Size(max = 64) String orderNo, - @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", + @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"}) @Size(max = 32) String mainStatus, Long customerId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java index afd4525c9..9b71552f0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java @@ -16,27 +16,16 @@ public interface LoadingCompletionGateMapper { COALESCE(SUM(CASE WHEN EXISTS ( SELECT 1 FROM fl_truck_appointment t WHERE t.container_booking_id=p.container_booking_id AND t.loading_plan_id=p.id - AND t.status='COMPLETED') THEN 1 ELSE 0 END),0) truckReady, - COALESCE(SUM(CASE WHEN EXISTS ( - SELECT 1 FROM fl_customs_case c - JOIN fl_customs_receipt r ON r.customs_case_id=c.id - WHERE c.tenant_id=l.tenant_id AND c.inbound_id=l.inbound_id - AND c.container_booking_id=p.container_booking_id - AND c.status='CLEARANCE_COMPLETED' AND r.receipt_type='RELEASED' AND r.file_id IS NOT NULL - AND NOT EXISTS (SELECT 1 FROM fl_customs_receipt newer - WHERE newer.submission_batch_id=r.submission_batch_id - AND newer.receipt_type=r.receipt_type AND newer.receipt_version>r.receipt_version)) - THEN 1 ELSE 0 END),0) customsReady + AND t.status='COMPLETED') THEN 1 ELSE 0 END),0) truckReady FROM fl_preload_loading_plan_link l JOIN fl_loading_plan p ON p.id=l.loading_plan_id WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId} """) GateSnapshot snapshot(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); - record GateSnapshot(Integer linkCount, Integer oceanReady, Integer truckReady, Integer customsReady) { + record GateSnapshot(Integer linkCount, Integer oceanReady, Integer truckReady) { public boolean allBookingReady() { return count(linkCount) > 0 && count(linkCount) == count(oceanReady); } public boolean allTruckReady() { return count(linkCount) > 0 && count(linkCount) == count(truckReady); } - public boolean allCustomsReady() { return count(linkCount) > 0 && count(linkCount) == count(customsReady); } private static int count(Integer value) { return value == null ? 0 : value; } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java index ead66da9f..21410817b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java @@ -11,14 +11,13 @@ import org.springframework.stereotype.Service; public class LoadingCompletionGateService { @Resource private LoadingCompletionGateMapper mapper; - public void requireBookingTransportAndCustoms(long tenantId, long inboundId) { + public void requireBookingAndTransport(long tenantId, long inboundId) { LoadingCompletionGateMapper.GateSnapshot gate = mapper.snapshot(tenantId, inboundId); if (gate == null || gate.linkCount() == null || gate.linkCount() == 0) { throw blocked("未找到有效柜次关联"); } if (!gate.allBookingReady()) throw blocked("订舱尚未完成(必须为 RELEASED)"); if (!gate.allTruckReady()) throw blocked("拖车尚未完成(必须为 COMPLETED)"); - if (!gate.allCustomsReady()) throw blocked("缺少有效报关 RELEASED 放行回执"); } private static BizException blocked(String message) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 81c146df0..654431214 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -111,7 +111,7 @@ public class LoadingDeliveryService { } if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中"); loadingModelBridge.requireCompleted(tenantId, inboundId); - completionGate.requireBookingTransportAndCustoms(tenantId, inboundId); + completionGate.requireBookingAndTransport(tenantId, inboundId); requireCompletedPicking(tenantId, inboundId); requireCompletionEvidence(tenantId, inboundId); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); @@ -132,7 +132,7 @@ public class LoadingDeliveryService { throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中"); } loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId()); - completionGate.requireBookingTransportAndCustoms(tenantId, target.sourceInboundId()); + completionGate.requireBookingAndTransport(tenantId, target.sourceInboundId()); requireCompletedPicking(tenantId, target.sourceInboundId()); requireCompletionEvidence(tenantId, target.sourceInboundId()); if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 9f72ff393..3b13525db 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -92,6 +92,10 @@ public interface ContainerBookingMapper extends BaseMapper { @Update(""" UPDATE fl_inbound_order - SET process_status = 'INBOUNDED', measure_review_status = 'APPROVED', + SET process_status = 'WAIT_PRICING', measure_review_status = 'APPROVED', actual_gross_weight_kg = (SELECT COALESCE(SUM(item.total_weight_kg), 0) FROM fl_inbound_measurement_item item WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), @@ -21,7 +21,6 @@ public interface InboundOrderMapper extends BaseMapper { volume_weight_kg = (SELECT COALESCE(SUM(item.volume_weight_kg), 0) FROM fl_inbound_measurement_item item WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), - inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)), updated_by = #{operatorId}, version = version + 1 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0 AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING' @@ -69,7 +68,7 @@ public interface InboundOrderMapper extends BaseMapper { @Update(""" UPDATE fl_inbound_order - SET process_status = 'INBOUNDED', fee_status = 'SENT', notification_status = 'PENDING', + SET process_status = 'WAIT_SPLIT', fee_status = 'SENT', notification_status = 'PENDING', inbounded_at = CURRENT_TIMESTAMP(3), updated_by = #{operatorId}, version = version + 1 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0 AND process_status = 'INBOUNDING' AND fee_status = 'CONFIRMED' diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 1006e5c2a..7e2e7b14c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -213,6 +213,8 @@ public interface LoadingListQueryMapper { b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName}, b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1 WHERE p.id = #{planId} AND p.plan_status = 'LOADING' + AND (#{containerNo} IS NOT NULL OR NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_task t WHERE t.loading_plan_id = p.id)) """) int updateTransport(@Param("planId") long planId, @Param("containerNo") String containerNo, @Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 08ff987b7..7b8485b7e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -23,7 +23,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id - WHERE o.warehouse_id=#{warehouseId} AND o.process_status='INBOUNDED' + WHERE o.warehouse_id=#{warehouseId} AND o.process_status IN ('WAIT_SPLIT','INBOUNDED') AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND i.inbound_detail_id IS NOT NULL AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li @@ -61,7 +61,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id - WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED' + WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status IN ('WAIT_SPLIT','INBOUNDED') AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND i.inbound_detail_id=#{detailId} ORDER BY m.id DESC LIMIT 1 FOR UPDATE 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 ba8af670b..be2e42b92 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -209,7 +209,7 @@ public class FbaPricingRepository { .eq(InboundPricingEntity::getProcessStatus, processStatus) .in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED")) .set(InboundPricingEntity::getFeeStatus, "CALCULATED"); - if (!"INBOUNDED".equals(processStatus)) { + if (!List.of("WAIT_SPLIT", "INBOUNDED").contains(processStatus)) { update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING"); } if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable); @@ -229,7 +229,7 @@ public class FbaPricingRepository { 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", "INBOUNDED")) + .in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT", "INBOUNDED")) .eq(InboundPricingEntity::getFeeStatus, "CALCULATED") .set(InboundPricingEntity::getFeeStatus, "CONFIRMED")); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java index fdb23a085..215980b1e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java @@ -58,7 +58,8 @@ public class AutomaticInboundCompletionService { } private static boolean completed(InboundOrderEntity order) { - return "INBOUNDED".equals(order.getProcessStatus()) && "SENT".equals(order.getFeeStatus()); + return ("WAIT_SPLIT".equals(order.getProcessStatus()) || "INBOUNDED".equals(order.getProcessStatus())) + && "SENT".equals(order.getFeeStatus()); } private InboundOrderEntity requireOrder(long tenantId, long inboundId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 7542111c9..d0b61ee26 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity; import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Autowired; @@ -31,6 +32,7 @@ public class ContainerService { @Resource private ContainerRepository repository; @Resource private WarehouseMasterDataClient warehouses; @Resource private SmartLoadingRepository smartLoadingRepository; + @Resource private LoadingPickRepository loadingPickRepository; @Resource private LoadingPickService loadingPickService; @Autowired(required = false) private ContainerTransportHistoryMapper transportHistories; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @@ -86,6 +88,10 @@ public class ContainerService { requireVersion(current, request.version()); if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改"); String containerNo = trim(request.containerNo()); + LoadingPlanEntity plan = smartLoadingRepository.planByBooking(id); + if (containerNo == null && plan != null && loadingPickRepository.taskByPlan(plan.getId()) != null) { + throw conflict("PDA拣货任务已创建,不能清空真实集装箱号"); + } if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在"); try { if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(), 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 d8a9e75cd..db192edbb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -82,8 +82,8 @@ public class FbaPricingService { throw conflict("入库单状态已变化,费用试算未保存"); } complete(token, fee.getId(), now); - String nextStatus = "INBOUNDED".equals(inbound.getProcessStatus()) - ? "INBOUNDED" : "FEE_CONFIRMING"; + String nextStatus = isCompletedInbound(inbound.getProcessStatus()) + ? inbound.getProcessStatus() : "FEE_CONFIRMING"; return response(inbound, fee, rate.getUnitPrice(), nextStatus); } @@ -114,8 +114,8 @@ public class FbaPricingService { String hash = hash("SEND", inboundId, current.getId().toString()); InboundPricingIdempotencyEntity token = beginIdempotency( tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash); - String nextProcessStatus = "INBOUNDED".equals(inbound.getProcessStatus()) - ? "INBOUNDED" : "WAIT_PALLET"; + String nextProcessStatus = isCompletedInbound(inbound.getProcessStatus()) + ? inbound.getProcessStatus() : "WAIT_PALLET"; if ("COMPLETED".equals(token.getStatus())) { feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(), operatorId, null, LocalDateTime.now(clock)); @@ -144,7 +144,7 @@ public class FbaPricingService { AdjustFeeRequest request, String idempotencyKey) { InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId); if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); - if (!"INBOUNDED".equals(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) { + if (!isCompletedInbound(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) { throw conflict("只能修改已完成入库且已发布的费用"); } InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId); @@ -156,7 +156,7 @@ public class FbaPricingService { InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, "FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator)); if ("COMPLETED".equals(token.getStatus())) { - return response(tenantId, token.getResultResourceId(), "INBOUNDED"); + return response(tenantId, token.getResultResourceId(), inbound.getProcessStatus()); } LocalDateTime now = LocalDateTime.now(clock); @@ -204,12 +204,12 @@ public class FbaPricingService { financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED, tenantId, inboundId, adjusted.getId(), nextVersion, now); complete(token, adjusted.getId(), now); - return response(tenantId, adjusted.getId(), "INBOUNDED"); + return response(tenantId, adjusted.getId(), inbound.getProcessStatus()); } private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) { InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); - if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDED").contains(inbound.getProcessStatus()) + if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT", "INBOUNDED").contains(inbound.getProcessStatus()) || !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) { throw conflict("当前入库单状态不允许费用试算"); } @@ -234,6 +234,10 @@ public class FbaPricingService { return inbound; } + private static boolean isCompletedInbound(String processStatus) { + return "WAIT_SPLIT".equals(processStatus) || "INBOUNDED".equals(processStatus); + } + private static PricingDestinationType destinationType(InboundPricingEntity inbound) { try { return PricingDestinationType.valueOf(inbound.getDestinationType()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java index 17fc906e4..c946f2e9e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java @@ -93,7 +93,7 @@ public class InboundCompletionOrchestrator { var published = feeDetails.publishFreight(tenantId, inboundId, feeId, operatorId, operatorName, LocalDateTime.now()); repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId, - "INBOUNDING", "INBOUNDED", "SEND_FEE_COMPLETE", null, operatorId, operatorName)); + "INBOUNDING", "WAIT_SPLIT", "SEND_FEE_COMPLETE", null, operatorId, operatorName)); financeOutbox.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED, tenantId, inboundId, feeId, published.getSourceFeeVersion(), LocalDateTime.now()); idempotency.complete(context, inboundId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 3d63331b9..8137d71fa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -30,6 +30,7 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -103,7 +104,18 @@ public class LoadingListService { String key, DocumentAttachRequest request) { AggregateRow plan = requireBookingPlan(bookingId); return attachDocument(plan, DESTINATION_CLEARANCE, CLEARANCE_FILE_TYPE, - Long.toString(bookingId), operatorId, operatorName, key, request); + bookingId, Long.toString(bookingId), operatorId, operatorName, key, request); + } + + @Transactional + @BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_UPLOAD", resourceType = "CONTAINER", + resourceId = "#bookingId", description = "直接上传目的港清关资料") + public BusinessDocument uploadContainerClearance(long bookingId, long operatorId, String operatorName, + String key, int expectedVersion, String operationReason, + MultipartFile file) { + AggregateRow plan = requireBookingPlan(bookingId); + return uploadDocument(plan, DESTINATION_CLEARANCE, CLEARANCE_FILE_TYPE, bookingId, + Long.toString(bookingId), operatorId, operatorName, key, expectedVersion, operationReason, file); } @Transactional @@ -111,7 +123,7 @@ public class LoadingListService { resourceId = "#bookingId", description = "确认目的港清关完成") public BusinessDocument completeContainerClearance(long bookingId, long operatorId, String operatorName, String key, DocumentCompleteRequest request) { - return completeDocument(requireBookingPlan(bookingId), DESTINATION_CLEARANCE, + return completeDocument(requireBookingPlan(bookingId), DESTINATION_CLEARANCE, bookingId, operatorId, operatorName, key, request); } @@ -132,7 +144,20 @@ public class LoadingListService { requireWarehouse(plan, warehouseId); requireCompletedPlan(plan); return attachDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE, - Long.toString(planId), operatorId, operatorName, key, request); + planId, Long.toString(planId), operatorId, operatorName, key, request); + } + + @Transactional + @BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_UPLOAD", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "直接上传出口报关文档") + public BusinessDocument uploadLoadingDeclaration(long planId, long warehouseId, long operatorId, + String operatorName, String key, int expectedVersion, + String operationReason, MultipartFile file) { + AggregateRow plan = requirePlan(planId); + requireWarehouse(plan, warehouseId); + requireCompletedPlan(plan); + return uploadDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE, planId, + Long.toString(planId), operatorId, operatorName, key, expectedVersion, operationReason, file); } @Transactional @@ -144,28 +169,55 @@ public class LoadingListService { AggregateRow plan = requirePlan(planId); requireWarehouse(plan, warehouseId); requireCompletedPlan(plan); - return completeDocument(plan, CUSTOMS_DECLARATION, operatorId, operatorName, key, request); + return completeDocument(plan, CUSTOMS_DECLARATION, planId, operatorId, operatorName, key, request); } private BusinessDocument attachDocument(AggregateRow plan, String documentType, String businessType, - String businessId, long operatorId, String operatorName, + long identityId, String businessId, long operatorId, String operatorName, String key, DocumentAttachRequest request) { - String requestHash = sha256("ATTACH|" + documentType + '|' + plan.getPlanId() + '|' + String requestHash = sha256("ATTACH|" + documentType + '|' + identityId + '|' + request.fileId() + '|' + request.expectedVersion() + '|' + trim(request.operationReason())); - String operationId = operationId("ATTACH_" + documentType, plan.getPlanId(), key); + String operationId = operationId("ATTACH_" + documentType, identityId, key); HistoryRow replay = repository.history(operationId); if (replay != null) { requireSameRequest(replay, requestHash); return requireDocument(plan.getContainerBookingId(), documentType); } ContainerDocumentEntity current = repository.document(plan.getContainerBookingId(), documentType); - if (current == null && request.expectedVersion() != 0) throw conflict("资料版本已变化,请刷新后重试"); - if (current != null && !java.util.Objects.equals(current.getVersion(), request.expectedVersion())) { - throw conflict("资料版本已变化,请刷新后重试"); - } - if (current != null && current.getCompletedAt() != null) throw conflict("已完成的资料不可替换"); + requireReplaceableDocument(current, request.expectedVersion()); LoadingDocumentFileClient.Metadata metadata = validateDocumentFile( businessType, businessId, request.fileId()); + return persistDocument(plan, documentType, current, metadata, operatorId, operatorName, + operationId, "ATTACH_" + documentType, requestHash, request.operationReason()); + } + + private BusinessDocument uploadDocument(AggregateRow plan, String documentType, String businessType, + long identityId, String businessId, long operatorId, + String operatorName, String key, int expectedVersion, + String operationReason, MultipartFile file) { + requireUploadFile(file); + String fileHash = multipartHash(file); + String requestHash = sha256("UPLOAD|" + documentType + '|' + identityId + '|' + + expectedVersion + '|' + trim(operationReason) + '|' + fileHash); + String operationId = operationId("UPLOAD_" + documentType, identityId, key); + HistoryRow replay = repository.history(operationId); + if (replay != null) { + requireSameRequest(replay, requestHash); + return requireDocument(plan.getContainerBookingId(), documentType); + } + ContainerDocumentEntity current = repository.document(plan.getContainerBookingId(), documentType); + requireReplaceableDocument(current, expectedVersion); + LoadingDocumentFileClient.Metadata metadata = uploadDocumentFile( + businessType, businessId, operatorId, file, fileHash); + return persistDocument(plan, documentType, current, metadata, operatorId, operatorName, + operationId, "UPLOAD_" + documentType, requestHash, operationReason); + } + + private BusinessDocument persistDocument(AggregateRow plan, String documentType, + ContainerDocumentEntity current, + LoadingDocumentFileClient.Metadata metadata, + long operatorId, String operatorName, String operationId, + String actionType, String requestHash, String operationReason) { Map beforeSnapshot = documentSnapshot(current); ContainerDocumentEntity value = current == null ? new ContainerDocumentEntity() : current; if (current == null) value.setId(IdWorker.getId()); @@ -179,19 +231,19 @@ public class LoadingListService { throw conflict("资料已被其他操作上传,请刷新后重试"); } ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType); - repository.appendHistory(plan.getPlanId(), operationId, "ATTACH_" + documentType, + repository.appendHistory(plan.getPlanId(), operationId, actionType, plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), json(values("requestHash", requestHash, "document", documentSnapshot(saved))), - operatorId, trim(operatorName), trim(request.operationReason())); + operatorId, trim(operatorName), trim(operationReason)); return document(saved); } - private BusinessDocument completeDocument(AggregateRow plan, String documentType, long operatorId, + private BusinessDocument completeDocument(AggregateRow plan, String documentType, long identityId, long operatorId, String operatorName, String key, DocumentCompleteRequest request) { - String requestHash = sha256("COMPLETE|" + documentType + '|' + plan.getPlanId() + '|' + String requestHash = sha256("COMPLETE|" + documentType + '|' + identityId + '|' + request.expectedVersion() + '|' + trim(request.operationReason())); - String operationId = operationId("COMPLETE_" + documentType, plan.getPlanId(), key); + String operationId = operationId("COMPLETE_" + documentType, identityId, key); HistoryRow replay = repository.history(operationId); if (replay != null) { requireSameRequest(replay, requestHash); @@ -224,7 +276,8 @@ public class LoadingListService { || !businessType.equals(response.data().businessType()) || !businessId.equals(response.data().businessId()) || !StringUtils.hasText(response.data().fileName()) - || !StringUtils.hasText(response.data().sha256())) { + || response.data().sha256() == null + || !response.data().sha256().matches("[0-9a-fA-F]{64}")) { throw badRequest("附件不存在、归属不匹配或文件元数据不完整"); } return response.data(); @@ -233,6 +286,48 @@ public class LoadingListService { } } + private LoadingDocumentFileClient.Metadata uploadDocumentFile(String businessType, String businessId, + long operatorId, MultipartFile file, + String expectedHash) { + try { + var uploaded = documentFiles.upload(internalToken, operatorId, 0, businessType, businessId, file); + if (uploaded == null || !uploaded.successful() || uploaded.data() == null + || uploaded.data().fileId() == null || uploaded.data().fileId() <= 0) { + throw badRequest("附件上传失败"); + } + LoadingDocumentFileClient.Metadata metadata = validateDocumentFile( + businessType, businessId, uploaded.data().fileId()); + if (!expectedHash.equalsIgnoreCase(metadata.sha256())) { + throw badRequest("附件内容哈希校验失败"); + } + return metadata; + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } + + private static void requireReplaceableDocument(ContainerDocumentEntity current, int expectedVersion) { + if (current == null && expectedVersion != 0) throw conflict("资料版本已变化,请刷新后重试"); + if (current != null && !java.util.Objects.equals(current.getVersion(), expectedVersion)) { + throw conflict("资料版本已变化,请刷新后重试"); + } + if (current != null && current.getCompletedAt() != null) throw conflict("已完成的资料不可替换"); + } + + private static void requireUploadFile(MultipartFile file) { + if (file == null || file.isEmpty()) throw badRequest("必须选择上传文件"); + } + + private static String multipartHash(MultipartFile file) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(file.getBytes())); + } catch (java.io.IOException exception) { + throw badRequest("读取上传文件失败"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + private AggregateRow requireBookingPlan(long bookingId) { AggregateRow row = repository.aggregateByBooking(bookingId); if (row == null) throw notFound("货柜或关联装柜计划不存在"); @@ -286,6 +381,9 @@ public class LoadingListService { } requireLoadingMutable(before); + if (containerNo == null && pickRepository.taskByPlan(planId) != null) { + throw conflict("PDA拣货任务已创建,不能清空真实集装箱号"); + } if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, operatorId, trim(operatorName)) != 1) { throw conflict("预装计划已被其他操作修改,请刷新后重试"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index 623d98386..a5d30516f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -21,8 +21,7 @@ import java.time.LocalDateTime; import java.math.BigDecimal; /** - * 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 INBOUNDED; - * 计费、库位和其他资料不作为本版本的强制门禁。 + * 测量审核应用服务。审核通过后进入内部计费编排,完成费用发布和入库后再进入 WAIT_SPLIT。 */ @Service public class MeasurementReviewService { @@ -100,7 +99,7 @@ public class MeasurementReviewService { repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result, request.reason(), false, null, false, operatorId, operatorName)); repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING", - approved ? "INBOUNDED" : "MEASURE_REJECTED", + approved ? "WAIT_PRICING" : "MEASURE_REJECTED", approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName)); idempotency.complete(context, inboundId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index bbc021cab..b5f778838 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -28,9 +28,7 @@ import java.util.stream.Collectors; @Service public class UnifiedInboundQueryService { private static final Set MAIN_STATUSES = Set.of( - "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED"); - /** 仅兼容旧客户端筛选值,响应和 OpenAPI 均只使用 INBOUNDED。 */ - private static final String LEGACY_WAIT_SPLIT = "WAIT_SPLIT"; + "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); @Resource private InboundOrderRepository inbounds; @@ -140,9 +138,9 @@ public class UnifiedInboundQueryService { static String mainStatus(String status) { if (status == null) return null; return switch (status) { - case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE"; - case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", LEGACY_WAIT_SPLIT -> "INBOUNDED"; - case "LOADING", "LOADED", "DELIVERED" -> status; + case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", + "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING" -> "WAIT_MEASURE"; + case "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED" -> status; default -> null; }; } @@ -152,7 +150,7 @@ public class UnifiedInboundQueryService { return switch (mainStatus) { case "PENDING_REVIEW" -> "APPROVE_ORDER"; case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null; - case "INBOUNDED" -> !("INBOUNDED".equals(processStatus) || LEGACY_WAIT_SPLIT.equals(processStatus)) ? null + case "INBOUNDED", "WAIT_SPLIT" -> !("INBOUNDED".equals(processStatus) || "WAIT_SPLIT".equals(processStatus)) ? null : "CONFIRMED".equals(preloadStatus) ? null : preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD"; case "LOADING" -> "COMPLETE_LOADING"; @@ -169,8 +167,10 @@ public class UnifiedInboundQueryService { private List internalStatuses(String mainStatus) { if (!StringUtils.hasText(mainStatus)) return List.of(); return switch (mainStatus) { - case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED"); - case "INBOUNDED", LEGACY_WAIT_SPLIT -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", LEGACY_WAIT_SPLIT); + case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", + "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING"); + case "INBOUNDED" -> List.of("INBOUNDED"); + case "WAIT_SPLIT" -> List.of("WAIT_SPLIT"); case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus); default -> List.of("__NO_MATCH__"); }; @@ -189,7 +189,7 @@ public class UnifiedInboundQueryService { private void validate(UnifiedInboundQuery query) { if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus()) - && !LEGACY_WAIT_SPLIT.equals(query.mainStatus())) { + ) { throw new com.baiyue.common.core.exception.BizException( com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java index 159ef5853..93077291a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java @@ -13,7 +13,7 @@ public interface SplitInboundMapper { @Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, " + "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 " - + "AND process_status='INBOUNDED' AND version=#{expectedVersion}") + + "AND process_status IN ('WAIT_SPLIT','INBOUNDED') AND version=#{expectedVersion}") int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 89e12cbb3..4bfb7402a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -44,7 +44,7 @@ public class SplitPreloadService { SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, SMART_SPLIT, key); if (replay != null) return replay; InboundSnapshot inbound = requireInbound(tenantId, inboundId); - if (!"INBOUNDED".equals(inbound.processStatus())) { + if (!isWaitingSplit(inbound.processStatus())) { throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以生成预装清单"); } PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); @@ -69,7 +69,7 @@ public class SplitPreloadService { } PreloadPlanResponse result = response(repository.findPlan(tenantId, inboundId)); return complete(tenantId, inboundId, operatorId, SMART_SPLIT, key, - new SplitActionResponse("INBOUNDED", result)); + new SplitActionResponse("WAIT_SPLIT", result)); } @Transactional @@ -85,12 +85,12 @@ public class SplitPreloadService { // confirmation is submitted; retain the linked-container hard gate. loadingModelBridge.requireStarted(tenantId, inboundId); } else { - requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以提交预装清单确认"); + requireWaitingSplit(inbound, "仅待分柜订单可以提交预装清单确认"); } PreloadPlanEntity plan = requirePlan(tenantId, inboundId); if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) { return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, - new SplitActionResponse(loading ? "LOADING" : "INBOUNDED", response(plan))); + new SplitActionResponse(loading ? "LOADING" : "WAIT_SPLIT", response(plan))); } if (!PreloadStatus.PENDING.equals(plan.getStatus()) || repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING, @@ -98,7 +98,7 @@ public class SplitPreloadService { throw concurrent(); } return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, - new SplitActionResponse(loading ? "LOADING" : "INBOUNDED", + new SplitActionResponse(loading ? "LOADING" : "WAIT_SPLIT", response(repository.findPlan(tenantId, inboundId)))); } @@ -114,7 +114,7 @@ public class SplitPreloadService { return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); } - requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以确认预装清单"); + requireWaitingSplit(inbound, "仅待分柜订单可以确认预装清单"); PreloadPlanEntity plan = requirePlan(tenantId, inboundId); if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) { throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试"); @@ -127,7 +127,7 @@ public class SplitPreloadService { if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) { throw concurrent(); } - addHistory(tenantId, inboundId, "INBOUNDED", "LOADING", CONFIRM_PRELOAD, + addHistory(tenantId, inboundId, inbound.processStatus(), "LOADING", CONFIRM_PRELOAD, operatorId, operatorName); return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); @@ -172,8 +172,12 @@ public class SplitPreloadService { return plan; } - private static void requireStatus(InboundSnapshot inbound, String expected, String message) { - if (!expected.equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, message); + private static void requireWaitingSplit(InboundSnapshot inbound, String message) { + if (!isWaitingSplit(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, message); + } + + private static boolean isWaitingSplit(String status) { + return "WAIT_SPLIT".equals(status) || "INBOUNDED".equals(status); } private void addHistory(long tenantId, long inboundId, String from, String to, String action, diff --git a/firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql b/firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql index c7351b090..557d786eb 100644 --- a/firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql +++ b/firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql @@ -1,4 +1,4 @@ --- 二、三期对外统一使用 INBOUNDED。历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED, +-- 新完成入库记录使用 WAIT_SPLIT,存量 INBOUNDED 继续作为可分柜兼容状态。 -- 本脚本不改写业务主表或历史状态,避免影响既有审计记录。 USE baiyue_firstleg; @@ -13,7 +13,7 @@ BEGIN WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_status_history' AND column_name IN ('from_status', 'to_status')) <> 2 THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'INBOUNDED 兼容校验失败:入库状态字段不完整'; + SET MESSAGE_TEXT = 'WAIT_SPLIT/INBOUNDED 兼容校验失败:入库状态字段不完整'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql index 9254149d5..4bfe14ab4 100644 --- a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql +++ b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql @@ -28,6 +28,43 @@ PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')); +-- Keep the container master immutable after loading; only the two document rows remain operable. +DROP TRIGGER IF EXISTS trg_container_booking_before_update; +DELIMITER $$ +CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container_booking FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.id; + IF current_plan_status='COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜全部只读,不允许修改'; + END IF; + IF current_plan_status='LOADING' AND ( + NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) + OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) + OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) + OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) + OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) + OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) OR NOT (OLD.vessel_name <=> NEW.vessel_name) + OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) + OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) + OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) + OR NOT (OLD.destination_port_code <=> NEW.destination_port_code) OR NOT (OLD.destination_port_name <=> NEW.destination_port_name) + OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) + OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) + OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) + OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) + OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) + OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) + OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) + OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) + OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) + OR NOT (OLD.created_at <=> NEW.created_at)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜中仅允许修改集装箱号与航次'; + END IF; +END$$ +DELIMITER ; + DROP TRIGGER IF EXISTS trg_container_document_before_insert; DELIMITER $$ CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_container_document FOR EACH ROW diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 84263b880..0a24ead3c 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -562,8 +562,7 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + IF current_plan_status = 'COMPLETED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; END IF; IF current_plan_status = 'LOADING' AND ( @@ -927,7 +926,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -942,7 +941,8 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; - IF current_plan_status = 'COMPLETED' THEN + IF current_plan_status = 'COMPLETED' + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 39f78c4de..078c3c9d3 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -68,8 +68,8 @@ paths: post: summary: 按统一订单条件分页查询履约摘要(Order 内部) description: >- - mainStatus 接受六态产品状态;历史 WAIT_SPLIT 仅作为兼容筛选值,按 INBOUNDED 范围查询。 - 所有响应 mainStatus 均归并为六态产品状态,不返回 WAIT_SPLIT。 + mainStatus 接受七态产品状态;WAIT_SPLIT 为正式待分柜状态,INBOUNDED 仅兼容存量已入库记录。 + 计费与入库处理中状态在完成前仍归并为 WAIT_MEASURE,不提前开放分柜。 security: - internalServiceToken: [] requestBody: @@ -102,7 +102,7 @@ paths: /internal/firstleg/v1/inbounds/unified-orders/detail: post: summary: 按订单 ID 查询统一履约详情(Order 内部) - description: 时间线保留 Firstleg 内部 fromStatus/toStatus;summary.mainStatus 始终使用六态产品状态。 + description: 时间线保留 Firstleg 内部 fromStatus/toStatus;summary.mainStatus 使用七态产品状态。 security: - internalServiceToken: [] requestBody: @@ -258,6 +258,17 @@ paths: required: true content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}} responses: {'200': {description: 成功、文件归属不匹配或版本冲突}} + /api/platform/v1/containers/{id}/clearance/document/upload: + post: + summary: 直接选择文件上传或替换货柜目的港清关资料 + description: Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌;旧 JSON document 接口仅兼容存量调用。 + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + multipart/form-data: + schema: {$ref: '#/components/schemas/ContainerDocumentUploadRequest'} + responses: {'200': {description: 成功、文件上传失败或版本冲突}} /api/platform/v1/containers/{id}/clearance/complete: post: summary: 点击完成目的港清关 @@ -645,8 +656,8 @@ components: schemas: UnifiedMainStatus: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED] - description: 产品主状态;历史内部状态 WAIT_SPLIT 在响应中统一归并为 INBOUNDED + enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + description: 七态产品主状态;WAIT_SPLIT 为正式待分柜状态,INBOUNDED 仅兼容存量已入库记录 UnifiedInboundQuery: type: object properties: @@ -657,7 +668,7 @@ components: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT] nullable: true - description: WAIT_SPLIT 仅为历史兼容筛选值,等价于 INBOUNDED + description: WAIT_SPLIT 与 INBOUNDED 为独立筛选值 customerId: {type: integer, format: int64, nullable: true} receivingWarehouseId: {type: integer, format: int64, nullable: true} destinationWarehouseId: {type: integer, format: int64, nullable: true} @@ -756,7 +767,7 @@ components: orderId: {type: integer, format: int64, nullable: true} orderNo: {type: string, nullable: true} inboundCode: {type: string, nullable: true} - processStatus: {type: string, nullable: true, description: Firstleg 内部状态,历史数据可能为 WAIT_SPLIT} + processStatus: {type: string, nullable: true, description: Firstleg 内部状态;新入库完成记录进入 WAIT_SPLIT,历史记录可能为 INBOUNDED} orderStatusSnapshot: {type: string, nullable: true} measureReviewStatus: {type: string, nullable: true} feeStatus: {type: string, nullable: true} @@ -1054,7 +1065,7 @@ components: type: object required: [version, reason] properties: - containerNo: {type: string, maxLength: 64, nullable: true} + containerNo: {type: string, maxLength: 64, nullable: true, description: 真实集装箱号;PDA任务创建前可空,任务创建后允许替换但禁止清空} voyageNo: {type: string, maxLength: 64, nullable: true} version: {type: integer, minimum: 0} reason: {type: string, maxLength: 500} @@ -1071,6 +1082,14 @@ components: properties: expectedVersion: {type: integer, minimum: 0} operationReason: {type: string, maxLength: 500, nullable: true} + ContainerDocumentUploadRequest: + type: object + required: [file, expectedVersion] + additionalProperties: false + properties: + file: {type: string, format: binary, description: 用户直接选择的清关资料} + expectedVersion: {type: integer, minimum: 0, description: 当前文档乐观锁版本,首次上传为 0} + operationReason: {type: string, maxLength: 500, nullable: true} MerchantInboundBillingEnvelope: type: object properties: diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index f9404e5b4..fbca8ba79 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -85,6 +85,19 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}} responses: '200': {$ref: '#/components/responses/BusinessDocumentResponse'} + /api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload: + post: + summary: 直接选择文件上传或替换装柜清单出口报关文档 + description: Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌;旧 JSON document 接口仅兼容存量调用。 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + multipart/form-data: + schema: {$ref: '#/components/schemas/DocumentUploadRequest'} + responses: + '200': {$ref: '#/components/responses/BusinessDocumentResponse'} /api/platform/v1/first-leg/loading-lists/{planId}/customs/complete: post: summary: 点击完成出口报关 @@ -353,7 +366,7 @@ components: required: [expectedVersion] additionalProperties: false properties: - containerNo: {type: string, maxLength: 64, nullable: true, description: 集装箱号} + containerNo: {type: string, maxLength: 64, nullable: true, description: 真实集装箱号;PDA任务创建前可空,任务创建后允许替换但禁止清空} voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次} expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因} @@ -372,6 +385,14 @@ components: properties: expectedVersion: {type: integer, format: int32, minimum: 0} operationReason: {type: string, maxLength: 500, nullable: true} + DocumentUploadRequest: + type: object + required: [file, expectedVersion] + additionalProperties: false + properties: + file: {type: string, format: binary, description: 用户直接选择的报关文档} + expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前文档乐观锁版本,首次上传为 0} + operationReason: {type: string, maxLength: 500, nullable: true} BusinessDocument: type: object required: [id, documentType, fileId, fileName, fileHash, uploadedBy, uploadedAt, version] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index aa60ad120..c8f651116 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -21,6 +21,7 @@ class ContainerControllerContractTest { assertPermission("create", "platform:firstleg:container:create"); assertPermission("updateDraft", "platform:firstleg:container:edit"); assertPermission("updateTransport", "platform:firstleg:container:edit"); + assertPermission("uploadClearance", "platform:firstleg:customs:manage"); } @Test @@ -33,6 +34,9 @@ class ContainerControllerContractTest { assertThat(section) .contains("/api/platform/v1/containers/{id}/transport:") + .contains("/api/platform/v1/containers/{id}/clearance/document/upload:") + .contains("multipart/form-data") + .contains("ContainerDocumentUploadRequest") .contains("platform:firstleg:container:list") .contains("EDITING") .contains("WAIT_LOADING") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 6638e2d3a..a9b9e88b6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.Yaml; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; import java.lang.reflect.RecordComponent; @@ -37,6 +38,8 @@ class LoadingListControllerContractTest { authority("declaration", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class); authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, long.class, long.class, String.class, DocumentAttachRequest.class); + authority("uploadDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, + long.class, long.class, String.class, MultipartFile.class, int.class, String.class); authority("completeDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, long.class, long.class, String.class, DocumentCompleteRequest.class); } @@ -101,6 +104,7 @@ class LoadingListControllerContractTest { "/api/platform/v1/first-leg/loading-lists/{planId}", "/api/platform/v1/first-leg/loading-lists/{planId}/customs", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document", + "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete"); Map components = stringMap(document.get("components")); Map schemas = stringMap(components.get("schemas")); @@ -108,7 +112,9 @@ class LoadingListControllerContractTest { "LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary", "ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail", "ConfirmedDetail", "TransportUpdateRequest", "BusinessDocumentEnvelope", - "DocumentAttachRequest", "DocumentCompleteRequest", "BusinessDocument"); + "DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument"); + assertThat(properties(schemas, "DocumentUploadRequest")) + .containsOnlyKeys("file", "expectedVersion", "operationReason"); assertThat(paths) .doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete"); assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index aef03ce21..ad1021278 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; class UnifiedInboundOpenApiContractTest { @Test - void documentsInternalUnifiedOrderEndpointsAndLegacyWaitSplitMapping() throws Exception { + void documentsInternalUnifiedOrderEndpointsAndSevenStatuses() throws Exception { String yaml = new ClassPathResource("openapi/firstleg-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); Map document = stringMap(new Yaml().load(yaml)); @@ -25,13 +25,13 @@ class UnifiedInboundOpenApiContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map responseStatus = stringMap(schemas.get("UnifiedMainStatus")); assertThat(stringList(responseStatus.get("enum"))).containsExactly( - "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED"); + "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED"); Map query = stringMap(schemas.get("UnifiedInboundQuery")); Map queryStatus = stringMap(stringMap(query.get("properties")).get("mainStatus")); assertThat(stringList(queryStatus.get("enum"))).contains("WAIT_SPLIT"); - assertThat(queryStatus.get("description").toString()).contains("INBOUNDED"); + assertThat(queryStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED", "独立"); Map inbound = stringMap(schemas.get("UnifiedInboundOrderSummary")); assertThat(stringMap(inbound.get("properties"))).containsKeys( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index 25644bae5..594eaf569 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; -import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.entity.ContainerDocumentEntity; +import com.baiyue.firstleg.repository.LoadingListRepository; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class CustomsDispatchGateContributorTest { - @Mock CustomsRepository repository; + @Mock LoadingListRepository repository; @InjectMocks CustomsDispatchGateContributor contributor; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @@ -22,12 +22,16 @@ class CustomsDispatchGateContributorTest { .isEqualTo("INVALID_CUSTOMER_SCOPE"); } - @Test void passesOnlyReleasedTerminalState() { - CustomsCaseEntity value = new CustomsCaseEntity(); value.setStatus("RECEIPT_AVAILABLE"); - when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value); + @Test void passesOnlyCompletedSimpleDeclaration() { + ContainerDocumentEntity value = new ContainerDocumentEntity(); + when(repository.document(81L, "CUSTOMS_DECLARATION")).thenReturn(value); assertThat(contributor.assess(context()).passed()).isFalse(); - value.setStatus("CLEARANCE_COMPLETED"); - assertThat(contributor.assess(context()).passed()).isTrue(); + value.setCompletedAt(java.time.LocalDateTime.now()); + assertThat(contributor.assess(context())) + .satisfies(item -> { + assertThat(item.passed()).isTrue(); + assertThat(item.requirement()).isEqualTo("COMPLETED"); + }); } private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java index 571127974..56687799e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java @@ -7,7 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat; class LoadingCompletionGateSqlContractTest { @Test - void requiresEveryLinkedContainerToPassBookingTruckAndCustomsReceiptGates() throws Exception { + void requiresEveryLinkedContainerToPassBookingAndTruckGates() throws Exception { Select select = LoadingCompletionGateMapper.class .getDeclaredMethod("snapshot", long.class, long.class).getAnnotation(Select.class); String sql = String.join(" ", select.value()).replaceAll("\\s+", " "); @@ -19,13 +19,9 @@ class LoadingCompletionGateSqlContractTest { .contains("b.status='RELEASED'") .contains("fl_truck_appointment") .contains("t.status='COMPLETED'") - .contains("fl_customs_case") - .contains("fl_customs_receipt") - .contains("c.status='CLEARANCE_COMPLETED'") - .contains("r.receipt_type='RELEASED'") - .contains("r.file_id IS NOT NULL") - .contains("newer.receipt_version>r.receipt_version") .contains("l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}") + .doesNotContain("truckReady, FROM") + .doesNotContain("fl_customs_case", "fl_customs_receipt", "customsReady") .doesNotContain("<", ">"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index f8266f9aa..8667704e6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -79,7 +79,7 @@ class LoadingDeliveryServiceTest { LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null)); assertThat(result.mainStatus()).isEqualTo("LOADED"); verify(loadingModelBridge).requireCompleted(10, 20); - verify(completionGate).requireBookingTransportAndCustoms(10, 20); + verify(completionGate).requireBookingAndTransport(10, 20); verify(histories).insert(any(InboundStatusHistoryEntity.class)); } @@ -113,7 +113,7 @@ class LoadingDeliveryServiceTest { assertThat(result.mainStatus()).isEqualTo("LOADED"); verify(loadingModelBridge).requireCompleted(10, 20); - verify(completionGate).requireBookingTransportAndCustoms(10, 20); + verify(completionGate).requireBookingAndTransport(10, 20); verify(repository).completeLoadedChild(10, 20, 7, 3); verify(repository).aggregateRootLoaded(10, 20, 7); verify(histories).insert(argThat((InboundStatusHistoryEntity history) -> @@ -172,7 +172,7 @@ class LoadingDeliveryServiceTest { verify(repository).completeLoadedChild(10, 20, 7, 5); verify(repository).aggregateRootLoaded(10, 10, 7); verify(loadingModelBridge).requireCompleted(10, 20); - verify(completionGate).requireBookingTransportAndCustoms(10, 20); + verify(completionGate).requireBookingAndTransport(10, 20); verify(histories).insert(any(InboundStatusHistoryEntity.class)); verify(repository).completeOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay", resultJson.get()); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index 3c1370f18..2e64d996b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -44,6 +44,8 @@ class ContainerBookingMapperContractTest { .contains("capacity_cbm") .contains("p.plan_status!='EDITING'") .contains("p.plan_status='COMPLETED'") + .contains("#{containerNo} IS NOT NULL") + .contains("fl_loading_pick_task") .contains("version=#{expectedVersion}") .doesNotContain("<") .doesNotContain(">"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java new file mode 100644 index 000000000..59bd005ff --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java @@ -0,0 +1,31 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundOrderStatusSqlContractTest { + @Test + void advancesThroughPricingBeforeOpeningWaitSplit() { + assertThat(sql("approveMeasurement")) + .contains("process_status = 'WAIT_PRICING'") + .doesNotContain("inbounded_at ="); + assertThat(sql("completeInbound")) + .contains("process_status = 'WAIT_SPLIT'") + .contains("inbounded_at = CURRENT_TIMESTAMP(3)") + .contains("process_status = 'INBOUNDING'"); + } + + private static String sql(String methodName) { + Method method = Arrays.stream(InboundOrderMapper.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst() + .orElseThrow(); + return String.join(" ", method.getAnnotation(Update.class).value()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java index b37801b5f..06f3c6305 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java @@ -18,11 +18,13 @@ class InboundStatusUnificationSqlContractTest { assertThat(module) .contains("CREATE PROCEDURE assert_inbounded_compatibility()") - .contains("二、三期对外统一使用 INBOUNDED") + .contains("新完成入库记录使用 WAIT_SPLIT") + .contains("存量 INBOUNDED") .doesNotContain("UPDATE fl_inbound_order") .doesNotContain("UPDATE fl_inbound_status_history"); assertThat(unified) - .contains("二、三期对外统一使用 INBOUNDED") + .contains("新完成入库记录使用 WAIT_SPLIT") + .contains("存量 INBOUNDED") .doesNotContain("SET process_status = 'INBOUNDED'") .doesNotContain("SET from_status = 'INBOUNDED'") .doesNotContain("SET to_status = 'INBOUNDED'"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index 054092567..4abca26ec 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -60,6 +60,8 @@ class LoadingListQueryMapperContractTest { .contains("version = version + 1"); assertThat(transport) .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") + .contains("#{containerNo} IS NOT NULL") + .contains("fl_loading_pick_task") .doesNotContain("DELETE") .doesNotContain("fl_loading_plan_item"); assertThat(advance) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index c9ad2d46e..31d436133 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -84,7 +84,7 @@ class Phase3FinalUnifiedSqlContractTest { "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql", "firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql"); assertBlock(unified, "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql", - "-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。", + "-- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。", "iam/src/main/resources/db/phase3-warehouse-roles.sql"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 2e8c0f92b..3772a35a7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -21,7 +21,7 @@ class SmartLoadingCandidateMapperContractTest { .contains("m2.status='APPROVED'") .contains("i.inbound_detail_id IS NOT NULL") .contains("o.warehouse_id=#{warehouseId}") - .contains("o.process_status='INBOUNDED'") + .contains("o.process_status IN ('WAIT_SPLIT','INBOUNDED')") .contains("SELECT SUM(li.package_count)") .contains("li.deleted=0") .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); @@ -38,7 +38,7 @@ class SmartLoadingCandidateMapperContractTest { .contains("m.status='APPROVED'") .contains("SELECT MAX(m2.id)") .contains("o.warehouse_id=#{warehouseId}") - .contains("o.process_status='INBOUNDED'") + .contains("o.process_status IN ('WAIT_SPLIT','INBOUNDED')") .contains("SELECT SUM(li.package_count)") .contains("FOR UPDATE"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index d67a17969..4de276cb7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -73,6 +73,30 @@ class SmartLoadingSqlContractTest { .contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); } + @Test + @DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希") + void shouldProvideSimpleCustomsDocuments() throws Exception { + assertSimpleCustomsDocumentContract(migration()); + assertSimpleCustomsDocumentContract(unifiedMigration()); + assertThat(Files.readString(Path.of("src/main/resources/db/phase3-simple-customs-document-migration.sql"), + StandardCharsets.UTF_8)) + .contains("CREATE TRIGGER trg_container_booking_before_update") + .contains("IF current_plan_status='COMPLETED' THEN") + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')"); + } + + private static void assertSimpleCustomsDocumentContract(String sql) { + assertThat(sql) + .contains("'DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER'") + .contains("file_hash VARCHAR(64) NULL") + .contains("completed_by BIGINT UNSIGNED NULL") + .contains("completed_at DATETIME(3) NULL") + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") + .doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读"); + } + @Test @DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色") void shouldProvidePlatformPermissions() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 81edc5ffb..8a4b579dd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -8,8 +8,10 @@ import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; @@ -37,6 +39,7 @@ class ContainerServiceTest { @MockBean private ContainerRepository repository; @MockBean private WarehouseMasterDataClient warehouses; @MockBean private SmartLoadingRepository smartLoadingRepository; + @MockBean private LoadingPickRepository loadingPickRepository; @MockBean private LoadingPickService loadingPickService; @MockBean private ContainerTransportHistoryMapper transportHistories; @Resource private ContainerService service; @@ -145,6 +148,25 @@ class ContainerServiceTest { org.mockito.ArgumentMatchers.eq("admin"), any()); } + @Test + @DisplayName("PDA任务创建后禁止清空真实集装箱号") + void rejectsClearingContainerNumberAfterPdaTaskStarts() { + ContainerBookingEntity current = booking("LOADING", 2); + LoadingPlanEntity plan = new LoadingPlanEntity(); + plan.setId(31L); + when(repository.find(11L)).thenReturn(current); + when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan); + when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity()); + + assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", + new TransportUpdateRequest(null, "V002", 2))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不能清空真实集装箱号"); + + verify(repository, never()).updateTransport(any(Long.class), any(), any(), + any(Integer.class), any(Long.class), any()); + } + @Test @DisplayName("已确认货柜全部只读") void rejectsConfirmedUpdate() { 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 ade9ec959..991093ca8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -114,7 +114,7 @@ class FbaPricingServiceTest { void calculatesAtWaitSplitWithoutRollingBackProcessStatus() { readyRepository(); InboundPricingEntity inbound = inbound("100.000", "1.0000"); - inbound.setProcessStatus("INBOUNDED"); + inbound.setProcessStatus("WAIT_SPLIT"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(priceBookEstimator.estimate(any())).thenReturn( estimate("KG", "167.000", "2.000000", "334.00", "0.00")); @@ -122,8 +122,8 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split"); assertThat(response.feeStatus()).isEqualTo("CALCULATED"); - assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); - verify(repository).markInboundCalculated(TENANT, INBOUND, "INBOUNDED", "KG", + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG", new BigDecimal("167.000")); } @@ -131,7 +131,7 @@ class FbaPricingServiceTest { @DisplayName("待分柜边界确认费用后仍保持主流程状态") void confirmsAtWaitSplitWithoutRollingBackProcessStatus() { InboundPricingEntity inbound = inbound("10.000", "1.0000"); - inbound.setProcessStatus("INBOUNDED"); + inbound.setProcessStatus("WAIT_SPLIT"); inbound.setFeeStatus("CALCULATED"); InboundFeeEntity fee = savedFee("CALCULATED"); when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee); @@ -148,7 +148,7 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.confirm( TENANT, OPERATOR, INBOUND, "confirm-wait-split"); - assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); verify(repository).markInboundConfirmed(TENANT, INBOUND); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 3caa2ca0c..bd4d503f5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -24,6 +24,7 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.mock.web.MockMultipartFile; import java.util.List; @@ -102,6 +103,23 @@ class LoadingListServiceTest { verify(repository, never()).updateTransport(eq(10L), eq(5), anyString(), anyString(), eq(9L), anyString()); } + @Test + void shouldRejectClearingContainerNumberAfterPdaTaskStarts() { + AggregateRow before = row("LOADING", 3); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before); + when(pickRepository.taskByPlan(10L)).thenReturn(new com.baiyue.firstleg.entity.LoadingPickTaskEntity()); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1", + new TransportUpdateRequest(null, "023E", 3, "清空测试"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不能清空真实集装箱号"); + verify(repository, never()).updateTransport(eq(10L), eq(3), + org.mockito.ArgumentMatchers.nullable(String.class), + org.mockito.ArgumentMatchers.nullable(String.class), eq(9L), + org.mockito.ArgumentMatchers.nullable(String.class)); + } + @Test void shouldCompleteLoadingAfterAllPdaItemsPicked() { AggregateRow before = row("LOADING", 2); @@ -206,14 +224,16 @@ class LoadingListServiceTest { .thenReturn(null, document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false)); when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata( - 99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "30", "clearance.pdf", "abc", "application/pdf", 20L))); + 99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "30", "clearance.pdf", hash(), "application/pdf", 20L))); when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1); var result = service.attachContainerClearance(30L, 9L, "operator", "key-clearance", new DocumentAttachRequest(99L, 0, "上传清关资料")); assertThat(result.fileName()).isEqualTo("clearance.pdf"); - verify(repository).appendHistory(eq(10L), anyString(), eq("ATTACH_DESTINATION_CLEARANCE"), + verify(repository).appendHistory(eq(10L), + eq("PL-" + sha256("ATTACH_DESTINATION_CLEARANCE|30|key-clearance")), + eq("ATTACH_DESTINATION_CLEARANCE"), eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"), eq("上传清关资料")); } @@ -241,7 +261,7 @@ class LoadingListServiceTest { when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null); when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata( - 99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "different-booking", "clearance.pdf", "abc", + 99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "different-booking", "clearance.pdf", hash(), "application/pdf", 20L))); assertThatThrownBy(() -> service.attachContainerClearance(30L, 9L, "operator", "key", @@ -306,6 +326,121 @@ class LoadingListServiceTest { org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); } + @Test + void rejectsReplacingCompletedDocument() { + AggregateRow plan = row("COMPLETED", 2); + plan.setContainerBookingId(30L); + when(repository.aggregate(10L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)) + .thenReturn(document(40L, LoadingListService.CUSTOMS_DECLARATION, 4, true)); + + assertThatThrownBy(() -> service.attachLoadingDeclaration(10L, 100L, 9L, "operator", "replace-key", + new DocumentAttachRequest(100L, 4, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(documentFiles, never()).metadata(anyString(), eq(0L), anyString(), anyString(), eq(100L)); + } + + @Test + void uploadsContainerClearanceThroughInternalFileClientAndAssociatesMetadata() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + byte[] content = "clearance-document".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String contentHash = sha256Bytes(content); + var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", content); + var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L, + "CONTAINER_CLEARANCE_DOCUMENT", "30", "clearance.pdf", contentHash, "application/pdf", + (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + ContainerDocumentEntity saved = document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false); + saved.setFileHash(contentHash); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq("30"), eq(99L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1); + + var result = service.uploadContainerClearance( + 30L, 9L, "operator", "upload-key", 0, "直接上传", file); + + assertThat(result.fileHash()).isEqualTo(contentHash); + verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq("30"), eq(file)); + verify(repository).appendHistory(eq(10L), + eq("PL-" + sha256("UPLOAD_DESTINATION_CLEARANCE|30|upload-key")), + eq("UPLOAD_DESTINATION_CLEARANCE"), eq("LOADING"), eq("LOADING"), anyString(), + anyString(), eq(9L), eq("operator"), eq("直接上传")); + } + + @Test + void multipartReplayDoesNotUploadFileAgain() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + byte[] content = "clearance-document".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", content); + String requestHash = sha256("UPLOAD|DESTINATION_CLEARANCE|30|0|null|" + sha256Bytes(content)); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"" + requestHash + "\"}"); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(replay); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)) + .thenReturn(document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false)); + + service.uploadContainerClearance(30L, 9L, "operator", "upload-key", 0, null, file); + + verify(documentFiles, never()).upload(anyString(), eq(9L), eq(0L), anyString(), anyString(), eq(file)); + } + + @Test + void uploadsLoadingDeclarationWithPlanIdentityAndWarehouseScope() { + AggregateRow plan = row("COMPLETED", 2); + plan.setContainerBookingId(30L); + byte[] content = "customs-declaration".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String contentHash = sha256Bytes(content); + var file = new MockMultipartFile("file", "customs.pdf", "application/pdf", content); + var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L, + "LOADING_CUSTOMS_DOCUMENT", "10", "customs.pdf", contentHash, "application/pdf", + (long) content.length); + ContainerDocumentEntity saved = document(41L, LoadingListService.CUSTOMS_DECLARATION, 0, false); + saved.setFileId(101L); saved.setFileName("customs.pdf"); saved.setFileHash(contentHash); + when(repository.aggregate(10L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(null, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + eq("10"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + eq("10"), eq(101L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1); + + var result = service.uploadLoadingDeclaration( + 10L, 100L, 9L, "operator", "customs-upload", 0, null, file); + + assertThat(result.documentType()).isEqualTo(LoadingListService.CUSTOMS_DECLARATION); + verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + eq("10"), eq(file)); + } + + @Test + void rejectsReusedIdempotencyKeyWithDifferentAttachRequest() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"different\"}"); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(replay); + + assertThatThrownBy(() -> service.attachContainerClearance(30L, 9L, "operator", "reused-key", + new DocumentAttachRequest(100L, 0, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).createDocument(org.mockito.ArgumentMatchers.any()); + } + private static AggregateRow row(String status, int version) { AggregateRow row = new AggregateRow(); row.setPlanId(10L); @@ -319,10 +454,31 @@ class LoadingListServiceTest { private static ContainerDocumentEntity document(long id, String type, int version, boolean completed) { ContainerDocumentEntity value = new ContainerDocumentEntity(); value.setId(id); value.setContainerBookingId(30L); value.setDocumentType(type); - value.setFileId(99L); value.setFileName("clearance.pdf"); value.setFileHash("abc"); + value.setFileId(99L); value.setFileName("clearance.pdf"); value.setFileHash(hash()); value.setUploadedBy(9L); value.setUploadedByName("operator"); value.setVersion(version); value.setCreatedAt(java.time.LocalDateTime.now()); if (completed) value.setCompletedAt(java.time.LocalDateTime.now()); return value; } + + private static String hash() { return "a".repeat(64); } + + private static String sha256Bytes(byte[] value) { + try { + return java.util.HexFormat.of().formatHex( + java.security.MessageDigest.getInstance("SHA-256").digest(value)); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static String sha256(String value) { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index ebe3005c2..dbe2ce599 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -78,7 +78,7 @@ class MeasurementReviewServiceTest { ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); verify(fixture.repository).insertHistory(history.capture()); assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING"); - assertThat(history.getValue().getToStatus()).isEqualTo("INBOUNDED"); + assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_PRICING"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java index d65ba444c..51eb8293b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -28,10 +28,10 @@ class UnifiedInboundQueryServiceTest { assertThat(UnifiedInboundQueryService.mainStatus("MEASURING")).isEqualTo("WAIT_MEASURE"); assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_PENDING")).isEqualTo("WAIT_MEASURE"); assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_REJECTED")).isEqualTo("WAIT_MEASURE"); - assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("INBOUNDED"); - assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("INBOUNDED"); - assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("INBOUNDED"); - assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("INBOUNDED"); + assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("WAIT_SPLIT"); assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED"); assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING"); assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED"); @@ -50,21 +50,21 @@ class UnifiedInboundQueryServiceTest { .isEqualTo("CONFIRM_PRELOAD"); assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDING", null)).isNull(); assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "CONFIRMED")).isNull(); - assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "WAIT_SPLIT", null)) + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null)) .isEqualTo("SMART_SPLIT"); assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)).isNull(); assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull(); } @Test - void inboundedFilterIncludesHistoricalWaitSplitRows() throws Exception { + void inboundedAndWaitSplitFiltersRemainDistinct() throws Exception { var method = UnifiedInboundQueryService.class.getDeclaredMethod("internalStatuses", String.class); method.setAccessible(true); assertThat(method.invoke(new UnifiedInboundQueryService(), "INBOUNDED")) - .isEqualTo(java.util.List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "WAIT_SPLIT")); + .isEqualTo(java.util.List.of("INBOUNDED")); assertThat(method.invoke(new UnifiedInboundQueryService(), "WAIT_SPLIT")) - .isEqualTo(java.util.List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "WAIT_SPLIT")); + .isEqualTo(java.util.List.of("WAIT_SPLIT")); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 4ed1d210d..b5cf588e7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -47,19 +47,19 @@ class SplitPreloadServiceTest { @Test void smartSplitCreatesEditablePendingPlan() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "INBOUNDED", 3)); + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); when(repository.findPlan(10, 20)).thenReturn(null); SmartSplitRequest request = new SmartSplitRequest(0, List.of(new PreloadItem("C1", "A", 2, BigDecimal.ONE, null)), "r"); SplitActionResponse result = service.smartSplit(10, 20, 7, "admin", "k2", request); - assertThat(result.mainStatus()).isEqualTo("INBOUNDED"); + assertThat(result.mainStatus()).isEqualTo("WAIT_SPLIT"); verify(repository).createPlan(any(PreloadPlanEntity.class)); } @Test void confirmsPlanAndMovesInboundToLoading() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "INBOUNDED", 4)); + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4)); PreloadPlanEntity plan = new PreloadPlanEntity(); plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1); plan.setItemsJson("[]"); diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 461b4c5af..8d98938c1 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -31,8 +31,7 @@ import java.util.stream.Collectors; @Service public class UnifiedOrderQueryService { private static final Set MAIN_STATUSES = Set.of( - "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED"); - private static final String LEGACY_WAIT_SPLIT = "WAIT_SPLIT"; + "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); private static final List ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of( OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, @@ -95,7 +94,7 @@ public class UnifiedOrderQueryService { return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); } FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query( - tenantId, query.keyword(), query.orderNo(), canonicalMainStatus(query.mainStatus()), query.customerId(), + tenantId, query.keyword(), query.orderNo(), query.mainStatus(), query.customerId(), query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(), query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(), query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(), @@ -229,9 +228,9 @@ public class UnifiedOrderQueryService { private String inboundMainStatus(String status) { if (status == null) return null; return switch (status) { - case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE"; - case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", LEGACY_WAIT_SPLIT -> "INBOUNDED"; - case "LOADING", "LOADED", "DELIVERED" -> status; + case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", + "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING" -> "WAIT_MEASURE"; + case "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED" -> status; default -> null; }; } @@ -243,7 +242,7 @@ public class UnifiedOrderQueryService { private void validate(UnifiedOrderQuery query) { if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus()) - && !LEGACY_WAIT_SPLIT.equals(query.mainStatus())) { + ) { throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法"); } if (query.createdFrom() != null && query.createdTo() != null @@ -273,10 +272,6 @@ public class UnifiedOrderQueryService { || StringUtils.hasText(query.measurementReviewStatus()); } - private static String canonicalMainStatus(String status) { - return LEGACY_WAIT_SPLIT.equals(status) ? "INBOUNDED" : status; - } - private ApiResponse call(java.util.concurrent.Callable> invocation) { try { return invocation.call(); } catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index efd6c2cd1..f594adbfb 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -335,9 +335,9 @@ components: schemas: UnifiedMainStatus: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED] + enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] description: >- - 统一订单产品主状态。历史内部状态 WAIT_SPLIT 仅作为查询入参兼容,所有列表、详情和时间线响应均归并为 INBOUNDED。 + 统一订单七态产品主状态。WAIT_SPLIT 为正式待分柜状态,INBOUNDED 仅兼容存量已入库记录。 UnifiedOrderPageEnvelope: type: object properties: @@ -669,7 +669,7 @@ components: name: mainStatus in: query required: false - description: 历史值 WAIT_SPLIT 仍可作为兼容筛选传入,但响应 mainStatus 始终归并为 INBOUNDED + description: 统一订单七态主状态筛选;WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询 schema: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT] diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 8f46d0e70..24b688cbe 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -25,7 +25,7 @@ class UnifiedOrderApiContractTest { } @Test - void documentsInboundedAsThePhaseTwoTerminalStatus() throws Exception { + void documentsSevenProductMainStatuses() throws Exception { String yaml = new ClassPathResource("openapi/order-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); Map document = stringMap(new Yaml().load(yaml)); @@ -46,7 +46,7 @@ class UnifiedOrderApiContractTest { Map schemas = stringMap(components.get("schemas")); Map responseStatus = stringMap(schemas.get("UnifiedMainStatus")); assertThat(stringList(responseStatus.get("enum"))).containsExactly( - "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED"); + "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED"); Map row = stringMap(schemas.get("UnifiedOrderRow")); diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs new file mode 100644 index 000000000..a80b76097 --- /dev/null +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const sql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') + +test('统一初始化 SQL 保留最小登录与客户主体', () => { + assert.match(sql, /一个平台系统管理员和一个 IT 测试客户管理员/) + assert.equal((sql.match(/'platform_admin'/g) || []).length, 1) + assert.equal((sql.match(/'merchant_admin'/g) || []).length, 1) + assert.match(sql, /\(10001, 'IT_TEST', 'IT 测试客户'/) +}) + +test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => { + for (const marker of [ + "'TP-001'", "'TP-002'", "'A1 普通库位'", "'A0 共享溢出位'", + "'CUS-001'", "'CUSTOMER'", "'SHIPPER'", "'EXP-TEST'", "'IT 测试快递'", + ]) assert.ok(sql.includes(marker), `missing initialization marker: ${marker}`) + assert.match(sql, /'EXP-TEST', 'IT 测试快递'[\s\S]*?'ACTIVE'/) +}) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 1396f57db..d2191ef9f 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -25,3 +25,19 @@ test('does not mutate historical fixtures or remove retained records', () => { assert.match(source, /fixtureMarker/) assert.match(source, /context\.marker = originalMarker/) }) + +test('covers simplified clearance and customs actions through public multipart upload and complete APIs', () => { + assert.match(source, /new FormData\(\)/) + assert.match(source, /form\.append\('file'/) + assert.match(source, /form\.append\('expectedVersion'/) + assert.match(source, /form\.append\('operationReason'/) + assert.match(source, /'Idempotency-Key'/) + assert.match(source, /clearancePath\}\/document\/upload/) + assert.match(source, /clearancePath\}\/complete/) + assert.match(source, /customsPath\}\/document\/upload\?\$\{warehouse\}/) + assert.match(source, /customsPath\}\/complete\?\$\{warehouse\}/) + assert.match(source, /matrix\.pages\.containerLoading\.actions/) + assert.match(source, /matrix\.pages\.loadingLists\.actions/) + assert.doesNotMatch(source, /internal\/file|business-files/) + assert.doesNotMatch(source, /\{\s*fileId\s*:/) +}) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 1c0f8b7d9..7d8c2bf57 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -22,6 +22,32 @@ async function post(context, path, body, label) { { 'Idempotency-Key': operationId(context, label) }) } +async function uploadDocument(context, path, expectedVersion, operationReason, label) { + const form = new FormData() + form.append('file', new Blob([`${context.marker}-${label}`], { type: 'text/plain' }), `${label}.txt`) + form.append('expectedVersion', String(expectedVersion)) + if (operationReason) form.append('operationReason', operationReason) + try { + const response = await fetch(`${context.options.gateway}${path}`, { + method: 'POST', + headers: { + Accept: 'application/json', Authorization: `Bearer ${context.token}`, + 'Idempotency-Key': operationId(context, `${label}-upload`), + }, + body: form, + signal: AbortSignal.timeout(60_000), + }) + const raw = await response.text() + let body = null + try { body = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method: 'POST', path, http: response.status, body, raw: body ? null : raw.slice(0, 500), + requestId: body?.requestId || response.headers.get('x-request-id'), + traceId: body?.traceId || response.headers.get('x-trace-id') } + } catch (error) { + return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } + } +} + async function prepareInbound(context, label) { const originalMarker = context.marker const fixtureMarker = `${originalMarker}-MAN-${label}` @@ -224,10 +250,71 @@ function fixture(value, status, detailPath, extra = {}) { return { id: id(value), status, detailPath, ...extra } } +async function completeSimpleDocuments(context, chain, matrix) { + const reason = `${context.marker} 人工页面动作验收` + const clearancePath = `${CONTAINERS}/${chain.containerId}/clearance` + const clearanceUpload = await uploadDocument(context, `${clearancePath}/document/upload`, 0, + reason, 'container-clearance') + const clearanceDocument = context.apiSuccess(MODULE, '货柜公开接口上传目的港清关资料', + clearanceUpload, '', OPTIONS) + context.assert(MODULE, '货柜清关上传返回真实文件事实', clearanceUpload, + Boolean(clearanceDocument?.id) && Boolean(clearanceDocument?.fileId) + && clearanceDocument?.documentType === 'DESTINATION_CLEARANCE' + && /^[0-9a-f]{64}$/i.test(String(clearanceDocument?.fileHash || '')) + && !clearanceDocument?.completedAt, + `documentId=${id(clearanceDocument?.id) || 'missing'}, version=${clearanceDocument?.version ?? 'missing'}`, OPTIONS) + if (!clearanceDocument?.id || !clearanceDocument?.fileId || !Number.isInteger(Number(clearanceDocument?.version))) return + const clearanceCompleteResponse = await post(context, `${clearancePath}/complete`, { + expectedVersion: Number(clearanceDocument?.version), operationReason: reason, + }, 'container-clearance-complete') + const clearanceCompleted = context.apiSuccess(MODULE, '货柜点击完成目的港清关', + clearanceCompleteResponse, '', OPTIONS) + context.assert(MODULE, '货柜清关完成事实可回读', clearanceCompleteResponse, + id(clearanceCompleted?.id) === id(clearanceDocument?.id) && Boolean(clearanceCompleted?.completedAt) + && Number(clearanceCompleted?.version) === Number(clearanceDocument?.version) + 1, + `documentId=${id(clearanceCompleted?.id) || 'missing'}, completedAt=${clearanceCompleted?.completedAt || 'missing'}`, OPTIONS) + + const customsPath = `${LOADING}/loading-lists/${chain.plan.id}/customs` + const warehouse = `warehouseId=${encodeURIComponent(chain.source.warehouseId)}` + const customsUpload = await uploadDocument(context, `${customsPath}/document/upload?${warehouse}`, 0, + reason, 'loading-customs') + const customsDocument = context.apiSuccess(MODULE, '装柜清单公开接口上传出口报关文档', + customsUpload, '', OPTIONS) + context.assert(MODULE, '装柜清单报关上传返回真实文件事实', customsUpload, + Boolean(customsDocument?.id) && Boolean(customsDocument?.fileId) + && customsDocument?.documentType === 'CUSTOMS_DECLARATION' + && /^[0-9a-f]{64}$/i.test(String(customsDocument?.fileHash || '')) + && !customsDocument?.completedAt, + `documentId=${id(customsDocument?.id) || 'missing'}, version=${customsDocument?.version ?? 'missing'}`, OPTIONS) + if (!customsDocument?.id || !customsDocument?.fileId || !Number.isInteger(Number(customsDocument?.version))) return + const customsCompleteResponse = await post(context, `${customsPath}/complete?${warehouse}`, { + expectedVersion: Number(customsDocument?.version), operationReason: reason, + }, 'loading-customs-complete') + const customsCompleted = context.apiSuccess(MODULE, '装柜清单点击完成出口报关', + customsCompleteResponse, '', OPTIONS) + context.assert(MODULE, '装柜清单报关完成事实可回读', customsCompleteResponse, + id(customsCompleted?.id) === id(customsDocument?.id) && Boolean(customsCompleted?.completedAt) + && Number(customsCompleted?.version) === Number(customsDocument?.version) + 1, + `documentId=${id(customsCompleted?.id) || 'missing'}, completedAt=${customsCompleted?.completedAt || 'missing'}`, OPTIONS) + + matrix.pages.containerLoading.actions = { clearance: fixture(chain.containerId, 'COMPLETED', + `/container-loading/${chain.containerId}/smart-loading`, { + documentId: id(clearanceCompleted?.id), documentVersion: Number(clearanceCompleted?.version), + completedAt: clearanceCompleted?.completedAt || null, + }) } + matrix.pages.loadingLists.actions = { customsDeclaration: fixture(chain.plan.id, 'COMPLETED', + `/loading-lists?planId=${chain.plan.id}&warehouseId=${chain.source.warehouseId}`, { + warehouseId: chain.source.warehouseId, documentId: id(customsCompleted?.id), + documentVersion: Number(customsCompleted?.version), completedAt: customsCompleted?.completedAt || null, + }) } +} + export async function run(context, matrix = context.createdResources.manualStateMatrix) { - matrix.pages.containerLoading = { requiredStatuses: CONTAINER_STATUSES, fixtures: {} } + matrix.pages.containerLoading = { requiredStatuses: CONTAINER_STATUSES, + requiredActions: ['clearance'], fixtures: {}, actions: {} } matrix.pages.preloadPlans = { requiredStatuses: PRELOAD_STATUSES, fixtures: {} } - matrix.pages.loadingLists = { requiredStatuses: ['COMPLETED'], fixtures: {} } + matrix.pages.loadingLists = { requiredStatuses: ['COMPLETED'], + requiredActions: ['customsDeclaration'], fixtures: {}, actions: {} } matrix.pages.pdaTasks = { requiredStatuses: PDA_STATUSES, fixtures: {} } const warehouseId = '193000000000200001' @@ -283,4 +370,6 @@ export async function run(context, matrix = context.createdResources.manualState context.assert(MODULE, 'COMPLETED 装柜清单详情身份正确', listDetailResponse, id(listDetail?.summary?.planId) === listFixture.id && listDetail?.summary?.status === 'COMPLETED', `planId=${listFixture.id}, actual=${listDetail?.summary?.status || 'missing'}`, OPTIONS) + + await completeSimpleDocuments(context, completed, matrix) } diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs index ba3b92639..4633d86a8 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -18,4 +18,7 @@ test('matrix delegates bounded groups and asserts every declared status has a re assert.match(source, /createdResources\.manualStateMatrix/) assert.match(source, /requiredStatuses/) assert.match(source, /fixtures\?\.\[status\]\?\.id/) + assert.match(source, /requiredActions/) + assert.match(source, /actions\?\.\[action\]\?\.documentId/) + assert.match(source, /actions\?\.\[action\]\?\.completedAt/) }) diff --git a/scripts/admin-test-env-modules/manual-state-matrix.mjs b/scripts/admin-test-env-modules/manual-state-matrix.mjs index b9d4ee486..27bd9c67d 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix.mjs @@ -23,7 +23,11 @@ export async function run(context) { const missing = pages.flatMap(([page, value]) => rows(value?.requiredStatuses) .filter((status) => !value?.fixtures?.[status]?.id) .map((status) => `${page}:${status}`)) + const missingActions = pages.flatMap(([page, value]) => rows(value?.requiredActions) + .filter((action) => !value?.actions?.[action]?.id + || !value?.actions?.[action]?.documentId || !value?.actions?.[action]?.completedAt) + .map((action) => `${page}:action:${action}`)) context.assert(MODULE, '人工验收列表状态数据全部生成并保留', null, - pages.length > 0 && missing.length === 0, - `pages=${pages.length}, missing=${missing.join(',') || 'none'}`, OPTIONS) + pages.length > 0 && missing.length === 0 && missingActions.length === 0, + `pages=${pages.length}, missing=${[...missing, ...missingActions].join(',') || 'none'}`, OPTIONS) } diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index 9175e63fc..7538c3c95 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop' $Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Workspace = (Resolve-Path (Join-Path $Repo '..')).Path $Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance' -$ExpectedSha256 = 'CD21001B8E0B5D751000754522D5158BD45AD5986ECA8D2E64B8389FBE82B260' -$ExpectedLines = 13800 +$ExpectedSha256 = '585464BE3FA0FB4141F47D0FAC57FA1FCAFCDD4596253256E8759BF930D9C7D3' +$ExpectedLines = 14433 if ($TargetHost -notin @('127.0.0.1', 'localhost')) { [Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.') diff --git a/warehouse/src/main/resources/db/warehouse-express-company.sql b/warehouse/src/main/resources/db/warehouse-express-company.sql index 82266dd4c..98ed344bd 100644 --- a/warehouse/src/main/resources/db/warehouse-express-company.sql +++ b/warehouse/src/main/resources/db/warehouse-express-company.sql @@ -22,3 +22,19 @@ CREATE TABLE IF NOT EXISTS wh_express_company ( UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted), KEY idx_wh_express_tenant_status (tenant_id, status, deleted) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共快递公司主数据,固定归属 tenant_id=0'; + +-- 开发/测试环境公共快递基线:供订单履约快递选择和人工验收使用。 +INSERT INTO wh_express_company ( + id, tenant_id, company_code, company_name, service_phone, website, + tracking_url_template, status, remark, created_by, updated_by, deleted, version +) VALUES ( + 191000000000000101, 0, 'EXP-TEST', 'IT 测试快递', '400-000-0000', NULL, + NULL, 'ACTIVE', '统一初始化测试基线', 1000001, 1000001, 0, 0 +) +ON DUPLICATE KEY UPDATE + tenant_id = VALUES(tenant_id), + company_name = VALUES(company_name), + service_phone = VALUES(service_phone), + status = 'ACTIVE', + remark = VALUES(remark), + deleted = 0;