diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java index 9fed8f7f9..93f3035fa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java @@ -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 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 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 response(Download value) { return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(MediaType.parseMediaType(value.contentType())) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java index 48c0df180..8ce46a403 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java @@ -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") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java index ffa5959db..509b9096e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java @@ -19,12 +19,13 @@ public final class ContainerCustomsDtos { public record DocumentView(long documentId, long tenantId, FileView source, List inboundIds, List 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 inbounds, List documents, LocalDateTime startedAt, LocalDateTime completedAt) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index f15db80a4..9d6cab745 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -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 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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index a55395fd6..5a40f339f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -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 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 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 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 scope) { try { return objectMapper.writeValueAsString(scope.stream().map(row -> Map.of("inboundId", row.inboundId(), "orderId", row.orderId(), "inboundCode", row.inboundCode())).toList()); } diff --git a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql index f151d6a41..3d2982d05 100644 --- a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql +++ b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql @@ -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), diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index 50b825526..f1c36d611 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -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}}}}}]} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index 181f8ea68..a2276aaad 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -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(); var commandState=new AtomicReference(); @@ -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(){