fix(firstleg): expose container documents and aggregate departure gates

This commit is contained in:
2026-08-26 13:07:30 +08:00
parent 1584c7a0a3
commit fc0a6ea7ca
22 changed files with 407 additions and 39 deletions
@@ -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 = "上传或替换货柜目的港清关资料")
@@ -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));
@@ -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);
@@ -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 (
@@ -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);
@@ -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}
@@ -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]")
@@ -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"));
@@ -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); }
@@ -47,10 +47,23 @@ class OutboundDispatchReadMapperContractTest {
.getAnnotation(Select.class).value()[0];
assertThat(sql)
.contains("seal_no<>''")
.contains("evidence.seal_no<>''")
.doesNotContain("&lt;", "&gt;");
}
@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)
@@ -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) {
@@ -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");
@@ -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());
@@ -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);