fix(firstleg): allow customs view for empty loading plan

This commit is contained in:
2026-09-10 14:02:26 +08:00
parent a3890e7a55
commit c026edbb2f
7 changed files with 75 additions and 13 deletions
@@ -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<InboundView> inbounds, List<DocumentView> documents,
LocalDateTime startedAt, LocalDateTime completedAt) {}
@@ -34,6 +34,15 @@ public interface ContainerCustomsMapper {
""")
List<InboundRow> 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) {}
@@ -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<InboundRow> actual=planRows(planId);
List<InboundRow> 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<InboundRow> rows = planRows(planId);
InboundRow first = rows.get(0);
if (!"COMPLETED".equals(first.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
List<InboundRow> 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<InboundRow> planRows(long planId) {
List<InboundRow> rows = mapper.planInbounds(planId);
List<InboundRow> rows = actualPlanRows(planId);
if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细");
return rows;
}
private List<InboundRow> actualPlanRows(long planId) {
List<InboundRow> 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<InboundRow> validateScope(long planId, long tenantId, List<Long> inboundIds) {
if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码");
Set<Long> requested = new LinkedHashSet<>(inboundIds);
@@ -304,7 +322,8 @@ public class ContainerCustomsService {
}
private ContainerView view(ContainerRow container, Long tenantId, boolean merchant) {
List<InboundRow> rows = planRows(container.loadingPlanId());
List<InboundRow> rows = actualPlanRows(container.loadingPlanId());
PlanRow plan = rows.isEmpty() ? requiredPlan(container.loadingPlanId()) : plan(rows.get(0));
List<DocumentRow> documents = mapper.documents(container.id(), tenantId);
Set<Long> covered = documents.stream().flatMap(doc -> mapper.scopes(doc.id()).stream()).map(ScopeRow::inboundId).collect(Collectors.toSet());
List<InboundRow> 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());
}
@@ -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}}}}}]}
@@ -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");
}
@@ -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'");
}
@@ -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);