package com.baiyue.lastleg.delivery; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.lastleg.client.FinanceFeeCoverageClient; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups; import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; import com.baiyue.lastleg.delivery.service.DispatchDeliveryFees; import com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine; import com.baiyue.lastleg.dispatch.port.DispatchContracts.SourceOrigin; import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.baiyue.lastleg.fleet.trip.FleetBookingFreight; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import java.math.BigDecimal; import java.util.List; import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; /** 新整车收费逻辑直接验证真实客户、明确覆盖、零约定和逐组归属。 */ class DispatchDeliveryFeesTest { private final FleetBookingFreight external=mock(FleetBookingFreight.class); private final FinanceFeeCoverageClient finance=mock(FinanceFeeCoverageClient.class); private final OverseasFeeSourceService sources=mock(OverseasFeeSourceService.class); private final DispatchDeliveryRepository repository=mock(DispatchDeliveryRepository.class); private final DeliveryCargoGroups grouping=new DeliveryCargoGroups(new ObjectMapper().findAndRegisterModules()); private final DispatchDeliveryFees service=new DispatchDeliveryFees(external,finance,sources,grouping,repository,"offline-test-token"); /** A客户原费不能免除B客户;全部覆盖只禁止应收,应付仍独立。 */ @Test void coverageRequiresCurrentCustomerVersionAndKeepsPayablesIndependent() { var row=firstleg(cargo(1,"101","ONT8",51,2));var group=grouping.groups(context(List.of(row),false)).get(0); var fee=original(101);when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(fee))); var input=input(group,List.of(new CoverageSelection(71L,1,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of()); var evidence=service.coverage(group,input,"OP",ordinary()); service.validate(group,input,evidence,ordinary()); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,input.feeCoverage(),List.of(fee("RECEIVABLE"))),evidence,ordinary())); service.validate(group,input(group,input.feeCoverage(),List.of(fee("PAYABLE"))),evidence,ordinary()); when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(original(102)))); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group,input,"OP",ordinary())); when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(fee))); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group, input(group,List.of(new CoverageSelection(71L,2,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of()),"OP",ordinary())); } /** 无引用时不强制Finance可用,已有外接约定组禁止新增派送应收。 */ @Test void ordinaryAllowsLaterManualFeeAndOldReceivableInputIsRejected() { var row=cargo(1,"101","ONT8",51,2);var group=grouping.groups(context(List.of(row),false)).get(0); var input=input(group,List.of(),List.of()); assertNull(service.coverage(group,input,"OP",ordinary()));verifyNoInteractions(finance); service.validate(group,input,null,ordinary()); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,ordinary())); var quote=new FleetBookingFreight.Quote("BOOK-1",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO); var allExternal=new FleetBookingFreight.Preview("freight",false,List.of(quote),List.of()); service.validate(group,input,null,allExternal); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,allExternal)); var mixed=grouping.groups(context(List.of(row,cargo(2,"101","ONT8",51,3)),false)).get(0); service.validate(mixed,input(mixed,List.of(),List.of()),null,allExternal); } /** 正数报价发布到自己的客户目的地,零约定仅保存商业操作不造零费用。 */ @Test void zeroAndPositiveQuotesKeepFactsWithoutGeneratingReceivables() { var row=cargo(1,"101","ONT8",51,2);var context=context(List.of(row),false);var group=grouping.groups(context).get(0); var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101");delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001"); var zero=new FleetBookingFreight.Quote("BOOK-ZERO",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO); service.publish(context,group,input(group,List.of(),List.of()),delivery,null,new FleetBookingFreight.Preview("freight",false,List.of(zero),List.of()),"OP","ARR-1"); verify(repository).operation(any());verifyNoInteractions(sources); var positive=new FleetBookingFreight.Quote("BOOK-POSITIVE",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.TEN,BigDecimal.ONE,"单",BigDecimal.TEN); service.publish(context,group,input(group,List.of(),List.of()),delivery,null,new FleetBookingFreight.Preview("freight",false,List.of(positive),List.of()),"OP","ARR-1"); verify(repository,times(2)).operation(any()); verifyNoInteractions(sources); } /** 合托两票一笔原费只能覆盖对应来源,不得免掉另一票新增应收。 */ @Test void mixedOriginalSourcesAreBatchedAndPartialCoverageStillRequiresFee() { var first=firstleg(cargo(1,"101","ONT8",51,2));var raw=firstleg(cargo(2,"101","ONT8",51,3)); var second=new CargoLine(raw.dispatchItemId(),raw.customerId(),raw.customerName(),raw.orderId(),raw.orderNo(),raw.sourceKey(),raw.sourceStage(), raw.sourceRegion(),"502",raw.inboundCode(),raw.planPalletId(),raw.warehousePalletId(),raw.palletNo(),raw.pickId(),raw.attributionId(),raw.inventoryPositionId(), raw.receiptId(),raw.returnReceiptId(),raw.returnCartonId(),raw.cartonMark(),raw.relabelNo(),raw.destination(),raw.packages(),raw.productQuantity(),raw.grossWeightKg(),raw.volumeCbm(),raw.batchAllocations(),raw.sourceOrigin()); var group=grouping.groups(context(List.of(first,second),false)).get(0); when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(original(101)))); var input=input(group,List.of(new CoverageSelection(71L,1,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of()); var evidence=service.coverage(group,input,"OP",ordinary()); assertEquals(2,evidence.cargo().size()); verify(finance).sources("offline-test-token",new FinanceFeeCoverageClient.Query(101L,List.of(501L,502L))); service.validate(group,input,evidence,ordinary()); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group, input(group,List.of(input.feeCoverage().get(0),input.feeCoverage().get(0)),List.of()),"OP",ordinary())); } /** 多个换标原货源共组费用只归真实派送,不借第一票原货源或履约单伪造单一归属。 */ @Test void relabelFeeRetainsDeliveryReferenceWithoutInventingOneOrigin() { var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null); var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null); var context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first), relabelCargo(3,"101","ONT8",51,3,second)),false); var group=grouping.groups(context).get(0); var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101"); delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001"); service.publish(context,group,input(group,List.of(),List.of(fee("PAYABLE"))),delivery,null,ordinary(),"OP","ARR-RELABEL"); var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class); verify(sources).publishApproved(eq("OP"),anyString(),saved.capture()); assertEquals("DELIVERY_ORDER",saved.getValue().businessType()); assertEquals(81L,saved.getValue().businessId());assertEquals("DL-101",saved.getValue().businessNo()); assertEquals("101",saved.getValue().customerId()); assertNull(saved.getValue().orderId());assertNull(saved.getValue().inboundId());assertNull(saved.getValue().inboundCode()); assertEquals(List.of(first,second),group.cargo().stream().map(CargoLine::sourceOrigin).toList()); assertTrue(group.cargo().stream().allMatch(c->c.sourceOrigin().productQuantity()==null&&c.grossWeightKg()==null&&c.volumeCbm()==null)); assertEquals(5,group.packages());assertEquals(1,group.palletCount()); } /** 只读平台候选接口仍拒绝客户身份,不能借组结果跨客户访问。 */ @Test void readOnlyCoverageControllerRejectsMerchantBeforeQuery() throws Exception { var delivery=mock(com.baiyue.lastleg.delivery.service.DispatchDeliveryService.class); var controller=new com.baiyue.lastleg.delivery.controller.PlatformDeliveryFeeCoverageController(delivery); var merchant=new com.baiyue.common.security.AuthenticatedUser(1L,101L,101L,1L,"test","MERCHANT","WEB",java.util.Set.of()); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->controller.preview(merchant,"DP-10")); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->controller.preview(null,"DP-10")); var method=controller.getClass().getMethod("preview",com.baiyue.common.security.AuthenticatedUser.class,String.class); assertTrue(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value().contains("dispatch:list")); verifyNoInteractions(delivery); } /** 构造当前客户原费用证据。 */ private FinanceFeeCoverageClient.Fee original(long customer) { return new FinanceFeeCoverageClient.Fee(71L,72L,"INBOUND_FEE",1,customer,501L,"IN-1","FIRSTLEG","原费用","CNY",BigDecimal.TEN,"FEE-HASH","AVAILABLE"); } /** 真实头程关联只在来源行声明,不从展示编号推断。 */ private CargoLine firstleg(CargoLine c) { return new CargoLine(c.dispatchItemId(),c.customerId(),c.customerName(),c.orderId(),c.orderNo(),c.sourceKey(),c.sourceStage(), "FIRSTLEG","501",c.inboundCode(),c.planPalletId(),c.warehousePalletId(),c.palletNo(),c.pickId(),c.attributionId(),c.inventoryPositionId(),c.receiptId(), c.returnReceiptId(),c.returnCartonId(),c.cartonMark(),c.relabelNo(),c.destination(),c.packages(),c.productQuantity(),c.grossWeightKg(),c.volumeCbm(),c.batchAllocations(),c.sourceOrigin()); } /** 输入仅使用共同ArrangeGroup,不保留旧单客户协议别名。 */ private ArrangeGroup input(DeliveryCargoGroups.Group group,List coverage,List fees) { return new ArrangeGroup(group.key(),group.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,"freight",group.cargoHash(),coverage,fees); } /** 无外接约定仍需按普通未覆盖部分收费。 */ private FleetBookingFreight.Preview ordinary() { return new FleetBookingFreight.Preview("freight",true,List.of(),List.of()); } /** 同一费用类型的应收/应付保留独立规则。 */ private FeeItem fee(String direction) { return new FeeItem("DELIVERY_FEE","派送费",direction,"USD",BigDecimal.TEN,null,null,null,null,null,null,null,null,null,null,null,null,null,null); } }