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));
}
@GetMapping("/documents/{documentId}/completion/download")
@GetMapping("/documents/{documentId}/receipt/download")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
public ResponseEntity<byte[]> downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
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")
@PreAuthorize("hasAuthority('merchant:order:edit')")
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));
}
@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')")
@BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT")
public ApiResponse<DocumentView> uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@@ -57,6 +68,18 @@ public class PlatformContainerCustomsController {
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")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER")
@@ -84,6 +107,14 @@ public class PlatformContainerCustomsController {
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) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
@@ -19,8 +19,8 @@ public final class ContainerCustomsDtos {
public record DocumentView(long documentId, long tenantId, FileView source,
List<Long> inboundIds, List<String> inboundCodes,
boolean frozen, FileView completion, String receiptStatus,
String receiptRejectReason) {}
boolean frozen, FileView receipt, String receiptStatus,
String receiptRejectReason, FileView finalDocument) {}
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
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.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 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,
@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 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
@@ -268,6 +285,22 @@ public interface ContainerCustomsMapper {
@Param("status") String status,@Param("reason") String reason,
@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 COUNT(*) FROM fl_customs_customer_document d
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("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 INTO fl_customs_container_history
(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,
Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version,
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,
Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {}
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 COMPLETED = "COMPLETED";
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 long MAX_FILE_BYTES = 50L * 1024 * 1024;
@@ -172,6 +173,24 @@ public class ContainerCustomsService {
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
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, MultipartFile file) {
@@ -187,7 +206,7 @@ public class ContainerCustomsService {
throw conflict("当前客户资料不在报关中冻结版本");
}
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;
int version;
if (completion == null) {
@@ -227,12 +246,47 @@ public class ContainerCustomsService {
if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED",
confirm ? null : trim(reason), operatorId, operatorName) != 1) throw conflict("回执状态已变化,请刷新后重试");
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);
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
public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId;
@@ -261,12 +315,23 @@ public class ContainerCustomsService {
public Download downloadCompletion(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("报关完成文件不存在");
if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) {
throw notFound("报关行回执不存在");
}
CompletionRow completion = mapper.completion(documentId);
if (completion == null) throw notFound("报关完成文件不存在");
return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
if (completion == null) throw notFound("报关行回执不存在");
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) {
@@ -367,12 +432,14 @@ public class ContainerCustomsService {
private DocumentView documentView(DocumentRow source, boolean merchant) {
List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id());
CompletionRow completion = mapper.completion(source.id());
FinalRow finalDocument = mapper.finalDocument(source.id());
ContainerRow container = mapper.containerById(source.customsContainerId());
FileView result = completion == null ? null : file(completion);
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,
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) {
@@ -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(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 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("无法读取上传文件"); } }
@@ -12,6 +12,9 @@ CREATE TABLE IF NOT EXISTS fl_customs_container (
completed_by BIGINT UNSIGNED NULL COMMENT '完成报关操作人ID',
completed_by_name VARCHAR(64) 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 '聚合创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) 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='开始报关时冻结的客户资料覆盖范围';
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 '一份冻结客户资料严格对应一份完成文件逻辑根',
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摘要',
content_type VARCHAR(128) 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),
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)
) ENGINE=InnoDB COMMENT='报关完成文件当前版本';
) ENGINE=InnoDB COMMENT='报关行回执当前版本';
CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
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)
) 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 (
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'}]
requestBody: {$ref: '#/components/requestBodies/ActionBody'}
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:
summary: 上传或覆盖冻结客户资料的一对一完成文件
description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回,不自动完成整柜报关。
summary: 将客户确认回执发送给报关行执行
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
parameters:
- {$ref: '#/components/parameters/DocumentId'}
@@ -38,9 +45,22 @@ paths:
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}/completion/download:
/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download:
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
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
@@ -93,9 +113,15 @@ paths:
x-permission: merchant:order:view
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/merchant/v1/customs/documents/{documentId}/completion/download:
/api/merchant/v1/customs/documents/{documentId}/receipt/download:
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
parameters: [{$ref: '#/components/parameters/DocumentId'}]
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}}}
FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}}
InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}}
DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, 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}}}
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}}}}}]}
@@ -248,9 +248,9 @@ class ContainerCustomsServiceTest {
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.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,
"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.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")));
@@ -259,7 +259,7 @@ class ContainerCustomsServiceTest {
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
var result=service.uploadCompletion(1L,5L,"admin","k",0,file);
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());
}
@@ -282,7 +282,7 @@ class ContainerCustomsServiceTest {
var commandState=new AtomicReference<ContainerCustomsMapper.CommandRow>();
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 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);
when(mapper.document(1L)).thenReturn(source);
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.lockDocument(1L)).thenReturn(source);
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));
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->{
@@ -319,12 +319,12 @@ class ContainerCustomsServiceTest {
var first=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(replay.completion().fileId()).isEqualTo(99L);
assertThat(first.receipt().fileId()).isEqualTo(99L);
assertThat(replay.receipt().fileId()).isEqualTo(99L);
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,changedFile))
.isInstanceOfSatisfying(BizException.class,
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)).insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),
eq(1L),eq(5L),eq("admin"));
@@ -335,7 +335,7 @@ class ContainerCustomsServiceTest {
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
.hasMessageContaining("完成文件不存在");
.hasMessageContaining("报关行回执不存在");
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/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-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',
'merchantConfirmReceipt',
'platformStart',
'platformUploadCompletion',
'platformUploadReceipt',
'platformSubmitToBroker',
'platformUploadFinal',
'platformComplete',
'platformDownload',
'merchantDownload',
'platformDownloadReceipt',
'platformDownloadFinal',
'merchantDownloadReceipt',
'merchantDownloadFinal',
'outboundState',
'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: '开始与完成', 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: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' },
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' },
{ 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_completion_document',
'fl_customs_completion_document_version',
'fl_customs_final_document',
'fl_customs_command',
'fl_customs_container_history',
'fl_outbound_dispatch',
@@ -88,16 +88,27 @@ function routes(context, merchantToken) {
platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
platformUploadCompletion: (ctx, fixture, request, idempotencyKey) => multipart(
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/completion/upload?expectedVersion=${Number(request.expectedVersion)}&finalConfirmation=${request.finalConfirmation === true}`,
platformUploadReceipt: (ctx, fixture, request, idempotencyKey) => multipart(
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/upload?expectedVersion=${Number(request.expectedVersion)}`,
{}, 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',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
platformDownload: (ctx, fixture, request) => binary('GET',
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`),
merchantDownload: (ctx, fixture, customer, request) => binary('GET',
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`,
platformUploadFinal: (ctx, fixture, request, idempotencyKey) => multipart(
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/upload?expectedVersion=${Number(request.expectedVersion)}`,
{}, request, ctx.token, idempotencyKey),
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),
buyDeclarationXlsx: async (ctx, request) => {
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 || []
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),
fileName: `${context.marker}-completion-${index + 1}.done`,
contentType: 'application/x-customs-completion', content: Uint8Array.from([0x43, index + 1]),
}, key(context, `completion-${index + 1}`))
context.assert(MODULE, `逐份上传完成文件${index + 1}`, completion,
success(completion)
&& zeroDrift(completion.body?.data?.inboundIds, document.inboundIds),
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)
}
fileName: `${context.marker}-broker-receipt-${index + 1}.pdf`,
contentType: 'application/pdf', content: Uint8Array.from([0x52, index + 1]),
}, key(context, `receipt-${index + 1}`))
context.assert(MODULE, `逐份上传报关行回执${index + 1}`, receipt,
success(receipt) && zeroDrift(receipt.body?.data?.inboundIds, document.inboundIds),
evidence(receipt, `sourceDocumentId=${id(document.documentId)}`), OPTIONS)
}
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 = []
for (const customer of customers) {
const document = documents.find((item) => (item.inboundIds || []).some((inboundId) =>
customer.inboundIds.map(id).includes(id(inboundId))))
if (!document) continue
const version = Number(document.source?.version || completedReady.body?.data?.documents
?.find((item) => id(item.documentId) === id(document.documentId))?.source?.version || 0)
const current = await platformDetail(context, routes, fixture)
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, {
documentId: id(document.documentId), expectedVersion: version, confirm: true, remark: context.marker,
}, key(context, `refund-receipt-confirm-${customer.customerId}`))
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)
context.assert(MODULE, '客户确认整份回执后纯退税柜完成', completed,
success(completed) && completed.body?.data?.status === 'COMPLETED'
&& confirmed.every(success), evidence(completed), OPTIONS)
context.assert(MODULE, '真实报关完税文件齐全后纯退税柜完成', completed,
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
const firstDocument = documents[0]
if (firstDocument) {
const platformDownload = await routes.platformDownload(context, fixture, {
documentId: id(firstDocument.documentId), completion: true,
})
context.assert(MODULE, 'Admin下载完成文件窗口', platformDownload,
platformDownload.http === 200 && platformDownload.bytes?.length > 0,
`HTTP=${platformDownload.http}, bytes=${platformDownload.bytes?.length ?? 0}`, OPTIONS)
const merchantDownload = await routes.merchantDownload(context, fixture, mutable, {
documentId: id(firstDocument.documentId), completion: true,
})
context.assert(MODULE, 'Client仅下载本客户完成文件窗口', merchantDownload,
merchantDownload.http === 200 && merchantDownload.bytes?.length > 0,
`HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes?.length ?? 0}`, OPTIONS)
const platformReceipt = await routes.platformDownloadReceipt(context, fixture, { documentId: id(firstDocument.documentId) })
context.assert(MODULE, 'Admin下载报关行回执', platformReceipt,
platformReceipt.http === 200 && platformReceipt.bytes?.length > 0, evidence(platformReceipt), OPTIONS)
const platformFinal = await routes.platformDownloadFinal(context, fixture, { documentId: id(firstDocument.documentId) })
context.assert(MODULE, 'Admin下载真实报关完税文件', platformFinal,
platformFinal.http === 200 && platformFinal.bytes?.length > 0, evidence(platformFinal), OPTIONS)
const merchantFinal = await routes.merchantDownloadFinal(context, fixture, mutable, { documentId: id(firstDocument.documentId) })
context.assert(MODULE, 'Client仅下载本客户真实报关完税文件', merchantFinal,
merchantFinal.http === 200 && merchantFinal.bytes?.length > 0, evidence(merchantFinal), OPTIONS)
}
}
async function runMixedConcurrency(context, routes, fixture) {
@@ -320,44 +335,55 @@ async function runMixedConcurrency(context, routes, fixture) {
}, key(context, 'locked-scope'))
await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked)
const completionRequest = {
const receiptRequest = {
documentId: customer.documentId, expectedVersion: 0,
finalConfirmation: true, fileName: `${context.marker}-mixed-result.done`,
contentType: 'application/x-customs-completion', content: Uint8Array.from([0x52, 0x45, 0x53]),
fileName: `${context.marker}-mixed-broker-receipt.pdf`,
contentType: 'application/pdf', content: Uint8Array.from([0x52, 0x45, 0x43]),
}
const completionKey = key(context, 'mixed-completion')
const completion = await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey)
const completionReplay = success(completion)
? await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey) : null
const completionConverged = success(completion) && success(completionReplay)
&& id(completion.body?.data?.documentId) === id(completionReplay.body?.data?.documentId)
&& id(completion.body?.data?.completion?.fileId) === id(completionReplay.body?.data?.completion?.fileId)
context.assert(MODULE, '最后一份TAX完成文件二次确认并同键重放', completionReplay || completion,
completionConverged,
[completion, completionReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS)
if (!completionConverged) {
context.addCoverageGap(MODULE, OPTIONS.scenario, '最后一份完成文件二次确认',
'完成文件未成功或同键重放未收敛,已停止后续自动离仓写入',
'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 receiptKey = key(context, 'mixed-receipt-upload')
const receipt = await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey)
const receiptReplay = success(receipt)
? await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey) : null
const receiptConverged = success(receipt) && success(receiptReplay)
&& id(receipt.body?.data?.documentId) === id(receiptReplay.body?.data?.documentId)
&& id(receipt.body?.data?.receipt?.fileId) === id(receiptReplay.body?.data?.receipt?.fileId)
context.assert(MODULE, '报关行回执同键重放收敛', receiptReplay || receipt, receiptConverged,
[receipt, receiptReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS)
const receiptDifferent = await routes.platformUploadReceipt(context, fixture,
{ ...receiptRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, receiptKey)
context.assert(MODULE, '回执同键异载荷A0409', receiptDifferent, a0409(receiptDifferent), evidence(receiptDifferent), OPTIONS)
const receiptVersion = Number(receipt.body?.data?.receipt?.version || 0)
const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, {
documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, 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,
}, key(context, 'mixed-receipt-confirm'))
context.assert(MODULE, '客户确认整份回执并同键重放', receiptReplay || receiptConfirm,
success(receiptConfirm) && success(receiptReplay)
&& receiptConfirm.body?.data?.status === 'COMPLETED'
&& receiptReplay.body?.data?.status === 'COMPLETED',
[receiptConfirm, receiptReplay].map((response) => evidence(response)).join('; '), OPTIONS)
context.assert(MODULE, '客户确认回执后仍未完成整柜', receiptConfirmReplay || receiptConfirm,
success(receiptConfirm) && success(receiptConfirmReplay)
&& receiptConfirm.body?.data?.status === 'DECLARING'
&& receiptConfirmReplay.body?.data?.status === 'DECLARING',
[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)
context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed,
@@ -396,10 +422,10 @@ async function runMixedConcurrency(context, routes, fixture) {
evidence(queryDetail), OPTIONS)
}
const completionDownload = await routes.merchantDownload(context, fixture, customer, {
documentId: customer.documentId, completion: true,
const completionDownload = await routes.merchantDownloadFinal(context, fixture, customer, {
documentId: customer.documentId,
})
context.assert(MODULE, '整柜完成后Client可下载自身TAX完成文件', completionDownload,
context.assert(MODULE, '整柜完成后Client可下载自身真实报关完税文件', completionDownload,
completionDownload.http === 200 && completionDownload.bytes?.length > 0,
`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='真实报关完税文件当前版本';