From ed692ee3bb9753713dfdbb1cc2a67f0e29cd1247 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 27 Sep 2026 20:23:53 +0800 Subject: [PATCH] feat(firstleg): model declaration batches and customs results --- firstleg/AGENTS.md | 2 +- .../MerchantContainerCustomsController.java | 7 + .../PlatformContainerCustomsController.java | 61 ++++++++ .../container/dto/ContainerCustomsDtos.java | 25 +++ .../mapper/ContainerCustomsMapper.java | 102 +++++++++++++ .../service/ContainerCustomsService.java | 143 ++++++++++++++++++ .../db/phase3-container-customs-schema.sql | 50 ++++++ .../main/resources/openapi/customs-v1.yaml | 59 ++++++++ ...ontainerCustomsControllerContractTest.java | 9 +- .../ContainerCustomsMapperContractTest.java | 20 ++- .../repository/CustomsSqlContractTest.java | 5 + 11 files changed, 480 insertions(+), 3 deletions(-) diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 8b67df4c5..691ae867a 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -51,7 +51,7 @@ | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | -| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | +| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | | 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | | 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java index 2b448a216..5fe429d46 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java @@ -33,6 +33,13 @@ public class MerchantContainerCustomsController { return ApiResponse.success(service.merchantOrderView(FirstlegScopeGuard.merchant(user), orderId)); } + @GetMapping("/orders/{orderId}/clearance-results") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ApiResponse> clearanceResults(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long orderId) { + return ApiResponse.success(service.merchantClearanceResults(FirstlegScopeGuard.merchant(user), orderId)); + } + @PostMapping(value="/loading-plans/{planId}/documents/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('merchant:order:edit')") public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java index f25e831be..b6e5d7132 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.nio.charset.StandardCharsets; +import java.util.List; @Validated @RestController @@ -33,6 +34,66 @@ public class PlatformContainerCustomsController { return ApiResponse.success(service.platformView(planId)); } + @GetMapping("/{planId}/declaration-batches") + @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") + public ApiResponse> declarationBatches(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.platformDeclarationBatches(planId)); + } + + @PostMapping("/{planId}/declaration-batches") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_DECLARATION_BATCH_CREATE",resourceType="CUSTOMS_CONTAINER") + public ApiResponse createDeclarationBatch(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody DeclarationBatchRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.createDeclarationBatch(planId,user.userId(),user.username(),key,request)); + } + + @PostMapping("/declaration-batches/{batchId}/submit") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_DECLARATION_BATCH_SUBMIT",resourceType="CUSTOMS_DECLARATION_BATCH") + public ApiResponse submitDeclarationBatch(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long batchId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody DeclarationBatchRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.submitDeclarationBatch(batchId,user.userId(),user.username(),key,request)); + } + + @GetMapping("/declaration-batches/{batchId}/results") + @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") + public ApiResponse> clearanceResults(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long batchId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.platformClearanceResults(batchId)); + } + + @PostMapping("/declaration-batches/{batchId}/results") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_CLEARANCE_RESULT_RECORD",resourceType="CUSTOMS_DECLARATION_BATCH") + public ApiResponse addClearanceResult(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long batchId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ClearanceResultRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.addClearanceResult(batchId,user.userId(),user.username(),key,request)); + } + + @PostMapping("/clearance-results/{resultId}/resolve") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_CLEARANCE_RESULT_RESOLVE",resourceType="CUSTOMS_CLEARANCE_RESULT") + public ApiResponse resolveClearanceResult(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long resultId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ClearanceResolveRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.resolveClearanceResult(resultId,user.userId(),user.username(),key,request)); + } + @PostMapping("/{planId}/start") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @BusinessAudit(event="CONTAINER_CUSTOMS_START",resourceType="CUSTOMS_CONTAINER") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java index bc96143fa..aeea14fe5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java @@ -35,5 +35,30 @@ public final class ContainerCustomsDtos { public record ActionRequest(@Min(0) int expectedVersion, boolean confirm, @Size(max = 500) String remark) {} + public record DeclarationBatchView(long id, long customsContainerId, long loadingPlanId, + String batchNo, String status, int sourceVersion, + String customsEntryNo, long version, + LocalDateTime submittedAt, String submittedByName, + LocalDateTime acknowledgedAt, String acknowledgedByName, + String remark, List results) {} + + public record DeclarationBatchRequest(@Min(0) int expectedVersion, + @Size(max = 64) String batchNo, + @Size(max = 64) String customsEntryNo, + @Size(max = 500) String remark) {} + + public record ClearanceResultView(long id, long declarationBatchId, long customsContainerId, + String resultType, String resultStatus, String customsEntryNo, + LocalDateTime occurredAt, String resolution, Long evidenceFileId, + long recordedBy, String recordedByName, LocalDateTime recordedAt, + Long previousResultId) {} + + public record ClearanceResultRequest(@Min(0) int expectedVersion, @Size(max = 32) String resultType, + LocalDateTime occurredAt, @Size(max = 1000) String resolution, + Long evidenceFileId, boolean resolved) {} + + public record ClearanceResolveRequest(@Min(0) int expectedVersion, + @Size(max = 1000) String resolution) {} + public record Download(String fileName, String contentType, byte[] content) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index 0d9b75de5..15eaf48d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -119,6 +119,94 @@ public interface ContainerCustomsMapper { @Select("SELECT broker_submitted_at FROM fl_customs_container WHERE id=#{id}") LocalDateTime brokerSubmittedAt(@Param("id") long id); + @Select("SELECT " + DECLARATION_BATCH_COLUMNS + " FROM fl_customs_declaration_batch b " + + "WHERE customs_container_id=#{containerId} ORDER BY created_at DESC,id DESC") + List declarationBatches(@Param("containerId") long containerId); + + @Select("SELECT " + DECLARATION_BATCH_COLUMNS + " FROM fl_customs_declaration_batch b WHERE b.id=#{id}") + DeclarationBatchRow declarationBatch(@Param("id") long id); + + @Select("SELECT " + DECLARATION_BATCH_COLUMNS + " FROM fl_customs_declaration_batch b WHERE b.id=#{id} FOR UPDATE") + DeclarationBatchRow lockDeclarationBatch(@Param("id") long id); + + @Insert(""" + INSERT INTO fl_customs_declaration_batch + (id,customs_container_id,loading_plan_id,batch_no,status,source_version,source_snapshot_json,remark, + version,created_at,updated_at) + VALUES(#{id},#{containerId},#{planId},#{batchNo},'READY',#{sourceVersion},CAST(#{snapshotJson} AS JSON),#{remark}, + 0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertDeclarationBatch(@Param("id") long id,@Param("containerId") long containerId,@Param("planId") long planId, + @Param("batchNo") String batchNo,@Param("sourceVersion") int sourceVersion, + @Param("snapshotJson") String snapshotJson,@Param("remark") String remark); + + @Update(""" + UPDATE fl_customs_declaration_batch SET batch_no=#{batchNo},status='SUBMITTED',customs_entry_no=#{entryNo}, + submitted_by=#{operatorId},submitted_by_name=#{operatorName},submitted_at=CURRENT_TIMESTAMP(3),remark=#{remark}, + version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND version=#{version} AND status='READY' + """) + int submitDeclarationBatch(@Param("id") long id,@Param("version") int version,@Param("batchNo") String batchNo, + @Param("entryNo") String entryNo,@Param("operatorId") long operatorId, + @Param("operatorName") String operatorName,@Param("remark") String remark); + + @Update(""" + UPDATE fl_customs_declaration_batch SET status=#{status},acknowledged_by=#{operatorId}, + acknowledged_by_name=#{operatorName},acknowledged_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND version=#{version} AND status IN ('SUBMITTED','RESULT_PENDING','ACKNOWLEDGED') + """) + int acknowledgeDeclarationBatch(@Param("id") long id,@Param("version") int version,@Param("status") String status, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Select("SELECT " + CLEARANCE_RESULT_COLUMNS + " FROM fl_customs_clearance_result r " + + "WHERE declaration_batch_id=#{batchId} ORDER BY occurred_at,id") + List clearanceResults(@Param("batchId") long batchId); + + @Select("SELECT " + CLEARANCE_RESULT_COLUMNS + " FROM fl_customs_clearance_result r WHERE r.id=#{id}") + ClearanceResultRow clearanceResult(@Param("id") long id); + + @Select("SELECT " + CLEARANCE_RESULT_COLUMNS + " FROM fl_customs_clearance_result r WHERE r.id=#{id} FOR UPDATE") + ClearanceResultRow lockClearanceResult(@Param("id") long id); + + @Insert(""" + INSERT INTO fl_customs_clearance_result + (id,declaration_batch_id,customs_container_id,result_type,result_status,customs_entry_no,occurred_at, + resolution,evidence_file_id,recorded_by,recorded_by_name,recorded_at,previous_result_id,created_at,updated_at) + VALUES(#{id},#{batchId},#{containerId},#{resultType},#{resultStatus},#{entryNo},#{occurredAt},#{resolution}, + #{evidenceFileId},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),#{previousResultId},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertClearanceResult(@Param("id") long id,@Param("batchId") long batchId,@Param("containerId") long containerId, + @Param("resultType") String resultType,@Param("resultStatus") String resultStatus, + @Param("entryNo") String entryNo,@Param("occurredAt") LocalDateTime occurredAt, + @Param("resolution") String resolution,@Param("evidenceFileId") Long evidenceFileId, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName, + @Param("previousResultId") Long previousResultId); + + @Update(""" + UPDATE fl_customs_clearance_result SET result_status='RESOLVED',resolution=#{resolution},updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND result_status='OPEN' + """) + int resolveClearanceResult(@Param("id") long id,@Param("resolution") String resolution); + + @Select("SELECT COUNT(*) FROM fl_customs_clearance_result WHERE declaration_batch_id=#{batchId} AND result_status='OPEN'") + int openClearanceResultCount(@Param("batchId") long batchId); + + @Update("UPDATE fl_customs_declaration_batch SET status='CLOSED',version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status IN ('SUBMITTED','ACKNOWLEDGED','RESULT_PENDING')") + int closeDeclarationBatch(@Param("id") long id); + + @Update("UPDATE fl_customs_declaration_batch SET status='RESULT_PENDING',version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status IN ('SUBMITTED','ACKNOWLEDGED')") + int markDeclarationResultPending(@Param("id") long id); + + @Select(""" + SELECT DISTINCT b.id + FROM fl_customs_declaration_batch b + JOIN fl_customs_clearance_result r ON r.declaration_batch_id=b.id + JOIN fl_customs_customer_document d ON d.customs_container_id=b.customs_container_id AND d.tenant_id=#{tenantId} AND d.status='ACTIVE' + JOIN fl_customs_frozen_document_inbound s ON s.document_id=d.id AND s.order_id=#{orderId} + ORDER BY b.id DESC + """) + List merchantDeclarationBatchIds(@Param("tenantId") long tenantId,@Param("orderId") long orderId); + @Update(""" UPDATE fl_customs_container SET status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName}, completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 @@ -469,6 +557,20 @@ public interface ContainerCustomsMapper { Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {} record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status, Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {} + String DECLARATION_BATCH_COLUMNS = "b.id id,b.customs_container_id customsContainerId,b.loading_plan_id loadingPlanId,"+ + "b.batch_no batchNo,b.status status,b.source_version sourceVersion,b.customs_entry_no customsEntryNo,b.submitted_by_name submittedByName,"+ + "b.submitted_at submittedAt,b.acknowledged_by_name acknowledgedByName,b.acknowledged_at acknowledgedAt,b.remark remark,b.version version,b.created_at createdAt,b.updated_at updatedAt"; + String CLEARANCE_RESULT_COLUMNS = "r.id id,r.declaration_batch_id declarationBatchId,r.customs_container_id customsContainerId,"+ + "r.result_type resultType,r.result_status resultStatus,r.customs_entry_no customsEntryNo,r.occurred_at occurredAt,"+ + "r.resolution resolution,r.evidence_file_id evidenceFileId,r.recorded_by recordedBy,r.recorded_by_name recordedByName,"+ + "r.recorded_at recordedAt,r.previous_result_id previousResultId"; + record DeclarationBatchRow(Long id,Long customsContainerId,Long loadingPlanId,String batchNo,String status, + Integer sourceVersion,String customsEntryNo,String submittedByName,LocalDateTime submittedAt, + String acknowledgedByName,LocalDateTime acknowledgedAt,String remark,Integer version, + LocalDateTime createdAt,LocalDateTime updatedAt) {} + record ClearanceResultRow(Long id,Long declarationBatchId,Long customsContainerId,String resultType,String resultStatus, + String customsEntryNo,LocalDateTime occurredAt,String resolution,Long evidenceFileId, + Long recordedBy,String recordedByName,LocalDateTime recordedAt,Long previousResultId) {} record BuyExportRow(Long itemId,String planNo,String cabinetNo,String containerNo,String customerName,String orderNo, String inboundCode,String skuCode,String productNameZh,String productNameEn,Integer packageCount, Integer productQuantity,BigDecimal grossWeightKg,BigDecimal netWeightKg,BigDecimal volumeCbm, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index f46221205..e6f32f784 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -309,6 +309,149 @@ public class ContainerCustomsService { return platformView(planId); } + public List platformDeclarationBatches(long planId) { + ContainerRow container = mapper.container(planId); + if (container == null) throw notFound("整柜报关尚未生成"); + return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList(); + } + + @Transactional + public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName, + String key, DeclarationBatchRequest request) { + String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId; + String requestHash = hash(action, request.expectedVersion(), request.batchNo(), request.remark()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId())); + ContainerRow container = mapper.lockContainer(planId); + if (container == null || !COMPLETED.equals(container.status())) throw conflict("报关完税文件未全部完成,不能创建正式申报批次"); + if (mapper.taxRefundCount(planId) == 0) throw conflict("全买单柜不创建退税正式申报批次"); + if (mapper.missingFinalCount(container.id()) != 0 || mapper.unconfirmedReceiptCount(container.id()) != 0 + || mapper.brokerSubmittedAt(container.id()) == null) { + throw conflict("当前柜尚未完成退税资料、回执确认和报关行执行"); + } + if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); + long batchId = IdWorker.getId(); + String batchNo = trim(request.batchNo()); + if (batchNo == null) batchNo = "DECL-" + batchId; + String snapshot = declarationSnapshot(container.id()); + if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot, + trim(request.remark())) != 1) throw conflict("正式申报批次创建失败"); + history(container, "CREATE_CUSTOMS_DECLARATION_BATCH", container.status(), container.status(), batchNo, operatorId, operatorName); + completeCommand(operatorId, action, key, batchId); + return declarationBatchView(mapper.declarationBatch(batchId)); + } + + @Transactional + public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName, + String key, DeclarationBatchRequest request) { + String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId; + String entryNo = trim(request.customsEntryNo()); + if (entryNo == null) throw bad("正式申报必须回填报关单号"); + String requestHash = hash(action, request.expectedVersion(), request.batchNo(), entryNo, request.remark()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId)); + DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId); + if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态"); + if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent(); + String batchNo = trim(request.batchNo()); + if (batchNo == null) batchNo = batch.batchNo(); + if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName, + trim(request.remark())) != 1) throw concurrent(); + ContainerRow container = mapper.lockContainerById(batch.customsContainerId()); + if (container != null) history(container, "SUBMIT_CUSTOMS_DECLARATION_BATCH", container.status(), container.status(), entryNo, operatorId, operatorName); + completeCommand(operatorId, action, key, batchId); + return declarationBatchView(mapper.declarationBatch(batchId)); + } + + public List platformClearanceResults(long batchId) { + if (mapper.declarationBatch(batchId) == null) throw notFound("正式申报批次不存在"); + return mapper.clearanceResults(batchId).stream().map(this::clearanceResultView).toList(); + } + + @Transactional + public ClearanceResultView addClearanceResult(long batchId, long operatorId, String operatorName, + String key, ClearanceResultRequest request) { + String type = trim(request.resultType()); + if (!Set.of("INSPECTION", "SUPPLEMENT_REQUIRED", "RELEASED", "DETAINED", "OTHER").contains(type)) { + throw bad("海关结果类型无效"); + } + if (request.occurredAt() == null) throw bad("海关结果发生时间不能为空"); + if (Set.of("DETAINED", "OTHER", "SUPPLEMENT_REQUIRED").contains(type) + && (request.resolution() == null || request.resolution().isBlank())) throw bad("该海关结果必须填写处理说明"); + String action = "ADD_CUSTOMS_CLEARANCE_RESULT_" + batchId; + String requestHash = hash(action, request.expectedVersion(), type, request.occurredAt(), request.resolution(), + request.evidenceFileId(), request.resolved()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return clearanceResultView(mapper.clearanceResult(replay.resultId())); + DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId); + if (batch == null || !Set.of("SUBMITTED", "ACKNOWLEDGED", "RESULT_PENDING").contains(batch.status())) { + throw conflict("正式申报批次不在可录入海关结果状态"); + } + if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent(); + String resultStatus = "RELEASED".equals(type) || request.resolved() ? "RESOLVED" : "OPEN"; + long resultId = IdWorker.getId(); + if (mapper.insertClearanceResult(resultId, batch.id(), batch.customsContainerId(), type, resultStatus, + batch.customsEntryNo(), request.occurredAt(), trim(request.resolution()), request.evidenceFileId(), + operatorId, operatorName, null) != 1) throw conflict("海关结果保存失败"); + if ("OPEN".equals(resultStatus)) mapper.markDeclarationResultPending(batch.id()); + else mapper.closeDeclarationBatch(batch.id()); + ContainerRow container = mapper.lockContainerById(batch.customsContainerId()); + if (container != null) history(container, "RECORD_CUSTOMS_CLEARANCE_RESULT", container.status(), container.status(), type, operatorId, operatorName); + completeCommand(operatorId, action, key, resultId); + return clearanceResultView(mapper.clearanceResult(resultId)); + } + + @Transactional + public ClearanceResultView resolveClearanceResult(long resultId, long operatorId, String operatorName, + String key, ClearanceResolveRequest request) { + String action = "RESOLVE_CUSTOMS_CLEARANCE_RESULT_" + resultId; + String requestHash = hash(action, request.expectedVersion(), request.resolution()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return clearanceResultView(mapper.clearanceResult(resultId)); + ClearanceResultRow result = mapper.lockClearanceResult(resultId); + if (result == null) throw notFound("海关结果不存在"); + DeclarationBatchRow batch = mapper.lockDeclarationBatch(result.declarationBatchId()); + if (batch == null || !Objects.equals(batch.version(), request.expectedVersion())) throw concurrent(); + if (mapper.resolveClearanceResult(resultId, trim(request.resolution())) != 1) throw conflict("海关结果已被其他人处理"); + if (mapper.openClearanceResultCount(batch.id()) == 0) mapper.closeDeclarationBatch(batch.id()); + ContainerRow container = mapper.lockContainerById(batch.customsContainerId()); + if (container != null) history(container, "RESOLVE_CUSTOMS_CLEARANCE_RESULT", container.status(), container.status(), trim(request.resolution()), operatorId, operatorName); + completeCommand(operatorId, action, key, resultId); + return clearanceResultView(mapper.clearanceResult(resultId)); + } + + public List merchantClearanceResults(long tenantId, long orderId) { + return mapper.merchantDeclarationBatchIds(tenantId, orderId).stream() + .flatMap(id -> mapper.clearanceResults(id).stream()).map(this::clearanceResultView).toList(); + } + + private DeclarationBatchView declarationBatchView(DeclarationBatchRow row) { + if (row == null) throw notFound("正式申报批次不存在"); + return new DeclarationBatchView(row.id(), row.customsContainerId(), row.loadingPlanId(), row.batchNo(), row.status(), + row.sourceVersion() == null ? 0 : row.sourceVersion(), row.customsEntryNo(), row.version() == null ? 0 : row.version(), + row.submittedAt(), row.submittedByName(), row.acknowledgedAt(), row.acknowledgedByName(), row.remark(), + mapper.clearanceResults(row.id()).stream().map(this::clearanceResultView).toList()); + } + + private ClearanceResultView clearanceResultView(ClearanceResultRow row) { + if (row == null) throw notFound("海关结果不存在"); + return new ClearanceResultView(row.id(), row.declarationBatchId(), row.customsContainerId(), row.resultType(), + row.resultStatus(), row.customsEntryNo(), row.occurredAt(), row.resolution(), row.evidenceFileId(), + row.recordedBy() == null ? 0 : row.recordedBy(), row.recordedByName(), row.recordedAt(), row.previousResultId()); + } + + private String declarationSnapshot(long containerId) { + List> snapshot = mapper.documents(containerId, null).stream().map(doc -> { + CompletionRow receipt = mapper.completion(doc.id()); + FinalRow finalDocument = mapper.finalDocument(doc.id()); + return Map.of("documentId", doc.id(), "tenantId", doc.tenantId(), "sourceVersion", doc.version(), + "receiptVersion", receipt == null ? 0 : receipt.version(), "receiptStatus", receipt == null ? "MISSING" : receipt.customerConfirmStatus(), + "finalVersion", finalDocument == null ? 0 : finalDocument.version()); + }).toList(); + try { return objectMapper.writeValueAsString(snapshot); } + catch (JsonProcessingException ex) { throw new IllegalStateException(ex); } + } + public Download downloadSource(long tenantId, long documentId, boolean platform) { DocumentRow source = requiredDocument(documentId); if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在"); diff --git a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql index 92ea78873..fd34035cb 100644 --- a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql +++ b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql @@ -185,6 +185,56 @@ CREATE TABLE IF NOT EXISTS fl_customs_command ( CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS')) ) ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实'; +CREATE TABLE IF NOT EXISTS fl_customs_declaration_batch ( + id BIGINT UNSIGNED NOT NULL COMMENT '正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + batch_no VARCHAR(64) NULL COMMENT '平台正式申报批次号', + status VARCHAR(32) NOT NULL COMMENT '批次状态:READY/SUBMITTED/ACKNOWLEDGED/RESULT_PENDING/CLOSED/CANCELLED', + source_version INT UNSIGNED NOT NULL COMMENT '创建时整柜报关聚合版本', + source_snapshot_json JSON NOT NULL COMMENT '提交时资料、回执和确认版本快照', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号', + submitted_by BIGINT UNSIGNED NULL COMMENT '正式申报提交人', + submitted_by_name VARCHAR(64) NULL COMMENT '正式申报提交人名称快照', + submitted_at DATETIME(3) NULL COMMENT '正式申报提交时间', + acknowledged_by BIGINT UNSIGNED NULL COMMENT '报关行确认受理人', + acknowledged_by_name VARCHAR(64) NULL COMMENT '报关行确认受理人名称快照', + acknowledged_at DATETIME(3) NULL COMMENT '报关行确认受理时间', + remark VARCHAR(500) NULL COMMENT '批次说明', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_declaration_batch_container(customs_container_id, id), + KEY idx_customs_declaration_batch_plan(loading_plan_id,status,created_at), + CONSTRAINT fk_customs_declaration_batch_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_declaration_batch_status CHECK(status IN ('READY','SUBMITTED','ACKNOWLEDGED','RESULT_PENDING','CLOSED','CANCELLED')) +) ENGINE=InnoDB COMMENT='整柜正式申报批次事实'; + +CREATE TABLE IF NOT EXISTS fl_customs_clearance_result ( + id BIGINT UNSIGNED NOT NULL COMMENT '海关结果事实ID', + declaration_batch_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + result_type VARCHAR(32) NOT NULL COMMENT '结果类型:INSPECTION/SUPPLEMENT_REQUIRED/RELEASED/DETAINED/OTHER', + result_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '结果状态:OPEN/RESOLVED/CANCELLED', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号快照', + occurred_at DATETIME(3) NOT NULL COMMENT '海关事实发生时间', + resolution VARCHAR(1000) NULL COMMENT '处理结果或异常说明', + evidence_file_id BIGINT UNSIGNED NULL COMMENT '结果凭证File文件ID', + recorded_by BIGINT UNSIGNED NOT NULL COMMENT '平台录入人', + recorded_by_name VARCHAR(64) NULL COMMENT '平台录入人名称快照', + recorded_at DATETIME(3) NOT NULL COMMENT '平台录入时间', + previous_result_id BIGINT UNSIGNED NULL COMMENT '前序结果事实ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '结果创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '结果更新时间', + PRIMARY KEY(id), + KEY idx_customs_clearance_result_batch(declaration_batch_id,occurred_at,id), + KEY idx_customs_clearance_result_container(customs_container_id, result_type, result_status), + CONSTRAINT fk_customs_clearance_result_batch FOREIGN KEY(declaration_batch_id) REFERENCES fl_customs_declaration_batch(id), + CONSTRAINT chk_customs_clearance_result_type CHECK(result_type IN ('INSPECTION','SUPPLEMENT_REQUIRED','RELEASED','DETAINED','OTHER')), + CONSTRAINT chk_customs_clearance_result_status CHECK(result_status IN ('OPEN','RESOLVED','CANCELLED')) +) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实'; + USE baiyue_iam; DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN(5102171,5102173,5102174,5102175); DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175) diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index 1a4695d49..1f5345c2c 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -21,6 +21,44 @@ paths: parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {$ref: '#/components/requestBodies/ActionBody'} responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/platform/v1/customs/loading-plans/{planId}/declaration-batches: + get: + summary: 查询整柜正式申报批次和海关结果 + x-permission: platform:firstleg:customs:list + parameters: [{$ref: '#/components/parameters/PlanId'}] + responses: {'200': {$ref: '#/components/responses/DeclarationBatchListResponse'}} + post: + summary: 创建整柜正式申报批次并冻结提交快照 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchRequest'}}}} + responses: {'200': {$ref: '#/components/responses/DeclarationBatchResponse'}} + /api/platform/v1/customs/declaration-batches/{batchId}/submit: + post: + summary: 平台人工提交正式申报并回填报关单号 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/BatchId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchRequest'}}}} + responses: {'200': {$ref: '#/components/responses/DeclarationBatchResponse'}} + /api/platform/v1/customs/declaration-batches/{batchId}/results: + get: + summary: 查询正式申报批次的海关结果历史 + x-permission: platform:firstleg:customs:list + parameters: [{$ref: '#/components/parameters/BatchId'}] + responses: {'200': {$ref: '#/components/responses/ClearanceResultListResponse'}} + post: + summary: 平台人工录入查验、补资料、放行、扣货或其他海关结果 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/BatchId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResultRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ClearanceResultResponse'}} + /api/platform/v1/customs/clearance-results/{resultId}/resolve: + post: + summary: 解决一条待处理海关结果 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/ResultId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResolveRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ClearanceResultResponse'}} /api/platform/v1/customs/loading-plans/{planId}/submit-to-broker: post: summary: 将客户确认回执发送给报关行执行 @@ -70,6 +108,12 @@ paths: x-permission: merchant:order:view parameters: [{$ref: '#/components/parameters/OrderId'}] responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/merchant/v1/customs/orders/{orderId}/clearance-results: + get: + summary: 客户只读查看自身订单可见的海关结果 + x-permission: merchant:order:view + parameters: [{$ref: '#/components/parameters/OrderId'}] + responses: {'200': {$ref: '#/components/responses/ClearanceResultListResponse'}} /api/merchant/v1/customs/loading-plans/{planId}/documents/upload: post: summary: 上传资料并关联当前柜内本客户一个或多个退税入仓码 @@ -150,6 +194,8 @@ components: PlanId: {name: planId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} OrderId: {name: orderId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} DocumentId: {name: documentId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} + BatchId: {name: batchId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} + ResultId: {name: resultId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} InboundIds: {name: inboundIds, in: query, required: true, schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}} requestBodies: @@ -159,16 +205,29 @@ components: ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}} DocumentResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}} BinaryResponse: {description: 文件二进制, headers: {Content-Disposition: {schema: {type: string}}, Cache-Control: {schema: {type: string, example: no-store}}}, content: {application/octet-stream: {schema: {type: string, format: binary}}}} + DeclarationBatchResponse: {description: 正式申报批次, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchEnvelope'}}}} + DeclarationBatchListResponse: {description: 正式申报批次列表, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchListEnvelope'}}}} + ClearanceResultResponse: {description: 海关结果, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResultEnvelope'}}}} + ClearanceResultListResponse: {description: 海关结果列表, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResultListEnvelope'}}}} schemas: LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串} ApiEnvelope: {type: object, required: [code, message, timestamp], properties: {code: {type: string}, message: {type: string}, requestId: {type: string, nullable: true}, traceId: {type: string, nullable: true}, timestamp: {type: string, format: date-time}}} ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]} DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]} ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}} + DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, batchNo: {type: string, maxLength: 64, nullable: true}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}} + ClearanceResultRequest: {type: object, required: [expectedVersion, resultType, occurredAt], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, occurredAt: {type: string, format: date-time}, resolution: {type: string, maxLength: 1000, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, resolved: {type: boolean}}} + ClearanceResolveRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resolution: {type: string, maxLength: 1000, nullable: true}}} ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}} FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}} InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}} DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, receipt: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 报关行回执文件}, receiptStatus: {type: string, enum: [PENDING, CONFIRMED, REJECTED], nullable: true}, receiptRejectReason: {type: string, nullable: true}, finalDocument: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 真实报关完税文件}}} ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, actualBoxCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, hasActualCargo: {type: boolean, description: 当前完成装柜计划是否仍有实际装柜货物;false时查询成功返回空明细且无需发起报关}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, actualBoxCount: {type: integer, format: int64, minimum: 0, description: 当前视角可见的实际装箱数;部分装柜只展示该数量,线下处理后续}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, brokerSubmittedAt: {type: string, format: date-time, nullable: true, description: 已将客户确认后的回执发送给报关行执行的时间;不表示报关完成}, completedAt: {type: string, format: date-time, nullable: true}}} + DeclarationBatch: {type: object, required: [id, customsContainerId, loadingPlanId, status, sourceVersion, version, results], properties: {id: {$ref: '#/components/schemas/LongId'}, customsContainerId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, batchNo: {type: string}, status: {type: string, enum: [READY, SUBMITTED, ACKNOWLEDGED, RESULT_PENDING, CLOSED, CANCELLED]}, sourceVersion: {type: integer}, customsEntryNo: {type: string, nullable: true}, version: {type: integer}, submittedAt: {type: string, format: date-time, nullable: true}, submittedByName: {type: string, nullable: true}, acknowledgedAt: {type: string, format: date-time, nullable: true}, acknowledgedByName: {type: string, nullable: true}, remark: {type: string, nullable: true}, results: {type: array, items: {$ref: '#/components/schemas/ClearanceResult'}}}} + ClearanceResult: {type: object, required: [id, declarationBatchId, customsContainerId, resultType, resultStatus, occurredAt, recordedAt], properties: {id: {$ref: '#/components/schemas/LongId'}, declarationBatchId: {$ref: '#/components/schemas/LongId'}, customsContainerId: {$ref: '#/components/schemas/LongId'}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, resultStatus: {type: string, enum: [OPEN, RESOLVED, CANCELLED]}, customsEntryNo: {type: string, nullable: true}, occurredAt: {type: string, format: date-time}, resolution: {type: string, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, recordedBy: {$ref: '#/components/schemas/LongId'}, recordedByName: {type: string, nullable: true}, recordedAt: {type: string, format: date-time}, previousResultId: {$ref: '#/components/schemas/LongId', nullable: true}}} + DeclarationBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DeclarationBatch'}}}]} + DeclarationBatchListEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/DeclarationBatch'}}}}]} + ClearanceResultEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/ClearanceResult'}}}]} + ClearanceResultListEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/ClearanceResult'}}}}]} ExportBatchRequest: {type: object, required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit], properties: {taskId: {type: string}, tenantId: {type: string, enum: ['0']}, appCode: {type: string, enum: [PLATFORM]}, requestedBy: {type: string}, dispatchVersion: {type: integer}, parameters: {type: object, required: [loadingPlanId], additionalProperties: false, properties: {loadingPlanId: {type: string}}}, cursor: {type: string, nullable: true}, snapshotToken: {type: string, nullable: true}, limit: {type: integer, minimum: 1, maximum: 500}}} ExportBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: object, properties: {schemaVersion: {type: integer}, schemaHash: {type: string}, snapshotToken: {type: string}, columns: {type: array, items: {type: object}}, rows: {type: array, items: {type: object}}, nextCursor: {type: string}, hasMore: {type: boolean}, totalRows: {type: integer, format: int64, nullable: true}}}}}]} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java index 5cd227593..2a0adb447 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java @@ -19,6 +19,11 @@ class ContainerCustomsControllerContractTest { assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage"); assertPermission(PlatformContainerCustomsController.class,"uploadCompletion","platform:firstleg:customs:manage"); assertPermission(PlatformContainerCustomsController.class,"completeAllBuy","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"declarationBatches","platform:firstleg:customs:list"); + assertPermission(PlatformContainerCustomsController.class,"createDeclarationBatch","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"submitDeclarationBatch","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"addClearanceResult","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"resolveClearanceResult","platform:firstleg:customs:manage"); } @Test void merchantUsesOrderViewAndEditWithoutTrustingTenantInput() { assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value()) @@ -28,11 +33,13 @@ class ContainerCustomsControllerContractTest { assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit"); + assertPermission(MerchantContainerCustomsController.class,"clearanceResults","merchant:order:view"); } @Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{ String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8); new Yaml().load(yaml); - assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:") + assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:", + "DeclarationBatch:","ClearanceResult:","declaration-batches","clearance-results") .contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:","hasActualCargo:","nullable: true") .contains("LOADING_LIST_BUY_DECLARATION_ITEMS") .doesNotContain("/cases/","CUSTOMS_DECLARATION"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java index cc115e3fd..ee9306812 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java @@ -47,7 +47,24 @@ class ContainerCustomsMapperContractTest { assertRecordProjection("lockCompletion", ContainerCustomsMapper.CompletionRow.class, long.class); } + @Test void declarationAndClearanceQueriesMapExplicitHistoricalFacts() throws Exception { + assertRecordProjection("declarationBatches", ContainerCustomsMapper.DeclarationBatchRow.class, long.class); + assertRecordProjection("declarationBatch", ContainerCustomsMapper.DeclarationBatchRow.class, long.class); + assertRecordProjection("lockDeclarationBatch", ContainerCustomsMapper.DeclarationBatchRow.class, long.class); + assertRecordProjectionWithoutVersion("clearanceResults", ContainerCustomsMapper.ClearanceResultRow.class, long.class); + assertRecordProjectionWithoutVersion("clearanceResult", ContainerCustomsMapper.ClearanceResultRow.class, long.class); + assertRecordProjectionWithoutVersion("lockClearanceResult", ContainerCustomsMapper.ClearanceResultRow.class, long.class); + } + private static void assertRecordProjection(String method,Class recordType,Class... parameterTypes) throws Exception { + assertProjection(method, recordType, true, parameterTypes); + } + + private static void assertRecordProjectionWithoutVersion(String method,Class recordType,Class... parameterTypes) throws Exception { + assertProjection(method, recordType, false, parameterTypes); + } + + private static void assertProjection(String method,Class recordType,boolean requireVersion,Class... parameterTypes) throws Exception { String value=sql(method,parameterTypes).replaceAll("\\s+"," ").trim(); assertThat(value).doesNotContainIgnoringCase("SELECT *"); String projection=value.substring(value.indexOf("SELECT ")+7,value.indexOf(" FROM ")); @@ -56,7 +73,8 @@ class ContainerCustomsMapperContractTest { .map(column->column.substring(column.lastIndexOf(' ')+1)) .toList(); List components=Arrays.stream(recordType.getRecordComponents()).map(RecordComponent::getName).toList(); - assertThat(aliases).containsExactlyElementsOf(components).contains("version"); + assertThat(aliases).containsExactlyElementsOf(components); + if (requireVersion) assertThat(aliases).contains("version"); } private static String sql(String name,Class... types) throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java index 50edf3a3d..465582f4e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java @@ -21,6 +21,9 @@ class CustomsSqlContractTest { "fl_customs_frozen_document_inbound", "fl_customs_completion_document", "fl_customs_completion_document_version", + "fl_customs_final_document", + "fl_customs_declaration_batch", + "fl_customs_clearance_result", "fl_customs_container_history", "fl_customs_command"); private static final Pattern CREATE_TABLE = Pattern.compile( @@ -38,6 +41,8 @@ class CustomsSqlContractTest { .contains("fl_customs_customer_document_version", "scope_snapshot_json JSON NOT NULL") .contains("fl_customs_frozen_document_inbound", "fl_customs_completion_document_version") .contains("UNIQUE KEY uk_customs_completion_source(source_document_id)") + .contains("fl_customs_declaration_batch", "fl_customs_clearance_result", "customs_entry_no") + .contains("INSPECTION", "SUPPLEMENT_REQUIRED", "RELEASED", "DETAINED") .contains("fl_customs_command", "uk_customs_command(actor_id,action,idempotency_key)") .doesNotContain("platform:firstleg:loading-list:export"); }