Merge remote-tracking branch 'origin/test' into test

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
This commit is contained in:
2026-08-13 09:51:02 +08:00
69 changed files with 1224 additions and 220 deletions
@@ -847,6 +847,22 @@ CREATE TABLE IF NOT EXISTS wh_express_company (
KEY idx_wh_express_tenant_status (tenant_id, status, 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'; ) 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 -- 来源:firstleg\src\main\resources\db\firstleg.sql
-- ============================================================================ -- ============================================================================
@@ -5132,8 +5148,7 @@ BEGIN
DECLARE current_plan_status VARCHAR(24); DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE container_booking_id = OLD.id; FROM fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status = 'COMPLETED' IF current_plan_status = 'COMPLETED' THEN
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改';
END IF; END IF;
IF current_plan_status = 'LOADING' AND ( IF current_plan_status = 'LOADING' AND (
@@ -5483,7 +5498,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = NEW.container_booking_id; WHERE container_booking_id = NEW.container_booking_id;
IF current_plan_status = 'COMPLETED' 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 = '装柜完成后货柜文件不可新增'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
END IF; END IF;
END$$ END$$
@@ -5498,7 +5513,8 @@ BEGIN
DECLARE current_plan_status VARCHAR(24); DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id; 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 = '装柜完成后货柜文件不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
END IF; END IF;
END$$ END$$
@@ -14435,5 +14451,6 @@ ALTER TABLE baiyue_order.ord_order_cargo
MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克);草稿可空,提交前必须大于0', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克);草稿可空,提交前必须大于0',
MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米);草稿可空,提交前必须大于0'; MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米);草稿可空,提交前必须大于0';
-- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。
SELECT 'PASS' AS migration_status, SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
@@ -1,3 +1,87 @@
package com.baiyue.file.controller; 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 jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
@Service @Service
public class InternalBusinessFileService { public class InternalBusinessFileService {
private static final java.util.Set<String> BUSINESS_TYPES = java.util.Set.of( private static final Set<String> BUSINESS_TYPES = Set.of(
"TRUCK_APPOINTMENT_ATTACHMENT", "CUSTOMS_DOCUMENT", "CUSTOMS_RECEIPT", "FINANCE_VOUCHER"); "TRUCK_APPOINTMENT_ATTACHMENT",
@Resource FileObjectMapper files; @Resource BusinessFileStorage storage; "CUSTOMS_DOCUMENT",
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);} "CUSTOMS_RECEIPT",
public Metadata metadata(long tenantId,String businessType,String businessId,long fileId){return metadata(required(tenantId,businessType,businessId,fileId));} "FINANCE_VOUCHER",
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()));} "CONTAINER_CLEARANCE_DOCUMENT",
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;} "LOADING_CUSTOMS_DOCUMENT");
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){} @Resource private FileObjectMapper files;
public record Download(String fileName,String contentType,byte[] content){} @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"); 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() { @Test void unsupportedBusinessTypeFailsClosed() {
assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L, assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L,
new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()))) new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes())))
@@ -55,4 +88,25 @@ class InternalBusinessFileServiceTest {
assertThat(result.content()).isEqualTo("released".getBytes()); assertThat(result.content()).isEqualTo("released".getBytes());
verify(storage).read(value.getObjectKey()); 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.CreateSettlementPaymentRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetailEnvelope; 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 com.baiyue.finance.service.FinanceSettlementService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.slf4j.MDC; import org.slf4j.MDC;
import org.springframework.security.access.AccessDeniedException; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; 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.RestController;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.UUID; import java.util.UUID;
@RestController @RestController
@@ -33,6 +39,24 @@ public class FinanceSettlementController {
this.service = service; 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") @PostMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('platform:finance:settlement-ar:create')") @PreAuthorize("hasAuthority('platform:finance:settlement-ar:create')")
public SettlementDetailEnvelope createReceivableSettlement( public SettlementDetailEnvelope createReceivableSettlement(
@@ -82,6 +106,14 @@ public class FinanceSettlementController {
return new SettlementDetailEnvelope("200", "success", detail, requestId, traceId, Instant.now()); 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) { private static String first(String first, String second) {
return StringUtils.hasText(first) ? first : second; return StringUtils.hasText(first) ? first : second;
} }
@@ -70,24 +70,6 @@ public class PlatformFinanceController {
return FinanceResponses.success(queryService.statements(customerId, false)); 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") @PostMapping(value = "/vouchers", consumes = "multipart/form-data")
@PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create')") @PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create')")
public ApiResponse<FinanceFileClient.Metadata> uploadVoucher(@AuthenticationPrincipal AuthenticatedUser user, 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, public record SettlementDetail(@JsonUnwrapped SettlementSummary summary, List<SettlementLine> lines,
List<SettlementTransaction> transactions, List<SettlementTransaction> transactions,
List<SettlementRevision> revisions) {} 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, public record SettlementDetailEnvelope(String code, String message, SettlementDetail data,
String requestId, String traceId, Instant timestamp) {} String requestId, String traceId, Instant timestamp) {}
} }
@@ -193,6 +193,27 @@ public class FinanceSettlementService {
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions)); 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 @Transactional
public SettlementDetail createSettlementPayment(long settlementId, long operatorId, String key, public SettlementDetail createSettlementPayment(long settlementId, long operatorId, String key,
CreateSettlementPaymentRequest request) { CreateSettlementPaymentRequest request) {
@@ -1,6 +1,7 @@
package com.baiyue.finance; package com.baiyue.finance;
import com.baiyue.finance.controller.PlatformFinanceController; import com.baiyue.finance.controller.PlatformFinanceController;
import com.baiyue.finance.controller.FinanceSettlementController;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -29,8 +30,8 @@ class FinancePlatformListContractTest {
Map<String, String> expected = Map.of( Map<String, String> expected = Map.of(
"feePool", "hasAuthority('platform:finance:bill:view')", "feePool", "hasAuthority('platform:finance:bill:view')",
"statements", "hasAuthority('platform:finance:bill:view')", "statements", "hasAuthority('platform:finance:bill:view')",
"receivableSettlements", "hasAuthority('platform:finance:bill:view')", "receivableSettlements", "hasAuthority('platform:finance:settlement-ar:view')",
"payableSettlements", "hasAuthority('platform:finance:cost:view')"); "payableSettlements", "hasAuthority('platform:finance:settlement-ap:view')");
for (var entry : expected.entrySet()) { for (var entry : expected.entrySet()) {
assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).isEqualTo(entry.getValue()); 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 { private static Method find(String name) throws Exception {
return switch (name) { return switch (name) {
case "feePool" -> PlatformFinanceController.class.getMethod(name, Long.class, String.class); 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.FinanceFileClient;
import com.baiyue.finance.client.FirstlegCostSnapshotClient; import com.baiyue.finance.client.FirstlegCostSnapshotClient;
import com.baiyue.finance.controller.ClientFinanceController; import com.baiyue.finance.controller.ClientFinanceController;
import com.baiyue.finance.controller.FinanceSettlementController;
import com.baiyue.finance.controller.PlatformFinanceController; import com.baiyue.finance.controller.PlatformFinanceController;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt; import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation; import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.service.FinanceQueryService; import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.FinanceService; import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceSettlementService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -31,6 +33,7 @@ class FinanceReadApiBehaviorTest {
@Mock FinanceQueryService queryService; @Mock FinanceQueryService queryService;
@Mock FinanceFileClient fileClient; @Mock FinanceFileClient fileClient;
@Mock FirstlegCostSnapshotClient snapshotClient; @Mock FirstlegCostSnapshotClient snapshotClient;
@Mock FinanceSettlementService settlementService;
@Test @Test
void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception { void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception {
@@ -63,34 +66,36 @@ class FinanceReadApiBehaviorTest {
} }
@Test @Test
void fivePlatformPageMethodsUseTypedQueryFacade() { void fivePlatformPageMethodsUseTheirTypedQueryFacades() {
var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService); var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService);
var settlementController = new FinanceSettlementController(settlementService);
controller.customerAccount(11L); controller.customerAccount(11L);
controller.statements(11L); controller.statements(11L);
controller.receivableSettlements(11L); settlementController.receivableSettlements(11L);
controller.payableSettlements(11L); settlementController.payableSettlements(11L);
verify(queryService).customerAccount(11L); verify(queryService).customerAccount(11L);
verify(queryService).statements(11L, false); verify(queryService).statements(11L, false);
verify(queryService).receivableSettlements(11L, false); verify(settlementService).listSettlements("RECEIVABLE", 11L);
verify(queryService).payableSettlements(11L); verify(settlementService).listSettlements("PAYABLE", 11L);
} }
@Test @Test
void platformListsDefaultToAllCustomersAndKeepOptionalFilter() { void platformListsDefaultToAllCustomersAndKeepOptionalFilter() {
var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService); var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService);
var settlementController = new FinanceSettlementController(settlementService);
controller.feePool(null, null); controller.feePool(null, null);
controller.statements(null); controller.statements(null);
controller.receivableSettlements(null); settlementController.receivableSettlements(null);
controller.payableSettlements(null); settlementController.payableSettlements(null);
controller.statements(31L); controller.statements(31L);
verify(queryService).feePool(null, null); verify(queryService).feePool(null, null);
verify(queryService).statements(null, false); verify(queryService).statements(null, false);
verify(queryService).receivableSettlements(null, false); verify(settlementService).listSettlements("RECEIVABLE", null);
verify(queryService).payableSettlements(null); verify(settlementService).listSettlements("PAYABLE", null);
verify(queryService).statements(31L, false); verify(queryService).statements(31L, false);
} }
@@ -64,6 +64,44 @@ class FinanceSettlementBehaviorTest {
user(Set.of("platform:finance:settlement-ar:view")), 88L)).isInstanceOf(AccessDeniedException.class); 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 @Test
void arCreationRejectsNonCnyBeforeDatabaseAccess() { void arCreationRejectsNonCnyBeforeDatabaseAccess() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); 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.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; 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.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. */ /** Validates that a referenced File object belongs to the exact loading business resource. */
@FeignClient(name = "file-service", contextId = "firstlegLoadingDocumentFileClient", @FeignClient(name = "file-service", contextId = "firstlegLoadingDocumentFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files") url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
public interface LoadingDocumentFileClient { 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}") @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable String businessType, @PathVariable long tenantId, @PathVariable String businessType,
@@ -19,11 +19,15 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive; 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.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "货柜基础资料") @Validated @RestController @Tag(name = "货柜基础资料") @Validated @RestController
@RequestMapping("/api/platform/v1/containers") @RequestMapping("/api/platform/v1/containers")
@@ -87,6 +91,20 @@ public class PlatformContainerController {
id, user.userId(), user.username(), key, request)); 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") @PostMapping("/{id}/clearance/complete")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@Operation(summary = "确认货柜目的港清关完成") @Operation(summary = "确认货柜目的港清关完成")
@@ -19,7 +19,10 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive; 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.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated; 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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") @Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
@@ -116,6 +121,21 @@ public class PlatformLoadingListController {
planId, warehouseId, user.userId(), user.username(), key, request)); 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 = "确认装柜清单出口报关完成") @Operation(summary = "确认装柜清单出口报关完成")
@PostMapping("/loading-lists/{planId}/customs/complete") @PostMapping("/loading-lists/{planId}/customs/complete")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.customs.service; package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -9,7 +9,7 @@ import org.springframework.stereotype.Component;
@Component @Component
public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor { public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor {
@Resource private CustomsRepository repository; @Resource private LoadingListRepository repository;
@Override public String code() { return "CUSTOMS_CLEARANCE"; } @Override public String code() { return "CUSTOMS_CLEARANCE"; }
@@ -18,13 +18,12 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr
if (context.tenantId() <= 0 || context.customerId() <= 0) { if (context.tenantId() <= 0 || context.customerId() <= 0) {
return gate(false, "INVALID_CUSTOMER_SCOPE"); return gate(false, "INVALID_CUSTOMER_SCOPE");
} }
CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), context.inboundId(), ContainerDocumentEntity value = repository.document(context.containerBookingId(), "CUSTOMS_DECLARATION");
context.containerBookingId());
if (value == null) return gate(false, "MISSING"); 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) { 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 FEE_CONFIRMING = "FEE_CONFIRMING";
/** 费用已确认,正在执行托盘、库位占用和入库收口。 */ /** 费用已确认,正在执行托盘、库位占用和入库收口。 */
public static final String INBOUNDING = "INBOUNDING"; public static final String INBOUNDING = "INBOUNDING";
/** 已入库完成,费用已发送给客户。 */ /** 历史已入库终态;存量数据仍允许进入分柜。 */
public static final String INBOUNDED = "INBOUNDED"; public static final String INBOUNDED = "INBOUNDED";
/** /** 入库和费用发布完成,等待分柜。 */
* 历史兼容状态。二、三期已统一使用 {@link #INBOUNDED},不得再写入或对外返回此值。
*/
@Deprecated(forRemoval = false)
public static final String WAIT_SPLIT = "WAIT_SPLIT"; public static final String WAIT_SPLIT = "WAIT_SPLIT";
/** 已进入装柜作业,PDA 可执行拣货。 */ /** 已进入装柜作业,PDA 可执行拣货。 */
public static final String LOADING = "LOADING"; public static final String LOADING = "LOADING";
@@ -36,7 +33,7 @@ public final class InboundProcessStatus {
public static final Set<String> VALUES = Set.of( public static final Set<String> VALUES = Set.of(
WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED, 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 LOADING, LOADED, DELIVERED, VOIDED
); );
@@ -14,7 +14,7 @@ public record UnifiedInboundQuery(
Long tenantId, Long tenantId,
@Size(max = 64) String keyword, @Size(max = 64) String keyword,
@Size(max = 64) String orderNo, @Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED"}) "LOADING", "LOADED", "DELIVERED"})
@Size(max = 32) String mainStatus, @Size(max = 32) String mainStatus,
Long customerId, Long customerId,
@@ -16,27 +16,16 @@ public interface LoadingCompletionGateMapper {
COALESCE(SUM(CASE WHEN EXISTS ( COALESCE(SUM(CASE WHEN EXISTS (
SELECT 1 FROM fl_truck_appointment t SELECT 1 FROM fl_truck_appointment t
WHERE t.container_booking_id=p.container_booking_id AND t.loading_plan_id=p.id 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, 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
FROM fl_preload_loading_plan_link l FROM fl_preload_loading_plan_link l
JOIN fl_loading_plan p ON p.id=l.loading_plan_id JOIN fl_loading_plan p ON p.id=l.loading_plan_id
WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId} WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}
""") """)
GateSnapshot snapshot(@Param("tenantId") long tenantId, @Param("inboundId") long 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 allBookingReady() { return count(linkCount) > 0 && count(linkCount) == count(oceanReady); }
public boolean allTruckReady() { return count(linkCount) > 0 && count(linkCount) == count(truckReady); } 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; } private static int count(Integer value) { return value == null ? 0 : value; }
} }
} }
@@ -11,14 +11,13 @@ import org.springframework.stereotype.Service;
public class LoadingCompletionGateService { public class LoadingCompletionGateService {
@Resource private LoadingCompletionGateMapper mapper; @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); LoadingCompletionGateMapper.GateSnapshot gate = mapper.snapshot(tenantId, inboundId);
if (gate == null || gate.linkCount() == null || gate.linkCount() == 0) { if (gate == null || gate.linkCount() == null || gate.linkCount() == 0) {
throw blocked("未找到有效柜次关联"); throw blocked("未找到有效柜次关联");
} }
if (!gate.allBookingReady()) throw blocked("订舱尚未完成(必须为 RELEASED)"); if (!gate.allBookingReady()) throw blocked("订舱尚未完成(必须为 RELEASED)");
if (!gate.allTruckReady()) throw blocked("拖车尚未完成(必须为 COMPLETED)"); if (!gate.allTruckReady()) throw blocked("拖车尚未完成(必须为 COMPLETED)");
if (!gate.allCustomsReady()) throw blocked("缺少有效报关 RELEASED 放行回执");
} }
private static BizException blocked(String message) { private static BizException blocked(String message) {
@@ -111,7 +111,7 @@ public class LoadingDeliveryService {
} }
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中"); if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
loadingModelBridge.requireCompleted(tenantId, inboundId); loadingModelBridge.requireCompleted(tenantId, inboundId);
completionGate.requireBookingTransportAndCustoms(tenantId, inboundId); completionGate.requireBookingAndTransport(tenantId, inboundId);
requireCompletedPicking(tenantId, inboundId); requireCompletedPicking(tenantId, inboundId);
requireCompletionEvidence(tenantId, inboundId); requireCompletionEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
@@ -132,7 +132,7 @@ public class LoadingDeliveryService {
throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中"); throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中");
} }
loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId()); loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId());
completionGate.requireBookingTransportAndCustoms(tenantId, target.sourceInboundId()); completionGate.requireBookingAndTransport(tenantId, target.sourceInboundId());
requireCompletedPicking(tenantId, target.sourceInboundId()); requireCompletedPicking(tenantId, target.sourceInboundId());
requireCompletionEvidence(tenantId, target.sourceInboundId()); requireCompletionEvidence(tenantId, target.sourceInboundId());
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
@@ -92,6 +92,10 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
WHERE id=#{id} AND version=#{expectedVersion} WHERE id=#{id} AND version=#{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status='COMPLETED') 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, int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion, @Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion,
@@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update;
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> { public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update(""" @Update("""
UPDATE fl_inbound_order 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) actual_gross_weight_kg = (SELECT COALESCE(SUM(item.total_weight_kg), 0)
FROM fl_inbound_measurement_item item FROM fl_inbound_measurement_item item
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), 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) volume_weight_kg = (SELECT COALESCE(SUM(item.volume_weight_kg), 0)
FROM fl_inbound_measurement_item item FROM fl_inbound_measurement_item item
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}),
inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)),
updated_by = #{operatorId}, version = version + 1 updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING' AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
@@ -69,7 +68,7 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update(""" @Update("""
UPDATE fl_inbound_order 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 inbounded_at = CURRENT_TIMESTAMP(3), updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'INBOUNDING' AND fee_status = 'CONFIRMED' 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_by = #{operatorId}, b.updated_by_name = #{operatorName},
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1 b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
WHERE p.id = #{planId} AND p.plan_status = 'LOADING' 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, int updateTransport(@Param("planId") long planId, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId, @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 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') 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 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 (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.inbound_detail_id IS NOT NULL
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li 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 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') 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 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 (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.inbound_detail_id=#{detailId} AND i.inbound_detail_id=#{detailId}
ORDER BY m.id DESC LIMIT 1 FOR UPDATE ORDER BY m.id DESC LIMIT 1 FOR UPDATE
@@ -209,7 +209,7 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus) .eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED")) .in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED"); .set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if (!"INBOUNDED".equals(processStatus)) { if (!List.of("WAIT_SPLIT", "INBOUNDED").contains(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING"); update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
} }
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable); if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
@@ -229,7 +229,7 @@ public class FbaPricingRepository {
public int markInboundConfirmed(long tenantId, long inboundId) { public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>() return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) .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") .eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED")); .set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
} }
@@ -58,7 +58,8 @@ public class AutomaticInboundCompletionService {
} }
private static boolean completed(InboundOrderEntity order) { 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) { 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.entity.ContainerTransportHistoryEntity;
import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -31,6 +32,7 @@ public class ContainerService {
@Resource private ContainerRepository repository; @Resource private ContainerRepository repository;
@Resource private WarehouseMasterDataClient warehouses; @Resource private WarehouseMasterDataClient warehouses;
@Resource private SmartLoadingRepository smartLoadingRepository; @Resource private SmartLoadingRepository smartLoadingRepository;
@Resource private LoadingPickRepository loadingPickRepository;
@Resource private LoadingPickService loadingPickService; @Resource private LoadingPickService loadingPickService;
@Autowired(required = false) private ContainerTransportHistoryMapper transportHistories; @Autowired(required = false) private ContainerTransportHistoryMapper transportHistories;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
@@ -86,6 +88,10 @@ public class ContainerService {
requireVersion(current, request.version()); requireVersion(current, request.version());
if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改"); if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改");
String containerNo = trim(request.containerNo()); 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("集装箱号已存在"); if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
try { try {
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(), if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
@@ -82,8 +82,8 @@ public class FbaPricingService {
throw conflict("入库单状态已变化,费用试算未保存"); throw conflict("入库单状态已变化,费用试算未保存");
} }
complete(token, fee.getId(), now); complete(token, fee.getId(), now);
String nextStatus = "INBOUNDED".equals(inbound.getProcessStatus()) String nextStatus = isCompletedInbound(inbound.getProcessStatus())
? "INBOUNDED" : "FEE_CONFIRMING"; ? inbound.getProcessStatus() : "FEE_CONFIRMING";
return response(inbound, fee, rate.getUnitPrice(), nextStatus); return response(inbound, fee, rate.getUnitPrice(), nextStatus);
} }
@@ -114,8 +114,8 @@ public class FbaPricingService {
String hash = hash("SEND", inboundId, current.getId().toString()); String hash = hash("SEND", inboundId, current.getId().toString());
InboundPricingIdempotencyEntity token = beginIdempotency( InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash); tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
String nextProcessStatus = "INBOUNDED".equals(inbound.getProcessStatus()) String nextProcessStatus = isCompletedInbound(inbound.getProcessStatus())
? "INBOUNDED" : "WAIT_PALLET"; ? inbound.getProcessStatus() : "WAIT_PALLET";
if ("COMPLETED".equals(token.getStatus())) { if ("COMPLETED".equals(token.getStatus())) {
feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(), feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(),
operatorId, null, LocalDateTime.now(clock)); operatorId, null, LocalDateTime.now(clock));
@@ -144,7 +144,7 @@ public class FbaPricingService {
AdjustFeeRequest request, String idempotencyKey) { AdjustFeeRequest request, String idempotencyKey) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId); InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); 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("只能修改已完成入库且已发布的费用"); throw conflict("只能修改已完成入库且已发布的费用");
} }
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId); InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
@@ -156,7 +156,7 @@ public class FbaPricingService {
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
"FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator)); "FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator));
if ("COMPLETED".equals(token.getStatus())) { if ("COMPLETED".equals(token.getStatus())) {
return response(tenantId, token.getResultResourceId(), "INBOUNDED"); return response(tenantId, token.getResultResourceId(), inbound.getProcessStatus());
} }
LocalDateTime now = LocalDateTime.now(clock); LocalDateTime now = LocalDateTime.now(clock);
@@ -204,12 +204,12 @@ public class FbaPricingService {
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED, financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED,
tenantId, inboundId, adjusted.getId(), nextVersion, now); tenantId, inboundId, adjusted.getId(), nextVersion, now);
complete(token, adjusted.getId(), 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) { private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, 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())) { || !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算"); throw conflict("当前入库单状态不允许费用试算");
} }
@@ -234,6 +234,10 @@ public class FbaPricingService {
return inbound; return inbound;
} }
private static boolean isCompletedInbound(String processStatus) {
return "WAIT_SPLIT".equals(processStatus) || "INBOUNDED".equals(processStatus);
}
private static PricingDestinationType destinationType(InboundPricingEntity inbound) { private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
try { try {
return PricingDestinationType.valueOf(inbound.getDestinationType()); return PricingDestinationType.valueOf(inbound.getDestinationType());
@@ -93,7 +93,7 @@ public class InboundCompletionOrchestrator {
var published = feeDetails.publishFreight(tenantId, inboundId, feeId, operatorId, var published = feeDetails.publishFreight(tenantId, inboundId, feeId, operatorId,
operatorName, LocalDateTime.now()); operatorName, LocalDateTime.now());
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId, 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, financeOutbox.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED, tenantId,
inboundId, feeId, published.getSourceFeeVersion(), LocalDateTime.now()); inboundId, feeId, published.getSourceFeeVersion(), LocalDateTime.now());
idempotency.complete(context, inboundId); idempotency.complete(context, inboundId);
@@ -30,6 +30,7 @@ import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
@@ -103,7 +104,18 @@ public class LoadingListService {
String key, DocumentAttachRequest request) { String key, DocumentAttachRequest request) {
AggregateRow plan = requireBookingPlan(bookingId); AggregateRow plan = requireBookingPlan(bookingId);
return attachDocument(plan, DESTINATION_CLEARANCE, CLEARANCE_FILE_TYPE, 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 @Transactional
@@ -111,7 +123,7 @@ public class LoadingListService {
resourceId = "#bookingId", description = "确认目的港清关完成") resourceId = "#bookingId", description = "确认目的港清关完成")
public BusinessDocument completeContainerClearance(long bookingId, long operatorId, String operatorName, public BusinessDocument completeContainerClearance(long bookingId, long operatorId, String operatorName,
String key, DocumentCompleteRequest request) { String key, DocumentCompleteRequest request) {
return completeDocument(requireBookingPlan(bookingId), DESTINATION_CLEARANCE, return completeDocument(requireBookingPlan(bookingId), DESTINATION_CLEARANCE, bookingId,
operatorId, operatorName, key, request); operatorId, operatorName, key, request);
} }
@@ -132,7 +144,20 @@ public class LoadingListService {
requireWarehouse(plan, warehouseId); requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan); requireCompletedPlan(plan);
return attachDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE, 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 @Transactional
@@ -144,28 +169,55 @@ public class LoadingListService {
AggregateRow plan = requirePlan(planId); AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId); requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan); 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, 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 key, DocumentAttachRequest request) {
String requestHash = sha256("ATTACH|" + documentType + '|' + plan.getPlanId() + '|' String requestHash = sha256("ATTACH|" + documentType + '|' + identityId + '|'
+ request.fileId() + '|' + request.expectedVersion() + '|' + trim(request.operationReason())); + 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); HistoryRow replay = repository.history(operationId);
if (replay != null) { if (replay != null) {
requireSameRequest(replay, requestHash); requireSameRequest(replay, requestHash);
return requireDocument(plan.getContainerBookingId(), documentType); return requireDocument(plan.getContainerBookingId(), documentType);
} }
ContainerDocumentEntity current = repository.document(plan.getContainerBookingId(), documentType); ContainerDocumentEntity current = repository.document(plan.getContainerBookingId(), documentType);
if (current == null && request.expectedVersion() != 0) throw conflict("资料版本已变化,请刷新后重试"); requireReplaceableDocument(current, request.expectedVersion());
if (current != null && !java.util.Objects.equals(current.getVersion(), request.expectedVersion())) {
throw conflict("资料版本已变化,请刷新后重试");
}
if (current != null && current.getCompletedAt() != null) throw conflict("已完成的资料不可替换");
LoadingDocumentFileClient.Metadata metadata = validateDocumentFile( LoadingDocumentFileClient.Metadata metadata = validateDocumentFile(
businessType, businessId, request.fileId()); 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); Map<String, Object> beforeSnapshot = documentSnapshot(current);
ContainerDocumentEntity value = current == null ? new ContainerDocumentEntity() : current; ContainerDocumentEntity value = current == null ? new ContainerDocumentEntity() : current;
if (current == null) value.setId(IdWorker.getId()); if (current == null) value.setId(IdWorker.getId());
@@ -179,19 +231,19 @@ public class LoadingListService {
throw conflict("资料已被其他操作上传,请刷新后重试"); throw conflict("资料已被其他操作上传,请刷新后重试");
} }
ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType); 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), plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot),
json(values("requestHash", requestHash, "document", documentSnapshot(saved))), json(values("requestHash", requestHash, "document", documentSnapshot(saved))),
operatorId, trim(operatorName), trim(request.operationReason())); operatorId, trim(operatorName), trim(operationReason));
return document(saved); 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, String operatorName, String key,
DocumentCompleteRequest request) { DocumentCompleteRequest request) {
String requestHash = sha256("COMPLETE|" + documentType + '|' + plan.getPlanId() + '|' String requestHash = sha256("COMPLETE|" + documentType + '|' + identityId + '|'
+ request.expectedVersion() + '|' + trim(request.operationReason())); + 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); HistoryRow replay = repository.history(operationId);
if (replay != null) { if (replay != null) {
requireSameRequest(replay, requestHash); requireSameRequest(replay, requestHash);
@@ -224,7 +276,8 @@ public class LoadingListService {
|| !businessType.equals(response.data().businessType()) || !businessType.equals(response.data().businessType())
|| !businessId.equals(response.data().businessId()) || !businessId.equals(response.data().businessId())
|| !StringUtils.hasText(response.data().fileName()) || !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("附件不存在、归属不匹配或文件元数据不完整"); throw badRequest("附件不存在、归属不匹配或文件元数据不完整");
} }
return response.data(); 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) { private AggregateRow requireBookingPlan(long bookingId) {
AggregateRow row = repository.aggregateByBooking(bookingId); AggregateRow row = repository.aggregateByBooking(bookingId);
if (row == null) throw notFound("货柜或关联装柜计划不存在"); if (row == null) throw notFound("货柜或关联装柜计划不存在");
@@ -286,6 +381,9 @@ public class LoadingListService {
} }
requireLoadingMutable(before); requireLoadingMutable(before);
if (containerNo == null && pickRepository.taskByPlan(planId) != null) {
throw conflict("PDA拣货任务已创建,不能清空真实集装箱号");
}
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName)) != 1) { operatorId, trim(operatorName)) != 1) {
throw conflict("预装计划已被其他操作修改,请刷新后重试"); throw conflict("预装计划已被其他操作修改,请刷新后重试");
@@ -21,8 +21,7 @@ import java.time.LocalDateTime;
import java.math.BigDecimal; import java.math.BigDecimal;
/** /**
* 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 INBOUNDED; * 测量审核应用服务。审核通过后进入内部计费编排,完成费用发布和入库后再进入 WAIT_SPLIT。
* 计费、库位和其他资料不作为本版本的强制门禁。
*/ */
@Service @Service
public class MeasurementReviewService { public class MeasurementReviewService {
@@ -100,7 +99,7 @@ public class MeasurementReviewService {
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result, repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
request.reason(), false, null, false, operatorId, operatorName)); request.reason(), false, null, false, operatorId, operatorName));
repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING", 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)); approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName));
idempotency.complete(context, inboundId); idempotency.complete(context, inboundId);
} }
@@ -28,9 +28,7 @@ import java.util.stream.Collectors;
@Service @Service
public class UnifiedInboundQueryService { public class UnifiedInboundQueryService {
private static final Set<String> MAIN_STATUSES = Set.of( private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED"); "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
/** 仅兼容旧客户端筛选值,响应和 OpenAPI 均只使用 INBOUNDED。 */
private static final String LEGACY_WAIT_SPLIT = "WAIT_SPLIT";
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
@Resource private InboundOrderRepository inbounds; @Resource private InboundOrderRepository inbounds;
@@ -140,9 +138,9 @@ public class UnifiedInboundQueryService {
static String mainStatus(String status) { static String mainStatus(String status) {
if (status == null) return null; if (status == null) return null;
return switch (status) { return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE"; case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", LEGACY_WAIT_SPLIT -> "INBOUNDED"; "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING" -> "WAIT_MEASURE";
case "LOADING", "LOADED", "DELIVERED" -> status; case "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED" -> status;
default -> null; default -> null;
}; };
} }
@@ -152,7 +150,7 @@ public class UnifiedInboundQueryService {
return switch (mainStatus) { return switch (mainStatus) {
case "PENDING_REVIEW" -> "APPROVE_ORDER"; case "PENDING_REVIEW" -> "APPROVE_ORDER";
case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null; 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 : "CONFIRMED".equals(preloadStatus) ? null
: preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD"; : preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD";
case "LOADING" -> "COMPLETE_LOADING"; case "LOADING" -> "COMPLETE_LOADING";
@@ -169,8 +167,10 @@ public class UnifiedInboundQueryService {
private List<String> internalStatuses(String mainStatus) { private List<String> internalStatuses(String mainStatus) {
if (!StringUtils.hasText(mainStatus)) return List.of(); if (!StringUtils.hasText(mainStatus)) return List.of();
return switch (mainStatus) { return switch (mainStatus) {
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED"); 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); "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING");
case "INBOUNDED" -> List.of("INBOUNDED");
case "WAIT_SPLIT" -> List.of("WAIT_SPLIT");
case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus); case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
default -> List.of("__NO_MATCH__"); default -> List.of("__NO_MATCH__");
}; };
@@ -189,7 +189,7 @@ public class UnifiedInboundQueryService {
private void validate(UnifiedInboundQuery query) { private void validate(UnifiedInboundQuery query) {
if (StringUtils.hasText(query.mainStatus()) if (StringUtils.hasText(query.mainStatus())
&& !MAIN_STATUSES.contains(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())
&& !LEGACY_WAIT_SPLIT.equals(query.mainStatus())) { ) {
throw new com.baiyue.common.core.exception.BizException( throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法"); 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}, " @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 " + "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, int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@@ -44,7 +44,7 @@ public class SplitPreloadService {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, SMART_SPLIT, key); SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, SMART_SPLIT, key);
if (replay != null) return replay; if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId); InboundSnapshot inbound = requireInbound(tenantId, inboundId);
if (!"INBOUNDED".equals(inbound.processStatus())) { if (!isWaitingSplit(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以生成预装清单"); throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以生成预装清单");
} }
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
@@ -69,7 +69,7 @@ public class SplitPreloadService {
} }
PreloadPlanResponse result = response(repository.findPlan(tenantId, inboundId)); PreloadPlanResponse result = response(repository.findPlan(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, SMART_SPLIT, key, return complete(tenantId, inboundId, operatorId, SMART_SPLIT, key,
new SplitActionResponse("INBOUNDED", result)); new SplitActionResponse("WAIT_SPLIT", result));
} }
@Transactional @Transactional
@@ -85,12 +85,12 @@ public class SplitPreloadService {
// confirmation is submitted; retain the linked-container hard gate. // confirmation is submitted; retain the linked-container hard gate.
loadingModelBridge.requireStarted(tenantId, inboundId); loadingModelBridge.requireStarted(tenantId, inboundId);
} else { } else {
requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以提交预装清单确认"); requireWaitingSplit(inbound, "仅待分柜订单可以提交预装清单确认");
} }
PreloadPlanEntity plan = requirePlan(tenantId, inboundId); PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) { if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) {
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, 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()) if (!PreloadStatus.PENDING.equals(plan.getStatus())
|| repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING, || repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING,
@@ -98,7 +98,7 @@ public class SplitPreloadService {
throw concurrent(); throw concurrent();
} }
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse(loading ? "LOADING" : "INBOUNDED", new SplitActionResponse(loading ? "LOADING" : "WAIT_SPLIT",
response(repository.findPlan(tenantId, inboundId)))); response(repository.findPlan(tenantId, inboundId))));
} }
@@ -114,7 +114,7 @@ public class SplitPreloadService {
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
} }
requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以确认预装清单"); requireWaitingSplit(inbound, "仅待分柜订单可以确认预装清单");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId); PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) { if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试"); throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
@@ -127,7 +127,7 @@ public class SplitPreloadService {
if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) { if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) {
throw concurrent(); throw concurrent();
} }
addHistory(tenantId, inboundId, "INBOUNDED", "LOADING", CONFIRM_PRELOAD, addHistory(tenantId, inboundId, inbound.processStatus(), "LOADING", CONFIRM_PRELOAD,
operatorId, operatorName); operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
@@ -172,8 +172,12 @@ public class SplitPreloadService {
return plan; return plan;
} }
private static void requireStatus(InboundSnapshot inbound, String expected, String message) { private static void requireWaitingSplit(InboundSnapshot inbound, String message) {
if (!expected.equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, 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, 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; USE baiyue_firstleg;
@@ -13,7 +13,7 @@ BEGIN
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_status_history' WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_status_history'
AND column_name IN ('from_status', 'to_status')) <> 2 THEN AND column_name IN ('from_status', 'to_status')) <> 2 THEN
SIGNAL SQLSTATE '45000' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'INBOUNDED 兼容校验失败:入库状态字段不完整'; SET MESSAGE_TEXT = 'WAIT_SPLIT/INBOUNDED 兼容校验失败:入库状态字段不完整';
END IF; END IF;
END$$ END$$
DELIMITER ; 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 ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')); 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; DROP TRIGGER IF EXISTS trg_container_document_before_insert;
DELIMITER $$ DELIMITER $$
CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_container_document FOR EACH ROW 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); DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE container_booking_id = OLD.id; FROM fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status = 'COMPLETED' IF current_plan_status = 'COMPLETED' THEN
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改';
END IF; END IF;
IF current_plan_status = 'LOADING' AND ( IF current_plan_status = 'LOADING' AND (
@@ -927,7 +926,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = NEW.container_booking_id; WHERE container_booking_id = NEW.container_booking_id;
IF current_plan_status = 'COMPLETED' 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 = '装柜完成后货柜文件不可新增'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
END IF; END IF;
END$$ END$$
@@ -942,7 +941,8 @@ BEGIN
DECLARE current_plan_status VARCHAR(24); DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id; 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 = '装柜完成后货柜文件不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
END IF; END IF;
END$$ END$$
@@ -68,8 +68,8 @@ paths:
post: post:
summary: 按统一订单条件分页查询履约摘要(Order 内部) summary: 按统一订单条件分页查询履约摘要(Order 内部)
description: >- description: >-
mainStatus 接受六态产品状态;历史 WAIT_SPLIT 仅作为兼容筛选值,按 INBOUNDED 范围查询。 mainStatus 接受七态产品状态;WAIT_SPLIT 为正式待分柜状态,INBOUNDED 仅兼容存量已入库记录。
所有响应 mainStatus 均归并为六态产品状态,不返回 WAIT_SPLIT。 计费与入库处理中状态在完成前仍归并为 WAIT_MEASURE,不提前开放分柜。
security: security:
- internalServiceToken: [] - internalServiceToken: []
requestBody: requestBody:
@@ -102,7 +102,7 @@ paths:
/internal/firstleg/v1/inbounds/unified-orders/detail: /internal/firstleg/v1/inbounds/unified-orders/detail:
post: post:
summary: 按订单 ID 查询统一履约详情(Order 内部) summary: 按订单 ID 查询统一履约详情(Order 内部)
description: 时间线保留 Firstleg 内部 fromStatus/toStatus;summary.mainStatus 始终使用六态产品状态。 description: 时间线保留 Firstleg 内部 fromStatus/toStatus;summary.mainStatus 使用七态产品状态。
security: security:
- internalServiceToken: [] - internalServiceToken: []
requestBody: requestBody:
@@ -258,6 +258,17 @@ paths:
required: true required: true
content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}} content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}}
responses: {'200': {description: 成功、文件归属不匹配或版本冲突}} 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: /api/platform/v1/containers/{id}/clearance/complete:
post: post:
summary: 点击完成目的港清关 summary: 点击完成目的港清关
@@ -645,8 +656,8 @@ components:
schemas: schemas:
UnifiedMainStatus: UnifiedMainStatus:
type: string 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 description: 七态产品主状态;WAIT_SPLIT 为正式待分柜状态,INBOUNDED 仅兼容存量已入库记录
UnifiedInboundQuery: UnifiedInboundQuery:
type: object type: object
properties: properties:
@@ -657,7 +668,7 @@ components:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT] enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT]
nullable: true nullable: true
description: WAIT_SPLIT 仅为历史兼容筛选值,等价于 INBOUNDED description: WAIT_SPLIT 与 INBOUNDED 为独立筛选值
customerId: {type: integer, format: int64, nullable: true} customerId: {type: integer, format: int64, nullable: true}
receivingWarehouseId: {type: integer, format: int64, nullable: true} receivingWarehouseId: {type: integer, format: int64, nullable: true}
destinationWarehouseId: {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} orderId: {type: integer, format: int64, nullable: true}
orderNo: {type: string, nullable: true} orderNo: {type: string, nullable: true}
inboundCode: {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} orderStatusSnapshot: {type: string, nullable: true}
measureReviewStatus: {type: string, nullable: true} measureReviewStatus: {type: string, nullable: true}
feeStatus: {type: string, nullable: true} feeStatus: {type: string, nullable: true}
@@ -1054,7 +1065,7 @@ components:
type: object type: object
required: [version, reason] required: [version, reason]
properties: properties:
containerNo: {type: string, maxLength: 64, nullable: true} containerNo: {type: string, maxLength: 64, nullable: true, description: 真实集装箱号;PDA任务创建前可空,任务创建后允许替换但禁止清空}
voyageNo: {type: string, maxLength: 64, nullable: true} voyageNo: {type: string, maxLength: 64, nullable: true}
version: {type: integer, minimum: 0} version: {type: integer, minimum: 0}
reason: {type: string, maxLength: 500} reason: {type: string, maxLength: 500}
@@ -1071,6 +1082,14 @@ components:
properties: properties:
expectedVersion: {type: integer, minimum: 0} expectedVersion: {type: integer, minimum: 0}
operationReason: {type: string, maxLength: 500, nullable: true} 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: MerchantInboundBillingEnvelope:
type: object type: object
properties: properties:
@@ -85,6 +85,19 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}} content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}}
responses: responses:
'200': {$ref: '#/components/responses/BusinessDocumentResponse'} '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: /api/platform/v1/first-leg/loading-lists/{planId}/customs/complete:
post: post:
summary: 点击完成出口报关 summary: 点击完成出口报关
@@ -353,7 +366,7 @@ components:
required: [expectedVersion] required: [expectedVersion]
additionalProperties: false additionalProperties: false
properties: 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: 航次} voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因} operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
@@ -372,6 +385,14 @@ components:
properties: properties:
expectedVersion: {type: integer, format: int32, minimum: 0} expectedVersion: {type: integer, format: int32, minimum: 0}
operationReason: {type: string, maxLength: 500, nullable: true} 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: BusinessDocument:
type: object type: object
required: [id, documentType, fileId, fileName, fileHash, uploadedBy, uploadedAt, version] required: [id, documentType, fileId, fileName, fileHash, uploadedBy, uploadedAt, version]
@@ -21,6 +21,7 @@ class ContainerControllerContractTest {
assertPermission("create", "platform:firstleg:container:create"); assertPermission("create", "platform:firstleg:container:create");
assertPermission("updateDraft", "platform:firstleg:container:edit"); assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("updateTransport", "platform:firstleg:container:edit"); assertPermission("updateTransport", "platform:firstleg:container:edit");
assertPermission("uploadClearance", "platform:firstleg:customs:manage");
} }
@Test @Test
@@ -33,6 +34,9 @@ class ContainerControllerContractTest {
assertThat(section) assertThat(section)
.contains("/api/platform/v1/containers/{id}/transport:") .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("platform:firstleg:container:list")
.contains("EDITING") .contains("EDITING")
.contains("WAIT_LOADING") .contains("WAIT_LOADING")
@@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent; 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("declaration", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class);
authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
long.class, long.class, String.class, DocumentAttachRequest.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, authority("completeDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
long.class, long.class, String.class, DocumentCompleteRequest.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}",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs", "/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",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete"); "/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete");
Map<String, Object> components = stringMap(document.get("components")); Map<String, Object> components = stringMap(document.get("components"));
Map<String, Object> schemas = stringMap(components.get("schemas")); Map<String, Object> schemas = stringMap(components.get("schemas"));
@@ -108,7 +112,9 @@ class LoadingListControllerContractTest {
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary", "LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail", "ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "BusinessDocumentEnvelope", "ConfirmedDetail", "TransportUpdateRequest", "BusinessDocumentEnvelope",
"DocumentAttachRequest", "DocumentCompleteRequest", "BusinessDocument"); "DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument");
assertThat(properties(schemas, "DocumentUploadRequest"))
.containsOnlyKeys("file", "expectedVersion", "operationReason");
assertThat(paths) assertThat(paths)
.doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete"); .doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete");
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string"); assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class UnifiedInboundOpenApiContractTest { class UnifiedInboundOpenApiContractTest {
@Test @Test
void documentsInternalUnifiedOrderEndpointsAndLegacyWaitSplitMapping() throws Exception { void documentsInternalUnifiedOrderEndpointsAndSevenStatuses() throws Exception {
String yaml = new ClassPathResource("openapi/firstleg-v1.yaml") String yaml = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml)); 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> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus")); Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus"));
assertThat(stringList(responseStatus.get("enum"))).containsExactly( 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"); assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED");
Map<String, Object> query = stringMap(schemas.get("UnifiedInboundQuery")); Map<String, Object> query = stringMap(schemas.get("UnifiedInboundQuery"));
Map<String, Object> queryStatus = stringMap(stringMap(query.get("properties")).get("mainStatus")); Map<String, Object> queryStatus = stringMap(stringMap(query.get("properties")).get("mainStatus"));
assertThat(stringList(queryStatus.get("enum"))).contains("WAIT_SPLIT"); 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")); Map<String, Object> inbound = stringMap(schemas.get("UnifiedInboundOrderSummary"));
assertThat(stringMap(inbound.get("properties"))).containsKeys( assertThat(stringMap(inbound.get("properties"))).containsKeys(
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.customs.service; package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class CustomsDispatchGateContributorTest { class CustomsDispatchGateContributorTest {
@Mock CustomsRepository repository; @Mock LoadingListRepository repository;
@InjectMocks CustomsDispatchGateContributor contributor; @InjectMocks CustomsDispatchGateContributor contributor;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@@ -22,12 +22,16 @@ class CustomsDispatchGateContributorTest {
.isEqualTo("INVALID_CUSTOMER_SCOPE"); .isEqualTo("INVALID_CUSTOMER_SCOPE");
} }
@Test void passesOnlyReleasedTerminalState() { @Test void passesOnlyCompletedSimpleDeclaration() {
CustomsCaseEntity value = new CustomsCaseEntity(); value.setStatus("RECEIPT_AVAILABLE"); ContainerDocumentEntity value = new ContainerDocumentEntity();
when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value); when(repository.document(81L, "CUSTOMS_DECLARATION")).thenReturn(value);
assertThat(contributor.assess(context()).passed()).isFalse(); assertThat(contributor.assess(context()).passed()).isFalse();
value.setStatus("CLEARANCE_COMPLETED"); value.setCompletedAt(java.time.LocalDateTime.now());
assertThat(contributor.assess(context()).passed()).isTrue(); 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); } private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); }
@@ -7,7 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class LoadingCompletionGateSqlContractTest { class LoadingCompletionGateSqlContractTest {
@Test @Test
void requiresEveryLinkedContainerToPassBookingTruckAndCustomsReceiptGates() throws Exception { void requiresEveryLinkedContainerToPassBookingAndTruckGates() throws Exception {
Select select = LoadingCompletionGateMapper.class Select select = LoadingCompletionGateMapper.class
.getDeclaredMethod("snapshot", long.class, long.class).getAnnotation(Select.class); .getDeclaredMethod("snapshot", long.class, long.class).getAnnotation(Select.class);
String sql = String.join(" ", select.value()).replaceAll("\\s+", " "); String sql = String.join(" ", select.value()).replaceAll("\\s+", " ");
@@ -19,13 +19,9 @@ class LoadingCompletionGateSqlContractTest {
.contains("b.status='RELEASED'") .contains("b.status='RELEASED'")
.contains("fl_truck_appointment") .contains("fl_truck_appointment")
.contains("t.status='COMPLETED'") .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}") .contains("l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}")
.doesNotContain("truckReady, FROM")
.doesNotContain("fl_customs_case", "fl_customs_receipt", "customsReady")
.doesNotContain("&lt;", "&gt;"); .doesNotContain("&lt;", "&gt;");
} }
} }
@@ -79,7 +79,7 @@ class LoadingDeliveryServiceTest {
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null)); LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED"); assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20); verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireBookingTransportAndCustoms(10, 20); verify(completionGate).requireBookingAndTransport(10, 20);
verify(histories).insert(any(InboundStatusHistoryEntity.class)); verify(histories).insert(any(InboundStatusHistoryEntity.class));
} }
@@ -113,7 +113,7 @@ class LoadingDeliveryServiceTest {
assertThat(result.mainStatus()).isEqualTo("LOADED"); assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20); verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireBookingTransportAndCustoms(10, 20); verify(completionGate).requireBookingAndTransport(10, 20);
verify(repository).completeLoadedChild(10, 20, 7, 3); verify(repository).completeLoadedChild(10, 20, 7, 3);
verify(repository).aggregateRootLoaded(10, 20, 7); verify(repository).aggregateRootLoaded(10, 20, 7);
verify(histories).insert(argThat((InboundStatusHistoryEntity history) -> verify(histories).insert(argThat((InboundStatusHistoryEntity history) ->
@@ -172,7 +172,7 @@ class LoadingDeliveryServiceTest {
verify(repository).completeLoadedChild(10, 20, 7, 5); verify(repository).completeLoadedChild(10, 20, 7, 5);
verify(repository).aggregateRootLoaded(10, 10, 7); verify(repository).aggregateRootLoaded(10, 10, 7);
verify(loadingModelBridge).requireCompleted(10, 20); verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireBookingTransportAndCustoms(10, 20); verify(completionGate).requireBookingAndTransport(10, 20);
verify(histories).insert(any(InboundStatusHistoryEntity.class)); verify(histories).insert(any(InboundStatusHistoryEntity.class));
verify(repository).completeOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay", resultJson.get()); verify(repository).completeOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay", resultJson.get());
} }
@@ -44,6 +44,8 @@ class ContainerBookingMapperContractTest {
.contains("capacity_cbm") .contains("capacity_cbm")
.contains("p.plan_status!='EDITING'") .contains("p.plan_status!='EDITING'")
.contains("p.plan_status='COMPLETED'") .contains("p.plan_status='COMPLETED'")
.contains("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task")
.contains("version=#{expectedVersion}") .contains("version=#{expectedVersion}")
.doesNotContain("&lt;") .doesNotContain("&lt;")
.doesNotContain("&gt;"); .doesNotContain("&gt;");
@@ -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());
}
}
@@ -18,11 +18,13 @@ class InboundStatusUnificationSqlContractTest {
assertThat(module) assertThat(module)
.contains("CREATE PROCEDURE assert_inbounded_compatibility()") .contains("CREATE PROCEDURE assert_inbounded_compatibility()")
.contains("二、三期对外统一使用 INBOUNDED") .contains("新完成入库记录使用 WAIT_SPLIT")
.contains("存量 INBOUNDED")
.doesNotContain("UPDATE fl_inbound_order") .doesNotContain("UPDATE fl_inbound_order")
.doesNotContain("UPDATE fl_inbound_status_history"); .doesNotContain("UPDATE fl_inbound_status_history");
assertThat(unified) assertThat(unified)
.contains("二、三期对外统一使用 INBOUNDED") .contains("新完成入库记录使用 WAIT_SPLIT")
.contains("存量 INBOUNDED")
.doesNotContain("SET process_status = 'INBOUNDED'") .doesNotContain("SET process_status = 'INBOUNDED'")
.doesNotContain("SET from_status = 'INBOUNDED'") .doesNotContain("SET from_status = 'INBOUNDED'")
.doesNotContain("SET to_status = 'INBOUNDED'"); .doesNotContain("SET to_status = 'INBOUNDED'");
@@ -60,6 +60,8 @@ class LoadingListQueryMapperContractTest {
.contains("version = version + 1"); .contains("version = version + 1");
assertThat(transport) assertThat(transport)
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.contains("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task")
.doesNotContain("DELETE") .doesNotContain("DELETE")
.doesNotContain("fl_loading_plan_item"); .doesNotContain("fl_loading_plan_item");
assertThat(advance) assertThat(advance)
@@ -84,7 +84,7 @@ class Phase3FinalUnifiedSqlContractTest {
"-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql", "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql",
"firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql"); "firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql");
assertBlock(unified, "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.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"); "iam/src/main/resources/db/phase3-warehouse-roles.sql");
} }
@@ -21,7 +21,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("m2.status='APPROVED'") .contains("m2.status='APPROVED'")
.contains("i.inbound_detail_id IS NOT NULL") .contains("i.inbound_detail_id IS NOT NULL")
.contains("o.warehouse_id=#{warehouseId}") .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("SELECT SUM(li.package_count)")
.contains("li.deleted=0") .contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
@@ -38,7 +38,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("m.status='APPROVED'") .contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)") .contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}") .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("SELECT SUM(li.package_count)")
.contains("FOR UPDATE"); .contains("FOR UPDATE");
} }
@@ -73,6 +73,30 @@ class SmartLoadingSqlContractTest {
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); .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 @Test
@DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色") @DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色")
void shouldProvidePlatformPermissions() throws Exception { 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.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
@@ -37,6 +39,7 @@ class ContainerServiceTest {
@MockBean private ContainerRepository repository; @MockBean private ContainerRepository repository;
@MockBean private WarehouseMasterDataClient warehouses; @MockBean private WarehouseMasterDataClient warehouses;
@MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean private SmartLoadingRepository smartLoadingRepository;
@MockBean private LoadingPickRepository loadingPickRepository;
@MockBean private LoadingPickService loadingPickService; @MockBean private LoadingPickService loadingPickService;
@MockBean private ContainerTransportHistoryMapper transportHistories; @MockBean private ContainerTransportHistoryMapper transportHistories;
@Resource private ContainerService service; @Resource private ContainerService service;
@@ -145,6 +148,25 @@ class ContainerServiceTest {
org.mockito.ArgumentMatchers.eq("admin"), any()); 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 @Test
@DisplayName("已确认货柜全部只读") @DisplayName("已确认货柜全部只读")
void rejectsConfirmedUpdate() { void rejectsConfirmedUpdate() {
@@ -114,7 +114,7 @@ class FbaPricingServiceTest {
void calculatesAtWaitSplitWithoutRollingBackProcessStatus() { void calculatesAtWaitSplitWithoutRollingBackProcessStatus() {
readyRepository(); readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0000"); InboundPricingEntity inbound = inbound("100.000", "1.0000");
inbound.setProcessStatus("INBOUNDED"); inbound.setProcessStatus("WAIT_SPLIT");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenReturn( when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "167.000", "2.000000", "334.00", "0.00")); 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"); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
assertThat(response.feeStatus()).isEqualTo("CALCULATED"); assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "INBOUNDED", "KG", verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG",
new BigDecimal("167.000")); new BigDecimal("167.000"));
} }
@@ -131,7 +131,7 @@ class FbaPricingServiceTest {
@DisplayName("待分柜边界确认费用后仍保持主流程状态") @DisplayName("待分柜边界确认费用后仍保持主流程状态")
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() { void confirmsAtWaitSplitWithoutRollingBackProcessStatus() {
InboundPricingEntity inbound = inbound("10.000", "1.0000"); InboundPricingEntity inbound = inbound("10.000", "1.0000");
inbound.setProcessStatus("INBOUNDED"); inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("CALCULATED"); inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED"); InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee); when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
@@ -148,7 +148,7 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.confirm( FeeSnapshotResponse response = service.confirm(
TENANT, OPERATOR, INBOUND, "confirm-wait-split"); TENANT, OPERATOR, INBOUND, "confirm-wait-split");
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundConfirmed(TENANT, INBOUND); 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.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.mock.web.MockMultipartFile;
import java.util.List; import java.util.List;
@@ -102,6 +103,23 @@ class LoadingListServiceTest {
verify(repository, never()).updateTransport(eq(10L), eq(5), anyString(), anyString(), eq(9L), anyString()); 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 @Test
void shouldCompleteLoadingAfterAllPdaItemsPicked() { void shouldCompleteLoadingAfterAllPdaItemsPicked() {
AggregateRow before = row("LOADING", 2); AggregateRow before = row("LOADING", 2);
@@ -206,14 +224,16 @@ class LoadingListServiceTest {
.thenReturn(null, document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false)); .thenReturn(null, document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false));
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata( 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); when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1);
var result = service.attachContainerClearance(30L, 9L, "operator", "key-clearance", var result = service.attachContainerClearance(30L, 9L, "operator", "key-clearance",
new DocumentAttachRequest(99L, 0, "上传清关资料")); new DocumentAttachRequest(99L, 0, "上传清关资料"));
assertThat(result.fileName()).isEqualTo("clearance.pdf"); 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("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"),
eq("上传清关资料")); eq("上传清关资料"));
} }
@@ -241,7 +261,7 @@ class LoadingListServiceTest {
when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null); when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null);
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata( 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))); "application/pdf", 20L)));
assertThatThrownBy(() -> service.attachContainerClearance(30L, 9L, "operator", "key", assertThatThrownBy(() -> service.attachContainerClearance(30L, 9L, "operator", "key",
@@ -306,6 +326,121 @@ class LoadingListServiceTest {
org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); 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) { private static AggregateRow row(String status, int version) {
AggregateRow row = new AggregateRow(); AggregateRow row = new AggregateRow();
row.setPlanId(10L); row.setPlanId(10L);
@@ -319,10 +454,31 @@ class LoadingListServiceTest {
private static ContainerDocumentEntity document(long id, String type, int version, boolean completed) { private static ContainerDocumentEntity document(long id, String type, int version, boolean completed) {
ContainerDocumentEntity value = new ContainerDocumentEntity(); ContainerDocumentEntity value = new ContainerDocumentEntity();
value.setId(id); value.setContainerBookingId(30L); value.setDocumentType(type); 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.setUploadedBy(9L); value.setUploadedByName("operator"); value.setVersion(version);
value.setCreatedAt(java.time.LocalDateTime.now()); value.setCreatedAt(java.time.LocalDateTime.now());
if (completed) value.setCompletedAt(java.time.LocalDateTime.now()); if (completed) value.setCompletedAt(java.time.LocalDateTime.now());
return value; 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);
}
}
} }
@@ -78,7 +78,7 @@ class MeasurementReviewServiceTest {
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture()); verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING"); assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING");
assertThat(history.getValue().getToStatus()).isEqualTo("INBOUNDED"); assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_PRICING");
} }
@Test @Test
@@ -28,10 +28,10 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.mainStatus("MEASURING")).isEqualTo("WAIT_MEASURE"); assertThat(UnifiedInboundQueryService.mainStatus("MEASURING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_PENDING")).isEqualTo("WAIT_MEASURE"); assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_PENDING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_REJECTED")).isEqualTo("WAIT_MEASURE"); assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_REJECTED")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("INBOUNDED"); assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("INBOUNDED"); assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("INBOUNDED"); assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("INBOUNDED"); assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("WAIT_SPLIT");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED"); assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING"); assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING");
assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED"); assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED");
@@ -50,21 +50,21 @@ class UnifiedInboundQueryServiceTest {
.isEqualTo("CONFIRM_PRELOAD"); .isEqualTo("CONFIRM_PRELOAD");
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDING", null)).isNull(); assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "CONFIRMED")).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"); .isEqualTo("SMART_SPLIT");
assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)).isNull(); assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull(); assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull();
} }
@Test @Test
void inboundedFilterIncludesHistoricalWaitSplitRows() throws Exception { void inboundedAndWaitSplitFiltersRemainDistinct() throws Exception {
var method = UnifiedInboundQueryService.class.getDeclaredMethod("internalStatuses", String.class); var method = UnifiedInboundQueryService.class.getDeclaredMethod("internalStatuses", String.class);
method.setAccessible(true); method.setAccessible(true);
assertThat(method.invoke(new UnifiedInboundQueryService(), "INBOUNDED")) 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")) 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 @Test
@@ -47,19 +47,19 @@ class SplitPreloadServiceTest {
@Test @Test
void smartSplitCreatesEditablePendingPlan() { void smartSplitCreatesEditablePendingPlan() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); 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); when(repository.findPlan(10, 20)).thenReturn(null);
SmartSplitRequest request = new SmartSplitRequest(0, SmartSplitRequest request = new SmartSplitRequest(0,
List.of(new PreloadItem("C1", "A", 2, BigDecimal.ONE, null)), "r"); List.of(new PreloadItem("C1", "A", 2, BigDecimal.ONE, null)), "r");
SplitActionResponse result = service.smartSplit(10, 20, 7, "admin", "k2", request); 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)); verify(repository).createPlan(any(PreloadPlanEntity.class));
} }
@Test @Test
void confirmsPlanAndMovesInboundToLoading() { void confirmsPlanAndMovesInboundToLoading() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); 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(); PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1); plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1);
plan.setItemsJson("[]"); plan.setItemsJson("[]");
@@ -31,8 +31,7 @@ import java.util.stream.Collectors;
@Service @Service
public class UnifiedOrderQueryService { public class UnifiedOrderQueryService {
private static final Set<String> MAIN_STATUSES = Set.of( private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED"); "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
private static final String LEGACY_WAIT_SPLIT = "WAIT_SPLIT";
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); 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( private static final List<OrderStatus> ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of(
OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED,
@@ -95,7 +94,7 @@ public class UnifiedOrderQueryService {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
} }
FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query( 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.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(),
query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(), query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(),
query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(), query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(),
@@ -229,9 +228,9 @@ public class UnifiedOrderQueryService {
private String inboundMainStatus(String status) { private String inboundMainStatus(String status) {
if (status == null) return null; if (status == null) return null;
return switch (status) { return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE"; case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", LEGACY_WAIT_SPLIT -> "INBOUNDED"; "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING" -> "WAIT_MEASURE";
case "LOADING", "LOADED", "DELIVERED" -> status; case "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED" -> status;
default -> null; default -> null;
}; };
} }
@@ -243,7 +242,7 @@ public class UnifiedOrderQueryService {
private void validate(UnifiedOrderQuery query) { private void validate(UnifiedOrderQuery query) {
if (StringUtils.hasText(query.mainStatus()) if (StringUtils.hasText(query.mainStatus())
&& !MAIN_STATUSES.contains(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())
&& !LEGACY_WAIT_SPLIT.equals(query.mainStatus())) { ) {
throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法"); throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法");
} }
if (query.createdFrom() != null && query.createdTo() != null if (query.createdFrom() != null && query.createdTo() != null
@@ -273,10 +272,6 @@ public class UnifiedOrderQueryService {
|| StringUtils.hasText(query.measurementReviewStatus()); || 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) { private <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) {
try { return invocation.call(); } try { return invocation.call(); }
catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); } catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); }
@@ -335,9 +335,9 @@ components:
schemas: schemas:
UnifiedMainStatus: UnifiedMainStatus:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED] enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
description: >- description: >-
统一订单产品主状态。历史内部状态 WAIT_SPLIT 仅作为查询入参兼容,所有列表、详情和时间线响应均归并为 INBOUNDED。 统一订单七态产品主状态。WAIT_SPLIT 为正式待分柜状态,INBOUNDED 仅兼容存量已入库记录。
UnifiedOrderPageEnvelope: UnifiedOrderPageEnvelope:
type: object type: object
properties: properties:
@@ -669,7 +669,7 @@ components:
name: mainStatus name: mainStatus
in: query in: query
required: false required: false
description: 历史值 WAIT_SPLIT 仍可作为兼容筛选传入,但响应 mainStatus 始终归并为 INBOUNDED description: 统一订单七态主状态筛选;WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询
schema: schema:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT] enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT]
@@ -25,7 +25,7 @@ class UnifiedOrderApiContractTest {
} }
@Test @Test
void documentsInboundedAsThePhaseTwoTerminalStatus() throws Exception { void documentsSevenProductMainStatuses() throws Exception {
String yaml = new ClassPathResource("openapi/order-v1.yaml") String yaml = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml)); 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> schemas = stringMap(components.get("schemas"));
Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus")); Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus"));
assertThat(stringList(responseStatus.get("enum"))).containsExactly( 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"); assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED");
Map<String, Object> row = stringMap(schemas.get("UnifiedOrderRow")); 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, /fixtureMarker/)
assert.match(source, /context\.marker = originalMarker/) 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) }) { '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) { async function prepareInbound(context, label) {
const originalMarker = context.marker const originalMarker = context.marker
const fixtureMarker = `${originalMarker}-MAN-${label}` const fixtureMarker = `${originalMarker}-MAN-${label}`
@@ -224,10 +250,71 @@ function fixture(value, status, detailPath, extra = {}) {
return { id: id(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) { 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.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: {} } matrix.pages.pdaTasks = { requiredStatuses: PDA_STATUSES, fixtures: {} }
const warehouseId = '193000000000200001' const warehouseId = '193000000000200001'
@@ -283,4 +370,6 @@ export async function run(context, matrix = context.createdResources.manualState
context.assert(MODULE, 'COMPLETED 装柜清单详情身份正确', listDetailResponse, context.assert(MODULE, 'COMPLETED 装柜清单详情身份正确', listDetailResponse,
id(listDetail?.summary?.planId) === listFixture.id && listDetail?.summary?.status === 'COMPLETED', id(listDetail?.summary?.planId) === listFixture.id && listDetail?.summary?.status === 'COMPLETED',
`planId=${listFixture.id}, actual=${listDetail?.summary?.status || 'missing'}`, OPTIONS) `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, /createdResources\.manualStateMatrix/)
assert.match(source, /requiredStatuses/) assert.match(source, /requiredStatuses/)
assert.match(source, /fixtures\?\.\[status\]\?\.id/) 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) const missing = pages.flatMap(([page, value]) => rows(value?.requiredStatuses)
.filter((status) => !value?.fixtures?.[status]?.id) .filter((status) => !value?.fixtures?.[status]?.id)
.map((status) => `${page}:${status}`)) .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, context.assert(MODULE, '人工验收列表状态数据全部生成并保留', null,
pages.length > 0 && missing.length === 0, pages.length > 0 && missing.length === 0 && missingActions.length === 0,
`pages=${pages.length}, missing=${missing.join(',') || 'none'}`, OPTIONS) `pages=${pages.length}, missing=${[...missing, ...missingActions].join(',') || 'none'}`, OPTIONS)
} }
+2 -2
View File
@@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop'
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path $Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance' $Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
$ExpectedSha256 = 'CD21001B8E0B5D751000754522D5158BD45AD5986ECA8D2E64B8389FBE82B260' $ExpectedSha256 = '585464BE3FA0FB4141F47D0FAC57FA1FCAFCDD4596253256E8759BF930D9C7D3'
$ExpectedLines = 13800 $ExpectedLines = 14433
if ($TargetHost -notin @('127.0.0.1', 'localhost')) { if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
[Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.') [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), UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted),
KEY idx_wh_express_tenant_status (tenant_id, status, 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'; ) 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;