feat(firstleg): model declaration batches and customs results

This commit is contained in:
2026-09-27 20:23:53 +08:00
parent 69f2a18a44
commit ed692ee3bb
11 changed files with 480 additions and 3 deletions
+1 -1
View File
@@ -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` | | 船节点跨区交接事件 | `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` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 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` 发布应付 | | 订舱级拖车与整柜出仓调度 | `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 生成文件 | | 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | | 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` |
@@ -33,6 +33,13 @@ public class MerchantContainerCustomsController {
return ApiResponse.success(service.merchantOrderView(FirstlegScopeGuard.merchant(user), orderId)); return ApiResponse.success(service.merchantOrderView(FirstlegScopeGuard.merchant(user), orderId));
} }
@GetMapping("/orders/{orderId}/clearance-results")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<List<ClearanceResultView>> 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) @PostMapping(value="/loading-plans/{planId}/documents/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('merchant:order:edit')") @PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<DocumentView> upload(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<DocumentView> upload(@AuthenticationPrincipal AuthenticatedUser user,
@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List;
@Validated @Validated
@RestController @RestController
@@ -33,6 +34,66 @@ public class PlatformContainerCustomsController {
return ApiResponse.success(service.platformView(planId)); return ApiResponse.success(service.platformView(planId));
} }
@GetMapping("/{planId}/declaration-batches")
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
public ApiResponse<List<DeclarationBatchView>> 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<DeclarationBatchView> 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<DeclarationBatchView> 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<List<ClearanceResultView>> 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<ClearanceResultView> 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<ClearanceResultView> 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") @PostMapping("/{planId}/start")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="CONTAINER_CUSTOMS_START",resourceType="CUSTOMS_CONTAINER") @BusinessAudit(event="CONTAINER_CUSTOMS_START",resourceType="CUSTOMS_CONTAINER")
@@ -35,5 +35,30 @@ public final class ContainerCustomsDtos {
public record ActionRequest(@Min(0) int expectedVersion, boolean confirm, public record ActionRequest(@Min(0) int expectedVersion, boolean confirm,
@Size(max = 500) String remark) {} @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<ClearanceResultView> 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) {} public record Download(String fileName, String contentType, byte[] content) {}
} }
@@ -119,6 +119,94 @@ public interface ContainerCustomsMapper {
@Select("SELECT broker_submitted_at FROM fl_customs_container WHERE id=#{id}") @Select("SELECT broker_submitted_at FROM fl_customs_container WHERE id=#{id}")
LocalDateTime brokerSubmittedAt(@Param("id") long 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<DeclarationBatchRow> 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<ClearanceResultRow> 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<Long> merchantDeclarationBatchIds(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
@Update(""" @Update("""
UPDATE fl_customs_container SET status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName}, 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 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) {} Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {}
record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status, record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status,
Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {} 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, record BuyExportRow(Long itemId,String planNo,String cabinetNo,String containerNo,String customerName,String orderNo,
String inboundCode,String skuCode,String productNameZh,String productNameEn,Integer packageCount, String inboundCode,String skuCode,String productNameZh,String productNameEn,Integer packageCount,
Integer productQuantity,BigDecimal grossWeightKg,BigDecimal netWeightKg,BigDecimal volumeCbm, Integer productQuantity,BigDecimal grossWeightKg,BigDecimal netWeightKg,BigDecimal volumeCbm,
@@ -309,6 +309,149 @@ public class ContainerCustomsService {
return platformView(planId); return platformView(planId);
} }
public List<DeclarationBatchView> 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<ClearanceResultView> 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<ClearanceResultView> 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<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(doc -> {
CompletionRow receipt = mapper.completion(doc.id());
FinalRow finalDocument = mapper.finalDocument(doc.id());
return Map.<String,Object>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) { public Download downloadSource(long tenantId, long documentId, boolean platform) {
DocumentRow source = requiredDocument(documentId); DocumentRow source = requiredDocument(documentId);
if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在"); if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在");
@@ -185,6 +185,56 @@ CREATE TABLE IF NOT EXISTS fl_customs_command (
CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS')) CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实'; ) 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; USE baiyue_iam;
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN(5102171,5102173,5102174,5102175); 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) DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)
@@ -21,6 +21,44 @@ paths:
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {$ref: '#/components/requestBodies/ActionBody'} requestBody: {$ref: '#/components/requestBodies/ActionBody'}
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} 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: /api/platform/v1/customs/loading-plans/{planId}/submit-to-broker:
post: post:
summary: 将客户确认回执发送给报关行执行 summary: 将客户确认回执发送给报关行执行
@@ -70,6 +108,12 @@ paths:
x-permission: merchant:order:view x-permission: merchant:order:view
parameters: [{$ref: '#/components/parameters/OrderId'}] parameters: [{$ref: '#/components/parameters/OrderId'}]
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} 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: /api/merchant/v1/customs/loading-plans/{planId}/documents/upload:
post: post:
summary: 上传资料并关联当前柜内本客户一个或多个退税入仓码 summary: 上传资料并关联当前柜内本客户一个或多个退税入仓码
@@ -150,6 +194,8 @@ components:
PlanId: {name: planId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} PlanId: {name: planId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
OrderId: {name: orderId, 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'}} 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}} 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'}}} InboundIds: {name: inboundIds, in: query, required: true, schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}}
requestBodies: requestBodies:
@@ -159,16 +205,29 @@ components:
ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}} ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}}
DocumentResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}} 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}}}} 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: schemas:
LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串} 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}}} 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,表示报关流程尚未生成}}}]} 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'}}}]} 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}}} 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}}} 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}}} 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}}} 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: 真实报关完税文件}}} 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}}} 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}}} 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}}}}}]} 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}}}}}]}
@@ -19,6 +19,11 @@ class ContainerCustomsControllerContractTest {
assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage"); assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage");
assertPermission(PlatformContainerCustomsController.class,"uploadCompletion","platform:firstleg:customs:manage"); assertPermission(PlatformContainerCustomsController.class,"uploadCompletion","platform:firstleg:customs:manage");
assertPermission(PlatformContainerCustomsController.class,"completeAllBuy","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() { @Test void merchantUsesOrderViewAndEditWithoutTrustingTenantInput() {
assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value()) assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
@@ -28,11 +33,13 @@ class ContainerCustomsControllerContractTest {
assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"clearanceResults","merchant:order:view");
} }
@Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{ @Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{
String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8); String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
new Yaml().load(yaml); 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("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:","hasActualCargo:","nullable: true")
.contains("LOADING_LIST_BUY_DECLARATION_ITEMS") .contains("LOADING_LIST_BUY_DECLARATION_ITEMS")
.doesNotContain("/cases/","CUSTOMS_DECLARATION"); .doesNotContain("/cases/","CUSTOMS_DECLARATION");
@@ -47,7 +47,24 @@ class ContainerCustomsMapperContractTest {
assertRecordProjection("lockCompletion", ContainerCustomsMapper.CompletionRow.class, long.class); 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 { 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(); String value=sql(method,parameterTypes).replaceAll("\\s+"," ").trim();
assertThat(value).doesNotContainIgnoringCase("SELECT *"); assertThat(value).doesNotContainIgnoringCase("SELECT *");
String projection=value.substring(value.indexOf("SELECT ")+7,value.indexOf(" FROM ")); String projection=value.substring(value.indexOf("SELECT ")+7,value.indexOf(" FROM "));
@@ -56,7 +73,8 @@ class ContainerCustomsMapperContractTest {
.map(column->column.substring(column.lastIndexOf(' ')+1)) .map(column->column.substring(column.lastIndexOf(' ')+1))
.toList(); .toList();
List<String> components=Arrays.stream(recordType.getRecordComponents()).map(RecordComponent::getName).toList(); List<String> 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 { private static String sql(String name,Class<?>... types) throws Exception {
@@ -21,6 +21,9 @@ class CustomsSqlContractTest {
"fl_customs_frozen_document_inbound", "fl_customs_frozen_document_inbound",
"fl_customs_completion_document", "fl_customs_completion_document",
"fl_customs_completion_document_version", "fl_customs_completion_document_version",
"fl_customs_final_document",
"fl_customs_declaration_batch",
"fl_customs_clearance_result",
"fl_customs_container_history", "fl_customs_container_history",
"fl_customs_command"); "fl_customs_command");
private static final Pattern CREATE_TABLE = Pattern.compile( 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_customer_document_version", "scope_snapshot_json JSON NOT NULL")
.contains("fl_customs_frozen_document_inbound", "fl_customs_completion_document_version") .contains("fl_customs_frozen_document_inbound", "fl_customs_completion_document_version")
.contains("UNIQUE KEY uk_customs_completion_source(source_document_id)") .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)") .contains("fl_customs_command", "uk_customs_command(actor_id,action,idempotency_key)")
.doesNotContain("platform:firstleg:loading-list:export"); .doesNotContain("platform:firstleg:loading-list:export");
} }