This commit is contained in:
2026-10-29 14:29:02 +08:00
parent 1b27d7e6c2
commit 849f8dc5f2
13 changed files with 129 additions and 27 deletions
+1 -1
View File
@@ -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/` |
+1 -1
View File
@@ -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/`。
@@ -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(),
@@ -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,
@@ -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);
@@ -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);
@@ -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<List<FleetTripModels.Stop>>() {});
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> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } }
/** 按显式集合类型读取资源站点,禁止损坏数据回退为假路线。 */
@@ -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:
@@ -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事务。 */
@@ -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); }
}
@@ -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();
}
@@ -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"));
}
}
@@ -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);