diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index 0decb1063..cfbb44d48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -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 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 = "上传或替换货柜目的港清关资料") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 153ae3d48..803be7cda 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -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 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')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java index f33063e95..6bf93297d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java @@ -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() + .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() .eq(LoadingPlanEntity::getContainerBookingId, containerId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 94eb4cc89..ed2c87fc9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -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()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java index 2614509e7..f19381c0c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java @@ -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 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 documents(@Param("id") long id); @Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List 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 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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 39b7d12e9..519cc03e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -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 ( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index 3ad889820..cf510a1e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 2ba9538d6..0568fef73 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -23,7 +23,7 @@ import java.util.*; @Service public class OutboundDispatchService { - private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); + private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE"); private final OutboundDispatchRepository repository; private final Map 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 containerContributorGates = contributorGates(first, ocean); List inboundGates = new ArrayList<>(); List inboundDtos = new ArrayList<>(); List 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 containerContributorGates) { List 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 contributorGates(OutboundDispatchReadMapper.Binding binding, + OutboundDispatchReadMapper.OceanBinding ocean) { + List 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("数据已被其他人修改,请刷新后重试"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 2b287f49d..fc96d5cf4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -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 = "上传出口报关文档") diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 652cd8003..6b4dc5e8e 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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: 直接选择文件上传或替换货柜目的港清关资料 diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 59c5fbc0d..18b436f15 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -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: 直接选择文件上传或替换装柜清单出口报关文档 diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index 2b7420a4d..47bf7403f 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -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}}} diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index f2dc216bb..aeeeb7b18 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index d94c7e2b7..33327afb2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -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]") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index d228c6137..28be884a7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -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 components = stringMap(document.get("components")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index 6695ea5d8..be11e6816 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -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); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index 2a15df4bf..1efcea3a3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -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) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 3b5ec26d7..09c38aef4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -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 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 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) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index 076c56f63..024cefac7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -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"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index afecb8b9e..b19652ad9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -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()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java index 0a7ea777f..114968d92 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -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() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index aac76e2e6..8d2e33874 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -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);