diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java index 11216d647..8a518868b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java @@ -7,4 +7,6 @@ public class FleetBookingCargoRepository { public FleetBookingCargoRepository(FleetBookingMapper bookings){this.bookings=bookings;} /** 按客户与真实入仓码定位承运,历史取消记录交由业务层分别处理。 */ public FleetBookingEntity forInboundSource(String customerId,String inboundCode){return bookings.forInboundSource(customerId,inboundCode);} + /** 在车次事务内读取全部承运预留,统一计算区段容量。 */ + public java.util.List listForTrip(long tripId){return bookings.list(tripId);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java index 5ffa4641c..7c2d1aa87 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java @@ -28,12 +28,27 @@ public class FleetBookingReservations { row.getOfferId()==null?null:offers.selectById(row.getOfferId()).getOfferNo(),row.getExpectedPalletCount()); } public FleetQuoteCandidateModels.SourceSnapshot quoteSource(FleetBookingEntity row){return row.getQuoteSourceJson()==null?null:read(row.getQuoteSourceJson(),FleetQuoteCandidateModels.SourceSnapshot.class);} + /** 使用当前自有货计划与全部未绑定预留计算区段,不重复计入待替换承运。 */ public List segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own, Long excludedId, FleetBookingEntity addition) { + return segments(trip,own.pallets(),excludedId,addition); + } + /** 已实收承运解除唯一排车后,自有货为零仍可按本票新实测和其他预留重新核容。 */ + public void requireReceiptCapacity(FleetTripEntity trip,FleetExecutionModels.Planning own,FleetBookingEntity row) { + boolean releasedReceipt=own.cargo().isEmpty() && "RECEIVED".equals(row.getStatus()) && receipt(row)!=null; + if(!releasedReceipt && (!own.planValid() || own.blockReason()!=null)) + throw conflict("请先重新核对自有货容量计划,再确认外接实收"); + // 空自有货只用于已有实收的重新确认;不创建计划、不允许首次实收跳过自有货计划。 + List pallets=releasedReceipt?List.of():own.capacityPlan().pallets(); + segments(trip,pallets,row.getId(),row); + } + /** 所有承诺使用同一容量计算,空自有货也必须保留其他承运占位及车辆上限。 */ + private List segments(FleetTripEntity trip,List own, + Long excludedId,FleetBookingEntity addition) { var stops=stops(trip); var loads=new ArrayList(); - for (var pallet:own.pallets()) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(), + for (var pallet:own) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(), new TripCapacityPolicy.Load(pallet.palletSpaces(),pallet.weightKg(),pallet.volumeCbm()))); - for(var row:bookings.list(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row); + for(var row:new FleetBookingCargoRepository(bookings).listForTrip(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row); if(addition!=null) add(loads,stops,addition); try { return TripCapacityPolicy.calculate(stops.size(),new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),loads); } catch(IllegalArgumentException|ArithmeticException error){throw conflict(error.getMessage());} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index e21cbe43f..fde620dc2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -176,8 +176,7 @@ public class FleetBookingService { int totalSpaces=spaces.values().stream().reduce(0,Math::addExact);var old=reservations.cargo(row); apply(trip,row,new FleetBookingModels.Cargo(old.destinationKey(),old.cargoName(),count,totalSpaces,weight,volume,old.priceBasis(),request.unitPrice(),old.currency(),old.contactName(),old.contactPhone(),old.remark())); var plan=execution.capacityPlan(tripNo); - if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先重新核对自有货容量计划,再确认外接实收"); - reservations.segments(trip,plan.capacityPlan(),row.getId(),row); + reservations.requireReceiptCapacity(trip,plan,row); var confirmed=incoming.confirm(row.getCustomerId(),row.getIncomingNo(),operator,"FLEET:"+bookingNo+":RECEIPT"); if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对"); var measuredReceipt=new FleetBookingIncomingGateway.Evidence(confirmed.incomingNo(),confirmed.inboundCode(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(),confirmed.receiptHash(),java.util.List.copyOf(measured),confirmed.receivedPackages()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetReceiptCapacityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetReceiptCapacityTest.java new file mode 100644 index 000000000..ab8615184 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetReceiptCapacityTest.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetReceiptCapacityTest { + private final FleetBookingMapper bookings=mock(FleetBookingMapper.class); + private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + private final FleetBookingReservations reservations=new FleetBookingReservations(bookings,json); + private FleetTripEntity trip; + private FleetBookingEntity row; + + /** 已有实收只保留本票承运,当前自有货清空,车辆和路线仍为权威容量依据。 */ + @BeforeEach void setup() throws Exception { + trip=new FleetTripEntity();trip.setId(2L);trip.setTripNo("TR-1");trip.setMaxPalletSpaces(2); + trip.setMaxLoadKg(new BigDecimal("100"));trip.setMaxVolumeCbm(BigDecimal.TEN); + trip.setStopsJson(json.writeValueAsString(List.of(new FleetTripModels.Stop("起运仓",null,null),new FleetTripModels.Stop("目的仓",null,"FBA:ONT8")))); + row=booking(1L,1); + row.setReceiptJson(json.writeValueAsString(new FleetBookingModels.AcceptedReceipt( + new FleetBookingIncomingGateway.Evidence("WT-1","WT-1","92","FBA:ONT8","READY_FOR_DISPATCH","old", + List.of(new FleetBookingIncomingGateway.Pallet("901","PLT-901",1,BigDecimal.TEN,BigDecimal.ONE)),1), + List.of(new FleetBookingModels.PalletSpaces("901",1,null,null)),"1",Instant.parse("2026-10-01T00:00:00Z")))); + when(bookings.list(2L)).thenReturn(List.of(row)); + } + + /** 构造待替换或其他仍有效承运,测试中不发布任何库存或计划。 */ + private FleetBookingEntity booking(long id,int spaces) throws Exception { + var value=new FleetBookingEntity();value.setId(id);value.setTripId(2L);value.setBookingNo("FB-"+id); + value.setStatus("RECEIVED");value.setDestinationKey("FBA:ONT8"); + value.setCargoJson(json.writeValueAsString(new FleetBookingModels.Cargo("FBA:ONT8","测试",1,spaces, + BigDecimal.TEN,BigDecimal.ONE,"PALLET",BigDecimal.ONE,"USD",null,null,null))); + return value; + } + + /** 空货物不伪造一份有效自有货计划。 */ + private FleetExecutionModels.Planning emptyOwn() { + return new FleetExecutionModels.Planning("TR-1",1L,List.of(),"empty",null,false,"请先关联本车自有货排车单",List.of()); + } + + /** 已实收重核从零自有货计算;当前承运只作为替换项计算一次。 */ + @Test void receivedReconfirmationFitsWithoutCreatingAnOwnCargoPlan() { + assertThatCode(()->reservations.requireReceiptCapacity(trip,emptyOwn(),row)).doesNotThrowAnyException(); + verify(bookings,never()).updateById(any(FleetBookingEntity.class)); + } + + /** 首次实收和没有原实收凭据的记录不能借用重核入口。 */ + @Test void firstReceiptOrMissingPreviousReceiptStillRequiresValidOwnPlan() { + row.setStatus("CONFIRMED"); + assertThatThrownBy(()->reservations.requireReceiptCapacity(trip,emptyOwn(),row)).hasMessageContaining("自有货容量"); + row.setStatus("RECEIVED");row.setReceiptJson(null); + assertThatThrownBy(()->reservations.requireReceiptCapacity(trip,emptyOwn(),row)).hasMessageContaining("自有货容量"); + } + + /** 自有货仍存在时必须保持最新计划,不能借重新实收忽略计划失效。 */ + @Test void nonemptyStaleOwnPlanRemainsBlocked() { + var cargo=new FleetExecutionModels.Cargo("DP-1","9","1","902","PLT-902",1,"FBA:ONT8","目的仓"); + var own=new FleetExecutionModels.Planning("TR-1",1L,List.of(cargo),"stale",null,false,null,List.of()); + assertThatThrownBy(()->reservations.requireReceiptCapacity(trip,own,row)).hasMessageContaining("自有货容量"); + } + + /** 其他承运仍占位,不能把空自有货解释为清空整车预留。 */ + @Test void otherConfirmedReservationsStillConsumeCapacity() throws Exception { + var other=booking(3L,2);other.setStatus("CONFIRMED");when(bookings.list(2L)).thenReturn(List.of(row,other)); + assertThatThrownBy(()->reservations.requireReceiptCapacity(trip,emptyOwn(),row)).isInstanceOf(ResponseStatusException.class); + } + + /** 本票新实测也不得超出当前车辆载重。 */ + @Test void remeasuredExternalCargoStillCannotExceedVehicleWeight() throws Exception { + row.setCargoJson(json.writeValueAsString(new FleetBookingModels.Cargo("FBA:ONT8","测试",1,1, + new BigDecimal("101"),BigDecimal.ONE,"PALLET",BigDecimal.ONE,"USD",null,null,null))); + assertThatThrownBy(()->reservations.requireReceiptCapacity(trip,emptyOwn(),row)).isInstanceOf(ResponseStatusException.class); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index 0d016c685..bc0abf03f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -532,10 +532,11 @@ class FleetTripPersistenceTest { } finally { executor.shutdownNow(); } } - @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) - void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation(boolean automatic) { + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.CsvSource({"false,false","false,true","true,false","true,true"}) + // 同时验证保留自有货与解绑全部自有货后的实收重新核对,避免依靠额外货物解开前置环。 + void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation(boolean automatic,boolean emptyOwn) { var source=enableQuote(); - String number=readyTrip();var basis=execution.capacityPlan(number); + String number=emptyOwn?unpickedPlanningTrip():readyTrip();var basis=execution.capacityPlan(number); execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); var draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create",quoteReference())); var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null)); @@ -595,15 +596,26 @@ class FleetTripPersistenceTest { assertThat(renewed.booking().cargo().packageCount()).isEqualTo(22); assertThat(bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",receiptAgain).replayed()).isTrue(); assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); + if(emptyOwn) { + for(var binding:service.detail(number).dispatches()) + service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(service.detail(number).trip().recordVersion(),binding.dispatchNo(),"重新核对前解除全部实际排车")); + assertThat(service.detail(number).dispatches()).isEmpty(); + assertThat(execution.capacityPlan(number).blockReason()).contains("关联本车自有货"); + } + long movedTripVersion=service.detail(number).trip().recordVersion(); // 实物换托后源摘要变化且旧测量失效;缺测量拒绝,显式实测后更新快照及容量。 var moved=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","READY_FOR_DISPATCH","moved-receipt", List.of(new FleetBookingIncomingGateway.Pallet("903","PALLET-903",22,null,null)),22); when(incoming.inspect("9","WT-EXT",true)).thenReturn(moved); when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(moved); - var withoutMeasure=new FleetBookingModels.AcceptReceipt(renewed.tripRecordVersion(),renewed.booking().recordVersion(),moved.receiptHash(), + var withoutMeasure=new FleetBookingModels.AcceptReceipt(movedTripVersion,renewed.booking().recordVersion(),moved.receiptHash(), List.of(new FleetBookingModels.PalletSpaces("903",1,null,null)),new BigDecimal("55.00"),"moved-confirm"); assertThatThrownBy(()->bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withoutMeasure)).hasMessageContaining("实际托盘"); - var withMeasure=new FleetBookingModels.AcceptReceipt(renewed.tripRecordVersion(),renewed.booking().recordVersion(),moved.receiptHash(), + var overCapacity=new FleetBookingModels.AcceptReceipt(movedTripVersion,renewed.booking().recordVersion(),moved.receiptHash(), + List.of(new FleetBookingModels.PalletSpaces("903",31,new BigDecimal("170"),new BigDecimal("1.7"))),new BigDecimal("55.00"),"moved-over-capacity"); + assertThatThrownBy(()->bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",overCapacity)).isInstanceOf(ResponseStatusException.class); + assertThat(bookingService.list(number).records().get(0).receipt()).isEqualTo(renewed.booking().receipt()); + var withMeasure=new FleetBookingModels.AcceptReceipt(movedTripVersion,renewed.booking().recordVersion(),moved.receiptHash(), List.of(new FleetBookingModels.PalletSpaces("903",1,new BigDecimal("170"),new BigDecimal("1.7"))),new BigDecimal("55.00"),"moved-confirm"); var measuredAgain=bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withMeasure); assertThat(measuredAgain.booking().cargo().weightKg()).isEqualByComparingTo("170"); @@ -1147,6 +1159,15 @@ class FleetTripPersistenceTest { assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); } + /** 未拣货的真实自有货计划,允许通过正式命令解绑全部排车。 */ + private String unpickedPlanningTrip() { + String number=assignedTrip();dispatch(11,"CLIENT-A","92");dispatch(12,"CLIENT-B","92"); + service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(1L,"DP-11","加入")); + service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(2L,"DP-12","加入")); + for(long id:List.of(11L,12L)) + jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id) VALUES(?,?,?,901,'PALLET-901',10,'RECEIVED','ONT8','92')",id+3000,id,id==11?"CLIENT-A":"CLIENT-B"); + return number; + } private String readyTrip() { String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B"); diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs index b7aab25aa..d3ab96a18 100644 --- a/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs @@ -188,8 +188,13 @@ function mixedFixture(fault = '') { const samples = [1, 2].map(i => ({ displayKind: i === 1 ? 'ORDINARY' : 'EXTERNAL', dispatchNo: i === 1 ? 'DP-N' : 'DP-E', ordinaryInboundCode: `IN-N${i}`, externalInboundCode: `IN-E${i}`, bookingNo: `BK${i}`, palletId: `30${i}` })) const ctx = context((method, path) => { - if (path.includes('/inventory/balances') && !path.includes('/positions')) return ok({ total: 4, - records: [1, 2].flatMap(i => ['N', 'E'].map(kind => ({ id: `${kind}${i}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${kind}${i}` }))) }) + if (path.includes('/inventory/balances') && !path.includes('/positions')) { + const inboundCode = new URL(path, 'https://test.invalid').searchParams.get('inboundCode') + const all = [1, 2].flatMap(i => ['N', 'E'].map(kind => ({ id: `${kind}${i}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${kind}${i}` }))) + // Without source filtering equal-SKU groups expose only the first source's representative balance IDs. + const records = inboundCode ? all.filter(row => row.inboundCode === inboundCode) : all.filter(row => row.id.endsWith('1')) + return ok({ total: records.length, records }) + } if (path.includes('/positions')) { const balance = path.match(/balances\/([NE][12])\//)[1], kind = balance[0], i = balance[1] return ok([{ id: `POS-${balance}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${balance}`, @@ -208,6 +213,7 @@ test('independent mixed display permutations prove per-source packages and comme const f = mixedFixture(), result = await execute(f.ctx, { mixedSources: f.fixture }) assert.equal(result.cases.mixedSources.status, 'PASS') assert.deepEqual(result.cases.mixedSources.evidence.map(x => x.expectedSources.map(y => y.packages)), [[3, 2], [3, 2]]) + assert.deepEqual(f.ctx.calls.filter(x => x.path.includes('/inventory/balances?')).map(x => new URL(x.path, 'https://test.invalid').searchParams.get('inboundCode')), ['IN-N1', 'IN-E1', 'IN-N2', 'IN-E2']) }) for (const fault of ['duplicate-sample', 'wrong-display', 'wrong-component', 'wrong-quantity', 'wrong-amount']) test(`mixed source ${fault} cannot pass`, async () => { const f = mixedFixture(fault) diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs index ef726f6a6..7e1d42e75 100644 --- a/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs @@ -309,8 +309,11 @@ export async function verifyDispatchRepair(context, fixtures, { execute = false const displayed = current.items?.find(row => id(row.warehousePalletId) === id(sample.palletId)) const expectedDisplay = sample.displayKind === 'ORDINARY' ? normal.key : external.key must(displayed?.inboundCode === expectedDisplay && displayed.palletNo && id(current.overseasWarehouseId) === id(f.warehouseId), 'Displayed source does not match its independently produced ordinary/external origin') - const balances = (await pages(`${BASE}/inventory/balances?${new URLSearchParams({ customerId: f.customerId, overseasWarehouseId: f.warehouseId })}`)) - .filter(row => id(row.customerId) === id(f.customerId) && id(row.overseasWarehouseId) === id(f.warehouseId)) + // The list groups equal SKUs across inbounds; constrain each real source before taking representative balance IDs. + const sourceBalances = [] + for (const inboundCode of [normal.key, external.key]) sourceBalances.push(...await pages(`${BASE}/inventory/balances?${new URLSearchParams({ customerId: f.customerId, overseasWarehouseId: f.warehouseId, inboundCode })}`)) + const balances = [...new Map(sourceBalances.filter(row => id(row.customerId) === id(f.customerId) + && id(row.overseasWarehouseId) === id(f.warehouseId)).map(row => [id(row.id), row])).values()] const quantities = new Map(), positions = [] for (const balance of balances) { const rows = await call('GET', `${BASE}/inventory/balances/${encodeURIComponent(id(balance.id))}/positions?${new URLSearchParams({ customerId: f.customerId, palletNo: displayed.palletNo })}`)