feat(fleet): integrate costs quotes operations and in-transit handovers
This commit is contained in:
@@ -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) { }
|
||||
}
|
||||
+93
@@ -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); }
|
||||
}
|
||||
+143
@@ -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); }
|
||||
}
|
||||
+2
-1
@@ -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>();
|
||||
|
||||
+37
@@ -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));
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -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);
|
||||
}
|
||||
|
||||
+3
-3
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user