fix(firstleg): expose container documents and aggregate departure gates
This commit is contained in:
+15
@@ -128,6 +128,21 @@ public class PlatformContainerController {
|
||||
return ApiResponse.success(loadingLists.containerClearance(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/clearance/document/{fileId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。")
|
||||
public ResponseEntity<byte[]> downloadClearance(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||
@PathVariable @Positive long fileId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
var download = loadingLists.downloadContainerClearance(id, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
|
||||
.filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(download.contentType()).contentLength(download.content().length)
|
||||
.body(download.content());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/clearance/document")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@Operation(summary = "上传或替换货柜目的港清关资料")
|
||||
|
||||
+21
@@ -25,6 +25,10 @@ import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -40,6 +44,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
|
||||
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
|
||||
@Validated
|
||||
@@ -124,6 +130,21 @@ public class PlatformLoadingListController {
|
||||
return ApiResponse.success(service.loadingDeclaration(planId, warehouseId));
|
||||
}
|
||||
|
||||
@Operation(summary = "下载装柜清单出口报关附件", description = "完成态仍可下载;服务端校验装柜计划、仓库、当前附件及 File 业务归属。")
|
||||
@GetMapping("/loading-lists/{planId}/customs/document/{fileId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
|
||||
public ResponseEntity<byte[]> downloadDeclaration(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@PathVariable @Positive long fileId, @RequestParam @Positive long warehouseId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
var download = service.downloadLoadingDeclaration(planId, warehouseId, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||
.filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(download.contentType()).contentLength(download.content().length)
|
||||
.body(download.content());
|
||||
}
|
||||
|
||||
@Operation(summary = "上传或替换装柜清单出口报关文档")
|
||||
@PostMapping("/loading-lists/{planId}/customs/document")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
|
||||
@@ -81,6 +81,12 @@ public class CustomsRepository {
|
||||
.eq(CustomsCaseEntity::getContainerBookingId, containerBookingId)
|
||||
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
|
||||
}
|
||||
public CustomsCaseEntity clearedContainerCase(long containerBookingId) {
|
||||
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
|
||||
.eq(CustomsCaseEntity::getContainerBookingId, containerBookingId)
|
||||
.eq(CustomsCaseEntity::getStatus, "CLEARANCE_COMPLETED")
|
||||
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
|
||||
}
|
||||
public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) {
|
||||
LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
|
||||
.eq(LoadingPlanEntity::getContainerBookingId, containerId));
|
||||
|
||||
+1
-2
@@ -18,8 +18,7 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr
|
||||
if (context.tenantId() <= 0 || context.customerId() <= 0) {
|
||||
return gate(false, "INVALID_CUSTOMER_SCOPE");
|
||||
}
|
||||
CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(),
|
||||
context.inboundId(), context.containerBookingId());
|
||||
CustomsCaseEntity value = repository.clearedContainerCase(context.containerBookingId());
|
||||
if (value == null) return gate(false, "MISSING");
|
||||
return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
@Mapper public interface ContainerOverviewMapper {
|
||||
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
|
||||
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
|
||||
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List<ContainerTransportHistoryEntity> transport(@Param("id") long id);
|
||||
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id);
|
||||
@Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash);
|
||||
|
||||
+13
-6
@@ -63,13 +63,20 @@ public interface OutboundDispatchReadMapper {
|
||||
IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN
|
||||
('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount,
|
||||
MAX(CASE WHEN status='ACTIVE' AND evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL
|
||||
AND seal_no<>'' THEN 1 ELSE 0 END) validSeal
|
||||
FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}
|
||||
SELECT COUNT(DISTINCT CASE WHEN evidence.status='ACTIVE' AND evidence.file_id IS NOT NULL
|
||||
AND evidence.evidence_type IN ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL')
|
||||
THEN evidence.evidence_type END) evidenceCount,
|
||||
MAX(CASE WHEN evidence.status='ACTIVE' AND evidence.evidence_type='SEAL'
|
||||
AND evidence.file_id IS NOT NULL AND evidence.seal_no IS NOT NULL
|
||||
AND evidence.seal_no<>'' THEN 1 ELSE 0 END) validSeal
|
||||
FROM fl_loading_plan plan
|
||||
JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0
|
||||
JOIN fl_loading_evidence evidence ON evidence.inbound_id=item.inbound_id
|
||||
AND evidence.tenant_id=item.account_id
|
||||
WHERE plan.id=#{loadingPlanId} AND plan.container_booking_id=#{containerBookingId}
|
||||
""")
|
||||
Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
Evidence evidence(@Param("containerBookingId") long containerBookingId,
|
||||
@Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@Select("""
|
||||
SELECT module,action,fromStatus,toStatus,operatorId,operatorName,remark,occurredAt FROM (
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ public class OutboundDispatchRepository {
|
||||
public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) {
|
||||
return reads.truck(containerId, planId);
|
||||
}
|
||||
public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) {
|
||||
return reads.evidence(tenantId, inboundId);
|
||||
public OutboundDispatchReadMapper.Evidence evidence(long containerId, long loadingPlanId) {
|
||||
return reads.evidence(containerId, loadingPlanId);
|
||||
}
|
||||
public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) {
|
||||
return dispatches.find(tenantId, inboundId, containerId);
|
||||
|
||||
+15
-7
@@ -23,7 +23,7 @@ import java.util.*;
|
||||
|
||||
@Service
|
||||
public class OutboundDispatchService {
|
||||
private static final Set<String> REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE");
|
||||
private static final Set<String> REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE");
|
||||
|
||||
private final OutboundDispatchRepository repository;
|
||||
private final Map<String, OutboundDispatchGateContributor> contributors;
|
||||
@@ -152,12 +152,13 @@ public class OutboundDispatchService {
|
||||
var pick = repository.pick(first.loadingPlanId());
|
||||
var ocean = repository.ocean(first.containerBookingId());
|
||||
var truck = repository.truck(first.containerBookingId(), first.loadingPlanId());
|
||||
var evidence = repository.evidence(first.containerBookingId(), first.loadingPlanId());
|
||||
List<GateItem> containerContributorGates = contributorGates(first, ocean);
|
||||
List<InboundGate> inboundGates = new ArrayList<>();
|
||||
List<InboundBinding> inboundDtos = new ArrayList<>();
|
||||
List<OutboundDispatchEntity> states = new ArrayList<>();
|
||||
for (OutboundDispatchReadMapper.Binding binding : bindings) {
|
||||
var evidence = repository.evidence(binding.tenantId(), binding.inboundId());
|
||||
GateResult gate = gate(binding, pick, ocean, truck, evidence);
|
||||
GateResult gate = gate(binding, pick, ocean, evidence, containerContributorGates);
|
||||
inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate));
|
||||
inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(),
|
||||
binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(),
|
||||
@@ -175,8 +176,8 @@ public class OutboundDispatchService {
|
||||
}
|
||||
|
||||
private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick,
|
||||
OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.IdStatus truck,
|
||||
OutboundDispatchReadMapper.Evidence evidence) {
|
||||
OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence,
|
||||
List<GateItem> containerContributorGates) {
|
||||
List<GateItem> items = new ArrayList<>();
|
||||
items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()),
|
||||
binding.inboundStatus(), "LOADED"));
|
||||
@@ -192,6 +193,13 @@ public class OutboundDispatchService {
|
||||
evidence == null ? null : evidence.evidenceCount() + "个证据/有效铅封=" + evidence.validSeal(),
|
||||
"PHOTO_STEP_1..4和含铅封号的SEAL"));
|
||||
|
||||
items.addAll(containerContributorGates);
|
||||
return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now());
|
||||
}
|
||||
|
||||
private List<GateItem> contributorGates(OutboundDispatchReadMapper.Binding binding,
|
||||
OutboundDispatchReadMapper.OceanBinding ocean) {
|
||||
List<GateItem> items = new ArrayList<>();
|
||||
var context = new OutboundDispatchGateContributor.Context(binding.tenantId(), binding.customerId(),
|
||||
binding.orderId(), binding.inboundId(), binding.containerBookingId(), binding.loadingPlanId(), id(ocean));
|
||||
for (String code : REQUIRED_CONTRIBUTORS.stream().sorted().toList()) {
|
||||
@@ -207,7 +215,7 @@ public class OutboundDispatchService {
|
||||
items.add(item(code, contributorName(code), false, "ASSESSMENT_ERROR", "模块返回可放行状态"));
|
||||
}
|
||||
}
|
||||
return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now());
|
||||
return List.copyOf(items);
|
||||
}
|
||||
|
||||
private Detail replay(long containerBookingId, long operatorId, String operation, String key, String hash) {
|
||||
@@ -333,7 +341,7 @@ public class OutboundDispatchService {
|
||||
return new GateItem(code, name, pass, actual, requirement);
|
||||
}
|
||||
private static String contributorName(String code) {
|
||||
return "CUSTOMS_CLEARANCE".equals(code) ? "报关协同完成" : "船期有效且未过期";
|
||||
return "报关协同完成";
|
||||
}
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
|
||||
|
||||
@@ -55,7 +55,7 @@ public class LoadingListService {
|
||||
public static final String DELIVERY_ORDER = "DELIVERY_ORDER";
|
||||
public static final String PICKUP_ORDER = "PICKUP_ORDER";
|
||||
private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
|
||||
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DOCUMENT";
|
||||
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION";
|
||||
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
|
||||
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
|
||||
@Resource
|
||||
@@ -128,6 +128,41 @@ public class LoadingListService {
|
||||
}
|
||||
catch(FeignException e){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}
|
||||
}
|
||||
public DocumentDownload downloadContainerClearance(long bookingId, long fileId) {
|
||||
requireBookingPlan(bookingId);
|
||||
ContainerDocumentEntity document = repository.document(bookingId, DESTINATION_CLEARANCE);
|
||||
if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) {
|
||||
throw notFound("目的港清关附件不存在或不属于当前货柜");
|
||||
}
|
||||
String businessId = Long.toString(bookingId);
|
||||
try {
|
||||
var metadata = documentFiles.metadata(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId);
|
||||
if (metadata == null || !metadata.successful() || metadata.data() == null
|
||||
|| !Long.valueOf(fileId).equals(metadata.data().fileId())
|
||||
|| !Long.valueOf(0).equals(metadata.data().tenantId())
|
||||
|| !CLEARANCE_FILE_TYPE.equals(metadata.data().businessType())
|
||||
|| !businessId.equals(metadata.data().businessId())) {
|
||||
throw notFound("目的港清关附件不存在或归属不匹配");
|
||||
}
|
||||
var response = documentFiles.download(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId);
|
||||
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw notFound("目的港清关附件不存在");
|
||||
}
|
||||
MediaType contentType;
|
||||
try {
|
||||
contentType = MediaType.parseMediaType(metadata.data().contentType());
|
||||
} catch (RuntimeException exception) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
String fileName = StringUtils.hasText(document.getFileName())
|
||||
? document.getFileName() : metadata.data().fileName();
|
||||
return new DocumentDownload(fileName, contentType, response.getBody());
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw notFound("目的港清关附件不存在或归属不匹配");
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
|
||||
}
|
||||
}
|
||||
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
|
||||
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); }
|
||||
private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; }
|
||||
@@ -169,6 +204,44 @@ public class LoadingListService {
|
||||
return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION));
|
||||
}
|
||||
|
||||
public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) {
|
||||
AggregateRow plan = requirePlan(planId);
|
||||
requireWarehouse(plan, warehouseId);
|
||||
requireCompletedPlan(plan);
|
||||
ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION);
|
||||
if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) {
|
||||
throw notFound("出口报关附件不存在或不属于当前装柜计划");
|
||||
}
|
||||
String businessId = Long.toString(planId);
|
||||
try {
|
||||
var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
|
||||
if (metadata == null || !metadata.successful() || metadata.data() == null
|
||||
|| !Long.valueOf(fileId).equals(metadata.data().fileId())
|
||||
|| !Long.valueOf(0).equals(metadata.data().tenantId())
|
||||
|| !DECLARATION_FILE_TYPE.equals(metadata.data().businessType())
|
||||
|| !businessId.equals(metadata.data().businessId())) {
|
||||
throw notFound("出口报关附件不存在或归属不匹配");
|
||||
}
|
||||
var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
|
||||
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw notFound("出口报关附件不存在");
|
||||
}
|
||||
MediaType contentType;
|
||||
try {
|
||||
contentType = MediaType.parseMediaType(metadata.data().contentType());
|
||||
} catch (RuntimeException exception) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
String fileName = StringUtils.hasText(document.getFileName())
|
||||
? document.getFileName() : metadata.data().fileName();
|
||||
return new DocumentDownload(fileName, contentType, response.getBody());
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw notFound("出口报关附件不存在或归属不匹配");
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "上传出口报关文档")
|
||||
|
||||
@@ -356,6 +356,16 @@ paths:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}}
|
||||
responses: {'200': {description: 成功、文件归属不匹配或版本冲突}}
|
||||
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
||||
get:
|
||||
summary: 下载货柜目的港清关附件
|
||||
description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
'200': {description: 附件二进制内容,包含 Content-Disposition 与 Cache-Control no-store}
|
||||
'404': {description: 当前附件不存在或归属不匹配}
|
||||
/api/platform/v1/containers/{id}/clearance/document/upload:
|
||||
post:
|
||||
summary: 直接选择文件上传或替换货柜目的港清关资料
|
||||
|
||||
@@ -99,6 +99,29 @@ paths:
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/BusinessDocumentResponse'}
|
||||
/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download:
|
||||
get:
|
||||
summary: 下载装柜清单出口报关附件
|
||||
description: 完成态仍可下载。服务端校验 planId、warehouseId、当前 CUSTOMS_DECLARATION 的 fileId,以及 File 元数据 tenantId=0、businessType=LOADING_CUSTOMS_DECLARATION、businessId=planId;错误 fileId 或归属返回 A0404。响应使用 attachment 文件名并禁止缓存。
|
||||
x-permission: platform:firstleg:loading-list:list
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/PlanId'}
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
- name: fileId
|
||||
in: path
|
||||
required: true
|
||||
description: File 服务长整型文件 ID
|
||||
schema: {$ref: '#/components/schemas/LongId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 报关附件二进制内容;包含 Content-Disposition,Cache-Control 为 no-store
|
||||
headers:
|
||||
Content-Disposition: {schema: {type: string}}
|
||||
Cache-Control: {schema: {type: string, example: no-store}}
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: {type: string, format: binary}
|
||||
'404': {description: 装柜计划、仓库、当前附件或 File 归属不匹配}
|
||||
/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload:
|
||||
post:
|
||||
summary: 直接选择文件上传或替换装柜清单出口报关文档
|
||||
|
||||
@@ -36,6 +36,12 @@ paths:
|
||||
description: '权限:platform:firstleg:container:list;Firstleg代理File内部下载,不向调用方暴露内部令牌'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||
responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}}
|
||||
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
||||
get:
|
||||
operationId: downloadContainerDestinationClearance
|
||||
description: '权限:platform:firstleg:customs:manage;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||
responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}}
|
||||
components:
|
||||
parameters:
|
||||
ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
@@ -51,7 +57,7 @@ components:
|
||||
container: {$ref: '#/components/schemas/Container'}
|
||||
loadingPlanId: {type: integer, format: int64}
|
||||
pda: {$ref: '#/components/schemas/PdaSummary'}
|
||||
documents: {type: array, items: {$ref: '#/components/schemas/Document'}}
|
||||
documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version', items: {$ref: '#/components/schemas/Document'}}
|
||||
exception: {$ref: '#/components/schemas/ExceptionState'}
|
||||
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
|
||||
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
|
||||
@@ -59,7 +65,7 @@ components:
|
||||
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
|
||||
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
|
||||
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
|
||||
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer}, completed: {type: boolean}}}
|
||||
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, CUSTOMS_DECLARATION, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}}
|
||||
ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}}
|
||||
ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
|
||||
TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
|
||||
|
||||
@@ -37,6 +37,7 @@ paths:
|
||||
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate:
|
||||
get:
|
||||
summary: Evaluate every inbound binding in the fail-closed container outbound gate
|
||||
description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. Customs clearance is also resolved once at container scope. A missing standalone sailing schedule is reference-only and never affects gate.passed.
|
||||
x-permission: platform:firstleg:outbound-dispatch:check
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
|
||||
@@ -98,7 +99,7 @@ components:
|
||||
loadingPlanId: {type: integer, format: int64}
|
||||
loadingPlanStatus: {type: string}
|
||||
oceanBookingId: {type: integer, format: int64, nullable: true}
|
||||
oceanBookingStatus: {type: string, nullable: true}
|
||||
oceanBookingStatus: {type: string, nullable: true, description: Booking reference retained independently from the non-blocking standalone sailing schedule}
|
||||
truckAppointmentId: {type: integer, format: int64, nullable: true, description: Reference-only truck appointment; absence never blocks manual outbound confirmation}
|
||||
truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status; PENDING or any non-terminal state never affects gate.passed}
|
||||
dispatchStatus: {type: string, description: One visible container-level status}
|
||||
|
||||
+5
@@ -33,6 +33,7 @@ class ContainerControllerContractTest {
|
||||
assertPermission("logisticsDocument", "platform:firstleg:container:list");
|
||||
assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit");
|
||||
assertPermission("downloadDocument", "platform:firstleg:container:list");
|
||||
assertPermission("downloadClearance", "platform:firstleg:customs:manage");
|
||||
}
|
||||
|
||||
@Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception {
|
||||
@@ -40,6 +41,8 @@ class ContainerControllerContractTest {
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
|
||||
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
|
||||
"/clearance/document/{fileId}/download:",
|
||||
"DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER",
|
||||
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:",
|
||||
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
|
||||
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
|
||||
@@ -77,6 +80,8 @@ class ContainerControllerContractTest {
|
||||
.doesNotContain("tenantId")
|
||||
.doesNotContain("accountId");
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
|
||||
.contains("Content-Disposition 与 Cache-Control no-store")
|
||||
.contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
|
||||
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
|
||||
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
|
||||
|
||||
+19
@@ -16,6 +16,8 @@ import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
@@ -41,6 +43,8 @@ class LoadingListControllerContractTest {
|
||||
ConfirmedQuery.class);
|
||||
authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
|
||||
authority("declaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
|
||||
authority("downloadDeclaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
|
||||
long.class, long.class, long.class);
|
||||
authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
|
||||
long.class, long.class, String.class, DocumentAttachRequest.class);
|
||||
authority("uploadDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
|
||||
@@ -88,6 +92,8 @@ class LoadingListControllerContractTest {
|
||||
.contains("oceanBookingStatus")
|
||||
.contains("pdaTaskId")
|
||||
.contains("cabinetNo")
|
||||
.contains("LOADING_CUSTOMS_DECLARATION")
|
||||
.contains("Cache-Control 为 no-store")
|
||||
.doesNotContain("internalContainerNo")
|
||||
.doesNotContain("rollback")
|
||||
.doesNotContain("delete:")
|
||||
@@ -118,6 +124,18 @@ class LoadingListControllerContractTest {
|
||||
assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void customsDownloadUsesStablePlanPathAndBinaryResponse() throws Exception {
|
||||
Method method = PlatformLoadingListController.class.getDeclaredMethod("downloadDeclaration",
|
||||
AuthenticatedUser.class, long.class, long.class, long.class);
|
||||
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly(
|
||||
"/loading-lists/{planId}/customs/document/{fileId}/download");
|
||||
assertThat(method.getReturnType()).isEqualTo(ResponseEntity.class);
|
||||
assertThat(method.getParameters()[1].getName()).isEqualTo("planId");
|
||||
assertThat(method.getParameters()[2].getName()).isEqualTo("fileId");
|
||||
assertThat(method.getParameters()[3].getName()).isEqualTo("warehouseId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void yamlParses() throws Exception {
|
||||
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
|
||||
@@ -134,6 +152,7 @@ class LoadingListControllerContractTest {
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete");
|
||||
Map<String, Object> components = stringMap(document.get("components"));
|
||||
|
||||
+3
-2
@@ -24,15 +24,16 @@ class CustomsDispatchGateContributorTest {
|
||||
|
||||
@Test void passesOnlyCanonicalClearanceCompletedCase() {
|
||||
CustomsCaseEntity value = new CustomsCaseEntity();
|
||||
value.setStatus("RECEIPT_AVAILABLE");
|
||||
when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value);
|
||||
assertThat(contributor.assess(context()).passed()).isFalse();
|
||||
value.setStatus("CLEARANCE_COMPLETED");
|
||||
when(repository.clearedContainerCase(81L)).thenReturn(value);
|
||||
assertThat(contributor.assess(context()))
|
||||
.satisfies(item -> {
|
||||
assertThat(item.passed()).isTrue();
|
||||
assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED");
|
||||
});
|
||||
verify(repository, times(2)).clearedContainerCase(81L);
|
||||
verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); }
|
||||
|
||||
+14
-1
@@ -47,10 +47,23 @@ class OutboundDispatchReadMapperContractTest {
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
|
||||
assertThat(sql)
|
||||
.contains("seal_no<>''")
|
||||
.contains("evidence.seal_no<>''")
|
||||
.doesNotContain("<", ">");
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceGateAggregatesOnePhysicalEvidenceSetAcrossTheWholeLoadingPlan() throws Exception {
|
||||
String sql = OutboundDispatchReadMapper.class.getMethod("evidence", long.class, long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
|
||||
assertThat(sql)
|
||||
.contains("plan.id=#{loadingPlanId}", "plan.container_booking_id=#{containerBookingId}")
|
||||
.contains("item.loading_plan_id=plan.id", "item.deleted=0")
|
||||
.contains("evidence.inbound_id=item.inbound_id", "evidence.tenant_id=item.account_id")
|
||||
.contains("COUNT(DISTINCT CASE", "evidence.evidence_type")
|
||||
.doesNotContain("WHERE tenant_id=#{tenantId}", "inbound_id=#{inboundId}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception {
|
||||
String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class)
|
||||
|
||||
+46
-9
@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.LongStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -30,18 +31,38 @@ class OutboundDispatchServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void gateAggregatesEveryRealInboundAndFailsClosedWhenOneBindingFails() {
|
||||
void containerEvidenceFailureFailsEveryBindingConsistently() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1));
|
||||
when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1));
|
||||
|
||||
ContainerGateResult result = service.checkGate(22L);
|
||||
|
||||
assertThat(result.passed()).isFalse();
|
||||
assertThat(result.inbounds()).hasSize(2);
|
||||
assertThat(result.inbounds().get(1).gate().items())
|
||||
assertThat(result.inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items())
|
||||
.filteredOn(item -> "LOADING_EVIDENCE_COMPLETE".equals(item.code()))
|
||||
.extracting(GateItem::passed).containsExactly(false);
|
||||
.extracting(GateItem::passed).containsExactly(false));
|
||||
verify(repository, times(1)).evidence(22L, 44L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tenInboundBindingsShareOneCompleteEvidenceSetAndOneContainerCustomsResult() {
|
||||
OutboundDispatchGateContributor customs = mock(OutboundDispatchGateContributor.class);
|
||||
when(customs.code()).thenReturn("CUSTOMS_CLEARANCE");
|
||||
when(customs.assess(any())).thenReturn(new GateItem(
|
||||
"CUSTOMS_CLEARANCE", "报关完成", true, "CLEARANCE_COMPLETED", "CLEARANCE_COMPLETED"));
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null);
|
||||
List<OutboundDispatchReadMapper.Binding> tenBindings = LongStream.rangeClosed(11L, 20L)
|
||||
.mapToObj(id -> binding(id, "I-" + id)).toList();
|
||||
stubContainer(tenBindings);
|
||||
|
||||
ContainerGateResult result = service.checkGate(22L);
|
||||
|
||||
assertThat(result.passed()).isTrue();
|
||||
assertThat(result.inbounds()).hasSize(10).allSatisfy(value -> assertThat(value.gate().passed()).isTrue());
|
||||
verify(repository, times(1)).evidence(22L, 44L);
|
||||
verify(customs, times(1)).assess(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -72,6 +93,19 @@ class OutboundDispatchServiceTest {
|
||||
verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmAllowsMissingSailingScheduleContributor() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
stubConfirmMutation("no-schedule");
|
||||
|
||||
Detail result = service.confirm(22L, 9L, "admin", "no-schedule", new ActionRequest(0, null));
|
||||
|
||||
assertThat(result.aggregate().gate().passed()).isTrue();
|
||||
assertThat(result.aggregate().gate().inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items())
|
||||
.extracting(GateItem::code).doesNotContain("SAILING_SCHEDULE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailReturnsOneVersionAndAllInboundBindings() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
@@ -117,7 +151,7 @@ class OutboundDispatchServiceTest {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.lockBindings(22L)).thenReturn(bindings());
|
||||
when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1));
|
||||
when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1));
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("blocked"), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "blocked", new ActionRequest(0, null)))
|
||||
@@ -201,16 +235,19 @@ class OutboundDispatchServiceTest {
|
||||
|
||||
private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) {
|
||||
return new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules);
|
||||
List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules);
|
||||
}
|
||||
|
||||
private void stubContainer() {
|
||||
when(repository.bindings(22L)).thenReturn(bindings());
|
||||
stubContainer(bindings());
|
||||
}
|
||||
|
||||
private void stubContainer(List<OutboundDispatchReadMapper.Binding> values) {
|
||||
when(repository.bindings(22L)).thenReturn(values);
|
||||
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED"));
|
||||
when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L));
|
||||
when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
|
||||
when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
|
||||
when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
|
||||
when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
|
||||
}
|
||||
|
||||
private void stubConfirmMutation(String key) {
|
||||
|
||||
+3
@@ -50,6 +50,9 @@ class OutboundDispatchSqlContractTest {
|
||||
.contains("Once an actual DEPARTED_WAREHOUSE node exists")
|
||||
.contains("absence never blocks manual outbound confirmation")
|
||||
.contains("PENDING or any non-terminal state never affects gate.passed")
|
||||
.contains("Loading evidence is evaluated once for the physical container/loading plan")
|
||||
.contains("Customs clearance is also resolved once at container scope")
|
||||
.contains("missing standalone sailing schedule is reference-only")
|
||||
.contains("expectedVersion")
|
||||
.contains("Idempotency-Key")
|
||||
.doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId");
|
||||
|
||||
+8
@@ -12,6 +12,14 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerOverviewSqlContractTest {
|
||||
@Test void overviewIncludesExactlyTheFourCurrentBusinessDocumentTypes() throws Exception {
|
||||
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql)
|
||||
.contains("'DESTINATION_CLEARANCE'", "'CUSTOMS_DECLARATION'", "'DELIVERY_ORDER'", "'PICKUP_ORDER'")
|
||||
.contains("ORDER BY document_type");
|
||||
}
|
||||
|
||||
@Test void pdaSummarySelectsRecordFieldsExplicitlyIncludingTaskVersion() throws Exception {
|
||||
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("pickTask", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
|
||||
+16
-1
@@ -47,7 +47,13 @@ class ContainerOverviewServiceTest {
|
||||
71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()),
|
||||
new ContainerOverviewMapper.PhotoRow(
|
||||
72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now())));
|
||||
when(mapper.documents(41L)).thenReturn(java.util.List.of());
|
||||
com.baiyue.firstleg.entity.ContainerDocumentEntity customs = new com.baiyue.firstleg.entity.ContainerDocumentEntity();
|
||||
customs.setId(103L); customs.setDocumentType("CUSTOMS_DECLARATION"); customs.setFileId(104L);
|
||||
customs.setFileName("customs.pdf"); customs.setVersion(3);
|
||||
com.baiyue.firstleg.entity.ContainerDocumentEntity clearance = new com.baiyue.firstleg.entity.ContainerDocumentEntity();
|
||||
clearance.setId(105L); clearance.setDocumentType("DESTINATION_CLEARANCE"); clearance.setFileId(106L);
|
||||
clearance.setFileName("clearance.pdf"); clearance.setVersion(1);
|
||||
when(mapper.documents(41L)).thenReturn(java.util.List.of(customs, clearance));
|
||||
when(mapper.exceptions(41L)).thenReturn(java.util.List.of());
|
||||
when(mapper.transport(41L)).thenReturn(java.util.List.of());
|
||||
when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of());
|
||||
@@ -66,6 +72,15 @@ class ContainerOverviewServiceTest {
|
||||
assertThat(count.evidenceType()).isEqualTo("LOADING_DOOR_OPEN");
|
||||
assertThat(count.count()).isEqualTo(1);
|
||||
});
|
||||
assertThat(overview.documents()).extracting(document -> document.documentType())
|
||||
.containsExactly("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE");
|
||||
assertThat(overview.documents()).filteredOn(document -> "CUSTOMS_DECLARATION".equals(document.documentType()))
|
||||
.singleElement().satisfies(document -> assertThat(document.version()).isEqualTo(3));
|
||||
assertThat(overview.documents()).filteredOn(document -> "DESTINATION_CLEARANCE".equals(document.documentType()))
|
||||
.singleElement().satisfies(document -> {
|
||||
assertThat(document.fileId()).isEqualTo(106L);
|
||||
assertThat(document.version()).isEqualTo(1);
|
||||
});
|
||||
}
|
||||
|
||||
@Test void downloadsOnlyActivePhotoOwnedByContainerLoadingPlan() {
|
||||
|
||||
@@ -450,16 +450,16 @@ class LoadingListServiceTest {
|
||||
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",
|
||||
"LOADING_CUSTOMS_DECLARATION", "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"),
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq("10"), eq(file))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"),
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq("10"), eq(101L))).thenReturn(ApiResponse.success(metadata));
|
||||
when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1);
|
||||
|
||||
@@ -467,10 +467,108 @@ class LoadingListServiceTest {
|
||||
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"),
|
||||
verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq("10"), eq(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadsCompletedLoadingDeclarationWithExactLongFileOwnership() {
|
||||
long planId = 9_007_199_254_740_993L;
|
||||
long fileId = 9_007_199_254_740_995L;
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
plan.setPlanId(planId);
|
||||
ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true);
|
||||
document.setFileId(fileId);
|
||||
document.setFileName("出口报关单.pdf");
|
||||
byte[] content = new byte[]{1, 2, 3};
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L,
|
||||
"LOADING_CUSTOMS_DECLARATION", Long.toString(planId), "出口报关单.pdf", hash(),
|
||||
"application/pdf", (long) content.length);
|
||||
when(repository.aggregate(planId)).thenReturn(plan);
|
||||
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document);
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq(Long.toString(planId)), eq(fileId))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq(Long.toString(planId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content));
|
||||
|
||||
var result = service.downloadLoadingDeclaration(planId, 100L, fileId);
|
||||
|
||||
assertThat(result.fileName()).isEqualTo("出口报关单.pdf");
|
||||
assertThat(result.content()).containsExactly(1, 2, 3);
|
||||
verify(documentFiles).download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq("9007199254740993"), eq(fileId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonCurrentLoadingDeclarationFileIdBeforeFileService() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
when(repository.aggregate(10L)).thenReturn(plan);
|
||||
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION))
|
||||
.thenReturn(document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true));
|
||||
|
||||
assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 100L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
|
||||
verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsLoadingDeclarationFileWithMismatchedBusinessOwnership() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true);
|
||||
when(repository.aggregate(10L)).thenReturn(plan);
|
||||
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L,
|
||||
"LOADING_CUSTOMS_DECLARATION", "other-plan", "customs.pdf", hash(),
|
||||
"application/pdf", 3L);
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq("10"), eq(99L))).thenReturn(ApiResponse.success(metadata));
|
||||
|
||||
assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 99L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
|
||||
verify(documentFiles, never()).download(anyString(), anyLong(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadsCurrentDestinationClearanceWithExactContainerOwnership() {
|
||||
long bookingId = 9_007_199_254_740_993L;
|
||||
long fileId = 9_007_199_254_740_995L;
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
ContainerDocumentEntity document = document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true);
|
||||
document.setContainerBookingId(bookingId);
|
||||
document.setFileId(fileId);
|
||||
document.setFileName("destination-clearance.pdf");
|
||||
byte[] content = new byte[]{4, 5, 6};
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L,
|
||||
"CONTAINER_CLEARANCE_DOCUMENT", Long.toString(bookingId), "destination-clearance.pdf",
|
||||
hash(), "application/pdf", (long) content.length);
|
||||
when(repository.aggregateByBooking(bookingId)).thenReturn(plan);
|
||||
when(repository.document(bookingId, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(document);
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
|
||||
eq(Long.toString(bookingId)), eq(fileId))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.download(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
|
||||
eq(Long.toString(bookingId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content));
|
||||
|
||||
var result = service.downloadContainerClearance(bookingId, fileId);
|
||||
|
||||
assertThat(result.fileName()).isEqualTo("destination-clearance.pdf");
|
||||
assertThat(result.content()).containsExactly(4, 5, 6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonCurrentDestinationClearanceFileIdAsNotFound() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE))
|
||||
.thenReturn(document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true));
|
||||
|
||||
assertThatThrownBy(() -> service.downloadContainerClearance(30L, 100L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
|
||||
verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsReusedIdempotencyKeyWithDifferentAttachRequest() {
|
||||
AggregateRow plan = row("LOADING", 2);
|
||||
|
||||
Reference in New Issue
Block a user