feat(firstleg): expose customs receipt history

This commit is contained in:
2026-09-28 09:49:53 +08:00
parent cf76f7d6d3
commit f6e70ea5d0
5 changed files with 23 additions and 3 deletions
@@ -17,10 +17,14 @@ public final class ContainerCustomsDtos {
public record FileView(long fileId, String fileName, String fileHash, String contentType, public record FileView(long fileId, String fileName, String fileHash, String contentType,
long sizeBytes, int version, LocalDateTime uploadedAt) {} long sizeBytes, int version, LocalDateTime uploadedAt) {}
public record ReceiptVersionView(long version, FileView file, String operatorName,
LocalDateTime createdAt) {}
public record DocumentView(long documentId, long tenantId, FileView source, public record DocumentView(long documentId, long tenantId, FileView source,
List<Long> inboundIds, List<String> inboundCodes, List<Long> inboundIds, List<String> inboundCodes,
boolean frozen, FileView receipt, String receiptStatus, boolean frozen, FileView receipt, String receiptStatus,
String receiptRejectReason, FileView finalDocument) {} String receiptRejectReason, FileView finalDocument,
List<ReceiptVersionView> receiptHistory) {}
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId, public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo, String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo,
@@ -340,6 +340,12 @@ public interface ContainerCustomsMapper {
"WHERE r.source_document_id=#{documentId} FOR UPDATE") "WHERE r.source_document_id=#{documentId} FOR UPDATE")
CompletionRow lockCompletion(@Param("documentId") long documentId); CompletionRow lockCompletion(@Param("documentId") long documentId);
@Select("SELECT v.document_version documentVersion,v.file_id fileId,v.file_name fileName,v.file_hash fileHash,"
+ "v.content_type contentType,v.size_bytes sizeBytes,v.operator_id operatorId,v.operator_name operatorName,v.created_at createdAt "
+ "FROM fl_customs_completion_document_version v WHERE v.completion_document_id=#{completionId} "
+ "ORDER BY v.document_version")
List<CompletionVersionRow> completionVersions(@Param("completionId") long completionId);
@Insert(""" @Insert("""
INSERT INTO fl_customs_completion_document INSERT INTO fl_customs_completion_document
(id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name, (id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name,
@@ -553,6 +559,8 @@ public interface ContainerCustomsMapper {
record CompletionRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType, record CompletionRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType,
Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version, Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version,
String customerConfirmStatus,String customerRejectReason) {} String customerConfirmStatus,String customerRejectReason) {}
record CompletionVersionRow(Integer documentVersion,Long fileId,String fileName,String fileHash,String contentType,
Long sizeBytes,Long operatorId,String operatorName,LocalDateTime createdAt) {}
record FinalRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType, record FinalRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType,
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,
@@ -581,11 +581,13 @@ public class ContainerCustomsService {
FinalRow finalDocument = mapper.finalDocument(source.id()); FinalRow finalDocument = mapper.finalDocument(source.id());
ContainerRow container = mapper.containerById(source.customsContainerId()); ContainerRow container = mapper.containerById(source.customsContainerId());
FileView result = completion == null ? null : file(completion); FileView result = completion == null ? null : file(completion);
List<ReceiptVersionView> receiptHistory = completion == null ? List.of() : Optional.ofNullable(mapper.completionVersions(completion.id())).orElseGet(List::of).stream()
.map(version -> new ReceiptVersionView(version.documentVersion(), file(version), version.operatorName(), version.createdAt())).toList();
return new DocumentView(source.id(), source.tenantId(), file(source), scopes.stream().map(ScopeRow::inboundId).toList(), return new DocumentView(source.id(), source.tenantId(), file(source), scopes.stream().map(ScopeRow::inboundId).toList(),
scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result, scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result,
completion == null ? null : completion.customerConfirmStatus(), completion == null ? null : completion.customerConfirmStatus(),
completion == null ? null : completion.customerRejectReason(), completion == null ? null : completion.customerRejectReason(),
finalDocument == null ? null : file(finalDocument)); finalDocument == null ? null : file(finalDocument), receiptHistory);
} }
private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) { private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) {
@@ -657,6 +659,10 @@ public class ContainerCustomsService {
private static FileView file(DocumentRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); } private static FileView file(DocumentRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
private static FileView file(CompletionRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); } private static FileView file(CompletionRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
private static FileView file(ContainerCustomsMapper.CompletionVersionRow row) {
return new FileView(row.fileId(), row.fileName(), row.fileHash(), row.contentType(), row.sizeBytes(),
row.documentVersion(), row.createdAt());
}
private static FileView file(FinalRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); } private static FileView file(FinalRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
private static long size(CustomsFileClient.Metadata value) { return value.sizeBytes() == null ? 0 : value.sizeBytes(); } private static long size(CustomsFileClient.Metadata value) { return value.sizeBytes() == null ? 0 : value.sizeBytes(); }
private static void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) throw bad("请选择文件"); if (file.getSize() > MAX_FILE_BYTES) throw bad("单文件不能超过50MB"); } private static void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) throw bad("请选择文件"); if (file.getSize() > MAX_FILE_BYTES) throw bad("单文件不能超过50MB"); }
@@ -221,7 +221,8 @@ components:
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: 真实报关完税文件}}} ReceiptVersionView: {type: object, required: [version, file, createdAt], properties: {version: {type: integer}, file: {$ref: '#/components/schemas/FileView'}, operatorName: {type: string, nullable: true}, createdAt: {type: string, format: date-time}}}
DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen, receiptHistory], 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: 真实报关完税文件}, receiptHistory: {type: array, items: {$ref: '#/components/schemas/ReceiptVersionView'}, 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'}}}} 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}}} 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}}}
@@ -45,6 +45,7 @@ class ContainerCustomsMapperContractTest {
@Test void completionQueriesExplicitlyMapEveryRecordComponentIncludingVersion() throws Exception { @Test void completionQueriesExplicitlyMapEveryRecordComponentIncludingVersion() throws Exception {
assertRecordProjection("completion", ContainerCustomsMapper.CompletionRow.class, long.class); assertRecordProjection("completion", ContainerCustomsMapper.CompletionRow.class, long.class);
assertRecordProjection("lockCompletion", ContainerCustomsMapper.CompletionRow.class, long.class); assertRecordProjection("lockCompletion", ContainerCustomsMapper.CompletionRow.class, long.class);
assertRecordProjectionWithoutVersion("completionVersions", ContainerCustomsMapper.CompletionVersionRow.class, long.class);
} }
@Test void declarationAndClearanceQueriesMapExplicitHistoricalFacts() throws Exception { @Test void declarationAndClearanceQueriesMapExplicitHistoricalFacts() throws Exception {