From 849f8dc5f2845cb086009eff6afed235979b6087 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 29 Oct 2026 14:29:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lastleg-service/AGENTS.md | 2 +- lastleg-service/docs/business-index.md | 2 +- .../service/DispatchCompletionService.java | 7 ++- .../lastleg/fleet/driver/DriverModels.java | 6 +-- .../fleet/pickup/FleetPickupService.java | 2 +- .../trip/FleetTripCompletionService.java | 17 ++++++- .../lastleg/fleet/trip/FleetTripService.java | 19 ++++++++ .../resources/openapi/fleet-trips-v1.yaml | 6 +-- .../DispatchCompletionPersistenceTest.java | 4 +- .../fleet/driver/DriverAuthorizationTest.java | 1 + .../fleet/driver/DriverPersistenceTest.java | 10 ++--- .../fleet/driver/DriverQueryBindingTest.java | 45 +++++++++++++++++++ .../fleet/trip/FleetTripPersistenceTest.java | 35 +++++++++++---- 13 files changed, 129 insertions(+), 27 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverQueryBindingTest.java diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index e56fbceba..003891a52 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -79,7 +79,7 @@ | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | | 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 | | 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 | -| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 | +| 车次货物结案、车辆和司机自动释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | | 测试 | `src/test/java/com/baiyue/lastleg/` | diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index dd250af30..0c47a8bcc 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -100,7 +100,7 @@ - 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 - 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。 - 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 -- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 +- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。全车正常派送完成自动结案,结案不改客户状态/费用;结案与车/司机释放占用同事务,提货全部到仓、本土卡车完成也直接释放,不需二次确认。自动释放记录运输完成时刻及路线终点,保留车辆主档状态。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 - 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 - 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。 - 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java index 918b346ec..78daacf14 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java @@ -27,10 +27,13 @@ public class DispatchCompletionService implements DispatchCompletionPort { private final DispatchDeliveryPort delivery; private final DispatchSignalPort signals; private final ObjectMapper json; + private final com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion; /** 仅依赖共同端口,禁止持签收子锁回调父流程。 */ public DispatchCompletionService(DispatchAggregateRepository repository,DispatchExecutionRepository reads,FleetTripExecutionService fleet, - DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json) { + DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json, + com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion) { this.repository=repository;this.reads=reads;this.fleet=fleet;this.execution=execution;this.delivery=delivery;this.signals=signals;this.json=json; + this.completion=completion; } /** 新事务先父锁后普通读全部已提交事实,失败回滚后才由worker记录退避。 */ @Override @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED) @@ -50,6 +53,8 @@ public class DispatchCompletionService implements DispatchCompletionPort { if(repository.history(IdWorker.getId(),row,"NORMAL_DELIVERY_COMPLETED",SYSTEM.operatorId(),null,write(Map.of("dispatch",row,"normalDeliveryFacts",facts)),now)!=1)throw new IllegalStateException("正常签收完成证据未保存"); changed=true; } + // 全部正常签收后直接结案释放,不再等待人工二次确认。 + if (facts.allNormallySigned()) completion.completeDelivered(identity.tripNo(), SYSTEM.operatorId()); // 不完整的合法部分签收也只确认本版本通知,未来签收产生自己的新信号。 signals.acknowledge(signal,Instant.now()); return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,facts.allNormallySigned(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java index 91d928def..e27add330 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java @@ -17,12 +17,8 @@ public final class DriverModels { String ownership, String region, String licenseState, @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd, @Size(max = 64) String dispatchNo) { + /** 保留唯一规范构造器供 MVC 绑定,缺省分页使用第一页、每页二十条。 */ public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } - /** 无排车上下文的档案查询保持原有语义。 */ - public Query(Integer page, Integer size, String keyword, Boolean active, String reviewStatus, - String ownership, String region, String licenseState, Instant expectedEnd) { - this(page, size, keyword, active, reviewStatus, ownership, region, licenseState, expectedEnd, null); - } } public record Draft(@NotBlank @Size(max = 128) String name, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java index c7bca9d45..02592b7c0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java @@ -189,7 +189,7 @@ public class FleetPickupService { trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator); repository.command(number,request.idempotencyKey(),hash);return tasks(number); } - /** 全部送仓后结案运输,车辆和司机仍沿原有入口分别结束任务。 */ + /** 全部送仓后结案运输,并在同一事务内自动释放车辆和司机。 */ @Transactional public FleetTripModels.Detail complete(String number,String operator,FleetPickupModels.Command request) { var trip=pickupTrip(number,true);String hash=commandHash("COMPLETE",request); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java index 94bd1ecf9..a93b386b5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java @@ -58,11 +58,26 @@ public class FleetTripCompletionService { Instant completedAt = inspection.outcomes().stream().map(FleetCompletionModels.Outcome::terminalAt).max(Instant::compareTo).orElseThrow(); trip.setStatus("COMPLETED"); trip.setCompletedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC)); trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(), hash, completedAt, Instant.now(), operator, inspection.outcomes()))); - // Completion freezes cargo outcomes. It deliberately does not release a driver still returning. + // 结案保存全部货物事实,并由统一执行入口同时释放车辆和司机。 history.recordExecution(trip, request.recordVersion(), "COMPLETE", request.reason(), operator); return history.detail(tripNo); } + /** 正常派送汇总后重核全车终态,未全部履约时继续保留占用。 */ + @Transactional + public void completeDelivered(String tripNo, String operator) { + var trip = required(tripNo, true); + if ("COMPLETED".equals(trip.getStatus())) { + if (trip.getVehicleReleaseJson() == null || trip.getDriverReleaseJson() == null) + history.recordExecution(trip, trip.getRecordVersion(), "AUTO_RELEASE", "派送完成自动释放", operator); + return; + } + var inspection = inspect(trip, true); + if (!inspection.canComplete()) return; + complete(tripNo, operator, new FleetCompletionModels.Complete(trip.getRecordVersion(), + "AUTO_COMPLETE:" + tripNo, "全部派送完成自动结案")); + } + @Transactional public FleetTripModels.Detail release(String tripNo, String operator, FleetCompletionModels.Release request) { var trip = required(tripNo, true); 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 68104f112..26424b07a 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 @@ -681,8 +681,27 @@ public class FleetTripService { /** 在调用方已有事务内保存执行事实及对应历史。 */ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) { + // 完成运输与释放占用同事务保存,任一步失败均不得留下半完成状态。 + if ("COMPLETED".equals(trip.getStatus())) releaseCompletedResources(trip, operator); persist(trip, version, action, reason, operator); } + /** 仅删除本车次占用;保留车辆主档状态及已有释放事实,不影响后续车次。 */ + private void releaseCompletedResources(FleetTripEntity trip, String operator) { + if (trip.getCompletedAt() == null || trip.getCompletionJson() == null) throw conflict("缺少运输完成依据"); + var stops = read(trip.getStopsJson(), new TypeReference>() {}); + String location = stops.isEmpty() ? null : stops.get(stops.size()-1).name(); + if (location == null || location.isBlank()) throw conflict("缺少运输终点"); + for (String kind : List.of("VEHICLE", "DRIVER")) { + boolean vehicle = "VEHICLE".equals(kind); + if ((vehicle ? trip.getVehicleReleaseJson() : trip.getDriverReleaseJson()) != null) continue; + Long id = vehicle ? trip.getVehicleId() : trip.getDriverId(); + if (id != null) repository.release(kind, id, trip.getId()); + String key = "AUTO_COMPLETE:" + trip.getTripNo() + ":" + kind; + var released = new FleetCompletionModels.Released(key, hash(key), instant(trip.getCompletedAt()), + location, null, operator, clock.instant()); + if (vehicle) trip.setVehicleReleaseJson(write(released)); else trip.setDriverReleaseJson(write(released)); + } + } /** 按指定模型解析历史 JSON,损坏记录明确报错。 */ private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } } /** 按显式集合类型读取资源站点,禁止损坏数据回退为假路线。 */ 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 b45c9d12b..9ea274c36 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -11,7 +11,7 @@ info: 车辆和司机资格、容量与实际占用由服务端复验;候选可用不表示已预留,临时外配证据依排车主单规则核验。 调度审核后经理货审核才可真实扫码;PDA拣货核平后,平台和PDA均可确认装车完成及实际发车。 发车前整车派送安排须完整,正常签收汇总推进排车派送完成;既有异常业务和附加资源能力保留。 - 货物结案不自动释放车辆或司机;各资源任务分别提交实际结束时间、地点和原因,车辆另确认结束状态。 + 全车派送完成自动结案并释放车辆和司机,不需要二次确认;提货全部到仓及本土卡车完成同事务释放,保留车辆主档状态。 版本、幂等、客户隔离及事务要求以各实际端点及排车公共契约为准;历史数量不授权当前分配。 security: - bearerAuth: [] @@ -290,14 +290,14 @@ paths: parameters: [{$ref: '#/components/parameters/TripNo'}] post: summary: 核对全部货物并结案车次 - description: '权限:platform:fleet:trip:complete;仅IN_TRANSIT,实发数量由全部已签收或已真实退回派送单覆盖;拒收、待退回、取消不算终态完成。不改写客户单、不新增收费、不释放资源;同键同内容重放,旧版本或不同内容拒绝。' + description: '权限:platform:fleet:trip:complete;仅IN_TRANSIT,实发数量由全部已签收或已真实退回派送单覆盖;拒收、待退回、取消不算终态完成。不改写客户单、不新增收费、同事务自动释放车辆和司机;同键同内容重放,旧版本或不同内容拒绝。' requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Complete'}}}} responses: {'200': {$ref: '#/components/responses/Detail'}} /api/platform/v1/last-leg/fleet/trips/{tripNo}/resources/release: parameters: [{$ref: '#/components/parameters/TripNo'}] post: summary: 单独结束车辆或司机本次任务并释放占用 - description: '权限:platform:fleet:trip:release;货物结案后才允许;实际结束时间不早于货物完成且不晚于当前时间,地点及原因必填。车辆状态与释放同事务更新;司机可继续返程而保持占用。原请求重放不释放后来另一个车次的占用。' + description: '权限:platform:fleet:trip:release;货物结案后才允许;实际结束时间不早于货物完成且不晚于当前时间,地点及原因必填。该入口仅保留处理未释放记录,当前完成流程自动释放,不要求二次确认。原请求重放不释放后来另一个车次的占用。' requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Release'}}}} responses: {'200': {$ref: '#/components/responses/Detail'}} components: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionPersistenceTest.java index 5f60b8a8f..17741130f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionPersistenceTest.java @@ -28,6 +28,7 @@ class DispatchCompletionPersistenceTest { assertThat(changed.changed()).isTrue();assertThat(changed.stage()).isEqualTo(Stage.DELIVERED); assertThat(repeated.changed()).isFalse();assertThat(repeated.recordVersion()).isEqualTo(changed.recordVersion()); assertThat(fixture.completedHistory()).isOne(); + verify(fixture.completion,atLeastOnce()).completeDelivered(anyString(),eq("SYSTEM_DELIVERY_RECONCILE")); assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); } /** 部分签收只确认本信号,不把异常或返回数凑作全签收;后续版本独立触发完成。 */ @@ -44,6 +45,7 @@ class DispatchCompletionPersistenceTest { private static final class CompletionFixture { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);final DispatchSignalPort signals=mock(DispatchSignalPort.class); + final com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion=mock(com.baiyue.lastleg.fleet.trip.FleetTripCompletionService.class); final DispatchCompletionPort port;final String number;final Signal signal;final Instant signedAt=Instant.now().minusSeconds(5); /** 使用真实Spring事务代理,REQUIRES_NEW与回滚均按生产注解执行。 */ CompletionFixture() throws Exception { @@ -51,7 +53,7 @@ class DispatchCompletionPersistenceTest { f.picked(1,"101",501,3);f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.ofInstant(signedAt.minusSeconds(60),ZoneOffset.UTC)); f.db.execute("CREATE TABLE completion_test_signal(id BIGINT PRIMARY KEY,state VARCHAR(32))");f.db.update("INSERT INTO completion_test_signal VALUES(1,'PENDING')"); signal=new Signal(1L,number,"DELIVERY-A",1,0,Instant.now()); - var target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json); + var target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json,completion); var proxy=new ProxyFactory(target);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource()));port=(DispatchCompletionPort)proxy.getProxy(); } /** 父事务中的信号SQL参与同一个DataSource事务。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverAuthorizationTest.java index 57dcffe8a..c3cc3ba0b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverAuthorizationTest.java @@ -55,6 +55,7 @@ class DriverAuthorizationTest { @Configuration @EnableMethodSecurity static class Config { + @Bean com.baiyue.lastleg.fleet.port.DriverAvailability availability() { return mock(com.baiyue.lastleg.fleet.port.DriverAvailability.class); } @Bean DriverService service() { return mock(DriverService.class); } @Bean PlatformDriverController controller(DriverService service) { return new PlatformDriverController(service); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverPersistenceTest.java index 3b6bd58fa..aaa928279 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverPersistenceTest.java @@ -72,7 +72,7 @@ class DriverPersistenceTest { var result = service.page(new DriverModels.Query(1,1,null,null,null,null,null,null,null,"PC1")); assertThat(result.total()).isEqualTo(1); assertThat(result.records()).extracting(DriverModels.Response::id).containsExactly(own.id()); - assertThat(service.page(new DriverModels.Query(1,20,null,null,null,null,null,null,null)).total()).isEqualTo(2); + assertThat(service.page(new DriverModels.Query(1,20,null,null,null,null,null,null,null, null)).total()).isEqualTo(2); } @Test void historyFailureRollsBackDriverStatusAndVersion() { @@ -126,16 +126,16 @@ class DriverPersistenceTest { service.create("operator", eastern); service.create("operator", western); service.create("operator", draft(null)); - var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null)); + var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null, null)); assertThat(all.total()).isEqualTo(3); assertThat(all.records()).hasSize(1); assertThat(all.summary()).isEqualTo(new DriverModels.Summary(3, 2, 1, 1, 1)); - var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null)); + var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null, null)); assertThat(expired.records()).extracting(row -> row.details().name()).containsExactly("东侧日期"); assertThat(expired.summary().expired()).isEqualTo(1); - var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null)); + var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null, null)); assertThat(expiring.records()).extracting(DriverModels.Response::licenseState).containsExactly("EXPIRING"); - var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null)); + var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null, null)); assertThat(missing.total()).isEqualTo(1); assertThat(missing.summary().expiringSoon()).isZero(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverQueryBindingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverQueryBindingTest.java new file mode 100644 index 000000000..5dea55f17 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverQueryBindingTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.fleet.driver; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.time.Instant; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 经真实 MVC 参数绑定验证列表入口,不能用直接调用 Service 代替。 */ +class DriverQueryBindingTest { + @Test void emptyListQueryBindsDefaults() throws Exception { + var service = mock(DriverService.class); + var mvc = MockMvcBuilders.standaloneSetup(new PlatformDriverController(service)).build(); + mvc.perform(get("/api/platform/v1/last-leg/fleet/drivers")).andExpect(status().isOk()); + var argument = ArgumentCaptor.forClass(DriverModels.Query.class); + verify(service).page(argument.capture()); + assertThat(argument.getValue().page()).isEqualTo(1); + assertThat(argument.getValue().size()).isEqualTo(20); + assertThat(argument.getValue().dispatchNo()).isNull(); + } + + @Test void dispatchCandidatesBindFiltersAndExpectedEnd() throws Exception { + var service = mock(DriverService.class); + var mvc = MockMvcBuilders.standaloneSetup(new PlatformDriverController(service)).build(); + mvc.perform(get("/api/platform/v1/last-leg/fleet/drivers") + .param("page", "2").param("size", "100").param("keyword", "司机") + .param("active", "true").param("reviewStatus", "APPROVED") + .param("region", "OVERSEAS").param("dispatchNo", "PC1") + .param("expectedEnd", "2026-10-30T12:00:00Z")).andExpect(status().isOk()); + var argument = ArgumentCaptor.forClass(DriverModels.Query.class); + verify(service).page(argument.capture()); + var query = argument.getValue(); + assertThat(query.page()).isEqualTo(2); + assertThat(query.size()).isEqualTo(100); + assertThat(query.keyword()).isEqualTo("司机"); + assertThat(query.active()).isTrue(); + assertThat(query.reviewStatus()).isEqualTo("APPROVED"); + assertThat(query.region()).isEqualTo("OVERSEAS"); + assertThat(query.dispatchNo()).isEqualTo("PC1"); + assertThat(query.expectedEnd()).isEqualTo(Instant.parse("2026-10-30T12:00:00Z")); + } +} 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 0d0198ac6..ac44c6a7f 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 @@ -48,21 +48,34 @@ class FleetTripPersistenceTest { assertThatThrownBy(()->f.tx.execute(s->f.nativeFleetService.updatePlan(trip(f),"operator",new FleetTripModels.Plan(detail.trip().recordVersion(),Instant.now().plusSeconds(60),Instant.now().plusSeconds(120),List.of(new FleetTripModels.Stop("起点",null,null),new FleetTripModels.Stop("终点",null,"FBA:ONT8")),null,"改计划")))).hasMessageContaining("排车主单"); assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isZero(); } - @Test void externalCargoCompletionAndTwoTaskEndsAreIndependentAndReplayable() throws Exception { + @Test void completionReleasesBothResourcesAndReplayKeepsLaterOccupancy() throws Exception { var scenario=new CompletedCargo();var f=scenario.f;var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isTrue(); var request=new FleetCompletionModels.Complete(inspection.recordVersion(),"complete-original","全部货物实际履约"); var complete=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(complete.trip().status()).isEqualTo("COMPLETED"); FleetTripModels.Detail recovered=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(recovered).isEqualTo(complete); - assertThat(complete.trip().vehicleRelease()).isNull();assertThat(complete.trip().driverRelease()).isNull(); - var end=Instant.now().minusSeconds(10);var vehicle=new FleetCompletionModels.Release(complete.trip().recordVersion(),"VEHICLE",end,"实际交车点","RETURNED","vehicle-end-original","外配车实际结束"); - var released=f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle));assertThat(released.trip().vehicleRelease()).isNotNull();assertThat(released.trip().driverRelease()).isNull(); - var driver=new FleetCompletionModels.Release(released.trip().recordVersion(),"DRIVER",end,"实际任务终点",null,"driver-end-original","司机实际结束"); - var finished=f.tx.execute(s->scenario.completion.release(trip(f),"operator",driver));assertThat(finished.trip().driverRelease()).isNotNull(); - assertThat(f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle)).trip().recordVersion()).isEqualTo(finished.trip().recordVersion()); - assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();verifyNoInteractions(scenario.vehicleService); + assertThat(complete.trip().vehicleRelease()).isNotNull();assertThat(complete.trip().driverRelease()).isNotNull(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero(); + f.db.update("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES('DRIVER',2,999,'NEXT-TRIP')"); + f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;}); + assertThat(f.db.queryForObject("SELECT trip_no FROM ll_fleet_resource_assignment WHERE resource_kind='DRIVER' AND resource_id=2",String.class)).isEqualTo("NEXT-TRIP"); + verifyNoInteractions(scenario.vehicleService); + } + /** 自动完成与资源占用、历史共同回滚,重试才真正释放。 */ + @Test void automaticDeliveryCompletionRollsBackWithItsCallerAndRetries() throws Exception { + var scenario=new CompletedCargo();var f=scenario.f; + assertThatThrownBy(()->f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");throw new IllegalStateException("模拟后续失败");})).isInstanceOf(IllegalStateException.class); + assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT"); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2); + f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;}); + var result=f.nativeFleetService.detail(trip(f)); + assertThat(result.trip().status()).isEqualTo("COMPLETED"); + assertThat(result.trip().vehicleRelease()).isNotNull();assertThat(result.trip().driverRelease()).isNotNull(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero(); } @Test void partialSignatureCannotCloseCargoOrEndExternalTasks() throws Exception { var scenario=new CompletedCargo();var f=scenario.f;f.db.update("UPDATE ll_delivery_order SET signed_at=NULL,signed_packages=NULL,status='DELIVERING'"); + f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;}); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2); var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isFalse(); assertThatThrownBy(()->f.tx.execute(s->scenario.completion.complete(trip(f),"operator",new FleetCompletionModels.Complete(inspection.recordVersion(),"partial-cannot-close","未全签")))).isInstanceOf(RuntimeException.class); assertThatThrownBy(()->f.tx.execute(s->scenario.completion.release(trip(f),"operator",new FleetCompletionModels.Release(inspection.recordVersion(),"DRIVER",Instant.now().minusSeconds(1),"未结束",null,"cannot-end-driver","尚未履约")))).isInstanceOf(RuntimeException.class); @@ -81,6 +94,12 @@ class FleetTripPersistenceTest { f=new Fixture();var created=f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-completion",List.of(f.source(1,"101",501,"ONT8",3)))));f.approve(created);f.picked(1,"101",501,3); f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10)); f.db.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT',departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10)); + // 为自动释放建立真实占用行,避免只验证没有资源的外配车次。 + var drivers=(com.baiyue.lastleg.fleet.driver.DriverService)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"drivers"); + when(drivers.detail(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.any())).thenReturn(mock(com.baiyue.lastleg.fleet.driver.DriverModels.Response.class)); + f.db.update("UPDATE ll_fleet_trip SET vehicle_id=1,driver_id=2"); + f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'VEHICLE',1,id,trip_no FROM ll_fleet_trip"); + f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'DRIVER',2,id,trip_no FROM ll_fleet_trip"); var root=f.repository.find(created.dispatchNo());var outbound=new OutboundOrder();outbound.setId(600L);outbound.setCustomerId("101");outbound.setOutboundNo("OB-REAL");outbound.setDispatchOrderId(root.getId());outbound.setOverseasWarehouseId("9");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(3);f.session.getMapper(OutboundOrderMapper.class).insert(outbound); var delivery=new DeliveryOrder();delivery.setId(700L);delivery.setCustomerId("101");delivery.setDeliveryNo("DL-REAL");delivery.setDispatchOrderId(root.getId());delivery.setOutboundOrderId(600L);delivery.setOverseasWarehouseId("9");delivery.setStatus("DELIVERED");delivery.setPackageCount(3);delivery.setSignedPackages(3);delivery.setSignedAt(LocalDateTime.now().minusMinutes(5));delivery.setVersion(0L);delivery.setDestinationType("FBA");delivery.setDestinationWarehouseCode("ONT8");f.session.getMapper(DeliveryOrderMapper.class).insert(delivery); completion=new FleetTripCompletionService(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetTripDispatchMapper.class),f.session.getMapper(FleetResourceMapper.class),(FleetDispatchGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"dispatches"),f.nativeFleetService,mock(VehicleRepository.class),vehicleService,f.json);