优化
This commit is contained in:
@@ -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$$
|
||||
@@ -14412,6 +14428,6 @@ BEGIN
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。
|
||||
-- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。
|
||||
SELECT 'PASS' AS migration_status,
|
||||
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
|
||||
|
||||
@@ -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<InternalBusinessFileService.Metadata> 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<InternalBusinessFileService.Metadata> 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<byte[]> 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<InternalBusinessFileService.Metadata> 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<InternalBusinessFileService.Metadata> 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<byte[]> 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, "客户作用域不能为负数");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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<FileObjectEntity>().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<String> 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<FileObjectEntity>()
|
||||
.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) {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SettlementSummary> 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;
|
||||
}
|
||||
|
||||
@@ -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<FinanceFileClient.Metadata> uploadVoucher(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
@@ -42,6 +42,8 @@ public final class FinanceSettlementDtos {
|
||||
public record SettlementDetail(@JsonUnwrapped SettlementSummary summary, List<SettlementLine> lines,
|
||||
List<SettlementTransaction> transactions,
|
||||
List<SettlementRevision> revisions) {}
|
||||
public record SettlementListEnvelope(String code, String message, List<SettlementSummary> data,
|
||||
String requestId, String traceId, Instant timestamp) {}
|
||||
public record SettlementDetailEnvelope(String code, String message, SettlementDetail data,
|
||||
String requestId, String traceId, Instant timestamp) {}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,27 @@ public class FinanceSettlementService {
|
||||
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
|
||||
}
|
||||
|
||||
public List<SettlementSummary> 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<Object> 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) {
|
||||
|
||||
@@ -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<String, String> 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, Object> 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<String> allSql = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> 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);
|
||||
|
||||
@@ -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<Metadata> 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> metadata(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId, @PathVariable String businessType,
|
||||
|
||||
+18
@@ -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<BusinessDocument> 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 = "确认货柜目的港清关完成")
|
||||
|
||||
+20
@@ -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<BusinessDocument> 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')")
|
||||
|
||||
+6
-7
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-13
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -92,6 +92,10 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
WHERE id=#{id} AND version=#{expectedVersion}
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
|
||||
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status='COMPLETED')
|
||||
AND (#{containerNo} IS NOT NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_task t
|
||||
JOIN fl_loading_plan p ON p.id=t.loading_plan_id
|
||||
WHERE p.container_booking_id=fl_container_booking.id))
|
||||
""")
|
||||
int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo,
|
||||
@Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion,
|
||||
|
||||
@@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update;
|
||||
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
|
||||
@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<InboundOrderEntity> {
|
||||
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<InboundOrderEntity> {
|
||||
|
||||
@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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<InboundPricingEntity>()
|
||||
.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"));
|
||||
}
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
@@ -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<String, Object> 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("预装计划已被其他操作修改,请刷新后重试");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+10
-10
@@ -28,9 +28,7 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class UnifiedInboundQueryService {
|
||||
private static final Set<String> 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<String> 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<String> 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, "统一订单主状态不合法");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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$$
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
+4
@@ -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")
|
||||
|
||||
+7
-1
@@ -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<String, Object> components = stringMap(document.get("components"));
|
||||
Map<String, Object> 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");
|
||||
|
||||
+3
-3
@@ -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<String, Object> document = stringMap(new Yaml().load(yaml));
|
||||
@@ -25,13 +25,13 @@ class UnifiedInboundOpenApiContractTest {
|
||||
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
|
||||
Map<String, Object> 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<String, Object> query = stringMap(schemas.get("UnifiedInboundQuery"));
|
||||
Map<String, Object> 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<String, Object> inbound = stringMap(schemas.get("UnifiedInboundOrderSummary"));
|
||||
assertThat(stringMap(inbound.get("properties"))).containsKeys(
|
||||
|
||||
+12
-8
@@ -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); }
|
||||
|
||||
+3
-7
@@ -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("<", ">");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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());
|
||||
}
|
||||
|
||||
+2
@@ -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(">");
|
||||
|
||||
+31
@@ -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());
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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'");
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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");
|
||||
}
|
||||
|
||||
+24
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+8
-8
@@ -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
|
||||
|
||||
+3
-3
@@ -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("[]");
|
||||
|
||||
@@ -31,8 +31,7 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class UnifiedOrderQueryService {
|
||||
private static final Set<String> 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<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
|
||||
private static final List<OrderStatus> 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 <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) {
|
||||
try { return invocation.call(); }
|
||||
catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); }
|
||||
|
||||
@@ -309,9 +309,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:
|
||||
@@ -611,7 +611,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]
|
||||
|
||||
@@ -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<String, Object> document = stringMap(new Yaml().load(yaml));
|
||||
@@ -46,7 +46,7 @@ class UnifiedOrderApiContractTest {
|
||||
Map<String, Object> schemas = stringMap(components.get("schemas"));
|
||||
Map<String, Object> 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<String, Object> row = stringMap(schemas.get("UnifiedOrderRow"));
|
||||
|
||||
@@ -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'/)
|
||||
})
|
||||
@@ -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*:/)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user