feat(fleet): integrate costs quotes operations and in-transit handovers

This commit is contained in:
2026-10-03 12:38:04 +08:00
parent 274ecac474
commit cb6c54ffe5
107 changed files with 7304 additions and 42 deletions
@@ -25,6 +25,10 @@ public interface FleetDispatchGateway {
void assign(Snapshot dispatch, Assignment assignment);
FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock);
FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock);
/** Actual departure/terminal evidence for an in-transit handover; does not replay departure. */
com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock);
/** Updates only unfinished delivery resource snapshots; departure and financial facts stay immutable. */
void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment);
FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator,
String idempotencyKey, String remark, Instant departedAt);
}
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.fleet.trip;
import java.util.*;
/** Cost weights come only from the immutable physical departure, never a reservation or current inventory. */
final class FleetTripCostBasis {
private FleetTripCostBasis() { }
record Share(String businessNo, String sourceType, String dispatchNo, long palletSpaces) { }
record Basis(String manifestHash, List<Share> shares, String blockReason) { }
static Basis from(FleetTripModels.AuditSnapshot snapshot) {
if (snapshot == null || snapshot.confirmedLoad() == null) return blocked("尚无实际发车装载记录");
var load = snapshot.confirmedLoad();
if (load.pallets() == null || load.pallets().isEmpty() || load.cargo() == null || load.cargo().isEmpty())
return blocked("发车装载记录缺少实运托盘或货物");
var measured = new HashMap<String, FleetExecutionModels.Measurement>();
for (var pallet : load.pallets()) {
if (pallet.palletSpaces() < 1 || measured.put(pallet.warehousePalletId(), pallet) != null)
return blocked("发车实测托位缺失或托盘重复");
}
var cargoByPallet = new HashMap<String, List<FleetExecutionModels.Cargo>>();
for (var cargo : load.cargo()) cargoByPallet.computeIfAbsent(cargo.warehousePalletId(), ignored -> new ArrayList<>()).add(cargo);
if (!measured.keySet().equals(cargoByPallet.keySet())) return blocked("发车货物与实测托盘未核平");
var external = new HashMap<String, FleetBookingModels.Response>();
if (snapshot.externalBookings() != null) for (var booking : snapshot.externalBookings()) {
if ("DRAFT".equals(booking.status()) || "CANCELLED".equals(booking.status())) continue;
if (!"BOUND".equals(booking.status()) || booking.bookingNo() == null || booking.bookingNo().isBlank()
|| booking.customerId() == null || booking.dispatchNo() == null || booking.dispatchNo().isBlank()
|| booking.receipt() == null || booking.receipt().source() == null)
return blocked("发车外接货缺少实收及排车依据");
var receipt = booking.receipt().source();
if (receipt.pallets() == null || receipt.pallets().isEmpty()) return blocked("发车外接货缺少实收托盘");
long packages = 0;
for (var pallet : receipt.pallets()) {
var cargo = cargoByPallet.get(pallet.warehousePalletId());
if (external.put(pallet.warehousePalletId(), booking) != null || cargo == null
|| cargo.stream().anyMatch(row -> !booking.dispatchNo().equals(row.dispatchNo()) || !booking.customerId().equals(row.customerId()))
|| cargo.stream().mapToLong(FleetExecutionModels.Cargo::packages).sum() != pallet.packages())
return blocked("外接实收与发车托盘归属未核平");
packages += pallet.packages();
}
if (packages != receipt.receivedPackages()) return blocked("外接实收总量与发车托盘未核平");
}
var shares = new TreeMap<String, Share>();
for (var entry : cargoByPallet.entrySet()) {
var owners = entry.getValue().stream().map(FleetExecutionModels.Cargo::dispatchNo).distinct().toList();
if (owners.size() != 1) return blocked("同一实物托盘关联多张排车单,暂不可按托位自动分摊");
var booking = external.get(entry.getKey());
String number = booking == null ? owners.get(0) : booking.bookingNo();
var previous = shares.get(number);
long spaces = measured.get(entry.getKey()).palletSpaces() + (previous == null ? 0 : previous.palletSpaces());
shares.put(number, new Share(number, booking == null ? "OWN" : "EXTERNAL", owners.get(0), spaces));
}
String reason = load.pallets().stream().map(FleetExecutionModels.Measurement::deliveryIndex).distinct().count() == 1
? null : "各票卸货站不同,暂不可按同路线托位自动分摊";
return new Basis(load.manifestHash(), List.copyOf(shares.values()), reason);
}
private static Basis blocked(String reason) { return new Basis(null, List.of(), reason); }
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.PageResult;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class FleetTripCostModels {
private FleetTripCostModels() { }
public record Draft(@NotBlank @Pattern(regexp="FUEL|DRIVER|TOLL|PARKING|OTHER") String category,
@NotBlank @Pattern(regexp="CNY|USD") String currency,
@NotNull @DecimalMin("0") @Digits(integer=12,fraction=2) BigDecimal amount,
@NotNull Instant incurredAt, @Size(max=1000) String remark) { }
public record Create(@NotBlank @Size(min=8,max=128) String idempotencyKey, @NotNull @Valid Draft details) { }
public record Update(@NotNull @Min(0) Long recordVersion, @NotNull @Valid Draft details, @Size(max=1000) String reason) { }
public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) {
public Query { page = page == null ? 1 : page; size = size == null ? 10 : size; }
public long offset() { return ((long) page - 1) * size; }
}
public record Cost(String costNo, String tripNo, Draft details, long recordVersion, Instant updatedAt) { }
public record Allocation(String businessNo, String sourceType, String dispatchNo, long palletSpaces, BigDecimal amount) { }
public record Total(String currency, BigDecimal directCost, List<Allocation> allocations, BigDecimal ownCost, BigDecimal externalCost) { }
public record Summary(Long departureVersion, String manifestHash, String allocationMethod, String allocationBlockReason, List<Total> totals) { }
public record Listing(PageResult<Cost> page, Summary summary) { }
public record Snapshot(Cost cost, Summary summary) { }
public record History(long recordVersion, String action, String reason, Instant occurredAt, Snapshot snapshot) { }
}
@@ -0,0 +1,93 @@
package com.baiyue.lastleg.fleet.trip;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.*;
import java.util.List;
import java.util.Map;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.ArrayList;
@Repository
public class FleetTripCostRepository {
record Row(long id, long tripId, String costNo, FleetTripCostModels.Draft details, long version,
String requestHash, LocalDateTime updatedAt) { }
record Amount(String currency, BigDecimal amount) { }
record Departure(long version, String snapshot) { }
record Audit(long version, String action, String reason, Instant occurredAt, String snapshot) { }
private static final RowMapper<Row> ROW = (r, ignored) -> new Row(r.getLong("id"), r.getLong("trip_id"), r.getString("cost_no"),
new FleetTripCostModels.Draft(r.getString("category"), r.getString("currency"), r.getBigDecimal("amount"),
r.getTimestamp("incurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), r.getString("remark")),
r.getLong("record_version"), r.getString("request_hash"), r.getTimestamp("updated_at").toLocalDateTime());
private final JdbcTemplate jdbc;
public FleetTripCostRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; }
Row find(long tripId, String number) {
return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? AND cost_no=?", ROW, tripId, number).stream().findFirst().orElse(null);
}
Row created(long tripId, String operator, String key) {
return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? AND created_by=? AND request_key=?", ROW, tripId, operator, key).stream().findFirst().orElse(null);
}
long count(long tripId) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost WHERE trip_id=?", Long.class, tripId); }
List<Row> page(long tripId, FleetTripCostModels.Query query) {
return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? ORDER BY incurred_at DESC,id DESC LIMIT ? OFFSET ?", ROW, tripId, query.size(), query.offset());
}
List<Amount> totals(long tripId) {
return jdbc.query("SELECT currency,SUM(amount) total FROM ll_fleet_trip_cost WHERE trip_id=? GROUP BY currency ORDER BY currency",
(r, ignored) -> new Amount(r.getString("currency"), r.getBigDecimal("total")), tripId);
}
Departure departure(long tripId) {
var rows = jdbc.query("SELECT record_version,snapshot_json FROM ll_fleet_trip_history WHERE trip_id=? AND action='DEPART'",
(r, ignored) -> new Departure(r.getLong("record_version"), r.getString("snapshot_json")), tripId);
if (rows.size() > 1) throw new IllegalStateException("车次存在重复发车历史,不能计算成本分摊");
return rows.isEmpty() ? null : rows.get(0);
}
Map<String, Long> trips(Collection<String> numbers) {
var result = new LinkedHashMap<String, Long>();
jdbc.query("SELECT trip_no,id FROM ll_fleet_trip WHERE trip_no IN (" + placeholders(numbers.size()) + ")",
(org.springframework.jdbc.core.RowCallbackHandler) r -> result.put(r.getString("trip_no"), r.getLong("id")), numbers.toArray());
return result;
}
Map<Long, Departure> departures(Collection<Long> ids) {
var result = new LinkedHashMap<Long, Departure>();
if (ids.isEmpty()) return result;
jdbc.query("SELECT trip_id,record_version,snapshot_json FROM ll_fleet_trip_history WHERE action='DEPART' AND trip_id IN (" + placeholders(ids.size()) + ")",
(org.springframework.jdbc.core.RowCallbackHandler) r -> {
if (result.put(r.getLong("trip_id"), new Departure(r.getLong("record_version"), r.getString("snapshot_json"))) != null)
throw new IllegalStateException("车次存在重复发车历史,不能计算成本分摊");
}, ids.toArray());
return result;
}
Map<Long, List<Amount>> totals(Collection<Long> ids) {
var result = new LinkedHashMap<Long, List<Amount>>();
if (ids.isEmpty()) return result;
jdbc.query("SELECT trip_id,currency,SUM(amount) total FROM ll_fleet_trip_cost WHERE trip_id IN (" + placeholders(ids.size()) + ") GROUP BY trip_id,currency ORDER BY trip_id,currency",
(org.springframework.jdbc.core.RowCallbackHandler) r -> result.computeIfAbsent(r.getLong("trip_id"), ignored -> new ArrayList<>())
.add(new Amount(r.getString("currency"), r.getBigDecimal("total"))), ids.toArray());
return result;
}
private static String placeholders(int count) { return String.join(",", Collections.nCopies(count, "?")); }
void insert(Row row, String operator, String key) {
jdbc.update("INSERT INTO ll_fleet_trip_cost(id,trip_id,cost_no,category,currency,amount,incurred_at,remark,record_version,request_key,request_hash,created_by,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
row.id(), row.tripId(), row.costNo(), row.details().category(), row.details().currency(), row.details().amount(),
utc(row.details().incurredAt()), row.details().remark(), row.version(), key, row.requestHash(), operator, operator, row.updatedAt(), row.updatedAt());
}
int update(Row row, long expected, String operator) {
return jdbc.update("UPDATE ll_fleet_trip_cost SET category=?,currency=?,amount=?,incurred_at=?,remark=?,record_version=?,updated_by=?,updated_at=? WHERE id=? AND record_version=?",
row.details().category(), row.details().currency(), row.details().amount(), utc(row.details().incurredAt()), row.details().remark(), row.version(), operator, row.updatedAt(), row.id(), expected);
}
void audit(long id, Row row, String operator, String action, String reason, String snapshot) {
jdbc.update("INSERT INTO ll_fleet_trip_cost_history(id,cost_id,record_version,action,reason,operator_id,occurred_at,snapshot_json) VALUES(?,?,?,?,?,?,?,?)",
id, row.id(), row.version(), action, reason, operator, row.updatedAt(), snapshot);
}
long historyCount(long id) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost_history WHERE cost_id=?", Long.class, id); }
List<Audit> history(long id, FleetTripCostModels.Query query) {
return jdbc.query("SELECT * FROM ll_fleet_trip_cost_history WHERE cost_id=? ORDER BY record_version DESC LIMIT ? OFFSET ?",
(r, ignored) -> new Audit(r.getLong("record_version"), r.getString("action"), r.getString("reason"),
r.getTimestamp("occurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), r.getString("snapshot_json")), id, query.size(), query.offset());
}
private static LocalDateTime utc(Instant instant) { return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); }
}
@@ -0,0 +1,143 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.domain.TripCostAllocationPolicy;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.*;
import java.util.*;
@Service
public class FleetTripCostService {
private final FleetTripMapper trips;
private final FleetTripCostRepository costs;
private final ObjectMapper json;
public FleetTripCostService(FleetTripMapper trips, FleetTripCostRepository costs, ObjectMapper json) {
this.trips = trips; this.costs = costs; this.json = json;
}
@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ)
public FleetTripCostModels.Listing list(String tripNo, FleetTripCostModels.Query query) {
var trip = required(tripNo, false);
long count = costs.count(trip.getId());
return new FleetTripCostModels.Listing(new PageResult<>(costs.page(trip.getId(), query).stream().map(row -> response(tripNo, row)).toList(),
query.page(), query.size(), count, pages(count, query.size())), summary(trip));
}
/** At most 100 distinct trips. Missing trips are omitted; existing trips with no costs have empty totals. */
@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ)
public Map<String, FleetTripCostModels.Summary> summaries(Collection<String> tripNos) {
if (tripNos == null || tripNos.size() > 100 || tripNos.stream().anyMatch(no -> no == null || no.isBlank()))
throw error(HttpStatus.BAD_REQUEST, "批量成本查询最多100个有效车次号");
if (tripNos.isEmpty()) return Map.of();
var numbers = new LinkedHashSet<>(tripNos);
var tripsByNo = costs.trips(numbers);
var departures = costs.departures(tripsByNo.values());
var amounts = costs.totals(tripsByNo.values());
var result = new LinkedHashMap<String, FleetTripCostModels.Summary>();
for (var number : numbers) {
var id = tripsByNo.get(number);
if (id != null) result.put(number, summary(departures.get(id), amounts.getOrDefault(id, List.of())));
}
return Collections.unmodifiableMap(result);
}
@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetTripCostModels.History> history(String tripNo, String costNo, FleetTripCostModels.Query query) {
var trip = required(tripNo, false); var row = cost(trip.getId(), costNo); long count = costs.historyCount(row.id());
return new PageResult<>(costs.history(row.id(), query).stream().map(h -> new FleetTripCostModels.History(h.version(), h.action(), h.reason(), h.occurredAt(),
read(h.snapshot(), FleetTripCostModels.Snapshot.class))).toList(), query.page(), query.size(), count, pages(count, query.size()));
}
@Transactional(isolation=Isolation.READ_COMMITTED)
public FleetTripCostModels.Cost create(String tripNo, String operator, FleetTripCostModels.Create request) {
var trip = required(tripNo, true);
String hash = digest(write(canonical(request.details())));
var prior = costs.created(trip.getId(), operator, request.idempotencyKey());
if (prior != null) {
if (!hash.equals(prior.requestHash())) throw error(HttpStatus.CONFLICT, "请求标识已用于其他成本内容");
return response(tripNo, prior);
}
validate(request.details());
var row = new FleetTripCostRepository.Row(IdWorker.getId(), trip.getId(), "TC-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT),
canonical(request.details()), 0, hash, LocalDateTime.now(ZoneOffset.UTC));
costs.insert(row, operator, request.idempotencyKey());
audit(trip, row, operator, "CREATE", "登记本趟直接成本");
return response(tripNo, row);
}
@Transactional(isolation=Isolation.READ_COMMITTED)
public FleetTripCostModels.Cost update(String tripNo, String costNo, String operator, FleetTripCostModels.Update request) {
var trip = required(tripNo, true); var prior = cost(trip.getId(), costNo);
if (prior.version() != request.recordVersion()) throw error(HttpStatus.CONFLICT, "成本记录已变化,请刷新后重试");
validate(request.details());
if (canonical(prior.details()).equals(canonical(request.details()))) return response(tripNo, prior);
var row = new FleetTripCostRepository.Row(prior.id(), prior.tripId(), prior.costNo(), canonical(request.details()), prior.version() + 1, prior.requestHash(), LocalDateTime.now(ZoneOffset.UTC));
if (costs.update(row, prior.version(), operator) != 1) throw error(HttpStatus.CONFLICT, "成本记录已变化,请刷新后重试");
audit(trip, row, operator, "UPDATE", request.reason() == null || request.reason().isBlank() ? "更正本趟直接成本" : request.reason().trim());
return response(tripNo, row);
}
private void audit(FleetTripEntity trip, FleetTripCostRepository.Row row, String operator, String action, String reason) {
costs.audit(IdWorker.getId(), row, operator, action, reason, write(new FleetTripCostModels.Snapshot(response(trip.getTripNo(), row), summary(trip))));
}
private FleetTripCostModels.Summary summary(FleetTripEntity trip) {
return summary(costs.departure(trip.getId()), costs.totals(trip.getId()));
}
private FleetTripCostModels.Summary summary(FleetTripCostRepository.Departure departure, List<FleetTripCostRepository.Amount> amountsByCurrency) {
var basis = FleetTripCostBasis.from(departure == null ? null : read(departure.snapshot(), FleetTripModels.AuditSnapshot.class));
var totals = new ArrayList<FleetTripCostModels.Total>();
for (var total : amountsByCurrency) {
var allocations = new ArrayList<FleetTripCostModels.Allocation>();
BigDecimal own = null, external = null;
if (basis.blockReason() == null) {
var amounts = new HashMap<String, BigDecimal>();
TripCostAllocationPolicy.allocate(total.amount(), total.currency(), basis.shares().stream()
.map(share -> new TripCostAllocationPolicy.Share(share.businessNo(), share.palletSpaces())).toList())
.forEach(part -> amounts.put(part.bookingNo(), part.amount()));
own = BigDecimal.ZERO.setScale(2); external = BigDecimal.ZERO.setScale(2);
for (var share : basis.shares()) {
var amount = amounts.get(share.businessNo());
allocations.add(new FleetTripCostModels.Allocation(share.businessNo(), share.sourceType(), share.dispatchNo(), share.palletSpaces(), amount));
if ("OWN".equals(share.sourceType())) own = own.add(amount); else external = external.add(amount);
}
}
totals.add(new FleetTripCostModels.Total(total.currency(), total.amount(), allocations, own, external));
}
return new FleetTripCostModels.Summary(departure == null ? null : departure.version(), basis.manifestHash(),
basis.blockReason() == null ? "SAME_ROUTE_PALLET_SPACES" : null, basis.blockReason(), totals);
}
private FleetTripEntity required(String number, boolean lock) {
var trip = lock ? trips.lockByNo(number) : trips.findByNo(number);
if (trip == null) throw error(HttpStatus.NOT_FOUND, "车次不存在"); return trip;
}
private FleetTripCostRepository.Row cost(long tripId, String number) {
var row = costs.find(tripId, number); if (row == null) throw error(HttpStatus.NOT_FOUND, "本车次成本记录不存在"); return row;
}
private static FleetTripCostModels.Cost response(String tripNo, FleetTripCostRepository.Row row) {
return new FleetTripCostModels.Cost(row.costNo(), tripNo, row.details(), row.version(), row.updatedAt().toInstant(ZoneOffset.UTC));
}
private static void validate(FleetTripCostModels.Draft draft) {
if (draft.incurredAt().isAfter(Instant.now())) throw error(HttpStatus.BAD_REQUEST, "实际费用时间不能晚于当前时间");
}
private static FleetTripCostModels.Draft canonical(FleetTripCostModels.Draft draft) {
String remark = draft.remark() == null || draft.remark().isBlank() ? null : draft.remark().trim();
return new FleetTripCostModels.Draft(draft.category(), draft.currency(), draft.amount().stripTrailingZeros(),
draft.incurredAt().truncatedTo(java.time.temporal.ChronoUnit.MILLIS), remark);
}
private String write(Object value) {
try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException("成本快照无法保存", error); }
}
private <T> T read(String value, Class<T> type) {
try { return json.readValue(value, type); } catch (JsonProcessingException error) { throw new IllegalStateException("成本依据无法读取", error); }
}
private static String digest(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private static long pages(long count, int size) { return count / size + (count % size == 0 ? 0 : 1); }
private static ResponseStatusException error(HttpStatus status, String text) { return new ResponseStatusException(status, text); }
}
@@ -209,7 +209,8 @@ public class FleetTripExecutionService {
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
if (reason == null) reason = loadingReviewBlockReason(trip);
if (reason != null) throw conflict(reason);
Instant now = Instant.now();
// Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result.
Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
trip.setStatus("DEPARTING");
if (trips.updateById(trip) != 1) throw conflict("车次状态已变化");
var results = new ArrayList<FleetExecutionModels.DepartedDispatch>();
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/platform/v1/last-leg/fleet/trips/{tripNo}/costs")
public class PlatformFleetTripCostController {
private final FleetTripCostService service;
public PlatformFleetTripCostController(FleetTripCostService service) { this.service = service; }
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetTripCostModels.Listing> list(@PathVariable String tripNo, @Valid FleetTripCostModels.Query query) {
return ApiResponse.success(service.list(tripNo, query));
}
@GetMapping("/{costNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetTripCostModels.History>> history(@PathVariable String tripNo, @PathVariable String costNo, @Valid FleetTripCostModels.Query query) {
return ApiResponse.success(service.history(tripNo, costNo, query));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:cost-create')")
public ApiResponse<FleetTripCostModels.Cost> create(@PathVariable String tripNo, @AuthenticationPrincipal AuthenticatedUser user,
@RequestBody @Valid FleetTripCostModels.Create request) {
return ApiResponse.success(service.create(tripNo, user.userId().toString(), request));
}
@PutMapping("/{costNo}")
@PreAuthorize("hasAuthority('platform:fleet:trip:cost-edit')")
public ApiResponse<FleetTripCostModels.Cost> update(@PathVariable String tripNo, @PathVariable String costNo,
@AuthenticationPrincipal AuthenticatedUser user, @RequestBody @Valid FleetTripCostModels.Update request) {
return ApiResponse.success(service.update(tripNo, costNo, user.userId().toString(), request));
}
}
@@ -8,8 +8,12 @@ import java.util.List;
/** Read projection in the Lastleg schema. Never derives vehicle identity from a mutable plate. */
@Mapper
public interface VehicleFulfillmentMapper {
String DEPARTED = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' "
String ORIGINAL_DEPARTURE = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' "
+ "AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))=#{vehicleKey})";
String HANDOVER = "EXISTS (SELECT 1 FROM ll_fleet_trip_handover ho WHERE ho.trip_id=t.id "
+ "AND JSON_UNQUOTE(JSON_EXTRACT(ho.snapshot_json,'$.replacement.vehicleId'))=#{vehicleKey} "
+ "AND JSON_CONTAINS(ho.snapshot_json,CONCAT('{\"customerId\":',JSON_QUOTE(d.customer_id),',\"deliveryNo\":',JSON_QUOTE(d.delivery_no),'}'),'$.carriedDeliveries')=1)";
String DEPARTED = "(" + ORIGINAL_DEPARTURE + " OR " + HANDOVER + ")";
String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b "
+ "ON b.dispatch_id=d.dispatch_order_id AND CAST(b.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128)) "
+ "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")";
@@ -17,7 +21,8 @@ public interface VehicleFulfillmentMapper {
String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))";
String CHANGES = " FROM ll_fleet_trip_history h JOIN ll_fleet_trip t ON t.id=h.trip_id "
+ "JOIN ll_fleet_trip_history p ON p.trip_id=h.trip_id AND p.record_version=h.record_version-1 "
+ "WHERE h.action='ASSIGN' AND " + PREVIOUS_VEHICLE + " IS NOT NULL AND " + PREVIOUS_VEHICLE + " <> 'null' "
+ "LEFT JOIN ll_fleet_trip_handover ho ON ho.trip_id=h.trip_id AND ho.trip_version=h.record_version "
+ "WHERE (h.action='ASSIGN' OR (h.action='HANDOVER' AND ho.id IS NOT NULL)) AND " + PREVIOUS_VEHICLE + " IS NOT NULL AND " + PREVIOUS_VEHICLE + " <> 'null' "
+ "AND (" + PREVIOUS_VEHICLE + "=#{vehicleKey} OR " + NEXT_VEHICLE + "=#{vehicleKey}) "
+ "AND (" + PREVIOUS_VEHICLE + "<>" + NEXT_VEHICLE
+ " OR JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.driverId'))<>JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.driverId')))";
@@ -32,7 +37,8 @@ public interface VehicleFulfillmentMapper {
@Param("offset") long offset, @Param("size") int size);
@Select("SELECT COUNT(*)" + CHANGES)
long countChanges(@Param("vehicleKey") String vehicleKey);
@Select("SELECT t.trip_no,h.record_version,h.occurred_at,h.reason,p.snapshot_json AS before_snapshot,h.snapshot_json AS after_snapshot"
@Select("SELECT t.trip_no,h.record_version,h.occurred_at,h.reason,p.snapshot_json AS before_snapshot,"
+ "CASE WHEN h.action='HANDOVER' THEN JSON_SET(h.snapshot_json,'$.handover',JSON_EXTRACT(ho.snapshot_json,'$')) ELSE h.snapshot_json END AS after_snapshot"
+ CHANGES + " ORDER BY h.occurred_at DESC,h.id DESC LIMIT #{size} OFFSET #{offset}")
List<VehicleFulfillmentModels.ChangeRow> changes(@Param("vehicleKey") String vehicleKey, @Param("offset") long offset, @Param("size") int size);
}
@@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.ZoneOffset;
import java.util.ArrayList;
@Service
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
@@ -37,8 +36,9 @@ public class VehicleFulfillmentService {
try {
JsonNode before = json.readTree(row.beforeSnapshot()).path("trip"), snapshot = json.readTree(row.afterSnapshot()), after = snapshot.path("trip");
if (!before.hasNonNull("vehicleId") || !after.hasNonNull("vehicleId")) throw new IllegalStateException("车辆更换历史缺少身份快照");
var dispatches = new ArrayList<String>();
for (JsonNode binding : snapshot.path("dispatches")) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText());
var dispatches = new java.util.LinkedHashSet<String>();
JsonNode associated = snapshot.hasNonNull("handover") ? snapshot.path("handover").path("cargo").path("remainingCargo") : snapshot.path("dispatches");
for (JsonNode binding : associated) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText());
String relationship = before.path("vehicleId").asText().equals(after.path("vehicleId").asText()) ? "DRIVER_CHANGED"
: key.equals(before.path("vehicleId").asText()) ? "REPLACED_OUT" : "REPLACED_IN";
return new VehicleFulfillmentModels.Change(row.tripNo(), row.recordVersion(), row.occurredAt().toInstant(ZoneOffset.UTC), row.reason(),