发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #30
+2
@@ -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<FleetBookingEntity> listForTrip(long tripId){return bookings.list(tripId);}
|
||||
}
|
||||
|
||||
+17
-2
@@ -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<TripCapacityPolicy.Segment> 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<FleetExecutionModels.Measurement> pallets=releasedReceipt?List.of():own.capacityPlan().pallets();
|
||||
segments(trip,pallets,row.getId(),row);
|
||||
}
|
||||
/** 所有承诺使用同一容量计算,空自有货也必须保留其他承运占位及车辆上限。 */
|
||||
private List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip,List<FleetExecutionModels.Measurement> own,
|
||||
Long excludedId,FleetBookingEntity addition) {
|
||||
var stops=stops(trip); var loads=new ArrayList<TripCapacityPolicy.Booking>();
|
||||
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());}
|
||||
|
||||
+1
-2
@@ -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());
|
||||
|
||||
+80
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
-5
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })}`)
|
||||
|
||||
Reference in New Issue
Block a user