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 = "上传出口报关文档")