diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 736accc84..d3cb48671 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -206,11 +206,36 @@ public class FleetTripService { try { if (bindings.insert(binding) != 1) throw conflict("排车单关联失败"); } catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); } bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator); + var route = matchedStops(response(trip).stops(), dispatches.manifest(dispatch.dispatchNo(), false).cargo()); + validatePlan(instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()), route); + bookingReservations.requireRoute(trip, route); + trip.setStopsJson(write(route)); dispatches.assign(dispatch, assignment(trip)); persist(trip, request.recordVersion(), "BIND_DISPATCH", request.reason(), operator); return detail(trip); } + static List matchedStops(List existing, List cargo) { + // Keep established stop indexes/order: confirmed bookings may refer to them. + var result = new ArrayList<>(existing); + boolean onlyPlaceholder = existing.size() == 2 && existing.get(1).destinationKey() == null + && existing.get(1).name().startsWith("待确定卸货点"); + if (onlyPlaceholder) result.remove(1); + var keys = new HashSet(); + for (var stop : result) if (stop.destinationKey() != null) keys.add(stop.destinationKey()); + for (var item : cargo) { + if (item.destinationKey() == null || item.destinationKey().isBlank()) + throw conflict("排车货物目的地未确认,请先完善排车单目的地"); + if (keys.add(item.destinationKey())) { + if (item.destinationLabel() == null || item.destinationLabel().isBlank()) + throw conflict("排车货物目的地名称缺失,请先完善排车单"); + result.add(new FleetTripModels.Stop(item.destinationLabel(), null, item.destinationKey())); + } + } + if (result.size() < 2) throw conflict("排车单没有可用卸货目的地"); + return List.copyOf(result); + } + @Transactional public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { var trip = planned(tripNo, request.recordVersion()); diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index 6ff58f439..66262cf51 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -220,7 +220,7 @@ paths: parameters: [{$ref: '#/components/parameters/TripNo'}] post: summary: 将客户排车单关联到车次 - description: '权限:platform:fleet:trip:edit;按业务单号解析客户归属,同仓且未开工;关联和车/司机快照同步原子提交。' + description: '权限:platform:fleet:trip:edit;按业务单号解析客户归属,同仓且未开工;关联、车/司机快照同步及按真实货物目的地标识补齐卸货站原子提交;目的地缺失拒绝关联,已有有效站点顺序与索引保留,初始默认占位站替换为真实目的地。' requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindingCommand'}}}} responses: {'200': {$ref: '#/components/responses/Detail'}} /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatches/unbind: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripMatchedStopsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripMatchedStopsTest.java new file mode 100644 index 000000000..b2453115b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripMatchedStopsTest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.fleet.trip; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +class FleetTripMatchedStopsTest { + private FleetExecutionModels.Cargo cargo(String key, String label) { + return new FleetExecutionModels.Cargo("DP-1", "A", "1", "2", "PLT-1", 1, key, label); + } + @Test void replacesOnlyInitialPlaceholderAndDeduplicatesByIdentity() { + var origin = new FleetTripModels.Stop("起运仓", null, null); + var result = FleetTripService.matchedStops(List.of(origin, new FleetTripModels.Stop("待确定卸货点1", null, null)), + List.of(cargo("FBA:ACY2", "ACY2"), cargo("FBA:ACY2", "ACY2"))); + assertThat(result).containsExactly(origin, new FleetTripModels.Stop("ACY2", null, "FBA:ACY2")); + } + @Test void preservesExistingStopIndexesAndAppendsDifferentIdentityEvenWhenNamesMatch() { + var existing = List.of(new FleetTripModels.Stop("起点", null, null), new FleetTripModels.Stop("ACY2", null, "FBA:ACY2")); + var result = FleetTripService.matchedStops(existing, List.of(cargo("PRIVATE:OTHER", "ACY2"))); + assertThat(result.subList(0,2)).isEqualTo(existing); + assertThat(result.get(2).destinationKey()).isEqualTo("PRIVATE:OTHER"); + } + @Test void rejectsMissingIdentityWithoutUsingDisplayNameAsKey() { + assertThatThrownBy(() -> FleetTripService.matchedStops(List.of(new FleetTripModels.Stop("起点", null, null)), List.of(cargo(null,"ACY2")))) + .hasMessageContaining("目的地未确认"); + } +}