完善退税报关回执确认流程

This commit is contained in:
2026-09-24 16:28:58 +08:00
parent bcf44affd7
commit 2486a01225
8 changed files with 154 additions and 46 deletions
@@ -91,6 +91,26 @@ public class MerchantContainerCustomsController {
return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false));
}
@PostMapping("/documents/{documentId}/receipt/confirm")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<ContainerView> confirmReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.confirmReceipt(tenantId,documentId,user.userId(),user.username(),key,request.expectedVersion(),true,request.remark()));
}
@PostMapping("/documents/{documentId}/receipt/reject")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<ContainerView> rejectReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.confirmReceipt(tenantId,documentId,user.userId(),user.username(),key,request.expectedVersion(),false,request.remark()));
}
private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
@@ -51,11 +51,10 @@ public class PlatformContainerCustomsController {
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Min(0) int expectedVersion,
@RequestParam(defaultValue="false") boolean finalConfirmation,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.uploadCompletion(documentId,user.userId(),user.username(),key,
expectedVersion,finalConfirmation,file));
expectedVersion,file));
}
@PostMapping("/{planId}/complete-all-buy")
@@ -19,12 +19,13 @@ public final class ContainerCustomsDtos {
public record DocumentView(long documentId, long tenantId, FileView source,
List<Long> inboundIds, List<String> inboundCodes,
boolean frozen, FileView completion) {}
boolean frozen, FileView completion, String receiptStatus,
String receiptRejectReason) {}
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo,
String merchantStatus,
int version, int taxRefundCount, int coveredTaxRefundCount,
int version, int taxRefundCount, int coveredTaxRefundCount, long actualBoxCount,
List<InboundView> inbounds, List<DocumentView> documents,
LocalDateTime startedAt, LocalDateTime completedAt) {}
@@ -17,7 +17,8 @@ public interface ContainerCustomsMapper {
String COMPLETION_COLUMNS = """
r.id id,r.source_document_id sourceDocumentId,r.file_id fileId,r.file_name fileName,
r.file_hash fileHash,r.content_type contentType,r.size_bytes sizeBytes,r.uploaded_by uploadedBy,
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
""";
@Select("""
@@ -34,6 +35,18 @@ public interface ContainerCustomsMapper {
""")
List<InboundRow> planInbounds(@Param("planId") long planId);
@Select("""
SELECT COALESCE(SUM(item.package_count),0)
FROM fl_loading_plan_item item
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE item.loading_plan_id=#{planId} AND item.deleted=0
AND (#{tenantId} IS NULL OR i.tenant_id=#{tenantId})
AND (#{taxMode} IS NULL OR i.tax_mode=#{taxMode})
""")
long actualBoxCount(@Param("planId") long planId, @Param("tenantId") Long tenantId,
@Param("taxMode") String taxMode);
@Select("""
SELECT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
p.plan_status loadingPlanStatus,b.cabinet_no cabinetNo,b.container_no containerNo
@@ -217,9 +230,9 @@ public interface ContainerCustomsMapper {
@Insert("""
INSERT INTO fl_customs_completion_document
(id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name,
uploaded_at,version,created_at,updated_at)
uploaded_at,version,customer_confirm_status,created_at,updated_at)
VALUES(#{id},#{documentId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},#{operatorId},#{operatorName},
CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
CURRENT_TIMESTAMP(3),1,'PENDING',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""")
int insertCompletion(@Param("id") long id,@Param("documentId") long documentId,@Param("fileId") long fileId,
@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@@ -229,7 +242,9 @@ public interface ContainerCustomsMapper {
@Update("""
UPDATE fl_customs_completion_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),updated_at=CURRENT_TIMESTAMP(3),version=version+1
uploaded_at=CURRENT_TIMESTAMP(3),customer_confirm_status='PENDING',customer_confirmed_by=NULL,
customer_confirmed_by_name=NULL,customer_confirmed_at=NULL,customer_reject_reason=NULL,
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version}
AND EXISTS(SELECT 1 FROM fl_customs_customer_document d JOIN fl_customs_container c ON c.id=d.customs_container_id
WHERE d.id=source_document_id AND c.status='DECLARING')
@@ -239,6 +254,28 @@ public interface ContainerCustomsMapper {
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_customs_completion_document r
JOIN fl_customs_customer_document d ON d.id=r.source_document_id
JOIN fl_customs_container c ON c.id=d.customs_container_id
SET r.customer_confirm_status=#{status},r.customer_confirmed_by=#{operatorId},
r.customer_confirmed_by_name=#{operatorName},r.customer_confirmed_at=CURRENT_TIMESTAMP(3),
r.customer_reject_reason=#{reason},r.updated_at=CURRENT_TIMESTAMP(3)
WHERE r.source_document_id=#{documentId} AND d.tenant_id=#{tenantId} AND c.status='DECLARING'
AND r.customer_confirm_status IN ('PENDING','REJECTED')
""")
int confirmReceipt(@Param("documentId") long documentId,@Param("tenantId") long tenantId,
@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 d.frozen_at IS NOT NULL
AND r.customer_confirm_status <> 'CONFIRMED'
""")
int pendingReceiptConfirmationCount(@Param("containerId") long containerId);
@Insert("""
INSERT INTO fl_customs_completion_document_version
(id,completion_document_id,document_version,file_id,file_name,file_hash,content_type,size_bytes,
@@ -251,13 +288,6 @@ public interface ContainerCustomsMapper {
@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_completion_document r ON r.source_document_id=d.id
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND d.frozen_at IS NOT NULL AND r.id IS NULL
""")
int missingCompletionCount(@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)
@@ -350,7 +380,8 @@ public interface ContainerCustomsMapper {
Integer frozenVersion,LocalDateTime frozenAt,Integer version) {}
record ScopeRow(Long documentId,Long inboundId,Long orderId,String inboundCode) {}
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) {}
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,
@@ -174,11 +174,11 @@ public class ContainerCustomsService {
@Transactional
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, boolean finalConfirmation, MultipartFile file) {
int expectedVersion, MultipartFile file) {
validateFile(file);
DocumentRow readable=requiredDocument(documentId);
String action = "UPLOAD_COMPLETION_" + documentId;
String requestHash = hash(action, expectedVersion, finalConfirmation, fileHash(file));
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());
@@ -187,10 +187,6 @@ public class ContainerCustomsService {
throw conflict("当前客户资料不在报关中冻结版本");
}
CompletionRow completion = mapper.lockCompletion(documentId);
int missingBefore=mapper.missingCompletionCount(container.id());
boolean lastNewCompletion=completion==null&&missingBefore==1;
if(lastNewCompletion&&!finalConfirmation)throw conflict("这是最后一份完成文件,请二次确认后上传");
if(finalConfirmation&&!lastNewCompletion)throw conflict("只有最后一份尚未上传的完成文件才能确认整柜完成");
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long completionId;
int version;
@@ -206,13 +202,37 @@ public class ContainerCustomsService {
}
mapper.insertCompletionVersion(IdWorker.getId(), completionId, version, metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName);
if (lastNewCompletion) {
completeContainer(container, operatorId, operatorName, "全部冻结客户资料已回传完成文件");
}
completeCommand(operatorId, action, key, completionId);
return documentView(requiredDocument(documentId), false);
}
@Transactional
public ContainerView confirmReceipt(long tenantId, long documentId, long operatorId, String operatorName,
String key, int expectedVersion, boolean confirm, String reason) {
String action = (confirm ? "CONFIRM_CUSTOMS_RECEIPT_" : "REJECT_CUSTOMS_RECEIPT_") + documentId;
String requestHash = hash(action, confirm, reason);
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) {
DocumentRow source = requiredDocument(documentId);
return merchantViewForDocument(tenantId, source);
}
DocumentRow source = requiredDocumentLocked(documentId, tenantId);
ContainerRow container = mapper.lockContainerById(source.customsContainerId());
CompletionRow receipt = mapper.lockCompletion(documentId);
if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null || receipt == null) {
throw conflict("当前回执不在待确认状态");
}
if (!Objects.equals(receipt.version(), expectedVersion)) throw concurrent();
if (!confirm && (reason == null || reason.isBlank())) throw bad("打回回执必须填写原因");
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, "客户已确认全部报关行回执");
}
completeCommand(operatorId, action, key, documentId);
return merchantViewForDocument(tenantId, source);
}
@Transactional
public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId;
@@ -336,19 +356,23 @@ public class ContainerCustomsService {
List<DocumentView> documentViews = documents.stream().map(doc -> documentView(doc, merchant)).toList();
int refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count();
int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count();
long actualBoxCount = mapper.actualBoxCount(container.loadingPlanId(), tenantId,
tenantId == null ? null : "TAX_REFUND");
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(),
plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund,
inbounds, documentViews, container.startedAt(), container.completedAt());
actualBoxCount, inbounds, documentViews, container.startedAt(), container.completedAt());
}
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());
ContainerRow container = mapper.containerById(source.customsContainerId());
FileView result = completion == null || (merchant && !COMPLETED.equals(container.status())) ? 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(),
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.customerRejectReason());
}
private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) {
@@ -406,6 +430,12 @@ public class ContainerCustomsService {
return value;
}
private ContainerView merchantViewForDocument(long tenantId, DocumentRow source) {
List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id());
if (scopes.isEmpty()) throw notFound("客户报送资料未关联入仓单");
return merchantOrderView(tenantId, scopes.get(0).orderId());
}
private String scopeJson(List<InboundRow> scope) {
try { return objectMapper.writeValueAsString(scope.stream().map(row -> Map.of("inboundId", row.inboundId(),
"orderId", row.orderId(), "inboundCode", row.inboundCode())).toList()); }
@@ -104,6 +104,11 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document (
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间',
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完成文件版本号',
customer_confirm_status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '客户回执确认状态:PENDING/CONFIRMED/REJECTED',
customer_confirmed_by BIGINT UNSIGNED NULL COMMENT '客户确认人ID',
customer_confirmed_by_name VARCHAR(64) NULL COMMENT '客户确认人名称快照',
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
customer_reject_reason VARCHAR(500) 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 '完成文件更新时间',
PRIMARY KEY(id),
@@ -24,13 +24,12 @@ paths:
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/upload:
post:
summary: 上传或覆盖冻结客户资料的一对一完成文件
description: 完成文件自动继承冻结入仓码且不可删除;最后一份须finalConfirmation=true,File调用前服务端预检。
description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回,不自动完成整柜报关。
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}}
- {name: finalConfirmation, in: query, required: true, schema: {type: boolean}}
requestBody: {$ref: '#/components/requestBodies/FileBody'}
responses: {'200': {$ref: '#/components/responses/DocumentResponse'}}
/api/platform/v1/customs/loading-plans/documents/{documentId}/download:
@@ -100,6 +99,20 @@ paths:
x-permission: merchant:order:view
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/merchant/v1/customs/documents/{documentId}/receipt/confirm:
post:
summary: 客户确认退税报关行整份回执
x-permission: merchant:order:edit
parameters: [{$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {$ref: '#/components/requestBodies/ActionBody'}
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
/api/merchant/v1/customs/documents/{documentId}/receipt/reject:
post:
summary: 客户打回退税报关行回执
x-permission: merchant:order:edit
parameters: [{$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {$ref: '#/components/requestBodies/ActionBody'}
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches:
post:
summary: File导出中心按loadingPlanId分页获取已完成装柜买单实际行
@@ -129,7 +142,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}}}
ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, 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}, 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}}}
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}}}
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}}}}}]}
@@ -88,11 +88,13 @@ class ContainerCustomsServiceTest {
when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L);
when(mapper.planInbounds(9L)).thenReturn(List.of(row));
when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,2));
when(mapper.actualBoxCount(9L,7L,"TAX_REFUND")).thenReturn(3L);
var view=service.merchantOrderView(7L,21L);
assertThat(view.loadingPlanId()).isEqualTo(9L);
assertThat(view.taxRefundCount()).isEqualTo(1);
assertThat(view.actualBoxCount()).isEqualTo(3L);
assertThat(view.inbounds()).extracting(value->value.orderId()).containsExactly(21L);
}
@@ -241,16 +243,24 @@ class ContainerCustomsServiceTest {
verify(mapper,times(1)).completeCommand(5L,"START_CONTAINER_CUSTOMS_9","start-key",2L);
}
@Test void lastCompletionRequiresConfirmationBeforeCallingFile(){
@Test void receiptUploadDoesNotRequireFinalConfirmation(){
var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3);
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(mapper.missingCompletionCount(2L)).thenReturn(1);
when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),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)));
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")));
when(mapper.completion(1L)).thenReturn(null);when(mapper.containerById(2L)).thenReturn(container);
when(mapper.completeCommand(anyLong(),eq("UPLOAD_COMPLETION_1"),eq("k"),anyLong())).thenReturn(1);
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,false,file))
.isInstanceOf(BizException.class).hasMessageContaining("最后一份");
verifyNoInteractions(files);
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(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
}
@Test void completedContainerLocksCompletionReplacement(){
@@ -259,15 +269,14 @@ class ContainerCustomsServiceTest {
when(mapper.lockContainerById(2L)).thenReturn(container(ContainerCustomsService.COMPLETED,4));
when(mapper.lockDocument(1L)).thenReturn(source);
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,false,file))
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,file))
.isInstanceOf(BizException.class).hasMessageContaining("不在报关中");
verifyNoInteractions(files);
}
@Test void finalCompletionReplaySucceedsAfterContainerCompletedAndRejectsChangedPayload(){
@Test void receiptReplaySucceedsAndDoesNotCompleteContainer(){
var source=document(1L);
var declaring=container(ContainerCustomsService.DECLARING,3);
var completed=container(ContainerCustomsService.COMPLETED,4);
var containerState=new AtomicReference<>(declaring);
var completionState=new AtomicReference<ContainerCustomsMapper.CompletionRow>();
var commandState=new AtomicReference<ContainerCustomsMapper.CommandRow>();
@@ -293,10 +302,10 @@ class ContainerCustomsServiceTest {
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->{
completionState.set(new ContainerCustomsMapper.CompletionRow(invocation.getArgument(0),1L,99L,
"result.pdf","b".repeat(64),"application/pdf",1L,5L,"admin",java.time.LocalDateTime.now(),1));
"result.pdf","b".repeat(64),"application/pdf",1L,5L,"admin",java.time.LocalDateTime.now(),1,
"PENDING",null));
return 1;
});
when(mapper.complete(2L,3,5L,"admin")).thenAnswer(invocation->{containerState.set(completed);return 1;});
when(mapper.completeCommand(eq(5L),eq("UPLOAD_COMPLETION_1"),eq("completion-key"),anyLong()))
.thenAnswer(invocation->{
var current=commandState.get();
@@ -308,19 +317,19 @@ class ContainerCustomsServiceTest {
when(mapper.completion(1L)).thenAnswer(invocation->completionState.get());
when(mapper.containerById(2L)).thenAnswer(invocation->containerState.get());
var first=service.uploadCompletion(1L,5L,"admin","completion-key",0,true,file);
var replay=service.uploadCompletion(1L,5L,"admin","completion-key",0,true,file);
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);
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,true,changedFile))
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(mapper,times(1)).lockContainerById(2L);
verify(mapper,times(1)).insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),
eq(1L),eq(5L),eq("admin"));
verify(mapper,times(1)).complete(2L,3,5L,"admin");
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
}
@Test void merchantCannotDownloadCompletionBeforeWholeContainerCompletes(){