完善退税报关回执确认流程
This commit is contained in:
+20
@@ -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()))
|
||||
|
||||
+1
-2
@@ -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")
|
||||
|
||||
+3
-2
@@ -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) {}
|
||||
|
||||
|
||||
+43
-12
@@ -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,
|
||||
|
||||
+42
-12
@@ -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}}}}}]}
|
||||
|
||||
+23
-14
@@ -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(){
|
||||
|
||||
Reference in New Issue
Block a user