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 3f812aa8f..ffa5959db 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 @@ -22,7 +22,8 @@ public final class ContainerCustomsDtos { boolean frozen, FileView completion) {} public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId, - String planNo, String cabinetNo, String containerNo, String status, String merchantStatus, + String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo, + String merchantStatus, int version, int taxRefundCount, int coveredTaxRefundCount, 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 8778d08e3..f15db80a4 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 @@ -34,6 +34,15 @@ public interface ContainerCustomsMapper { """) List planInbounds(@Param("planId") long planId); + @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 + FROM fl_loading_plan p + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + WHERE p.id=#{planId} + """) + PlanRow plan(@Param("planId") long planId); + @Select(""" SELECT p.id FROM fl_loading_plan p JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0 @@ -331,6 +340,8 @@ public interface ContainerCustomsMapper { record InboundRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus, String cabinetNo,String containerNo,Long inboundId,Long orderId,String orderNo,String inboundCode, Long tenantId,String customerName,String taxMode) {} + record PlanRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus, + String cabinetNo,String containerNo) {} record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy, String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName, LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {} 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 140fed70c..704c71017 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 @@ -155,6 +155,7 @@ public class ContainerCustomsService { CommandRow replay = claim(operatorId, action, key, requestHash); if (replay != null) return platformView(planId); ensure(planId); + if (actualPlanRows(planId).isEmpty()) throw conflict("当前装柜计划没有实际货物,无需报关"); ContainerRow locked = mapper.lockContainer(planId); if (locked == null || !READY.equals(locked.status())) throw conflict("当前柜报关资料尚未齐全或已开始报关"); if (!Objects.equals(locked.version(), request.expectedVersion())) throw concurrent(); @@ -219,7 +220,8 @@ public class ContainerCustomsService { ContainerRow container = mapper.lockContainer(planId); if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中"); if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); - List actual=planRows(planId); + List actual=actualPlanRows(planId); + if(actual.isEmpty())throw conflict("当前装柜计划没有实际货物,无需报关"); if(actual.stream().anyMatch(row->!"BUY_DECLARATION".equals(row.taxMode())))throw conflict("全买单柜完成只允许全部实际行均为BUY_DECLARATION"); completeContainer(container, operatorId, operatorName, request.remark()); completeCommand(operatorId, action, key, container.id()); @@ -245,11 +247,11 @@ public class ContainerCustomsService { } private ContainerRow ensure(long planId) { - List rows = planRows(planId); - InboundRow first = rows.get(0); - if (!"COMPLETED".equals(first.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料"); + List rows = actualPlanRows(planId); + PlanRow plan = rows.isEmpty() ? requiredPlan(planId) : plan(rows.get(0)); + if (!"COMPLETED".equals(plan.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料"); boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode())); - mapper.insertContainer(IdWorker.getId(), planId, first.containerBookingId(), refund ? WAITING : READY); + mapper.insertContainer(IdWorker.getId(), planId, plan.containerBookingId(), refund ? WAITING : READY); return refresh(planId); } @@ -283,11 +285,27 @@ public class ContainerCustomsService { } private List planRows(long planId) { - List rows = mapper.planInbounds(planId); + List rows = actualPlanRows(planId); if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细"); return rows; } + private List actualPlanRows(long planId) { + List rows = mapper.planInbounds(planId); + return rows == null ? List.of() : rows; + } + + private PlanRow requiredPlan(long planId) { + PlanRow plan = mapper.plan(planId); + if (plan == null) throw notFound("装柜计划不存在或不可用"); + return plan; + } + + private PlanRow plan(InboundRow row) { + return new PlanRow(row.loadingPlanId(), row.planNo(), row.containerBookingId(), row.loadingPlanStatus(), + row.cabinetNo(), row.containerNo()); + } + private List validateScope(long planId, long tenantId, List inboundIds) { if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码"); Set requested = new LinkedHashSet<>(inboundIds); @@ -304,7 +322,8 @@ public class ContainerCustomsService { } private ContainerView view(ContainerRow container, Long tenantId, boolean merchant) { - List rows = planRows(container.loadingPlanId()); + List rows = actualPlanRows(container.loadingPlanId()); + PlanRow plan = rows.isEmpty() ? requiredPlan(container.loadingPlanId()) : plan(rows.get(0)); List documents = mapper.documents(container.id(), tenantId); Set covered = documents.stream().flatMap(doc -> mapper.scopes(doc.id()).stream()).map(ScopeRow::inboundId).collect(Collectors.toSet()); List visibleRows=rows.stream().filter(row -> tenantId == null || (row.tenantId().equals(tenantId)&&"TAX_REFUND".equals(row.taxMode()))).toList(); @@ -315,9 +334,8 @@ public class ContainerCustomsService { 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(); String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund); - InboundRow first = rows.get(0); - return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), first.planNo(), - first.cabinetNo(), first.containerNo(), container.status(), merchantStatus, container.version(), 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()); } diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index b168e1ec2..165bfee34 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -130,6 +130,6 @@ components: 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, 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]}, 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}}} + 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}}} 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/ContainerCustomsControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java index c69f7edbb..c06784514 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java @@ -33,7 +33,7 @@ class ContainerCustomsControllerContractTest { String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8); new Yaml().load(yaml); assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:") - .contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:") + .contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:","hasActualCargo:") .contains("LOADING_LIST_BUY_DECLARATION_ITEMS") .doesNotContain("/cases/","CUSTOMS_DECLARATION"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java index bdf2b1967..cc115e3fd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java @@ -23,6 +23,9 @@ class ContainerCustomsMapperContractTest { @Test void actualCompletedLoadingItemsAndTaxModeAreTheOnlyTicketFacts() throws Exception { String plan=sql("planInbounds",long.class); assertThat(plan).contains("fl_loading_plan_item","fl_inbound_order","p.id=#{planId}","item.deleted=0"); + String planIdentity=sql("plan",long.class); + assertThat(planIdentity).contains("fl_loading_plan","fl_container_booking","p.id=#{planId}") + .doesNotContain("fl_loading_plan_item","fl_inbound_order"); String missing=sql("missingRefundCount",long.class,long.class); assertThat(missing).contains("i.tax_mode='TAX_REFUND'","fl_customs_customer_document_inbound","d.status='ACTIVE'"); } 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 6f51a5a7d..ba5a20ed9 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 @@ -44,6 +44,35 @@ class ContainerCustomsServiceTest { assertMerchantOrderNotFound(7L,22L); } + @Test void completedPlanWithoutActualCargoReturnsEmptyViewInsteadOfNotFound(){ + var plan=new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C"); + when(mapper.planInbounds(9L)).thenReturn(List.of()); + when(mapper.plan(9L)).thenReturn(plan); + when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,0)); + + var view=service.platformView(9L); + + assertThat(view.hasActualCargo()).isFalse(); + assertThat(view.planNo()).isEqualTo("LP9"); + assertThat(view.inbounds()).isEmpty(); + assertThat(view.documents()).isEmpty(); + assertThat(view.taxRefundCount()).isZero(); + verify(mapper).insertContainer(anyLong(),eq(9L),eq(8L),eq(ContainerCustomsService.READY)); + } + + @Test void completedPlanWithoutActualCargoCannotStartCustoms(){ + when(mapper.claimCommand(anyLong(),eq(5L),eq("START_CONTAINER_CUSTOMS_9"),eq("empty-plan"),anyString())).thenReturn(1); + when(mapper.planInbounds(9L)).thenReturn(List.of()); + when(mapper.plan(9L)).thenReturn(new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C")); + when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,0)); + + assertThatThrownBy(()->service.start(9L,5L,"admin","empty-plan",new ActionRequest(0,true,null))) + .isInstanceOf(BizException.class).hasMessageContaining("没有实际货物,无需报关"); + + verify(mapper,never()).lockContainer(9L); + verify(mapper,never()).start(anyLong(),anyInt(),anyLong(),anyString()); + } + @Test void merchantCompletedTaxRefundOrderReturnsContainerView(){ var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L);