feat(firstleg): split customs receipt and final document flow

This commit is contained in:
2026-09-26 13:28:18 +08:00
parent 5a895bf214
commit ff2386e5bf
13 changed files with 405 additions and 110 deletions
@@ -84,13 +84,20 @@ public class MerchantContainerCustomsController {
return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false)); return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false));
} }
@GetMapping("/documents/{documentId}/completion/download") @GetMapping("/documents/{documentId}/receipt/download")
@PreAuthorize("hasAuthority('merchant:order:view')") @PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user, public ResponseEntity<byte[]> downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) { @PathVariable @Positive long documentId) {
return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false)); return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false));
} }
@GetMapping("/documents/{documentId}/final/download")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadFinal(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
return response(service.downloadFinal(FirstlegScopeGuard.merchant(user),documentId,false));
}
@PostMapping("/documents/{documentId}/receipt/confirm") @PostMapping("/documents/{documentId}/receipt/confirm")
@PreAuthorize("hasAuthority('merchant:order:edit')") @PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<ContainerView> confirmReceipt(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ContainerView> confirmReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@@ -44,7 +44,18 @@ public class PlatformContainerCustomsController {
return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request)); return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request));
} }
@PostMapping(value="/documents/{documentId}/completion/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping("/{planId}/submit-to-broker")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="CUSTOMS_SUBMIT_TO_BROKER",resourceType="CUSTOMS_CONTAINER")
public ApiResponse<ContainerView> submitToBroker(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.submitToBroker(planId,user.userId(),user.username(),key,request));
}
@PostMapping(value="/documents/{documentId}/receipt/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT") @BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT")
public ApiResponse<DocumentView> uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<DocumentView> uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@@ -57,6 +68,18 @@ public class PlatformContainerCustomsController {
expectedVersion,file)); expectedVersion,file));
} }
@PostMapping(value="/documents/{documentId}/final/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="CUSTOMS_FINAL_DOCUMENT_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT")
public ApiResponse<DocumentView> uploadFinal(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Min(0) int expectedVersion,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.uploadFinalDocument(documentId,user.userId(),user.username(),key,expectedVersion,file));
}
@PostMapping("/{planId}/complete-all-buy") @PostMapping("/{planId}/complete-all-buy")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER") @BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER")
@@ -84,6 +107,14 @@ public class PlatformContainerCustomsController {
return response(service.downloadCompletion(0,documentId,true)); return response(service.downloadCompletion(0,documentId,true));
} }
@GetMapping("/documents/{documentId}/final/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
public ResponseEntity<byte[]> downloadFinal(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
FirstlegScopeGuard.platform(user);
return response(service.downloadFinal(0,documentId,true));
}
private static ResponseEntity<byte[]> response(Download value) { private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore()) return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType())) .contentType(MediaType.parseMediaType(value.contentType()))
@@ -19,8 +19,8 @@ public final class ContainerCustomsDtos {
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 completion, String receiptStatus, boolean frozen, FileView receipt, String receiptStatus,
String receiptRejectReason) {} String receiptRejectReason, FileView finalDocument) {}
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,
@@ -20,6 +20,11 @@ public interface ContainerCustomsMapper {
r.uploaded_by_name uploadedByName,r.uploaded_at uploadedAt,r.version version, r.uploaded_by_name uploadedByName,r.uploaded_at uploadedAt,r.version version,
r.customer_confirm_status customerConfirmStatus,r.customer_reject_reason customerRejectReason r.customer_confirm_status customerConfirmStatus,r.customer_reject_reason customerRejectReason
"""; """;
String FINAL_COLUMNS = """
f.id id,f.source_document_id sourceDocumentId,f.file_id fileId,f.file_name fileName,
f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy,
f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version
""";
@Select(""" @Select("""
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId, SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
@@ -102,6 +107,18 @@ public interface ContainerCustomsMapper {
int start(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId, int start(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName); @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_customs_container SET broker_submitted_at=CURRENT_TIMESTAMP(3),
broker_submitted_by=#{operatorId},broker_submitted_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version} AND status='DECLARING' AND broker_submitted_at IS NULL
""")
int submitToBroker(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("SELECT broker_submitted_at FROM fl_customs_container WHERE id=#{id}")
LocalDateTime brokerSubmittedAt(@Param("id") long id);
@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
@@ -268,6 +285,22 @@ public interface ContainerCustomsMapper {
@Param("status") String status,@Param("reason") String reason, @Param("status") String status,@Param("reason") String reason,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); @Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("""
SELECT COUNT(*) FROM fl_customs_customer_document d
JOIN fl_customs_completion_document r ON r.source_document_id=d.id
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE'
AND r.customer_confirm_status='CONFIRMED'
""")
int confirmedReceiptCount(@Param("containerId") long containerId);
@Select("""
SELECT COUNT(*) FROM fl_customs_customer_document d
LEFT JOIN fl_customs_completion_document r ON r.source_document_id=d.id
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE'
AND (r.id IS NULL OR r.customer_confirm_status<>'CONFIRMED')
""")
int unconfirmedReceiptCount(@Param("containerId") long containerId);
@Select(""" @Select("""
SELECT COUNT(*) FROM fl_customs_customer_document d SELECT COUNT(*) FROM fl_customs_customer_document d
JOIN fl_customs_completion_document r ON r.source_document_id=d.id JOIN fl_customs_completion_document r ON r.source_document_id=d.id
@@ -288,6 +321,40 @@ public interface ContainerCustomsMapper {
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes, @Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); @Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("SELECT " + FINAL_COLUMNS + " FROM fl_customs_final_document f WHERE f.source_document_id=#{documentId}")
FinalRow finalDocument(@Param("documentId") long documentId);
@Select("SELECT " + FINAL_COLUMNS + " FROM fl_customs_final_document f WHERE f.source_document_id=#{documentId} FOR UPDATE")
FinalRow lockFinalDocument(@Param("documentId") long documentId);
@Insert("""
INSERT INTO fl_customs_final_document
(id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name,
uploaded_at,version,created_at,updated_at)
VALUES(#{id},#{documentId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},#{operatorId},#{operatorName},
CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""")
int insertFinal(@Param("id") long id,@Param("documentId") long documentId,@Param("fileId") long fileId,
@Param("fileName") String fileName,@Param("fileHash") String fileHash,@Param("contentType") String contentType,
@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_customs_final_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash},
content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_by_name=#{operatorName},
uploaded_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND version=#{version}
""")
int replaceFinal(@Param("id") long id,@Param("version") int version,@Param("fileId") long fileId,
@Param("fileName") String fileName,@Param("fileHash") String fileHash,@Param("contentType") String contentType,
@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("""
SELECT COUNT(*) FROM fl_customs_customer_document d
LEFT JOIN fl_customs_final_document f ON f.source_document_id=d.id
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND f.id IS NULL
""")
int missingFinalCount(@Param("containerId") long containerId);
@Insert(""" @Insert("""
INSERT INTO fl_customs_container_history INSERT INTO fl_customs_container_history
(id,customs_container_id,action,from_status,to_status,remark,operator_id,operator_name,created_at) (id,customs_container_id,action,from_status,to_status,remark,operator_id,operator_name,created_at)
@@ -382,6 +449,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 FinalRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType,
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) {}
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,
@@ -33,6 +33,7 @@ public class ContainerCustomsService {
public static final String DECLARING = "DECLARING"; public static final String DECLARING = "DECLARING";
public static final String COMPLETED = "COMPLETED"; public static final String COMPLETED = "COMPLETED";
private static final String SOURCE_FILE_TYPE = "LOADING_LIST_CUSTOMS_SOURCE"; private static final String SOURCE_FILE_TYPE = "LOADING_LIST_CUSTOMS_SOURCE";
private static final String RECEIPT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RECEIPT";
private static final String RESULT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RESULT"; private static final String RESULT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RESULT";
private static final long MAX_FILE_BYTES = 50L * 1024 * 1024; private static final long MAX_FILE_BYTES = 50L * 1024 * 1024;
@@ -172,6 +173,24 @@ public class ContainerCustomsService {
return platformView(planId); return platformView(planId);
} }
@Transactional
public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId;
if (!request.confirm()) throw bad("请确认已将客户确认结果发送给报关行");
String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark());
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return platformView(planId);
ContainerRow container = mapper.lockContainer(planId);
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认");
if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行");
if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
history(container, "SUBMIT_CUSTOMS_TO_BROKER", container.status(), container.status(), request.remark(), operatorId, operatorName);
completeCommand(operatorId, action, key, container.id());
return platformView(planId);
}
@Transactional @Transactional
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key, public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, MultipartFile file) { int expectedVersion, MultipartFile file) {
@@ -187,7 +206,7 @@ public class ContainerCustomsService {
throw conflict("当前客户资料不在报关中冻结版本"); throw conflict("当前客户资料不在报关中冻结版本");
} }
CompletionRow completion = mapper.lockCompletion(documentId); CompletionRow completion = mapper.lockCompletion(documentId);
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long completionId; long completionId;
int version; int version;
if (completion == null) { if (completion == null) {
@@ -227,12 +246,47 @@ public class ContainerCustomsService {
if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED", if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED",
confirm ? null : trim(reason), operatorId, operatorName) != 1) throw conflict("回执状态已变化,请刷新后重试"); confirm ? null : trim(reason), operatorId, operatorName) != 1) throw conflict("回执状态已变化,请刷新后重试");
if (confirm && mapper.pendingReceiptConfirmationCount(container.id()) == 0) { if (confirm && mapper.pendingReceiptConfirmationCount(container.id()) == 0) {
completeContainer(container, operatorId, operatorName, "客户已确认全部报关行回执"); history(container, "ALL_CUSTOMS_RECEIPTS_CONFIRMED", container.status(), container.status(), "客户已确认全部报关行回执,待发送给报关行执行", operatorId, operatorName);
} }
completeCommand(operatorId, action, key, documentId); completeCommand(operatorId, action, key, documentId);
return merchantViewForDocument(tenantId, source); return merchantViewForDocument(tenantId, source);
} }
@Transactional
public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, MultipartFile file) {
validateFile(file);
DocumentRow readable = requiredDocument(documentId);
String action = "UPLOAD_FINAL_CUSTOMS_DOCUMENT_" + documentId;
String requestHash = hash(action, expectedVersion, fileHash(file));
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return documentView(requiredDocument(documentId), false);
ContainerRow container = mapper.lockContainerById(readable.customsContainerId());
DocumentRow source = requiredDocumentLocked(documentId, null);
if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) {
throw conflict("当前客户资料不在报关中冻结版本");
}
if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行");
CompletionRow receipt = mapper.lockCompletion(documentId);
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户回执尚未确认");
FinalRow existing = mapper.lockFinalDocument(documentId);
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long finalId;
int version;
if (existing == null) {
if (expectedVersion != 0) throw concurrent();
finalId = IdWorker.getId(); version = 1;
mapper.insertFinal(finalId, documentId, metadata.fileId(), metadata.fileName(), metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName);
} else {
finalId = existing.id(); version = existing.version() + 1;
if (mapper.replaceFinal(existing.id(), expectedVersion, metadata.fileId(), metadata.fileName(), metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName) != 1) throw concurrent();
}
history(container, "UPLOAD_FINAL_CUSTOMS_DOCUMENT", container.status(), container.status(), "真实报关完税文件已上传", operatorId, operatorName);
if (mapper.missingFinalCount(container.id()) == 0) completeContainer(container, operatorId, operatorName, "全部真实报关完税文件已上传");
completeCommand(operatorId, action, key, finalId);
return documentView(requiredDocument(documentId), false);
}
@Transactional @Transactional
public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) { public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId; String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId;
@@ -261,12 +315,23 @@ public class ContainerCustomsService {
public Download downloadCompletion(long tenantId, long documentId, boolean platform) { public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
DocumentRow source = requiredDocument(documentId); DocumentRow source = requiredDocument(documentId);
ContainerRow container = mapper.containerById(source.customsContainerId()); ContainerRow container = mapper.containerById(source.customsContainerId());
if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) { if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) {
throw notFound("报关完成文件不存在"); throw notFound("报关行回执不存在");
} }
CompletionRow completion = mapper.completion(documentId); CompletionRow completion = mapper.completion(documentId);
if (completion == null) throw notFound("报关完成文件不存在"); if (completion == null) throw notFound("报关行回执不存在");
return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName()); return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
}
public Download downloadFinal(long tenantId, long documentId, boolean platform) {
DocumentRow source = requiredDocument(documentId);
ContainerRow container = mapper.containerById(source.customsContainerId());
if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) {
throw notFound("报关完税文件不存在");
}
FinalRow finalDocument = mapper.finalDocument(documentId);
if (finalDocument == null) throw notFound("报关完税文件不存在");
return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), finalDocument.fileId(), finalDocument.fileName());
} }
private ContainerRow ensure(long planId) { private ContainerRow ensure(long planId) {
@@ -367,12 +432,14 @@ public class ContainerCustomsService {
private DocumentView documentView(DocumentRow source, boolean merchant) { private DocumentView documentView(DocumentRow source, boolean merchant) {
List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id()); List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id());
CompletionRow completion = mapper.completion(source.id()); CompletionRow completion = mapper.completion(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);
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));
} }
private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) { private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) {
@@ -444,6 +511,7 @@ 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(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"); }
private static String fileHash(MultipartFile file) { try (InputStream input=file.getInputStream()) { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.readAllBytes())); } catch(Exception ex){ throw bad("无法读取上传文件"); } } private static String fileHash(MultipartFile file) { try (InputStream input=file.getInputStream()) { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.readAllBytes())); } catch(Exception ex){ throw bad("无法读取上传文件"); } }
@@ -12,6 +12,9 @@ CREATE TABLE IF NOT EXISTS fl_customs_container (
completed_by BIGINT UNSIGNED NULL COMMENT '完成报关操作人ID', completed_by BIGINT UNSIGNED NULL COMMENT '完成报关操作人ID',
completed_by_name VARCHAR(64) NULL COMMENT '完成报关操作人名称快照', completed_by_name VARCHAR(64) NULL COMMENT '完成报关操作人名称快照',
completed_at DATETIME(3) NULL COMMENT '报关完成时间', completed_at DATETIME(3) NULL COMMENT '报关完成时间',
broker_submitted_by BIGINT UNSIGNED NULL COMMENT '已将客户确认结果发送给报关行的操作人ID',
broker_submitted_by_name VARCHAR(64) NULL COMMENT '发送报关行操作人名称快照',
broker_submitted_at DATETIME(3) NULL COMMENT '发送报关行执行时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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 '聚合更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
@@ -93,10 +96,10 @@ CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound (
) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围'; ) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围';
CREATE TABLE IF NOT EXISTS fl_customs_completion_document ( CREATE TABLE IF NOT EXISTS fl_customs_completion_document (
id BIGINT UNSIGNED NOT NULL COMMENT '报关完成文件ID', id BIGINT UNSIGNED NOT NULL COMMENT '报关行回执文件ID',
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根', source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '完成文件名称快照', file_name VARCHAR(255) NOT NULL COMMENT '回执文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '完成文件SHA-256摘要', file_hash CHAR(64) NOT NULL COMMENT '完成文件SHA-256摘要',
content_type VARCHAR(128) NULL COMMENT '完成文件媒体类型', content_type VARCHAR(128) NULL COMMENT '完成文件媒体类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '完成文件大小(字节)', size_bytes BIGINT UNSIGNED NOT NULL COMMENT '完成文件大小(字节)',
@@ -114,7 +117,7 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document (
PRIMARY KEY(id), PRIMARY KEY(id),
UNIQUE KEY uk_customs_completion_source(source_document_id), UNIQUE KEY uk_customs_completion_source(source_document_id),
CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
) ENGINE=InnoDB COMMENT='报关完成文件当前版本'; ) ENGINE=InnoDB COMMENT='报关行回执当前版本';
CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version ( CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
id BIGINT UNSIGNED NOT NULL COMMENT '完成文件版本记录ID', id BIGINT UNSIGNED NOT NULL COMMENT '完成文件版本记录ID',
@@ -133,6 +136,25 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id) CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id)
) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本'; ) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本';
CREATE TABLE IF NOT EXISTS fl_customs_final_document (
id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID',
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要',
content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间',
version INT UNSIGNED NOT NULL DEFAULT 1 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_final_source(source_document_id),
CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本';
CREATE TABLE IF NOT EXISTS fl_customs_container_history ( CREATE TABLE IF NOT EXISTS fl_customs_container_history (
id BIGINT UNSIGNED NOT NULL COMMENT '报关历史记录ID', id BIGINT UNSIGNED NOT NULL COMMENT '报关历史记录ID',
customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID',
@@ -21,10 +21,17 @@ 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/documents/{documentId}/completion/upload: /api/platform/v1/customs/loading-plans/{planId}/submit-to-broker:
post: post:
summary: 上传或覆盖冻结客户资料的一对一完成文件 summary: 将客户确认回执发送给报关行执行
description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回,不自动完成整柜报关。 x-permission: platform:firstleg:customs:manage
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/documents/{documentId}/receipt/upload:
post:
summary: 上传或覆盖冻结客户资料的一对一报关行回执
description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回。
x-permission: platform:firstleg:customs:manage x-permission: platform:firstleg:customs:manage
parameters: parameters:
- {$ref: '#/components/parameters/DocumentId'} - {$ref: '#/components/parameters/DocumentId'}
@@ -38,9 +45,22 @@ paths:
x-permission: platform:firstleg:customs:list x-permission: platform:firstleg:customs:list
parameters: [{$ref: '#/components/parameters/DocumentId'}] parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download: /api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download:
get: get:
summary: 平台下载报关完成文件 summary: 平台下载报关行回执
x-permission: platform:firstleg:customs:list
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/platform/v1/customs/loading-plans/documents/{documentId}/final/upload:
post:
summary: 上传真实报关完税文件
x-permission: platform:firstleg:customs:manage
parameters: [{$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}, {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 0}}]
requestBody: {$ref: '#/components/requestBodies/FileBody'}
responses: {'200': {$ref: '#/components/responses/DocumentResponse'}}
/api/platform/v1/customs/loading-plans/documents/{documentId}/final/download:
get:
summary: 平台下载真实报关完税文件
x-permission: platform:firstleg:customs:list x-permission: platform:firstleg:customs:list
parameters: [{$ref: '#/components/parameters/DocumentId'}] parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
@@ -93,9 +113,15 @@ paths:
x-permission: merchant:order:view x-permission: merchant:order:view
parameters: [{$ref: '#/components/parameters/DocumentId'}] parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/merchant/v1/customs/documents/{documentId}/completion/download: /api/merchant/v1/customs/documents/{documentId}/receipt/download:
get: get:
summary: 整柜完成后客户下载对应完成文件 summary: 客户下载报关行回执
x-permission: merchant:order:view
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/merchant/v1/customs/documents/{documentId}/final/download:
get:
summary: 整柜完成后客户下载真实报关完税文件
x-permission: merchant:order:view x-permission: merchant:order:view
parameters: [{$ref: '#/components/parameters/DocumentId'}] parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
@@ -142,7 +168,7 @@ 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}, completion: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 退税报关行回执文件}, receiptStatus: {type: string, enum: [PENDING, CONFIRMED, REJECTED], nullable: true}, receiptRejectReason: {type: string, nullable: true}}} 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}, 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}, completedAt: {type: string, format: date-time, nullable: true}}}
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}}}}}]}
@@ -248,9 +248,9 @@ class ContainerCustomsServiceTest {
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("k"),anyString())).thenReturn(1); when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("k"),anyString())).thenReturn(1);
when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container); when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container);
when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null); when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null);
when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),any())) when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),any()))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(new CustomsFileClient.Metadata(99L,7L, .thenReturn(com.baiyue.common.core.api.ApiResponse.success(new CustomsFileClient.Metadata(99L,7L,
"LOADING_LIST_CUSTOMS_RESULT","9","result.bin","b".repeat(64),"application/octet-stream",1L))); "LOADING_LIST_CUSTOMS_RECEIPT","9","result.bin","b".repeat(64),"application/octet-stream",1L)));
when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1); when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1);
when(mapper.insertCompletionVersion(anyLong(),anyLong(),eq(1),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1); when(mapper.insertCompletionVersion(anyLong(),anyLong(),eq(1),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1);
when(mapper.frozenScopes(1L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(1L,11L,21L,"I"))); when(mapper.frozenScopes(1L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(1L,11L,21L,"I")));
@@ -259,7 +259,7 @@ class ContainerCustomsServiceTest {
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1}); var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
var result=service.uploadCompletion(1L,5L,"admin","k",0,file); var result=service.uploadCompletion(1L,5L,"admin","k",0,file);
assertThat(result).isNotNull(); assertThat(result).isNotNull();
verify(files).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file)); verify(files).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file));
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString()); verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
} }
@@ -282,7 +282,7 @@ class ContainerCustomsServiceTest {
var commandState=new AtomicReference<ContainerCustomsMapper.CommandRow>(); var commandState=new AtomicReference<ContainerCustomsMapper.CommandRow>();
var file=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{1}); var file=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{1});
var changedFile=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{2}); var changedFile=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{2});
var metadata=new CustomsFileClient.Metadata(99L,7L,"LOADING_LIST_CUSTOMS_RESULT","9","result.pdf", var metadata=new CustomsFileClient.Metadata(99L,7L,"LOADING_LIST_CUSTOMS_RECEIPT","9","result.pdf",
"b".repeat(64),"application/pdf",1L); "b".repeat(64),"application/pdf",1L);
when(mapper.document(1L)).thenReturn(source); when(mapper.document(1L)).thenReturn(source);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("completion-key"),anyString())) when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("completion-key"),anyString()))
@@ -296,7 +296,7 @@ class ContainerCustomsServiceTest {
when(mapper.lockContainerById(2L)).thenAnswer(invocation->containerState.get()); when(mapper.lockContainerById(2L)).thenAnswer(invocation->containerState.get());
when(mapper.lockDocument(1L)).thenReturn(source); when(mapper.lockDocument(1L)).thenReturn(source);
when(mapper.lockCompletion(1L)).thenAnswer(invocation->completionState.get()); when(mapper.lockCompletion(1L)).thenAnswer(invocation->completionState.get());
when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file))) when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file)))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(metadata)); .thenReturn(com.baiyue.common.core.api.ApiResponse.success(metadata));
when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),eq("result.pdf"),eq("b".repeat(64)), when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),eq("result.pdf"),eq("b".repeat(64)),
eq("application/pdf"),eq(1L),eq(5L),eq("admin"))).thenAnswer(invocation->{ eq("application/pdf"),eq(1L),eq(5L),eq("admin"))).thenAnswer(invocation->{
@@ -319,12 +319,12 @@ class ContainerCustomsServiceTest {
var first=service.uploadCompletion(1L,5L,"admin","completion-key",0,file); var first=service.uploadCompletion(1L,5L,"admin","completion-key",0,file);
var replay=service.uploadCompletion(1L,5L,"admin","completion-key",0,file); var replay=service.uploadCompletion(1L,5L,"admin","completion-key",0,file);
assertThat(first.completion().fileId()).isEqualTo(99L); assertThat(first.receipt().fileId()).isEqualTo(99L);
assertThat(replay.completion().fileId()).isEqualTo(99L); assertThat(replay.receipt().fileId()).isEqualTo(99L);
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,changedFile)) assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,changedFile))
.isInstanceOfSatisfying(BizException.class, .isInstanceOfSatisfying(BizException.class,
error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
verify(files,times(1)).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file)); verify(files,times(1)).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file));
verify(mapper,times(1)).lockContainerById(2L); verify(mapper,times(1)).lockContainerById(2L);
verify(mapper,times(1)).insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(), verify(mapper,times(1)).insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),
eq(1L),eq(5L),eq("admin")); eq(1L),eq(5L),eq("admin"));
@@ -335,7 +335,7 @@ class ContainerCustomsServiceTest {
var source=document(1L);when(mapper.document(1L)).thenReturn(source); var source=document(1L);when(mapper.document(1L)).thenReturn(source);
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3)); when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class) assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
.hasMessageContaining("完成文件不存在"); .hasMessageContaining("报关行回执不存在");
verifyNoInteractions(files); verifyNoInteractions(files);
} }
+2
View File
@@ -4,6 +4,8 @@
`db/recent-schema-incremental-20260923.sql`:汇总 2026-09-21 至 2026-09-22 的近期结构变更,依次显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;补齐国内 PDA 作业仓字段及索引、人工 FBA 报价可空字段、墨家 SKU 测量字段、换标申请普通索引和 A0 共享溢出位字段。所有字段和索引变更均经 `information_schema` 判断,可重复执行;不创建数据库、不含凭据、权限种子或业务状态修正,末尾输出逐库 `READY/CHECK_REQUIRED`。由操作者选择并确认目标连接后执行,任一 DDL 报错立即停止。 `db/recent-schema-incremental-20260923.sql`:汇总 2026-09-21 至 2026-09-22 的近期结构变更,依次显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;补齐国内 PDA 作业仓字段及索引、人工 FBA 报价可空字段、墨家 SKU 测量字段、换标申请普通索引和 A0 共享溢出位字段。所有字段和索引变更均经 `information_schema` 判断,可重复执行;不创建数据库、不含凭据、权限种子或业务状态修正,末尾输出逐库 `READY/CHECK_REQUIRED`。由操作者选择并确认目标连接后执行,任一 DDL 报错立即停止。
`db/customs-two-file-incremental-20260926.sql`:报关两文件流程增量结构,保留报关行回执与新增真实报关完税文件,补充“已发送给报关行执行”的操作人和时间;仅新增结构,可重复执行,不修改业务数据。
`local-return-relabel-acceptance.mjs` 的 `LOCAL_RETURN_VERIFY_REMAINING=true` 专项新建3件退货,先换1件并客户确认,再对同一退货单剩余2件中的1件新建第二申请,验证同键重放、第二轮完工后剩余1件;不执行出库,报告 scope 为 `REMAINING_RETURN_RELABEL`,不代表出库验收通过。仍保留单据、使用正式API和合成照片,默认单票模式不变。 `local-return-relabel-acceptance.mjs` 的 `LOCAL_RETURN_VERIFY_REMAINING=true` 专项新建3件退货,先换1件并客户确认,再对同一退货单剩余2件中的1件新建第二申请,验证同键重放、第二轮完工后剩余1件;不执行出库,报告 scope 为 `REMAINING_RETURN_RELABEL`,不代表出库验收通过。仍保留单据、使用正式API和合成照片,默认单票模式不变。
`local-return-relabel-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-<marker>.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。 `local-return-relabel-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-<marker>.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。
@@ -17,10 +17,14 @@ export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([
'merchantDeleteDocument', 'merchantDeleteDocument',
'merchantConfirmReceipt', 'merchantConfirmReceipt',
'platformStart', 'platformStart',
'platformUploadCompletion', 'platformUploadReceipt',
'platformSubmitToBroker',
'platformUploadFinal',
'platformComplete', 'platformComplete',
'platformDownload', 'platformDownloadReceipt',
'merchantDownload', 'platformDownloadFinal',
'merchantDownloadReceipt',
'merchantDownloadFinal',
'outboundState', 'outboundState',
'satisfyOutboundPrerequisites', 'satisfyOutboundPrerequisites',
]) ])
@@ -29,7 +33,7 @@ export const CONTAINER_CUSTOMS_MATRIX = Object.freeze([
{ priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: 'LOADED详情与买单XLSX', expected: 'XLSX只含柜内买单实装行,下载窗口可用' }, { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: 'LOADED详情与买单XLSX', expected: 'XLSX只含柜内买单实装行,下载窗口可用' },
{ priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: '开始与完成', expected: 'READY_TO_START→DECLARING→COMPLETED,重放返回同一结果' }, { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: '开始与完成', expected: 'READY_TO_START→DECLARING→COMPLETED,重放返回同一结果' },
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '客户多对多资料', expected: '上传、覆盖、改关联、删除均保留版本与客户范围' }, { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '客户多对多资料', expected: '上传、覆盖、改关联、删除均保留版本与客户范围' },
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结、回执确认与逐份完成文件', expected: '开始冻结来源文件和关联;最后一份平台回执上传后由客户确认整份回执,才允许整柜完成' }, { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结、回执确认、报关行执行与完税文件', expected: '开始冻结来源文件和关联;上传回执、客户确认、发送报关行执行,最后上传真实报关完税文件才允许整柜完成' },
{ priority: 'P0', fixture: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' }, { priority: 'P0', fixture: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' },
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' }, { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' },
{ priority: 'P0', fixture: 'MIXED', scenario: '自动离仓失败恢复', expected: '报关COMPLETED不回滚;待条件满足后后台重试并单次离仓' }, { priority: 'P0', fixture: 'MIXED', scenario: '自动离仓失败恢复', expected: '报关COMPLETED不回滚;待条件满足后后台重试并单次离仓' },
@@ -91,6 +95,7 @@ export const READ_ONLY_DB_EVIDENCE = Object.freeze([
'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_command', 'fl_customs_command',
'fl_customs_container_history', 'fl_customs_container_history',
'fl_outbound_dispatch', 'fl_outbound_dispatch',
@@ -88,16 +88,27 @@ function routes(context, merchantToken) {
platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token, `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }), { 'Idempotency-Key': idempotencyKey }),
platformUploadCompletion: (ctx, fixture, request, idempotencyKey) => multipart( platformUploadReceipt: (ctx, fixture, request, idempotencyKey) => multipart(
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/completion/upload?expectedVersion=${Number(request.expectedVersion)}&finalConfirmation=${request.finalConfirmation === true}`, `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/upload?expectedVersion=${Number(request.expectedVersion)}`,
{}, request, ctx.token, idempotencyKey), {}, request, ctx.token, idempotencyKey),
platformSubmitToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/submit-to-broker`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token, `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }), { 'Idempotency-Key': idempotencyKey }),
platformDownload: (ctx, fixture, request) => binary('GET', platformUploadFinal: (ctx, fixture, request, idempotencyKey) => multipart(
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`), `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/upload?expectedVersion=${Number(request.expectedVersion)}`,
merchantDownload: (ctx, fixture, customer, request) => binary('GET', {}, request, ctx.token, idempotencyKey),
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`, platformDownloadReceipt: (ctx, fixture, request) => binary('GET',
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`),
platformDownloadFinal: (ctx, fixture, request) => binary('GET',
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/download`),
merchantDownloadReceipt: (ctx, fixture, customer, request) => binary('GET',
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`,
customer.customerToken || merchantToken),
merchantDownloadFinal: (ctx, fixture, customer, request) => binary('GET',
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/final/download`,
customer.customerToken || merchantToken), customer.customerToken || merchantToken),
buyDeclarationXlsx: async (ctx, request) => { buyDeclarationXlsx: async (ctx, request) => {
const created = await ctx.request('POST', '/api/platform/v1/files/export-tasks', { const created = await ctx.request('POST', '/api/platform/v1/files/export-tasks', {
@@ -183,56 +183,71 @@ async function runRefund(context, routes, fixture) {
const documents = frozenSnapshot?.documents || [] const documents = frozenSnapshot?.documents || []
for (const [index, document] of documents.entries()) { for (const [index, document] of documents.entries()) {
const completion = await routes.platformUploadCompletion(context, fixture, { const receipt = await routes.platformUploadReceipt(context, fixture, {
documentId: id(document.documentId), expectedVersion: Number(document.source?.version), documentId: id(document.documentId), expectedVersion: Number(document.source?.version),
fileName: `${context.marker}-completion-${index + 1}.done`, fileName: `${context.marker}-broker-receipt-${index + 1}.pdf`,
contentType: 'application/x-customs-completion', content: Uint8Array.from([0x43, index + 1]), contentType: 'application/pdf', content: Uint8Array.from([0x52, index + 1]),
}, key(context, `completion-${index + 1}`)) }, key(context, `receipt-${index + 1}`))
context.assert(MODULE, `逐份上传完成文件${index + 1}`, completion, context.assert(MODULE, `逐份上传报关行回执${index + 1}`, receipt,
success(completion) success(receipt) && zeroDrift(receipt.body?.data?.inboundIds, document.inboundIds),
&& zeroDrift(completion.body?.data?.inboundIds, document.inboundIds), evidence(receipt, `sourceDocumentId=${id(document.documentId)}`), OPTIONS)
evidence(completion, `sourceDocumentId=${id(document.documentId)}`), OPTIONS)
if (index + 1 < documents.length) {
const beforeEarlyComplete = await platformDetail(context, routes, fixture)
const early = await routes.platformComplete(context, fixture, {
expectedVersion: Number(beforeEarlyComplete.body?.data?.version), remark: 'not-last-completion',
}, key(context, `early-complete-${index + 1}`))
await assertZeroDrift(context, '最后一份完成文件前禁止整柜完成', routes, fixture, beforeEarlyComplete, early)
}
} }
const completedReady = await platformDetail(context, routes, fixture) const beforeConfirm = await platformDetail(context, routes, fixture)
const earlyFinal = await routes.platformUploadFinal(context, fixture, {
documentId: id(documents[0]?.documentId), expectedVersion: 0,
fileName: `${context.marker}-early-final.pdf`, contentType: 'application/pdf', content: Uint8Array.from([0x45]),
}, key(context, 'early-final'))
await assertZeroDrift(context, '回执未确认且未发送执行时禁止上传完税文件', routes, fixture, beforeConfirm, earlyFinal)
const confirmed = [] const confirmed = []
for (const customer of customers) { for (const customer of customers) {
const document = documents.find((item) => (item.inboundIds || []).some((inboundId) => const document = documents.find((item) => (item.inboundIds || []).some((inboundId) =>
customer.inboundIds.map(id).includes(id(inboundId)))) customer.inboundIds.map(id).includes(id(inboundId))))
if (!document) continue if (!document) continue
const version = Number(document.source?.version || completedReady.body?.data?.documents const current = await platformDetail(context, routes, fixture)
?.find((item) => id(item.documentId) === id(document.documentId))?.source?.version || 0) const live = current.body?.data?.documents?.find((item) => id(item.documentId) === id(document.documentId))
const version = Number(live?.receipt?.version || 0)
const response = await routes.merchantConfirmReceipt(context, fixture, customer, { const response = await routes.merchantConfirmReceipt(context, fixture, customer, {
documentId: id(document.documentId), expectedVersion: version, confirm: true, remark: context.marker, documentId: id(document.documentId), expectedVersion: version, confirm: true, remark: context.marker,
}, key(context, `refund-receipt-confirm-${customer.customerId}`)) }, key(context, `refund-receipt-confirm-${customer.customerId}`))
confirmed.push(response) confirmed.push(response)
} }
const afterConfirm = await platformDetail(context, routes, fixture)
context.assert(MODULE, '客户确认整份回执后仍处于报关中', afterConfirm,
success(afterConfirm) && afterConfirm.body?.data?.status === 'DECLARING' && confirmed.every(success),
evidence(afterConfirm), OPTIONS)
const submitted = await routes.platformSubmitToBroker(context, fixture,
{ expectedVersion: Number(afterConfirm.body?.data?.version), remark: context.marker }, key(context, 'submit-to-broker'))
context.assert(MODULE, '平台发送客户确认回执给报关行执行', submitted,
success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS)
const submitReplay = await routes.platformSubmitToBroker(context, fixture,
{ expectedVersion: Number(submitted.body?.data?.version), remark: context.marker }, key(context, 'submit-to-broker'))
context.assert(MODULE, '发送报关行执行同键重放', submitReplay, success(submitReplay), evidence(submitReplay), OPTIONS)
for (const [index, document] of documents.entries()) {
const finalDocument = await routes.platformUploadFinal(context, fixture, {
documentId: id(document.documentId), expectedVersion: 0,
fileName: `${context.marker}-customs-tax-final-${index + 1}.pdf`,
contentType: 'application/pdf', content: Uint8Array.from([0x46, index + 1]),
}, key(context, `final-${index + 1}`))
context.assert(MODULE, `逐份上传真实报关完税文件${index + 1}`, finalDocument,
success(finalDocument) && zeroDrift(finalDocument.body?.data?.inboundIds, document.inboundIds),
evidence(finalDocument), OPTIONS)
}
const completed = await platformDetail(context, routes, fixture) const completed = await platformDetail(context, routes, fixture)
context.assert(MODULE, '客户确认整份回执后纯退税柜完成', completed, context.assert(MODULE, '真实报关完税文件齐全后纯退税柜完成', completed,
success(completed) && completed.body?.data?.status === 'COMPLETED' success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
&& confirmed.every(success), evidence(completed), OPTIONS)
const firstDocument = documents[0] const firstDocument = documents[0]
if (firstDocument) { if (firstDocument) {
const platformDownload = await routes.platformDownload(context, fixture, { const platformReceipt = await routes.platformDownloadReceipt(context, fixture, { documentId: id(firstDocument.documentId) })
documentId: id(firstDocument.documentId), completion: true, context.assert(MODULE, 'Admin下载报关行回执', platformReceipt,
}) platformReceipt.http === 200 && platformReceipt.bytes?.length > 0, evidence(platformReceipt), OPTIONS)
context.assert(MODULE, 'Admin下载完成文件窗口', platformDownload, const platformFinal = await routes.platformDownloadFinal(context, fixture, { documentId: id(firstDocument.documentId) })
platformDownload.http === 200 && platformDownload.bytes?.length > 0, context.assert(MODULE, 'Admin下载真实报关完税文件', platformFinal,
`HTTP=${platformDownload.http}, bytes=${platformDownload.bytes?.length ?? 0}`, OPTIONS) platformFinal.http === 200 && platformFinal.bytes?.length > 0, evidence(platformFinal), OPTIONS)
const merchantDownload = await routes.merchantDownload(context, fixture, mutable, { const merchantFinal = await routes.merchantDownloadFinal(context, fixture, mutable, { documentId: id(firstDocument.documentId) })
documentId: id(firstDocument.documentId), completion: true, context.assert(MODULE, 'Client仅下载本客户真实报关完税文件', merchantFinal,
}) merchantFinal.http === 200 && merchantFinal.bytes?.length > 0, evidence(merchantFinal), OPTIONS)
context.assert(MODULE, 'Client仅下载本客户完成文件窗口', merchantDownload,
merchantDownload.http === 200 && merchantDownload.bytes?.length > 0,
`HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes?.length ?? 0}`, OPTIONS)
} }
} }
async function runMixedConcurrency(context, routes, fixture) { async function runMixedConcurrency(context, routes, fixture) {
@@ -320,44 +335,55 @@ async function runMixedConcurrency(context, routes, fixture) {
}, key(context, 'locked-scope')) }, key(context, 'locked-scope'))
await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked) await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked)
const completionRequest = { const receiptRequest = {
documentId: customer.documentId, expectedVersion: 0, documentId: customer.documentId, expectedVersion: 0,
finalConfirmation: true, fileName: `${context.marker}-mixed-result.done`, fileName: `${context.marker}-mixed-broker-receipt.pdf`,
contentType: 'application/x-customs-completion', content: Uint8Array.from([0x52, 0x45, 0x53]), contentType: 'application/pdf', content: Uint8Array.from([0x52, 0x45, 0x43]),
} }
const completionKey = key(context, 'mixed-completion') const receiptKey = key(context, 'mixed-receipt-upload')
const completion = await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey) const receipt = await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey)
const completionReplay = success(completion) const receiptReplay = success(receipt)
? await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey) : null ? await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey) : null
const completionConverged = success(completion) && success(completionReplay) const receiptConverged = success(receipt) && success(receiptReplay)
&& id(completion.body?.data?.documentId) === id(completionReplay.body?.data?.documentId) && id(receipt.body?.data?.documentId) === id(receiptReplay.body?.data?.documentId)
&& id(completion.body?.data?.completion?.fileId) === id(completionReplay.body?.data?.completion?.fileId) && id(receipt.body?.data?.receipt?.fileId) === id(receiptReplay.body?.data?.receipt?.fileId)
context.assert(MODULE, '最后一份TAX完成文件二次确认并同键重放', completionReplay || completion, context.assert(MODULE, '报关行回执同键重放收敛', receiptReplay || receipt, receiptConverged,
completionConverged, [receipt, receiptReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS)
[completion, completionReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS) const receiptDifferent = await routes.platformUploadReceipt(context, fixture,
if (!completionConverged) { { ...receiptRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, receiptKey)
context.addCoverageGap(MODULE, OPTIONS.scenario, '最后一份完成文件二次确认', context.assert(MODULE, '回执同键异载荷A0409', receiptDifferent, a0409(receiptDifferent), evidence(receiptDifferent), OPTIONS)
'完成文件未成功或同键重放未收敛,已停止后续自动离仓写入', const receiptVersion = Number(receipt.body?.data?.receipt?.version || 0)
'completion-idempotency', { deploymentBlocker: true })
return
}
const completionDifferent = await routes.platformUploadCompletion(context, fixture,
{ ...completionRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, completionKey)
context.assert(MODULE, '完成文件同键异载荷A0409', completionDifferent,
a0409(completionDifferent), evidence(completionDifferent), OPTIONS)
const receiptVersion = Number(completion.body?.data?.source?.version || completion.body?.data?.version || 0)
const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, { const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, {
documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'mixed-receipt-confirm')) }, key(context, 'mixed-receipt-confirm'))
const receiptReplay = await routes.merchantConfirmReceipt(context, fixture, customer, { const receiptConfirmReplay = await routes.merchantConfirmReceipt(context, fixture, customer, {
documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'mixed-receipt-confirm')) }, key(context, 'mixed-receipt-confirm'))
context.assert(MODULE, '客户确认整份回执并同键重放', receiptReplay || receiptConfirm, context.assert(MODULE, '客户确认回执后仍未完成整柜', receiptConfirmReplay || receiptConfirm,
success(receiptConfirm) && success(receiptReplay) success(receiptConfirm) && success(receiptConfirmReplay)
&& receiptConfirm.body?.data?.status === 'COMPLETED' && receiptConfirm.body?.data?.status === 'DECLARING'
&& receiptReplay.body?.data?.status === 'COMPLETED', && receiptConfirmReplay.body?.data?.status === 'DECLARING',
[receiptConfirm, receiptReplay].map((response) => evidence(response)).join('; '), OPTIONS) [receiptConfirm, receiptConfirmReplay].map((response) => evidence(response)).join('; '), OPTIONS)
const submitted = await routes.platformSubmitToBroker(context, fixture,
{ expectedVersion: Number(receiptConfirm.body?.data?.version), remark: context.marker }, key(context, 'mixed-submit-to-broker'))
context.assert(MODULE, '混合柜发送确认回执给报关行执行', submitted,
success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS)
const finalRequest = {
documentId: customer.documentId, expectedVersion: 0,
fileName: `${context.marker}-mixed-customs-tax-final.pdf`,
contentType: 'application/pdf', content: Uint8Array.from([0x46, 0x49, 0x4e]),
}
const finalKey = key(context, 'mixed-final')
const finalDocument = await routes.platformUploadFinal(context, fixture, finalRequest, finalKey)
const finalReplay = success(finalDocument)
? await routes.platformUploadFinal(context, fixture, finalRequest, finalKey) : null
const finalConverged = success(finalDocument) && success(finalReplay)
&& id(finalDocument.body?.data?.finalDocument?.fileId) === id(finalReplay.body?.data?.finalDocument?.fileId)
context.assert(MODULE, '真实报关完税文件同键重放收敛', finalReplay || finalDocument, finalConverged,
[finalDocument, finalReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS)
const finalDifferent = await routes.platformUploadFinal(context, fixture,
{ ...finalRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, finalKey)
context.assert(MODULE, '完税文件同键异载荷A0409', finalDifferent, a0409(finalDifferent), evidence(finalDifferent), OPTIONS)
let completed = await platformDetail(context, routes, fixture) let completed = await platformDetail(context, routes, fixture)
context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed, context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed,
@@ -396,10 +422,10 @@ async function runMixedConcurrency(context, routes, fixture) {
evidence(queryDetail), OPTIONS) evidence(queryDetail), OPTIONS)
} }
const completionDownload = await routes.merchantDownload(context, fixture, customer, { const completionDownload = await routes.merchantDownloadFinal(context, fixture, customer, {
documentId: customer.documentId, completion: true, documentId: customer.documentId,
}) })
context.assert(MODULE, '整柜完成后Client可下载自身TAX完成文件', completionDownload, context.assert(MODULE, '整柜完成后Client可下载自身真实报关完税文件', completionDownload,
completionDownload.http === 200 && completionDownload.bytes?.length > 0, completionDownload.http === 200 && completionDownload.bytes?.length > 0,
`HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS) `HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS)
} }
@@ -0,0 +1,28 @@
-- 报关两文件流程:回执与真实报关完税文件分离。
-- 仅在已确认目标测试库后执行;所有 DDL 可重复执行,不创建数据库、不写业务数据。
USE baiyue_firstleg;
SET @ddl = IF(NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_customs_container' AND column_name='broker_submitted_at'
), 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_by BIGINT UNSIGNED NULL COMMENT ''发送报关行执行的操作人ID'' AFTER completed_at, ADD COLUMN broker_submitted_by_name VARCHAR(64) NULL COMMENT ''发送报关行执行的操作人名称'' AFTER broker_submitted_by, ADD COLUMN broker_submitted_at DATETIME(3) NULL COMMENT ''发送报关行执行时间'' AFTER broker_submitted_by_name', 'SELECT 1');
PREPARE customs_container_broker_submit_stmt FROM @ddl; EXECUTE customs_container_broker_submit_stmt; DEALLOCATE PREPARE customs_container_broker_submit_stmt;
CREATE TABLE IF NOT EXISTS fl_customs_final_document (
id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID',
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要',
content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间',
version INT UNSIGNED NOT NULL DEFAULT 1 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_final_source(source_document_id),
CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本';