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
@@ -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: