fix(lastleg): reconfirm received bookings after releasing all own cargo

This commit is contained in:
2026-10-18 19:53:35 +08:00
parent 2c330bf9df
commit 45fbd6eff8
7 changed files with 139 additions and 13 deletions
@@ -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);
}
}
@@ -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");