fix(firstleg): expose container documents and aggregate departure gates
This commit is contained in:
+15
@@ -128,6 +128,21 @@ public class PlatformContainerController {
|
||||
return ApiResponse.success(loadingLists.containerClearance(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/clearance/document/{fileId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。")
|
||||
public ResponseEntity<byte[]> downloadClearance(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||
@PathVariable @Positive long fileId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
var download = loadingLists.downloadContainerClearance(id, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
|
||||
.filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(download.contentType()).contentLength(download.content().length)
|
||||
.body(download.content());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/clearance/document")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@Operation(summary = "上传或替换货柜目的港清关资料")
|
||||
|
||||
+21
@@ -25,6 +25,10 @@ import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -40,6 +44,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
|
||||
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
|
||||
@Validated
|
||||
@@ -124,6 +130,21 @@ public class PlatformLoadingListController {
|
||||
return ApiResponse.success(service.loadingDeclaration(planId, warehouseId));
|
||||
}
|
||||
|
||||
@Operation(summary = "下载装柜清单出口报关附件", description = "完成态仍可下载;服务端校验装柜计划、仓库、当前附件及 File 业务归属。")
|
||||
@GetMapping("/loading-lists/{planId}/customs/document/{fileId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
|
||||
public ResponseEntity<byte[]> downloadDeclaration(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@PathVariable @Positive long fileId, @RequestParam @Positive long warehouseId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
var download = service.downloadLoadingDeclaration(planId, warehouseId, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||
.filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(download.contentType()).contentLength(download.content().length)
|
||||
.body(download.content());
|
||||
}
|
||||
|
||||
@Operation(summary = "上传或替换装柜清单出口报关文档")
|
||||
@PostMapping("/loading-lists/{planId}/customs/document")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
|
||||
@@ -81,6 +81,12 @@ public class CustomsRepository {
|
||||
.eq(CustomsCaseEntity::getContainerBookingId, containerBookingId)
|
||||
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
|
||||
}
|
||||
public CustomsCaseEntity clearedContainerCase(long containerBookingId) {
|
||||
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
|
||||
.eq(CustomsCaseEntity::getContainerBookingId, containerBookingId)
|
||||
.eq(CustomsCaseEntity::getStatus, "CLEARANCE_COMPLETED")
|
||||
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
|
||||
}
|
||||
public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) {
|
||||
LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
|
||||
.eq(LoadingPlanEntity::getContainerBookingId, containerId));
|
||||
|
||||
+1
-2
@@ -18,8 +18,7 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr
|
||||
if (context.tenantId() <= 0 || context.customerId() <= 0) {
|
||||
return gate(false, "INVALID_CUSTOMER_SCOPE");
|
||||
}
|
||||
CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(),
|
||||
context.inboundId(), context.containerBookingId());
|
||||
CustomsCaseEntity value = repository.clearedContainerCase(context.containerBookingId());
|
||||
if (value == null) return gate(false, "MISSING");
|
||||
return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
@Mapper public interface ContainerOverviewMapper {
|
||||
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
|
||||
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
|
||||
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List<ContainerTransportHistoryEntity> transport(@Param("id") long id);
|
||||
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id);
|
||||
@Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash);
|
||||
|
||||
+13
-6
@@ -63,13 +63,20 @@ public interface OutboundDispatchReadMapper {
|
||||
IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN
|
||||
('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount,
|
||||
MAX(CASE WHEN status='ACTIVE' AND evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL
|
||||
AND seal_no<>'' THEN 1 ELSE 0 END) validSeal
|
||||
FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}
|
||||
SELECT COUNT(DISTINCT CASE WHEN evidence.status='ACTIVE' AND evidence.file_id IS NOT NULL
|
||||
AND evidence.evidence_type IN ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL')
|
||||
THEN evidence.evidence_type END) evidenceCount,
|
||||
MAX(CASE WHEN evidence.status='ACTIVE' AND evidence.evidence_type='SEAL'
|
||||
AND evidence.file_id IS NOT NULL AND evidence.seal_no IS NOT NULL
|
||||
AND evidence.seal_no<>'' THEN 1 ELSE 0 END) validSeal
|
||||
FROM fl_loading_plan plan
|
||||
JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0
|
||||
JOIN fl_loading_evidence evidence ON evidence.inbound_id=item.inbound_id
|
||||
AND evidence.tenant_id=item.account_id
|
||||
WHERE plan.id=#{loadingPlanId} AND plan.container_booking_id=#{containerBookingId}
|
||||
""")
|
||||
Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
Evidence evidence(@Param("containerBookingId") long containerBookingId,
|
||||
@Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@Select("""
|
||||
SELECT module,action,fromStatus,toStatus,operatorId,operatorName,remark,occurredAt FROM (
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ public class OutboundDispatchRepository {
|
||||
public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) {
|
||||
return reads.truck(containerId, planId);
|
||||
}
|
||||
public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) {
|
||||
return reads.evidence(tenantId, inboundId);
|
||||
public OutboundDispatchReadMapper.Evidence evidence(long containerId, long loadingPlanId) {
|
||||
return reads.evidence(containerId, loadingPlanId);
|
||||
}
|
||||
public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) {
|
||||
return dispatches.find(tenantId, inboundId, containerId);
|
||||
|
||||
+15
-7
@@ -23,7 +23,7 @@ import java.util.*;
|
||||
|
||||
@Service
|
||||
public class OutboundDispatchService {
|
||||
private static final Set<String> REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE");
|
||||
private static final Set<String> REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE");
|
||||
|
||||
private final OutboundDispatchRepository repository;
|
||||
private final Map<String, OutboundDispatchGateContributor> contributors;
|
||||
@@ -152,12 +152,13 @@ public class OutboundDispatchService {
|
||||
var pick = repository.pick(first.loadingPlanId());
|
||||
var ocean = repository.ocean(first.containerBookingId());
|
||||
var truck = repository.truck(first.containerBookingId(), first.loadingPlanId());
|
||||
var evidence = repository.evidence(first.containerBookingId(), first.loadingPlanId());
|
||||
List<GateItem> containerContributorGates = contributorGates(first, ocean);
|
||||
List<InboundGate> inboundGates = new ArrayList<>();
|
||||
List<InboundBinding> inboundDtos = new ArrayList<>();
|
||||
List<OutboundDispatchEntity> states = new ArrayList<>();
|
||||
for (OutboundDispatchReadMapper.Binding binding : bindings) {
|
||||
var evidence = repository.evidence(binding.tenantId(), binding.inboundId());
|
||||
GateResult gate = gate(binding, pick, ocean, truck, evidence);
|
||||
GateResult gate = gate(binding, pick, ocean, evidence, containerContributorGates);
|
||||
inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate));
|
||||
inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(),
|
||||
binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(),
|
||||
@@ -175,8 +176,8 @@ public class OutboundDispatchService {
|
||||
}
|
||||
|
||||
private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick,
|
||||
OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.IdStatus truck,
|
||||
OutboundDispatchReadMapper.Evidence evidence) {
|
||||
OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence,
|
||||
List<GateItem> containerContributorGates) {
|
||||
List<GateItem> items = new ArrayList<>();
|
||||
items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()),
|
||||
binding.inboundStatus(), "LOADED"));
|
||||
@@ -192,6 +193,13 @@ public class OutboundDispatchService {
|
||||
evidence == null ? null : evidence.evidenceCount() + "个证据/有效铅封=" + evidence.validSeal(),
|
||||
"PHOTO_STEP_1..4和含铅封号的SEAL"));
|
||||
|
||||
items.addAll(containerContributorGates);
|
||||
return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now());
|
||||
}
|
||||
|
||||
private List<GateItem> contributorGates(OutboundDispatchReadMapper.Binding binding,
|
||||
OutboundDispatchReadMapper.OceanBinding ocean) {
|
||||
List<GateItem> items = new ArrayList<>();
|
||||
var context = new OutboundDispatchGateContributor.Context(binding.tenantId(), binding.customerId(),
|
||||
binding.orderId(), binding.inboundId(), binding.containerBookingId(), binding.loadingPlanId(), id(ocean));
|
||||
for (String code : REQUIRED_CONTRIBUTORS.stream().sorted().toList()) {
|
||||
@@ -207,7 +215,7 @@ public class OutboundDispatchService {
|
||||
items.add(item(code, contributorName(code), false, "ASSESSMENT_ERROR", "模块返回可放行状态"));
|
||||
}
|
||||
}
|
||||
return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now());
|
||||
return List.copyOf(items);
|
||||
}
|
||||
|
||||
private Detail replay(long containerBookingId, long operatorId, String operation, String key, String hash) {
|
||||
@@ -333,7 +341,7 @@ public class OutboundDispatchService {
|
||||
return new GateItem(code, name, pass, actual, requirement);
|
||||
}
|
||||
private static String contributorName(String code) {
|
||||
return "CUSTOMS_CLEARANCE".equals(code) ? "报关协同完成" : "船期有效且未过期";
|
||||
return "报关协同完成";
|
||||
}
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
|
||||
|
||||
@@ -55,7 +55,7 @@ public class LoadingListService {
|
||||
public static final String DELIVERY_ORDER = "DELIVERY_ORDER";
|
||||
public static final String PICKUP_ORDER = "PICKUP_ORDER";
|
||||
private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
|
||||
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DOCUMENT";
|
||||
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION";
|
||||
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
|
||||
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
|
||||
@Resource
|
||||
@@ -128,6 +128,41 @@ public class LoadingListService {
|
||||
}
|
||||
catch(FeignException e){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}
|
||||
}
|
||||
public DocumentDownload downloadContainerClearance(long bookingId, long fileId) {
|
||||
requireBookingPlan(bookingId);
|
||||
ContainerDocumentEntity document = repository.document(bookingId, DESTINATION_CLEARANCE);
|
||||
if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) {
|
||||
throw notFound("目的港清关附件不存在或不属于当前货柜");
|
||||
}
|
||||
String businessId = Long.toString(bookingId);
|
||||
try {
|
||||
var metadata = documentFiles.metadata(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId);
|
||||
if (metadata == null || !metadata.successful() || metadata.data() == null
|
||||
|| !Long.valueOf(fileId).equals(metadata.data().fileId())
|
||||
|| !Long.valueOf(0).equals(metadata.data().tenantId())
|
||||
|| !CLEARANCE_FILE_TYPE.equals(metadata.data().businessType())
|
||||
|| !businessId.equals(metadata.data().businessId())) {
|
||||
throw notFound("目的港清关附件不存在或归属不匹配");
|
||||
}
|
||||
var response = documentFiles.download(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId);
|
||||
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw notFound("目的港清关附件不存在");
|
||||
}
|
||||
MediaType contentType;
|
||||
try {
|
||||
contentType = MediaType.parseMediaType(metadata.data().contentType());
|
||||
} catch (RuntimeException exception) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
String fileName = StringUtils.hasText(document.getFileName())
|
||||
? document.getFileName() : metadata.data().fileName();
|
||||
return new DocumentDownload(fileName, contentType, response.getBody());
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw notFound("目的港清关附件不存在或归属不匹配");
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
|
||||
}
|
||||
}
|
||||
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
|
||||
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); }
|
||||
private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; }
|
||||
@@ -169,6 +204,44 @@ public class LoadingListService {
|
||||
return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION));
|
||||
}
|
||||
|
||||
public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) {
|
||||
AggregateRow plan = requirePlan(planId);
|
||||
requireWarehouse(plan, warehouseId);
|
||||
requireCompletedPlan(plan);
|
||||
ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION);
|
||||
if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) {
|
||||
throw notFound("出口报关附件不存在或不属于当前装柜计划");
|
||||
}
|
||||
String businessId = Long.toString(planId);
|
||||
try {
|
||||
var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
|
||||
if (metadata == null || !metadata.successful() || metadata.data() == null
|
||||
|| !Long.valueOf(fileId).equals(metadata.data().fileId())
|
||||
|| !Long.valueOf(0).equals(metadata.data().tenantId())
|
||||
|| !DECLARATION_FILE_TYPE.equals(metadata.data().businessType())
|
||||
|| !businessId.equals(metadata.data().businessId())) {
|
||||
throw notFound("出口报关附件不存在或归属不匹配");
|
||||
}
|
||||
var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
|
||||
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw notFound("出口报关附件不存在");
|
||||
}
|
||||
MediaType contentType;
|
||||
try {
|
||||
contentType = MediaType.parseMediaType(metadata.data().contentType());
|
||||
} catch (RuntimeException exception) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
String fileName = StringUtils.hasText(document.getFileName())
|
||||
? document.getFileName() : metadata.data().fileName();
|
||||
return new DocumentDownload(fileName, contentType, response.getBody());
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw notFound("出口报关附件不存在或归属不匹配");
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "上传出口报关文档")
|
||||
|
||||
Reference in New Issue
Block a user