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
@@ -27,6 +27,7 @@ public class DirectEventClient {
EventTypes.OVERSEAS_DELIVERY_COMPLETED -> firstleg.receive(token, event);
case EventTypes.WALLET_COMMAND_REQUESTED -> checkedFinance(finance.wallet(token, event));
case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> checkedFinance(finance.fee(token, event));
case EventTypes.COMPANY_VEHICLE_COST_SUBMITTED -> checkedVehicleCost(finance.vehicleCost(token, event));
default -> throw new EventDeliveryFailure(true, "Unsupported outbound event type: " + event.eventType());
};
}
@@ -34,4 +35,10 @@ public class DirectEventClient {
EventDeliveryFailure.requireSuccessful(response, FINANCE_PERMANENT_CODES);
return response;
}
private ApiResponse<DeliveryAck> checkedVehicleCost(ApiResponse<DeliveryAck> response) {
var permanentCodes = new java.util.HashSet<>(FINANCE_PERMANENT_CODES);
permanentCodes.addAll(Set.of("FIN_400", "FIN_404", "FIN_409"));
EventDeliveryFailure.requireSuccessful(response, permanentCodes);
return response;
}
}
@@ -12,6 +12,9 @@ import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "finance-service", contextId = "FinanceBusinessEventClient",
url = "${FINANCE_SERVICE_URL:${baiyue.clients.finance.url:}}")
public interface FinanceBusinessEventClient {
@PostMapping("/internal/finance/v1/vehicle-costs/events")
ApiResponse<DeliveryAck> vehicleCost(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
@PostMapping("/internal/finance/v1/wallet-command-events")
ApiResponse<DeliveryAck> wallet(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
@@ -24,7 +24,7 @@ public class EventOperationsController {
ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) {
guard = new EventOperationsTokenGuard(token);
operations = new OutboxOperations(jdbc, transactions,
new OutboxOperations.Layout("ll_event_outbox", "id", "status", Set.of(EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)),
new OutboxOperations.Layout("ll_event_outbox", "id", "status", Set.of(EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.COMPANY_VEHICLE_COST_SUBMITTED)),
json -> {
try {
EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid();
@@ -174,6 +174,67 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), outcomes, reason);
}
// Existing delivery commands persist LocalDateTime.now(); convert in that server zone, never the browser zone.
@Override
public com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock) {
var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId())
: outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())
|| dispatch.getShippedPackageCount() == null || dispatch.getShippedPackageCount() <= 0
|| !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount()))
throw conflict("交接必须具有已完成出库的真实发运依据");
var rows = lock ? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId())
: deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId());
var facts = new java.util.ArrayList<com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery>();
long packages = 0;
for (var row : rows) {
if (!Objects.equals(row.getCustomerId(), dispatch.getCustomerId())
|| !Objects.equals(row.getDispatchOrderId(), dispatch.getId())
|| !Objects.equals(row.getOutboundOrderId(), outbound.getId())
|| !Objects.equals(row.getOverseasWarehouseId(), dispatch.getOverseasWarehouseId())
|| row.getPackageCount() == null || row.getPackageCount() <= 0 || row.getVersion() == null
|| row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus()))
throw conflict("派送货物归属或执行状态与原始发运不一致");
Instant terminal = originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus())
? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null;
if (("DELIVERED".equals(row.getStatus()) || "RETURNED".equals(row.getStatus())) && terminal == null)
throw conflict("派送终态缺少真实签收或退回完成证据");
String key = destination(row.getDestinationType(), row.getDestinationWarehouseCode(), null,
row.getDeliveryAddress(), row.getPostalCode());
if (key == null) throw conflict("派送单缺少真实卸货目的地");
var cargo = new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.Delivery(dispatch.getCustomerId(),
dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal);
facts.add(new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery(cargo, row.getStatus(), row.getVersion(),
new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Transport(row.getVehiclePlate(), row.getDriverName(), row.getDriverPhone())));
packages = Math.addExact(packages, row.getPackageCount());
}
if (facts.isEmpty() || packages != dispatch.getShippedPackageCount())
throw conflict("派送明细未核平本次实发货物");
return new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence(dispatchNo, dispatch.getCustomerId(),
dispatch.getOverseasWarehouseId(), dispatch.getShippedPackageCount(), java.util.List.copyOf(facts));
}
@Override
public void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment) {
var current = transitEvidence(evidence.dispatchNo(), true);
if (!current.equals(evidence)) throw conflict("交接货物已变化,请重新核对");
for (var fact : evidence.deliveries()) {
if (fact.cargo().terminalAt() != null) continue;
var row = deliveries.lockByNo(evidence.customerId(), fact.cargo().deliveryNo());
if (row == null || !Objects.equals(row.getVersion(), fact.recordVersion()) || row.getSignedAt() != null)
throw conflict("派送货物已变化,请重新核对交接");
if (deliveries.update(null, new LambdaUpdateWrapper<DeliveryOrder>()
.eq(DeliveryOrder::getId, row.getId()).eq(DeliveryOrder::getCustomerId, evidence.customerId())
.eq(DeliveryOrder::getVersion, fact.recordVersion()).eq(DeliveryOrder::getStatus, fact.status())
.isNull(DeliveryOrder::getSignedAt)
.set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName())
.set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, fact.recordVersion() + 1)
.set(DeliveryOrder::getUpdatedAt, LocalDateTime.now())) != 1)
throw conflict("派送交接保存失败,请刷新核对");
}
}
private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); }
private static boolean originallySigned(DeliveryOrder order) {
return order.getSignedAt() != null && java.util.Set.of("DELIVERED", "RETURN_PENDING", "RETURNED").contains(order.getStatus());
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/pda/v1/last-leg/dispatches")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaFleetHandoverController {
private final FleetHandoverPdaQuery service;
public PdaFleetHandoverController(FleetHandoverPdaQuery service) { this.service = service; }
@GetMapping("/{dispatchNo}/handover-status")
public ApiResponse<FleetHandoverPdaQuery.Status> status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) {
var identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.status(identity.overseasWarehouseId(), dispatchNo));
}
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.financefee.fleet;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
@Component
public class VehicleCareFinanceAdapter implements VehicleCareFinanceGateway {
private final OverseasFeeCurrencyConverter converter;
private final LastlegEventOutboxMapper outbox;
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public VehicleCareFinanceAdapter(OverseasFeeCurrencyConverter converter, LastlegEventOutboxMapper outbox, JdbcTemplate jdbc, ObjectMapper json) {
this.converter = converter; this.outbox = outbox; this.jdbc = jdbc; this.json = json;
}
@Override
public Conversion convert(String currency, BigDecimal amount, LocalDate businessDate) {
var converted = converter.convert(currency, amount, businessDate);
return new Conversion(converted.originalCurrency(), converted.originalAmount(), converted.exchangeRate(), converted.exchangeRateMonth(), converted.settlementAmount());
}
@Override @Transactional(propagation=Propagation.MANDATORY)
public void publish(EventEnvelope envelope) {
envelope.requireValid();
if (!EventTypes.COMPANY_VEHICLE_COST_SUBMITTED.equals(envelope.eventType()) || !"0".equals(envelope.tenantId()))
throw new IllegalArgumentException("车辆公司成本Outbox事件类型或平台归属无效");
var event = new LastlegEventOutbox(); event.setId(IdWorker.getId()); event.setCustomerId("0");
event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId());
event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); event.setEventKey(envelope.correlationId());
try { event.setPayloadJson(json.writeValueAsString(envelope)); }
catch (JsonProcessingException error) { throw new IllegalStateException("车辆公司成本事件无法保存", error); }
event.setStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
if (outbox.insert(event) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "财务投递记录保存失败");
}
@Override @Transactional(readOnly=true)
public Map<String, String> deliveryStates(List<String> keys) {
if (keys.size() > 100) throw new IllegalArgumentException("车辆成本投递查询最多100个事件");
if (keys.isEmpty()) return Map.of();
var result = new HashMap<String, String>();
jdbc.query("SELECT event_key,status FROM ll_event_outbox WHERE event_key IN (" + String.join(",", Collections.nCopies(keys.size(), "?")) + ")",
(org.springframework.jdbc.core.RowCallbackHandler) r -> result.put(r.getString("event_key"), r.getString("status")), keys.toArray());
return Map.copyOf(result);
}
}
@@ -0,0 +1,94 @@
package com.baiyue.lastleg.fleet.exception;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
import com.baiyue.lastleg.fleet.trip.*;
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.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.*;
/** Internal preparation for the handover transaction. No stock, fee or departure command is called. */
@Service
public class FleetHandoverInspectionService {
private final FleetTripMapper trips;
private final FleetTripHistoryMapper history;
private final FleetTripDispatchMapper bindings;
private final FleetDispatchGateway dispatches;
private final ObjectMapper json;
public FleetHandoverInspectionService(FleetTripMapper trips, FleetTripHistoryMapper history,
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, ObjectMapper json) {
this.trips = trips; this.history = history; this.bindings = bindings; this.dispatches = dispatches; this.json = json;
}
public record Prepared(long recordVersion, FleetHandoverPolicy.Inspection cargo,
List<FleetTransitEvidence> executionEvidence) { }
/** Caller holds a transaction; command preparation locks trip -> customer dispatch -> outbound -> deliveries. */
@Transactional(propagation = Propagation.MANDATORY)
public Prepared prepare(String tripNo, Instant handedOverAt, Load replacementCapacity, boolean lock) {
var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getDepartedAt() == null || trip.getCompletionJson() != null)
throw conflict("仅已发车且尚未结案的车次允许在途交接");
var departures = history.selectList(new LambdaQueryWrapper<FleetTripHistory>()
.eq(FleetTripHistory::getTripId, trip.getId()).eq(FleetTripHistory::getAction, "DEPART"));
if (departures.size() != 1) throw conflict("车次缺少唯一原始发车快照");
FleetTripModels.AuditSnapshot original;
try { original = json.readValue(departures.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class); }
catch (JsonProcessingException error) { throw new IllegalStateException("原始发车快照无法读取", error); }
if (original.trip() == null || !tripNo.equals(original.trip().tripNo())
|| !trip.getWarehouseId().toString().equals(original.trip().warehouseId())
|| !trip.getDepartedAt().toInstant(ZoneOffset.UTC).equals(original.trip().departedAt())
|| original.confirmedLoad() == null || original.confirmedLoad().cargo() == null
|| original.confirmedLoad().pallets() == null || original.dispatches() == null)
throw conflict("原始发车快照不完整或与车次不一致");
Map<String, FleetTripModels.DispatchBinding> departedBindings = new TreeMap<>();
for (var binding : original.dispatches()) {
if (departedBindings.putIfAbsent(binding.dispatchNo(), binding) != null)
throw conflict("原始发车快照包含重复客户排车");
}
var current = bindings.listByTrip(trip.getId());
if (current.size() != departedBindings.size()) throw conflict("车次成员与原始发车不一致");
Set<String> seen = new HashSet<>();
for (var binding : current) {
var originalBinding = departedBindings.get(binding.getDispatchNo());
if (!seen.add(binding.getDispatchNo()) || originalBinding == null
|| !Objects.equals(originalBinding.customerId(), binding.getCustomerId())
|| !Objects.equals(originalBinding.packageCount(), binding.getPackageCount()))
throw conflict("客户排车归属或箱数与原始发车不一致");
}
List<FleetTransitEvidence> evidence = new ArrayList<>();
for (var binding : departedBindings.values()) {
var fact = dispatches.transitEvidence(binding.dispatchNo(), lock);
if (!Objects.equals(binding.dispatchNo(), fact.dispatchNo())
|| !Objects.equals(binding.customerId(), fact.customerId())
|| !trip.getWarehouseId().toString().equals(fact.warehouseId())
|| binding.packageCount() != fact.shippedPackages())
throw conflict("实际发运与车次客户、仓库或箱数不一致");
evidence.add(fact);
}
var load = original.confirmedLoad();
var cargo = load.cargo().stream().map(row -> new FleetHandoverPolicy.Cargo(row.customerId(), row.dispatchNo(),
row.dispatchItemId(), row.warehousePalletId(), row.destinationKey(), row.packages())).toList();
var measured = load.pallets().stream().map(row -> new FleetHandoverPolicy.Pallet(row.warehousePalletId(),
new Load(row.palletSpaces(), row.weightKg(), row.volumeCbm()))).toList();
try {
var inspection = FleetHandoverPolicy.inspect(original.trip().departedAt(), handedOverAt, Instant.now(), cargo,
evidence.stream().flatMap(row -> row.deliveries().stream()).map(FleetTransitEvidence.Delivery::cargo).toList(),
measured, replacementCapacity);
return new Prepared(trip.getRecordVersion(), inspection, List.copyOf(evidence));
} catch (IllegalArgumentException invalid) { throw conflict(invalid.getMessage()); }
}
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.fleet.exception;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.Instant;
import java.util.List;
public final class FleetHandoverModels {
private FleetHandoverModels() { }
public record Selection(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
@NotNull @Positive Long driverId, @NotNull Instant expectedDriverEndAt, @NotNull Instant handedOverAt) { }
public record Confirm(@NotNull @Valid Selection selection, @NotBlank @Pattern(regexp="[0-9a-f]{64}") String cargoHash,
@NotBlank @Size(max=500) String location, @Size(max=1000) String reason,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Release(@NotNull @Min(0) Long recordVersion,
@NotBlank @Pattern(regexp="VEHICLE|DRIVER") String resourceKind,
@NotNull Instant actualEndAt, @NotBlank @Size(max=500) String location,
@Pattern(regexp="IN_SERVICE|MAINTENANCE|INACTIVE|RETURNED") String vehicleStatus,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Resource(String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone) { }
public record Preview(String tripNo, long recordVersion, String cargoHash, Resource previous, Resource replacement,
Load capacity, FleetHandoverPolicy.Inspection cargo, DriverQualificationPolicy.Qualification driverQualification) { }
public record DeliveryReference(String customerId, String deliveryNo) { }
public record Snapshot(Resource previous, Resource replacement, Instant handedOverAt, String location, String reason,
Instant expectedDriverEndAt, Load capacity, FleetHandoverPolicy.Inspection cargo,
List<FleetTransitEvidence> executionEvidence, List<DeliveryReference> carriedDeliveries, String operatorId, Instant recordedAt) { }
public record Released(String idempotencyKey, String requestHash, Instant actualEndAt, String location,
String vehicleStatus, String operatorId, Instant recordedAt) { }
public record Response(String handoverNo, String tripNo, long recordVersion, Snapshot snapshot,
Released vehicleRelease, Released driverRelease) { }
public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
public long offset() { return (page - 1L) * size; }
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.fleet.exception;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.fleet.trip.*;
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.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Warehouse-scoped projection; exposes only the selected customer dispatch, never a whole-trip snapshot. */
@Service
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public class FleetHandoverPdaQuery {
private final FleetTripDispatchMapper bindings;
private final FleetTripMapper trips;
private final FleetTripHistoryMapper history;
private final FleetDispatchGateway dispatches;
private final FleetHandoverRepository handovers;
private final ObjectMapper json;
public FleetHandoverPdaQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, FleetTripHistoryMapper history,
FleetDispatchGateway dispatches, FleetHandoverRepository handovers, ObjectMapper json) {
this.bindings = bindings; this.trips = trips; this.history = history; this.dispatches = dispatches; this.handovers = handovers; this.json = json;
}
public record Delivery(String deliveryNo, String status, int packages, Instant terminalAt, FleetTransitEvidence.Transport transport) { }
public record Handover(String handoverNo, Instant handedOverAt, String location,
FleetTransitEvidence.Transport previous, FleetTransitEvidence.Transport replacement) { }
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { }
public Status status(long warehouseId, String dispatchNo) {
var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) throw notFound();
var trip = trips.findByNo(binding.getTripNo());
// Check token-bound warehouse before reading any customer's transport or historical data.
if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound();
if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业");
var evidence = dispatches.transitEvidence(dispatchNo, false);
if (!Objects.equals(evidence.customerId(), binding.getCustomerId()) || !Long.toString(warehouseId).equals(evidence.warehouseId())
|| !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound();
var original = history.selectList(new LambdaQueryWrapper<FleetTripHistory>().eq(FleetTripHistory::getTripId, trip.getId())
.eq(FleetTripHistory::getAction, "DEPART"));
if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录");
try {
var departed = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class).trip();
if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound();
var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone());
var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(),
row.cargo().terminalAt(), row.transport())).toList();
var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo)));
Handover handover = null;
if (latest != null) {
var snapshot = json.readValue(latest.snapshot(), FleetHandoverModels.Snapshot.class);
handover = new Handover(latest.handoverNo(), snapshot.handedOverAt(), snapshot.location(),
transport(snapshot.previous()), transport(snapshot.replacement()));
}
return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover);
} catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); }
}
private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) {
return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone());
}
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "当前仓库没有该排车交接记录"); }
}
@@ -0,0 +1,104 @@
package com.baiyue.lastleg.fleet.exception;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
import java.time.Instant;
import java.util.*;
/** Pure inspection of server-owned departure and delivery facts; never changes stock or fees. */
public final class FleetHandoverPolicy {
private FleetHandoverPolicy() { }
public record Cargo(String customerId, String dispatchNo, String itemId, String palletId,
String destinationKey, int packages) { }
/** terminalAt is a signed or RETURN_COMPLETED fact, not a mutable updatedAt. */
public record Delivery(String customerId, String dispatchNo, String deliveryNo,
String destinationKey, int packages, Instant terminalAt) { }
public record Pallet(String palletId, Load measuredLoad) { }
public record Inspection(List<Cargo> remainingCargo, List<Delivery> completedDeliveries,
Set<String> remainingPalletIds, Load occupied) { }
private record Destination(String customerId, String dispatchNo, String key) { }
private record Item(String customerId, String dispatchNo, String itemId) { }
private record DeliveryKey(String customerId, String deliveryNo) { }
/**
* Caller locks trip, dispatches and deliveries and derives terminalAt from trusted execution facts.
* An unresolved refusal has no terminalAt and remains aboard. A signed delivery later returned
* uses its original signature. This policy does not invent per-box partial-signature facts.
*/
public static Inspection inspect(Instant departedAt, Instant handedOverAt, Instant now,
List<Cargo> departureCargo, List<Delivery> deliveries,
List<Pallet> departureMeasurements, Load replacementCapacity) {
if (departedAt == null || handedOverAt == null || now == null
|| handedOverAt.isBefore(departedAt) || handedOverAt.isAfter(now))
throw invalid("交接时间须在实际发车之后且不晚于当前时间");
if (departureCargo == null || departureCargo.isEmpty() || deliveries == null || deliveries.isEmpty())
throw invalid("缺少原始发运或派送依据");
Map<Destination, Integer> shipped = new HashMap<>();
Map<String, String> palletDestinations = new HashMap<>();
Set<Item> itemKeys = new HashSet<>();
for (Cargo cargo : departureCargo) {
if (cargo == null || blank(cargo.customerId()) || blank(cargo.dispatchNo()) || blank(cargo.itemId())
|| blank(cargo.palletId()) || blank(cargo.destinationKey()) || cargo.packages() <= 0)
throw invalid("原始发运缺少客户、排车、实托、目的地或箱数");
if (!itemKeys.add(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId())))
throw invalid("原始发运货物重复");
shipped.merge(destination(cargo), cargo.packages(), Math::addExact);
String previous = palletDestinations.putIfAbsent(cargo.palletId(), cargo.destinationKey());
if (previous != null && !previous.equals(cargo.destinationKey()))
throw invalid("同一实托包含不同卸货目的地,请先核对原始装载");
}
Map<Destination, Delivery> byDestination = new HashMap<>();
Set<DeliveryKey> deliveryKeys = new HashSet<>();
List<Delivery> completed = new ArrayList<>();
for (Delivery delivery : deliveries) {
if (delivery == null || blank(delivery.customerId()) || blank(delivery.dispatchNo())
|| blank(delivery.deliveryNo()) || blank(delivery.destinationKey()) || delivery.packages() <= 0)
throw invalid("派送事实缺少业务归属或箱数");
var key = new Destination(delivery.customerId(), delivery.dispatchNo(), delivery.destinationKey());
if (!deliveryKeys.add(new DeliveryKey(delivery.customerId(), delivery.deliveryNo()))
|| byDestination.putIfAbsent(key, delivery) != null)
throw invalid("派送事实重复或目的地拆分尚未核平");
if (!Objects.equals(shipped.get(key), delivery.packages()))
throw invalid("派送客户、排车、目的地或箱数与原始发运不一致");
if (delivery.terminalAt() != null) {
if (delivery.terminalAt().isBefore(departedAt) || delivery.terminalAt().isAfter(handedOverAt))
throw invalid("交接时间与已发生的签收或退回事实冲突,请刷新核对");
completed.add(delivery);
}
}
if (!byDestination.keySet().equals(shipped.keySet())) throw invalid("派送事实未覆盖全部原始发运货物");
Map<String, Load> measurements = new HashMap<>();
if (departureMeasurements == null) throw invalid("缺少原始实测装载");
for (Pallet pallet : departureMeasurements) {
if (pallet == null || blank(pallet.palletId()) || !positive(pallet.measuredLoad())
|| measurements.putIfAbsent(pallet.palletId(), pallet.measuredLoad()) != null)
throw invalid("原始实托测量缺失或重复");
}
if (!measurements.keySet().equals(palletDestinations.keySet())) throw invalid("实测托盘与原始货物不一致");
List<Cargo> remaining = departureCargo.stream()
.filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null).toList();
if (remaining.isEmpty()) throw invalid("全部货物已完成,请按结案和资源结束流程处理");
Set<String> palletIds = new TreeSet<>();
remaining.forEach(cargo -> palletIds.add(cargo.palletId()));
Load occupied = new Load(0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
// Keep the measured whole-pallet load when only one customer's portion has been signed.
// It is unsafe to infer residual weight/volume from package-count ratios.
for (String palletId : palletIds) occupied = occupied.plus(measurements.get(palletId));
if (!positive(replacementCapacity)) throw invalid("请确认接替车辆托位、载重和容积");
if (occupied.palletSpaces() > replacementCapacity.palletSpaces()
|| occupied.weightKg().compareTo(replacementCapacity.weightKg()) > 0
|| occupied.volumeCbm().compareTo(replacementCapacity.volumeCbm()) > 0)
throw invalid("未完成货物超过接替车辆容量");
return new Inspection(List.copyOf(remaining), List.copyOf(completed),
Collections.unmodifiableSet(palletIds), occupied);
}
private static Destination destination(Cargo cargo) {
return new Destination(cargo.customerId(), cargo.dispatchNo(), cargo.destinationKey());
}
private static boolean positive(Load load) {
return load != null && load.palletSpaces() > 0 && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0;
}
private static boolean blank(String text) { return text == null || text.isBlank(); }
private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); }
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.fleet.exception;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
/** All mutations are serialized by the owning trip row lock. Stored snapshots are immutable. */
@Repository
public class FleetHandoverRepository {
record Row(long id, long tripId, long tripVersion, String tripNo, String handoverNo, String requestKey, String requestHash,
long version, Instant handedOverAt, String snapshot, String vehicleRelease, String driverRelease) { }
private static final RowMapper<Row> ROW = (r, ignored) -> new Row(r.getLong("id"), r.getLong("trip_id"), r.getLong("trip_version"),
r.getString("trip_no"), r.getString("handover_no"), r.getString("request_key"), r.getString("request_hash"),
r.getLong("record_version"), r.getTimestamp("handed_over_at").toLocalDateTime().toInstant(ZoneOffset.UTC),
r.getString("snapshot_json"), r.getString("vehicle_release_json"), r.getString("driver_release_json"));
private final JdbcTemplate jdbc;
public FleetHandoverRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; }
Row created(long tripId, String key) {
return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND request_key=?", ROW, tripId, key).stream().findFirst().orElse(null);
}
Row find(long tripId, String number) {
return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND handover_no=?", ROW, tripId, number).stream().findFirst().orElse(null);
}
Row latest(long tripId) {
return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? ORDER BY id DESC LIMIT 1", ROW, tripId).stream().findFirst().orElse(null);
}
Row latestForCargo(long tripId, String customerDispatchJson) {
return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND JSON_CONTAINS(snapshot_json,?,'$.cargo.remainingCargo')=1 ORDER BY trip_version DESC LIMIT 1",
ROW, tripId, customerDispatchJson).stream().findFirst().orElse(null);
}
long count(long tripId) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_handover WHERE trip_id=?", Long.class, tripId); }
List<Row> page(long tripId, FleetHandoverModels.Query query) {
return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? ORDER BY id DESC LIMIT ? OFFSET ?", ROW, tripId, query.size(), query.offset());
}
int insert(Row row, String operator, Instant now) {
return jdbc.update("INSERT INTO ll_fleet_trip_handover(id,trip_id,trip_version,trip_no,handover_no,request_key,request_hash,record_version,handed_over_at,snapshot_json,created_by,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
row.id(), row.tripId(), row.tripVersion(), row.tripNo(), row.handoverNo(), row.requestKey(), row.requestHash(), row.version(),
LocalDateTime.ofInstant(row.handedOverAt(), ZoneOffset.UTC), row.snapshot(), operator, LocalDateTime.ofInstant(now, ZoneOffset.UTC));
}
int release(Row row, boolean vehicle, String result) {
String column = vehicle ? "vehicle_release_json" : "driver_release_json";
return jdbc.update("UPDATE ll_fleet_trip_handover SET " + column + "=?,record_version=record_version+1 WHERE id=? AND record_version=? AND " + column + " IS NULL",
result, row.id(), row.version());
}
}
@@ -0,0 +1,208 @@
package com.baiyue.lastleg.fleet.exception;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.driver.DriverModels;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DuplicateKeyException;
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.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.fleet.exception.FleetHandoverModels.*;
@Service
@Transactional(isolation = Isolation.READ_COMMITTED)
public class FleetHandoverService {
private final FleetTripMapper trips;
private final FleetResourceMapper resources;
private final FleetTripService history;
private final FleetDispatchGateway dispatches;
private final VehicleRepository vehicles;
private final VehicleApplicationService vehicleService;
private final DriverService drivers;
private final FleetHandoverInspectionService inspection;
private final FleetHandoverRepository repository;
private final ObjectMapper json;
public FleetHandoverService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripService history,
FleetDispatchGateway dispatches, VehicleRepository vehicles, VehicleApplicationService vehicleService,
DriverService drivers, FleetHandoverInspectionService inspection, FleetHandoverRepository repository, ObjectMapper json) {
this.trips = trips; this.resources = resources; this.history = history; this.dispatches = dispatches;
this.vehicles = vehicles; this.vehicleService = vehicleService; this.drivers = drivers;
this.inspection = inspection; this.repository = repository; this.json = json;
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public PageResult<Response> page(String tripNo, Query query) {
var trip = required(tripNo, false);
long count = repository.count(trip.getId());
return new PageResult<>(repository.page(trip.getId(), query).stream().map(this::response).toList(),
query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1));
}
public Preview preview(String tripNo, Selection selection) {
var trip = required(tripNo, true);
return prepare(trip, selection).preview();
}
public Response confirm(String tripNo, String operator, Confirm request) {
var trip = required(tripNo, true);
String requestHash = hash(request);
var previous = repository.created(trip.getId(), request.idempotencyKey());
if (previous != null) {
if (!previous.requestHash().equals(requestHash)) throw conflict("交接请求标识已用于不同内容");
return response(previous);
}
var prepared = prepare(trip, request.selection());
if (!prepared.preview().cargoHash().equals(request.cargoHash())) throw conflict("交接货物、资源或派送状态已变化,请重新核对");
if (request.location() == null || request.location().isBlank()) throw bad("请填写实际交接地点");
reserve("VEHICLE", request.selection().vehicleId(), trip);
reserve("DRIVER", request.selection().driverId(), trip);
var replacement = prepared.preview().replacement();
for (var evidence : prepared.evidence()) dispatches.handover(evidence,
new FleetDispatchGateway.Assignment(replacement.vehiclePlate(), replacement.driverName(), replacement.driverPhone()));
Instant recordedAt = Instant.now();
String reason = request.reason() == null || request.reason().isBlank() ? "在途车辆司机交接" : request.reason().trim();
var snapshot = new Snapshot(prepared.preview().previous(), replacement, request.selection().handedOverAt(),
request.location().trim(), reason, request.selection().expectedDriverEndAt(), prepared.preview().capacity(),
prepared.preview().cargo(), prepared.evidence(), prepared.evidence().stream().flatMap(item -> item.deliveries().stream())
.filter(item -> item.cargo().terminalAt() == null).map(item -> new DeliveryReference(item.cargo().customerId(), item.cargo().deliveryNo())).toList(), operator, recordedAt);
var row = new FleetHandoverRepository.Row(IdWorker.getId(), trip.getId(), request.selection().recordVersion() + 1, tripNo,
"HO-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT),
request.idempotencyKey(), requestHash, 0, request.selection().handedOverAt(), write(snapshot), null, null);
if (repository.insert(row, operator, recordedAt) != 1) throw conflict("交接记录保存失败");
trip.setVehicleId(request.selection().vehicleId()); trip.setVehiclePlate(replacement.vehiclePlate());
trip.setDriverId(request.selection().driverId()); trip.setDriverName(replacement.driverName()); trip.setDriverPhone(replacement.driverPhone());
trip.setMaxPalletSpaces(prepared.preview().capacity().palletSpaces()); trip.setMaxLoadKg(prepared.preview().capacity().weightKg());
trip.setMaxVolumeCbm(prepared.preview().capacity().volumeCbm());
trip.setExpectedDriverEndAt(LocalDateTime.ofInstant(request.selection().expectedDriverEndAt(), ZoneOffset.UTC));
// Old resources remain reserved until their own actual task ends. DEPART and load_json are never rewritten.
history.recordExecution(trip, request.selection().recordVersion(), "HANDOVER", reason, operator);
return response(row);
}
public Response release(String tripNo, String handoverNo, String operator, Release request) {
var trip = required(tripNo, true);
var row = repository.find(trip.getId(), handoverNo);
if (row == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "交接记录不存在");
if (!Set.of("VEHICLE", "DRIVER").contains(request.resourceKind())) throw bad("请选择车辆或司机任务");
boolean vehicle = "VEHICLE".equals(request.resourceKind());
String requestHash = hash(request), previous = vehicle ? row.vehicleRelease() : row.driverRelease();
if (previous != null) {
var released = read(previous, Released.class);
if (!released.idempotencyKey().equals(request.idempotencyKey()) || !released.requestHash().equals(requestHash))
throw conflict("原资源任务已结束,请核对原请求");
return response(row);
}
if (row.version() != request.recordVersion()) throw conflict("交接记录已变化,请刷新");
var snapshot = read(row.snapshot(), Snapshot.class);
String oldId = vehicle ? snapshot.previous().vehicleId() : snapshot.previous().driverId();
String nextId = vehicle ? snapshot.replacement().vehicleId() : snapshot.replacement().driverId();
Long currentId = vehicle ? trip.getVehicleId() : trip.getDriverId();
if (oldId.equals(nextId) || oldId.equals(Objects.toString(currentId, null)))
throw conflict("该资源仍承担当前运输任务,不能通过旧交接结束");
if (request.actualEndAt() == null || request.actualEndAt().isBefore(snapshot.handedOverAt())
|| request.actualEndAt().isAfter(Instant.now()) || request.location() == null || request.location().isBlank())
throw bad("请填写不早于交接且不晚于当前的实际结束时间和地点");
if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus())))
throw bad("请确认原车辆结束后的实际状态");
if (!vehicle && request.vehicleStatus() != null) throw bad("司机任务结束不能修改车辆状态");
long resourceId = Long.parseLong(oldId);
if (vehicle) {
var oldVehicle = vehicles.lockById(resourceId).orElseThrow(() -> conflict("原车辆档案不存在"));
releaseResource(request.resourceKind(), resourceId, trip.getId());
vehicleService.changeStatus(resourceId, operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), oldVehicle.recordVersion()));
} else releaseResource(request.resourceKind(), resourceId, trip.getId());
var released = new Released(request.idempotencyKey(), requestHash, request.actualEndAt(), request.location().trim(),
request.vehicleStatus(), operator, Instant.now());
if (repository.release(row, vehicle, write(released)) != 1) throw conflict("原资源结束记录已变化");
history.recordExecution(trip, trip.getRecordVersion(), vehicle ? "HANDOVER_RELEASE_VEHICLE" : "HANDOVER_RELEASE_DRIVER",
"结束交接前资源任务", operator);
return response(repository.find(trip.getId(), handoverNo));
}
private record Prepared(Preview preview, List<FleetTransitEvidence> evidence) { }
private record Basis(Selection selection, Resource previous, Resource replacement, Load capacity,
com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.Qualification qualification,
FleetHandoverInspectionService.Prepared inspection) { }
private Prepared prepare(FleetTripEntity trip, Selection selection) {
if (selection.recordVersion() == null || trip.getRecordVersion().longValue() != selection.recordVersion())
throw conflict("车次已变化,请刷新后重新核对");
if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getVehicleId() == null || trip.getDriverId() == null)
throw conflict("仅已发车且资源完整的车次可交接");
if (Objects.equals(selection.vehicleId(), trip.getVehicleId()) && Objects.equals(selection.driverId(), trip.getDriverId()))
throw bad("请选择需要接替的车辆或司机");
if (selection.expectedDriverEndAt() == null || !selection.expectedDriverEndAt().isAfter(Instant.now())
|| selection.handedOverAt() == null || !selection.expectedDriverEndAt().isAfter(selection.handedOverAt()))
throw bad("司机预计任务结束须晚于交接和当前时间");
var latest = repository.latest(trip.getId());
if (latest != null && selection.handedOverAt().isBefore(read(latest.snapshot(), Snapshot.class).handedOverAt()))
throw bad("交接时间不能早于上次交接");
requireOwned("VEHICLE", trip.getVehicleId(), trip.getId()); requireOwned("DRIVER", trip.getDriverId(), trip.getId());
var vehicle = vehicles.lockById(selection.vehicleId()).orElseThrow(() -> bad("接替车辆不存在"));
if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆");
if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null)
throw bad("请先确认接替车辆实际容量");
DriverModels.Response driver = drivers.requireDispatchable(selection.driverId(), selection.expectedDriverEndAt());
if (!"OVERSEAS".equals(driver.details().region())) throw bad("请选择海外司机");
requireAvailable("VEHICLE", selection.vehicleId(), trip.getVehicleId(), trip.getId());
requireAvailable("DRIVER", selection.driverId(), trip.getDriverId(), trip.getId());
var capacity = new Load(vehicle.maxPalletSpaces(), vehicle.maxLoadKg(), vehicle.maxVolumeCbm());
var checked = inspection.prepare(trip.getTripNo(), selection.handedOverAt(), capacity, true);
var previous = new Resource(trip.getVehicleId().toString(), trip.getVehiclePlate(), trip.getDriverId().toString(), trip.getDriverName(), trip.getDriverPhone());
var replacement = new Resource(Long.toString(vehicle.id()), vehicle.plateNumber(), driver.id(), driver.details().name(), driver.details().phone());
String cargoHash = hash(new Basis(selection, previous, replacement, capacity, driver.qualification(), checked));
return new Prepared(new Preview(trip.getTripNo(), trip.getRecordVersion(), cargoHash, previous, replacement,
capacity, checked.cargo(), driver.qualification()), checked.executionEvidence());
}
private void requireOwned(String kind, long id, long tripId) {
var owner = resources.find(kind, id);
if (owner == null || owner.tripId() != tripId) throw conflict("当前资源占用与车次不一致");
}
private void requireAvailable(String kind, long id, long currentId, long tripId) {
var owner = resources.find(kind, id);
if (owner != null && (id != currentId || owner.tripId() != tripId))
throw conflict("接替资源仍有未结束任务,请先核对实际任务结束");
}
private void reserve(String kind, long id, FleetTripEntity trip) {
long current = "VEHICLE".equals(kind) ? trip.getVehicleId() : trip.getDriverId();
if (id == current) { requireOwned(kind, id, trip.getId()); return; }
try { if (resources.reserve(kind, id, trip.getId(), trip.getTripNo()) != 1) throw conflict("接替资源占用保存失败"); }
catch (DuplicateKeyException occupied) { throw conflict("接替资源已被占用,请重新核对"); }
}
private void releaseResource(String kind, long id, long tripId) {
if (resources.release(kind, id, tripId) != 1) throw conflict("原资源占用不一致,未结束任务");
}
private FleetTripEntity required(String tripNo, boolean lock) {
var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
return trip;
}
private Response response(FleetHandoverRepository.Row row) {
return new Response(row.handoverNo(), row.tripNo(), row.version(), read(row.snapshot(), Snapshot.class),
row.vehicleRelease() == null ? null : read(row.vehicleRelease(), Released.class),
row.driverRelease() == null ? null : read(row.driverRelease(), Released.class));
}
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 String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.fleet.exception;
import java.util.List;
/** Internal port response. Identity always comes from the original customer dispatch. */
public record FleetTransitEvidence(String dispatchNo, String customerId, String warehouseId,
int shippedPackages, List<Delivery> deliveries) {
public record Transport(String vehiclePlate, String driverName, String driverPhone) { }
public record Delivery(FleetHandoverPolicy.Delivery cargo, String status, long recordVersion, Transport transport) { }
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.fleet.exception;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers")
public class PlatformFleetHandoverController {
private final FleetHandoverService service;
public PlatformFleetHandoverController(FleetHandoverService service) { this.service = service; }
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetHandoverModels.Response>> page(@PathVariable String tripNo, @Valid FleetHandoverModels.Query query) {
return ApiResponse.success(service.page(tripNo, query));
}
@PostMapping("/preview")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetHandoverModels.Preview> preview(@PathVariable String tripNo, @Valid @RequestBody FleetHandoverModels.Selection request) {
return ApiResponse.success(service.preview(tripNo, request));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:handover')")
public ApiResponse<FleetHandoverModels.Response> confirm(@PathVariable String tripNo, @AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody FleetHandoverModels.Confirm request) { return ApiResponse.success(service.confirm(tripNo, operator(user), request)); }
@PostMapping("/{handoverNo}/resources/release")
@PreAuthorize("hasAuthority('platform:fleet:trip:release')")
public ApiResponse<FleetHandoverModels.Response> release(@PathVariable String tripNo, @PathVariable String handoverNo,
@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetHandoverModels.Release request) {
return ApiResponse.success(service.release(tripNo, handoverNo, operator(user), request));
}
private static String operator(AuthenticatedUser user) {
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
return user.userId().toString();
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.common.core.api.ApiResponse;
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.*;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records")
public class PlatformVehicleCareFinanceController {
private final VehicleCareFinanceService service;
public PlatformVehicleCareFinanceController(VehicleCareFinanceService service) { this.service = service; }
@PostMapping("/finance-status")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ApiResponse<List<VehicleCareFinanceModels.Status>> statuses(@PathVariable long vehicleId, @Valid @RequestBody VehicleCareFinanceModels.Query request) {
return ApiResponse.success(service.statuses(vehicleId, request.recordNos()));
}
@PostMapping("/{recordNo}/finance")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-finance-submit')")
public ApiResponse<VehicleCareFinanceModels.Submitted> submit(@PathVariable long vehicleId, @PathVariable String recordNo,
@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody VehicleCareFinanceModels.Submit request) {
return ApiResponse.success(service.submit(vehicleId, recordNo, user.userId().toString(), request));
}
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@FeignClient(name="finance-service", contextId="vehicleCareFinance", url="${baiyue.clients.finance.url:}")
public interface VehicleCareFinanceClient {
@PostMapping("/internal/finance/v1/vehicle-costs/sources/query")
ApiResponse<List<VehicleCareFinanceModels.Cost>> query(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody VehicleCareFinanceModels.SourceQuery request);
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.common.event.EventEnvelope;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/** The fee slice owns currency conversion and the shared reliable outbox. */
public interface VehicleCareFinanceGateway {
record Conversion(String originalCurrency, BigDecimal originalAmount, BigDecimal exchangeRate,
String exchangeRateMonth, BigDecimal settlementAmount) { }
Conversion convert(String currency, BigDecimal amount, LocalDate businessDate);
/** Must join the caller's transaction; failure must roll back the fleet source mapping too. */
void publish(EventEnvelope event);
Map<String, String> deliveryStates(List<String> eventKeys);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.fleet.maintenance;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public final class VehicleCareFinanceModels {
private VehicleCareFinanceModels() { }
public record Submit(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long supplierId,
@NotNull LocalDate businessDate, @NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Query(@NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> recordNos) { }
public record Source(String sourceType, String sourceNo, long sourceVersion, String vehicleId, String vehiclePlate,
String tripNo, long supplierId, String feeName, LocalDate businessDate, String originalCurrency, BigDecimal originalAmount,
BigDecimal exchangeRate, String exchangeRateMonth, BigDecimal amount, String remark) { }
public record SourceKey(String sourceType, String sourceNo) { }
public record SourceQuery(List<SourceKey> sources) { }
public record Cost(String costNo, String subjectCode, Source source, String supplierName, String currency, String status,
BigDecimal paidAmount, BigDecimal outstandingAmount, long recordVersion) { }
public record Status(String recordNo, String status, Long sourceVersion, Long submittedRecordVersion, Cost finance) { }
public record Submitted(String recordNo, long sourceVersion, long submittedRecordVersion, boolean replayed, String status) { }
}
@@ -0,0 +1,149 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
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.time.*;
import java.util.*;
@Service
public class VehicleCareFinanceService {
private static final String EVENT_TYPE = EventTypes.COMPANY_VEHICLE_COST_SUBMITTED;
private final VehicleCareMapper records;
private final JdbcTemplate jdbc;
private final ObjectMapper json;
private final VehicleCareFinanceGateway accounting;
private final VehicleCareFinanceClient finance;
private final VehicleCareSupplierClient suppliers;
private final String token;
record Submission(long recordId, long sourceVersion, long recordVersion, String eventId, String requestHash, String payload) { }
public VehicleCareFinanceService(VehicleCareMapper records, JdbcTemplate jdbc, ObjectMapper json, VehicleCareFinanceGateway accounting,
VehicleCareFinanceClient finance, VehicleCareSupplierClient suppliers,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.records = records; this.jdbc = jdbc; this.json = json; this.accounting = accounting;
this.finance = finance; this.suppliers = suppliers; this.token = token;
}
@Transactional(isolation=Isolation.READ_COMMITTED)
public VehicleCareFinanceModels.Submitted submit(long vehicleId, String number, String operator, VehicleCareFinanceModels.Submit command) {
var row = records.lock(vehicleId, number); requireCostRecord(row);
String hash = hash(command);
var prior = jdbc.query("SELECT * FROM ll_fleet_finance_source WHERE record_id=? AND operator_id=? AND request_key=?",
(r, i) -> submission(r), row.getId(), operator, command.idempotencyKey());
if (!prior.isEmpty()) {
if (!hash.equals(prior.get(0).requestHash())) throw conflict("财务提交标识已用于其他内容");
return new VehicleCareFinanceModels.Submitted(number, prior.get(0).sourceVersion(), prior.get(0).recordVersion(), true, "PENDING_FINANCE");
}
if (!row.getRecordVersion().equals(command.recordVersion())) throw conflict("运维记录已变化,请重新核对后提交");
var latest = latest(row.getId());
var current = queryFinance(List.of(row)).get(number); // Fail closed if Finance cannot establish current facts.
if (latest != null) {
if (current == null || current.source().sourceVersion() != latest.sourceVersion()) throw conflict("上次提交尚未完成财务接收,请稍后核对");
if (!"VOIDED".equals(current.status())) throw conflict("费用已提交,重新登记须先在财务作废;已付款费用不可改写");
} else if (current != null) throw conflict("财务来源已有记录但本地提交关系不一致,请核对");
var details = draft(row); var incident = details.incident();
if (incident == null || incident.reportedCost() == null || incident.reportedCost().signum() <= 0) throw bad("请先保存正数申报费用;未填写或明确零费用不生成财务应付");
requireSupplier(command.supplierId());
var converted = accounting.convert(incident.currency(), incident.reportedCost(), command.businessDate());
if (converted.settlementAmount().signum() <= 0 || converted.settlementAmount().precision() - converted.settlementAmount().scale() > 15)
throw bad("折算金额必须为正数且在财务支持范围内");
var source = new VehicleCareFinanceModels.Source(row.getRecordType(), row.getRecordNo(), latest == null ? 1 : latest.sourceVersion() + 1,
row.getVehicleId().toString(), row.getVehiclePlate(), null, command.supplierId(), incident.item(), command.businessDate(),
converted.originalCurrency(), converted.originalAmount(), converted.exchangeRate(), converted.exchangeRateMonth(), converted.settlementAmount(), details.remark());
if (source.feeName() == null || source.feeName().length() > 200) throw bad("提交财务的维修或救援项目须在200字以内");
var payload = json.valueToTree(source); String eventId = UUID.randomUUID().toString();
String eventKey = EVENT_TYPE + ':' + row.getRecordType() + ':' + number + ':' + source.sourceVersion();
var envelope = new EventEnvelope(eventId, EVENT_TYPE, 1, Instant.now(), "0", "COMPANY_VEHICLE_COST",
row.getRecordType() + ':' + number, source.sourceVersion(), null, UUID.randomUUID().toString(), eventKey, null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid();
jdbc.update("INSERT INTO ll_fleet_finance_source(id,record_id,source_version,record_version,operator_id,request_key,request_hash,event_id,payload_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
IdWorker.getId(), row.getId(), source.sourceVersion(), row.getRecordVersion(), operator, command.idempotencyKey(), hash, eventId, write(source), LocalDateTime.now(ZoneOffset.UTC));
accounting.publish(envelope);
return new VehicleCareFinanceModels.Submitted(number, source.sourceVersion(), row.getRecordVersion(), false, "PENDING_FINANCE");
}
public List<VehicleCareFinanceModels.Status> statuses(long vehicleId, List<String> numbers) {
if (numbers == null || numbers.isEmpty() || numbers.size() > 100 || numbers.stream().anyMatch(n -> n == null || n.isBlank())) throw bad("财务状态查询须提供1至100个有效记录号");
var recordsByNo = records.findFinanceRecords(vehicleId, List.copyOf(new LinkedHashSet<>(numbers)));
if (recordsByNo.size() != new HashSet<>(numbers).size()) throw missing();
var latestById = latest(recordsByNo.stream().map(VehicleCareEntity::getId).toList());
var remote = latestById.isEmpty() ? Map.<String, VehicleCareFinanceModels.Cost>of() : queryFinance(recordsByNo.stream().filter(r -> latestById.containsKey(r.getId())).toList());
var deliveryKeys = recordsByNo.stream().filter(r -> latestById.containsKey(r.getId()))
.map(r -> EVENT_TYPE + ':' + r.getRecordType() + ':' + r.getRecordNo() + ':' + latestById.get(r.getId()).sourceVersion()).toList();
var deliveries = accounting.deliveryStates(deliveryKeys);
return recordsByNo.stream().map(row -> {
var local = latestById.get(row.getId()); if (local == null) return new VehicleCareFinanceModels.Status(row.getRecordNo(), "UNLINKED", null, null, null);
var cost = remote.get(row.getRecordNo());
if (cost == null || cost.source().sourceVersion() < local.sourceVersion()) {
var delivery = deliveries.get(EVENT_TYPE + ':' + row.getRecordType() + ':' + row.getRecordNo() + ':' + local.sourceVersion());
if (delivery == null) throw unavailable("财务投递关系缺失,状态尚未确认");
return new VehicleCareFinanceModels.Status(row.getRecordNo(), "DEAD".equals(delivery) ? "FINANCE_ATTENTION" : "PENDING_FINANCE", local.sourceVersion(), local.recordVersion(), null);
}
if (cost.source().sourceVersion() != local.sourceVersion()) throw conflict("财务来源版本与本地提交不一致");
if (!hash(cost.source()).equals(hash(read(local.payload(), VehicleCareFinanceModels.Source.class)))) throw conflict("财务来源内容与本地提交快照不一致");
return new VehicleCareFinanceModels.Status(row.getRecordNo(), cost.status(), local.sourceVersion(), local.recordVersion(), cost);
}).toList();
}
/** Called under the existing record lock; ordinary processing updates do not depend on Finance. */
public void guardFinancialEdit(VehicleCareEntity row, VehicleCareModels.Draft next) {
if (!Set.of("REPAIR", "RESCUE").contains(row.getRecordType())) return;
var before = draft(row);
if (Objects.equals(before.incident().item(), next.incident().item())
&& Objects.equals(before.incident().currency(), next.incident().currency())
&& equalAmount(before.incident().reportedCost(), next.incident().reportedCost())) return;
var latest = latest(row.getId()); if (latest == null) return;
var remote = queryFinance(List.of(row)).get(row.getRecordNo());
if (remote == null || remote.source().sourceVersion() != latest.sourceVersion()) throw conflict("财务接收状态未确认,暂不能更改费用字段");
if (!"VOIDED".equals(remote.status())) throw conflict("已提交费用须先在财务作废,已付款费用禁止改写");
}
private Map<String, VehicleCareFinanceModels.Cost> queryFinance(List<VehicleCareEntity> rows) {
requireToken();
try {
var keys = rows.stream().map(r -> new VehicleCareFinanceModels.SourceKey(r.getRecordType(), r.getRecordNo())).toList();
var response = finance.query(token, new VehicleCareFinanceModels.SourceQuery(keys));
if (response == null || !response.successful() || response.data() == null) throw unavailable("财务状态读取失败,未确认入账或付款");
var result = new HashMap<String, VehicleCareFinanceModels.Cost>();
for (var cost : response.data()) {
if (cost == null || cost.source() == null || !"PLATFORM_DEFAULT".equals(cost.subjectCode()) || !"CNY".equals(cost.currency())
|| !Set.of("PENDING","APPROVED","PARTIALLY_PAID","PAID","VOIDED").contains(Objects.toString(cost.status(), ""))
|| !keys.contains(new VehicleCareFinanceModels.SourceKey(cost.source().sourceType(), cost.source().sourceNo()))
|| rows.stream().noneMatch(r -> r.getRecordNo().equals(cost.source().sourceNo()) && r.getVehicleId().toString().equals(cost.source().vehicleId()))
|| cost.paidAmount() == null || cost.paidAmount().signum() < 0
|| result.put(cost.source().sourceNo(), cost) != null) throw unavailable("财务返回的来源归属或状态无效");
}
return result;
} catch (feign.FeignException error) { throw unavailable("Finance暂不可用,请稍后核对"); }
}
private void requireSupplier(long id) {
requireToken();
try { var r = suppliers.get(token, id, true);
if (r == null || !r.successful() || r.data() == null || r.data().supplierId() != id || !"ACTIVE".equals(r.data().status())) throw bad("供应商不存在或未启用");
} catch (feign.FeignException error) { throw unavailable("供应商主数据暂不可用"); }
}
private Submission latest(long recordId) { return latest(List.of(recordId)).get(recordId); }
private Map<Long, Submission> latest(List<Long> ids) {
if (ids.isEmpty()) return Map.of();
var rows = jdbc.query("SELECT s.* FROM ll_fleet_finance_source s JOIN (SELECT record_id,MAX(source_version) source_version FROM ll_fleet_finance_source WHERE record_id IN (" + placeholders(ids.size()) + ") GROUP BY record_id) v ON v.record_id=s.record_id AND v.source_version=s.source_version",
(r, i) -> submission(r), ids.toArray());
var map = new HashMap<Long, Submission>(); rows.forEach(r -> map.put(r.recordId(), r)); return map;
}
private static Submission submission(java.sql.ResultSet r) throws java.sql.SQLException { return new Submission(r.getLong("record_id"), r.getLong("source_version"), r.getLong("record_version"), r.getString("event_id"), r.getString("request_hash"), r.getString("payload_json")); }
private static String placeholders(int count) { return String.join(",", Collections.nCopies(count, "?")); }
private VehicleCareModels.Draft draft(VehicleCareEntity row) { return read(row.getDetailsJson(), VehicleCareModels.Draft.class); }
private static boolean equalAmount(BigDecimal a, BigDecimal b) { return a == null ? b == null : b != null && a.compareTo(b) == 0; }
private String hash(Object value) { return EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(value))); }
private String write(Object value) { try { return json.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException("车辆财务来源无法保存", e); } }
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (Exception e) { throw new IllegalStateException("车辆财务来源无法读取", e); } }
private static void requireCostRecord(VehicleCareEntity row) { if (row == null || !Set.of("REPAIR","RESCUE").contains(row.getRecordType())) throw missing(); }
private void requireToken() { if (token == null || token.isBlank()) throw unavailable("内部服务身份未配置"); }
private static ResponseStatusException bad(String text) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, text); }
private static ResponseStatusException conflict(String text) { return new ResponseStatusException(HttpStatus.CONFLICT, text); }
private static ResponseStatusException unavailable(String text) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, text); }
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "当前车辆维修救援记录不存在"); }
}
@@ -5,6 +5,10 @@ import org.apache.ibatis.annotations.*;
@Mapper
public interface VehicleCareMapper extends BaseMapper<VehicleCareEntity> {
@Select({"<script>SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no IN",
"<foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach>",
"AND record_type IN ('REPAIR','RESCUE')</script>"})
java.util.List<VehicleCareEntity> findFinanceRecords(@Param("vehicleId") long vehicleId, @Param("numbers") java.util.List<String> numbers);
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo}")
VehicleCareEntity find(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo);
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo} FOR UPDATE")
@@ -27,9 +27,10 @@ public class VehicleCareService {
private final VehicleRepository vehicles;
private final VehicleCareFileGateway files;
private final ObjectMapper json;
private final VehicleCareFinanceService finance;
public VehicleCareService(VehicleCareMapper records, VehicleCareHistoryMapper histories,
VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json) {
this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json;
VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json, VehicleCareFinanceService finance) {
this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json; this.finance = finance;
}
public PageResult<VehicleCareModels.Response> page(long vehicleId, VehicleCareModels.Query query) {
vehicles.findById(vehicleId).orElseThrow(VehicleCareService::missing);
@@ -65,6 +66,7 @@ public class VehicleCareService {
public VehicleCareModels.Response update(long vehicleId, String number, String operator, VehicleCareModels.Update request) {
var row = required(vehicleId, number, true); version(row, request.recordVersion()); validate(request.details());
if (!row.getRecordType().equals(request.details().recordType())) throw conflict("已保存记录不能改变运维类型");
finance.guardFinancialEdit(row, request.details());
if (request.reason() == null || request.reason().isBlank()) throw bad("请填写更正原因");
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details()));
persist(row, request.recordVersion(), "UPDATE", request.reason(), operator);
@@ -121,7 +123,7 @@ public class VehicleCareService {
return new VehicleCareModels.Response(row.getRecordNo(), row.getVehicleId().toString(), row.getVehiclePlate(),
read(row.getDetailsJson(), VehicleCareModels.Draft.class), attached(row).stream().map(VehicleCareModels.Attached::file).toList(),
row.getRecordVersion(), row.getUpdatedBy(), row.getUpdatedAt().toInstant(ZoneOffset.UTC),
Set.of("REPAIR", "RESCUE").contains(row.getRecordType()) ? "UNLINKED" : null);
null); // Current financial facts are queried in a separate bounded batch, never frozen into operational history.
}
private VehicleCareEntity required(long vehicleId, String number, boolean lock) {
var row = lock ? records.lock(vehicleId, number) : records.find(vehicleId, number);
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name="warehouse-service", contextId="vehicleCareSupplier", url="${baiyue.clients.warehouse.url:}")
public interface VehicleCareSupplierClient {
@GetMapping("/internal/warehouse/v1/suppliers/{supplierId}")
ApiResponse<Supplier> get(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long supplierId,
@RequestParam boolean requireActive);
record Supplier(long supplierId, String name, String category, String status) { }
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.fleet.quote;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
@Component
public class FleetQuoteDirectory {
private final FleetCustomerDirectory customers;
private final FleetQuoteSupplierClient suppliers;
private final String token;
public FleetQuoteDirectory(FleetCustomerDirectory customers,FleetQuoteSupplierClient suppliers,@Value("${baiyue.security.internal-service-token}") String token) {
this.customers=customers;this.suppliers=suppliers;this.token=token;
}
public String name(String kind,long id) {
if("SALE".equals(kind)) return customers.required(id).name();
try {
var result=suppliers.get(token,id,true,"TRUCKING");
if(result==null || !result.successful() || result.data()==null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"供应商目录暂不可用");
var row=result.data();
if(row.supplierId()!=id || !"ACTIVE".equals(row.status()) || !"TRUCKING".equals(row.category()) || !"OVERSEAS".equals(row.truckingScope()))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效的海外车队供应商");
return row.name();
} catch(ResponseStatusException e) { throw e; }
catch(RuntimeException e) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"供应商目录暂不可用,请重试"); }
}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.fleet.quote;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.*;
import java.util.List;
public final class FleetQuoteModels {
private FleetQuoteModels() { }
public record Draft(@NotBlank @Pattern(regexp="PURCHASE|SALE") String kind, @NotNull @Positive Long partyId,
@NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode,
@NotNull @DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal distanceMiles,
@NotBlank @Pattern(regexp="USD|CNY") String currency,
@DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal selfPickupPrice,
@DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal fullDeliveryPrice,
@DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal looseDeliveryPrice,
@NotNull LocalDate validFrom, @NotNull LocalDate validTo,
@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) { }
public record Status(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp="ACTIVE|INACTIVE") String status) { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Pattern(regexp="PURCHASE|SALE") String kind, @Size(max=128) String keyword,
@Pattern(regexp="ACTIVE|INACTIVE") String status, LocalDate effectiveOn) {
public Query { page=page==null?1:page; size=size==null?20:size; }
}
public record Quote(String quoteNo, Draft details, String partyName, String status, long recordVersion, Instant updatedAt) { }
public record History(long recordVersion, String action, Instant occurredAt, Quote snapshot) { }
public record Import(@NotBlank @Size(min=8,max=100) String idempotencyKey, @NotEmpty @Size(max=200) List<@Valid @NotNull Draft> rows) { }
public record ImportResult(int importedRows, List<Quote> records) { }
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.fleet.quote;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.*;
@Repository
public class FleetQuoteRepository {
record Row(long id,String quoteNo,String details,String partyName,String status,long version,String createHash,LocalDateTime updatedAt) { }
record Audit(long version,String action,LocalDateTime occurredAt,String snapshot) { }
private final JdbcTemplate jdbc;
public FleetQuoteRepository(JdbcTemplate jdbc) { this.jdbc=jdbc; }
private static final RowMapper<Row> MAPPER=(r,i)->new Row(r.getLong("id"),r.getString("quote_no"),r.getString("details_json"),r.getString("party_name"),r.getString("status"),r.getLong("record_version"),r.getString("create_hash"),r.getTimestamp("updated_at").toLocalDateTime());
Row find(String no,boolean lock) { return jdbc.query("SELECT * FROM ll_fleet_quote WHERE quote_no=?"+(lock?" FOR UPDATE":""),MAPPER,no).stream().findFirst().orElse(null); }
Row replay(String operator,String key) { return jdbc.query("SELECT * FROM ll_fleet_quote WHERE created_by=? AND create_key=?",MAPPER,operator,key).stream().findFirst().orElse(null); }
String creationSnapshot(long id) { return jdbc.queryForObject("SELECT snapshot_json FROM ll_fleet_quote_history WHERE quote_id=? AND record_version=0",String.class,id); }
List<Row> list(FleetQuoteModels.Query q) {
StringBuilder sql=new StringBuilder("SELECT * FROM ll_fleet_quote WHERE status<>'DELETED'"); var args=new ArrayList<Object>();
if(q.kind()!=null&&!q.kind().isBlank()) {sql.append(" AND kind=?");args.add(q.kind());}
if(q.status()!=null&&!q.status().isBlank()) {sql.append(" AND status=?");args.add(q.status());}
if(q.effectiveOn()!=null) {sql.append(" AND valid_from<=? AND valid_to>=? AND status='ACTIVE'");args.add(q.effectiveOn());args.add(q.effectiveOn());}
if(q.keyword()!=null&&!q.keyword().isBlank()) {sql.append(" AND (origin_city LIKE ? OR destination_code LIKE ? OR party_name LIKE ? OR quote_no LIKE ?)");for(int i=0;i<4;i++) args.add("%"+q.keyword().trim()+"%");}
sql.append(" ORDER BY updated_at DESC,id DESC LIMIT 5001"); return jdbc.query(sql.toString(),MAPPER,args.toArray());
}
void insert(Row r,FleetQuoteModels.Draft d,String natural,String operator,String key) {
jdbc.update("INSERT INTO ll_fleet_quote(id,quote_no,kind,party_id,party_name,origin_city,destination_code,valid_from,valid_to,status,natural_key,details_json,record_version,create_key,create_hash,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
r.id(),r.quoteNo(),d.kind(),d.partyId(),r.partyName(),d.originCity(),d.destinationCode(),d.validFrom(),d.validTo(),r.status(),natural,r.details(),r.version(),key,r.createHash(),operator,r.updatedAt(),r.updatedAt());
}
void update(Row r,FleetQuoteModels.Draft d,String natural) {
jdbc.update("UPDATE ll_fleet_quote SET kind=?,party_id=?,party_name=?,origin_city=?,destination_code=?,valid_from=?,valid_to=?,status=?,natural_key=?,details_json=?,record_version=?,updated_at=? WHERE id=?",
d.kind(),d.partyId(),r.partyName(),d.originCity(),d.destinationCode(),d.validFrom(),d.validTo(),r.status(),natural,r.details(),r.version(),r.updatedAt(),r.id());
}
void audit(long id,Row r,String action,String operator,String snapshot) {
jdbc.update("INSERT INTO ll_fleet_quote_history(id,quote_id,record_version,action,operator_id,occurred_at,snapshot_json) VALUES(?,?,?,?,?,?,?)",id,r.id(),r.version(),action,operator,r.updatedAt(),snapshot);
}
List<Audit> history(long id) {return jdbc.query("SELECT * FROM ll_fleet_quote_history WHERE quote_id=? ORDER BY record_version DESC LIMIT 200",(r,i)->new Audit(r.getLong("record_version"),r.getString("action"),r.getTimestamp("occurred_at").toLocalDateTime(),r.getString("snapshot_json")),id);}
}
@@ -0,0 +1,87 @@
package com.baiyue.lastleg.fleet.quote;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.*;
import java.util.*;
@Service
public class FleetQuoteService {
private final FleetQuoteRepository repository; private final FleetQuoteDirectory directory; private final ObjectMapper json;
public FleetQuoteService(FleetQuoteRepository repository,FleetQuoteDirectory directory,ObjectMapper json) {this.repository=repository;this.directory=directory;this.json=json;}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetQuoteModels.Quote> page(FleetQuoteModels.Query q) {
var rows=list(q);long total=rows.size();int start=(int)Math.min(total,((long)q.page()-1)*q.size());
return new PageResult<>(rows.subList(start,Math.min(rows.size(),start+q.size())),q.page(),q.size(),total,(total+q.size()-1)/q.size());
}
private List<FleetQuoteModels.Quote> list(FleetQuoteModels.Query q) {
var rows=repository.list(q);if(rows.size()>5000)throw bad("超过5000条报价,请缩小筛选范围");return rows.stream().map(this::response).toList();
}
@Transactional(readOnly=true) public FleetQuoteModels.Quote detail(String no) {return response(required(no,false));}
@Transactional(readOnly=true) public List<FleetQuoteModels.History> history(String no) {
return repository.history(required(no,false).id()).stream().map(r->new FleetQuoteModels.History(r.version(),r.action(),r.occurredAt().toInstant(ZoneOffset.UTC),read(r.snapshot(),FleetQuoteModels.Quote.class))).toList();
}
@Transactional public FleetQuoteModels.Quote create(String operator,FleetQuoteModels.Create request) {return createOne(operator,request,"");}
private FleetQuoteModels.Quote createOne(String operator,FleetQuoteModels.Create request,String batchHash) {
var draft=normalize(request.details());String hash=hash(batchHash+write(draft));var existing=repository.replay(operator,request.idempotencyKey());
if(existing!=null) {if(!existing.createHash().equals(hash))throw conflict("该请求键已用于不同报价");return read(repository.creationSnapshot(existing.id()),FleetQuoteModels.Quote.class);}
String name=directory.name(draft.kind(),draft.partyId());long id=IdWorker.getId();
var row=new FleetQuoteRepository.Row(id,"FQ"+id,write(draft),name,"ACTIVE",0,hash,LocalDateTime.now(ZoneOffset.UTC));
try {repository.insert(row,draft,natural(draft),operator,request.idempotencyKey());}
catch(DuplicateKeyException e) {throw conflict("报价重复或请求正在处理,请核对报价库后使用原请求重试");}
audit(row,"CREATE",operator);return response(row);
}
@Transactional public FleetQuoteModels.Quote update(String no,String operator,FleetQuoteModels.Update request) {
var old=required(no,true);version(old,request.recordVersion());var draft=normalize(request.details());
if(!draft.kind().equals(read(old.details(),FleetQuoteModels.Draft.class).kind())) throw bad("报价用途不能变更,请另建采购或销售报价");
var row=new FleetQuoteRepository.Row(old.id(),no,write(draft),directory.name(draft.kind(),draft.partyId()),old.status(),old.version()+1,old.createHash(),LocalDateTime.now(ZoneOffset.UTC));
try {repository.update(row,draft,natural(draft));}catch(DuplicateKeyException e){throw conflict("同一报价组合已存在");}
audit(row,"UPDATE",operator);return response(row);
}
@Transactional public FleetQuoteModels.Quote status(String no,String operator,FleetQuoteModels.Status request) {return changeStatus(no,operator,request.recordVersion(),request.status());}
@Transactional public FleetQuoteModels.Quote delete(String no,String operator,long version) {return changeStatus(no,operator,version,"DELETED");}
private FleetQuoteModels.Quote changeStatus(String no,String operator,long expected,String status) {
var old=required(no,true);version(old,expected);var draft=read(old.details(),FleetQuoteModels.Draft.class);
if("ACTIVE".equals(status))directory.name(draft.kind(),draft.partyId());
var row=new FleetQuoteRepository.Row(old.id(),no,old.details(),old.partyName(),status,old.version()+1,old.createHash(),LocalDateTime.now(ZoneOffset.UTC));
repository.update(row,draft,"DELETED".equals(status)?hash("DELETED:"+no):natural(draft));audit(row,status,operator);return response(row);
}
@Transactional public FleetQuoteModels.ImportResult importRows(String operator,FleetQuoteModels.Import request) {
var rows=new ArrayList<FleetQuoteModels.Quote>();
String batchHash=hash(write(request.rows().stream().map(FleetQuoteService::normalize).toList()));
for(int i=0;i<request.rows().size();i++)rows.add(createOne(operator,new FleetQuoteModels.Create(request.idempotencyKey()+":"+(i+1),request.rows().get(i)),batchHash));
return new FleetQuoteModels.ImportResult(rows.size(),List.copyOf(rows));
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public byte[] exportCsv(FleetQuoteModels.Query q) {
var csv=new StringBuilder("\uFEFF报价号,用途,往来单位,发货城市,目的仓码,预估距离(mile),币种,整车自提(每单),整车派送(每单),散板派送(每托),生效日,失效日,状态,版本,备注\r\n");
for(var r:list(q)){var d=r.details();csv.append(String.join(",",cell(r.quoteNo()),cell(d.kind()),cell(r.partyName()),cell(d.originCity()),cell(d.destinationCode()),cell(d.distanceMiles()),cell(d.currency()),cell(d.selfPickupPrice()),cell(d.fullDeliveryPrice()),cell(d.looseDeliveryPrice()),cell(d.validFrom()),cell(d.validTo()),cell(r.status()),cell(r.recordVersion()),cell(d.remark()))).append("\r\n");}
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
static FleetQuoteModels.Draft normalize(FleetQuoteModels.Draft d) {
if(d.validTo().isBefore(d.validFrom()))throw bad("失效日不得早于生效日");
if(d.selfPickupPrice()==null&&d.fullDeliveryPrice()==null&&d.looseDeliveryPrice()==null)throw bad("至少填写一种报价,留空表示未报价");
return new FleetQuoteModels.Draft(d.kind(),d.partyId(),d.originCity().trim(),d.destinationCode().trim().toUpperCase(Locale.ROOT),d.distanceMiles().stripTrailingZeros(),d.currency(),
decimal(d.selfPickupPrice()),decimal(d.fullDeliveryPrice()),decimal(d.looseDeliveryPrice()),d.validFrom(),d.validTo(),d.remark()==null?null:d.remark().trim());
}
private static java.math.BigDecimal decimal(java.math.BigDecimal n){return n==null?null:n.stripTrailingZeros();}
private String natural(FleetQuoteModels.Draft d){return hash(write(List.of(d.kind(),d.partyId(),d.originCity().toLowerCase(Locale.ROOT),d.destinationCode(),d.distanceMiles().toPlainString(),d.currency(),d.validFrom().toString(),d.validTo().toString())));}
private void version(FleetQuoteRepository.Row row,long expected){if("DELETED".equals(row.status())||row.version()!=expected)throw conflict("报价已变化,请重新加载后操作");}
private FleetQuoteRepository.Row required(String no,boolean lock){var row=repository.find(no,lock);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"报价不存在");return row;}
private void audit(FleetQuoteRepository.Row r,String action,String operator){repository.audit(IdWorker.getId(),r,action,operator,write(response(r)));}
private FleetQuoteModels.Quote response(FleetQuoteRepository.Row r){return new FleetQuoteModels.Quote(r.quoteNo(),read(r.details(),FleetQuoteModels.Draft.class),r.partyName(),r.status(),r.version(),r.updatedAt().toInstant(ZoneOffset.UTC));}
private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException e){throw new IllegalStateException(e);}}
private <T>T read(String value,Class<T> type){try{return json.readValue(value,type);}catch(JsonProcessingException e){throw new IllegalStateException("报价快照无法读取",e);}}
private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}}
private static String cell(Object o){String s=o==null?"":o.toString();if(s.matches("(?s)^[\\s]*[=+@-].*"))s="'"+s;return "\""+s.replace("\"","\"\"")+"\"";}
private static ResponseStatusException bad(String s){return new ResponseStatusException(HttpStatus.BAD_REQUEST,s);}
private static ResponseStatusException conflict(String s){return new ResponseStatusException(HttpStatus.CONFLICT,s);}
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.fleet.quote;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@FeignClient(name="warehouse-service",contextId="fleetQuoteSupplierClient",url="${baiyue.clients.warehouse.url:}")
public interface FleetQuoteSupplierClient {
@GetMapping("/internal/warehouse/v1/suppliers/{supplierId}")
ApiResponse<Supplier> get(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("supplierId") long supplierId,
@RequestParam("requireActive") boolean requireActive,@RequestParam("category") String category);
record Supplier(long supplierId,String name,String category,String status,List<String> carrierCodes,String truckingScope) { }
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.fleet.quote;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController @Validated @RequestMapping("/api/platform/v1/last-leg/fleet/quotes")
public class PlatformFleetQuoteController {
private final FleetQuoteService service; private final FleetCustomerDirectory customers;
public PlatformFleetQuoteController(FleetQuoteService service,FleetCustomerDirectory customers){this.service=service;this.customers=customers;}
@GetMapping @PreAuthorize("hasAuthority('platform:fleet:quote:list')")
public ApiResponse<PageResult<FleetQuoteModels.Quote>> page(@Valid FleetQuoteModels.Query query){return ApiResponse.success(service.page(query));}
@GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:quote:list')")
public ApiResponse<List<FleetCustomerDirectory.Customer>> customers(@RequestParam(required=false) @Size(max=128) String keyword){return ApiResponse.success(customers.options(keyword));}
@GetMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:list')")
public ApiResponse<FleetQuoteModels.Quote> detail(@PathVariable String quoteNo){return ApiResponse.success(service.detail(quoteNo));}
@GetMapping("/{quoteNo}/history") @PreAuthorize("hasAuthority('platform:fleet:quote:list')")
public ApiResponse<List<FleetQuoteModels.History>> history(@PathVariable String quoteNo){return ApiResponse.success(service.history(quoteNo));}
@PostMapping @PreAuthorize("hasAuthority('platform:fleet:quote:create')")
public ApiResponse<FleetQuoteModels.Quote> create(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetQuoteModels.Create request){return ApiResponse.success(service.create(operator(user),request));}
@PutMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:edit')")
public ApiResponse<FleetQuoteModels.Quote> update(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@Valid @RequestBody FleetQuoteModels.Update request){return ApiResponse.success(service.update(quoteNo,operator(user),request));}
@PatchMapping("/{quoteNo}/status") @PreAuthorize("hasAuthority('platform:fleet:quote:status')")
public ApiResponse<FleetQuoteModels.Quote> status(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@Valid @RequestBody FleetQuoteModels.Status request){return ApiResponse.success(service.status(quoteNo,operator(user),request));}
@DeleteMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:delete')")
public ApiResponse<FleetQuoteModels.Quote> delete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@RequestParam @Min(0) long recordVersion){return ApiResponse.success(service.delete(quoteNo,operator(user),recordVersion));}
@PostMapping("/import") @PreAuthorize("hasAuthority('platform:fleet:quote:import')")
public ApiResponse<FleetQuoteModels.ImportResult> importRows(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetQuoteModels.Import request){return ApiResponse.success(service.importRows(operator(user),request));}
@GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:quote:export')")
public ResponseEntity<byte[]> export(@Valid FleetQuoteModels.Query query){return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=fleet-quotes.csv").body(service.exportCsv(query));}
private static String operator(AuthenticatedUser user){if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护报价");return user.userId().toString();}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@RestController @Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/operations")
public class PlatformTripOperationsController {
private final TripOperationsService service;
public PlatformTripOperationsController(TripOperationsService service) { this.service=service; }
@GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')")
public ApiResponse<TripOperationsModels.Result> report(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { return ApiResponse.success(service.report(query,canReadCosts(user))); }
@GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:operations:export')")
public ResponseEntity<byte[]> export(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) {
return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8"))
.header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=trip-operations.csv").body(service.exportCsv(query,canReadCosts(user)));
}
private static boolean canReadCosts(AuthenticatedUser user){return user!=null&&user.permissions().contains("platform:fleet:trip:list");}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController @Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/operations/{tripNo}/record")
public class PlatformTripOperationsRecordController {
private final TripOperationsRecordService service;
public PlatformTripOperationsRecordController(TripOperationsRecordService service){this.service=service;}
@GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')")
public ApiResponse<TripOperationsRecordModels.Record> get(@PathVariable String tripNo){return ApiResponse.success(service.get(tripNo));}
@GetMapping("/history") @PreAuthorize("hasAuthority('platform:fleet:operations:list')")
public ApiResponse<PageResult<TripOperationsRecordModels.History>> history(@PathVariable String tripNo,@RequestParam(defaultValue="1") @Min(1) int page,@RequestParam(defaultValue="20") @Min(1) @Max(100) int size){return ApiResponse.success(service.history(tripNo,page,size));}
@PutMapping @PreAuthorize("hasAuthority('platform:fleet:operations:edit')")
public ApiResponse<TripOperationsRecordModels.Record> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody TripOperationsRecordModels.Save request){
if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护实测记录");
return ApiResponse.success(service.save(tripNo,user.userId().toString(),request));
}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.fleet.reporting;
import java.math.*;
import java.time.ZoneOffset;
import java.util.*;
/** Every chart uses the exact same filtered departures as the table and summary. */
final class TripOperationsCharts {
private TripOperationsCharts() { }
static TripOperationsModels.Charts from(List<TripOperationsModels.Row> rows) {
var pallets=new TreeMap<String,BigDecimal>();var destinations=new TreeMap<String,BigDecimal>();var labels=new HashMap<String,String>();
var amounts=new TreeMap<String,Map<String,BigDecimal>>();var rateAmounts=new TreeMap<String,Map<String,BigDecimal>>();var quantities=new TreeMap<String,Map<String,BigDecimal>>();
for(var row:rows) {
String day=row.departedAt().atOffset(ZoneOffset.UTC).toLocalDate().toString();
pallets.merge(day,BigDecimal.valueOf(row.physicalPallets()),BigDecimal::add);
for(var destination:row.destinations()) {destinations.merge(destination.key(),BigDecimal.ONE,BigDecimal::add);labels.put(destination.key(),destination.label());}
for(var money:row.externalAgreedAmounts())amounts.computeIfAbsent(money.currency(),ignored->new TreeMap<>()).merge(day,money.amount(),BigDecimal::add);
for(var rate:row.externalPalletRates()) {
rateAmounts.computeIfAbsent(rate.currency(),ignored->new TreeMap<>()).merge(day,rate.agreedAmount(),BigDecimal::add);
quantities.computeIfAbsent(rate.currency(),ignored->new TreeMap<>()).merge(day,BigDecimal.valueOf(rate.pricedPalletSpaces()),BigDecimal::add);
}
}
var average=new TreeMap<String,Map<String,BigDecimal>>();
rateAmounts.forEach((currency,days)->days.forEach((day,amount)->average.computeIfAbsent(currency,ignored->new TreeMap<>()).put(day,
amount.divide(quantities.get(currency).get(day),2,RoundingMode.HALF_UP))));
var destinationPoints=destinations.entrySet().stream().map(e->new TripOperationsModels.Point(labels.get(e.getKey()),e.getValue())).toList();
return new TripOperationsModels.Charts(List.of(new TripOperationsModels.Series("每日实运托盘","托",points(pallets))),
List.of(new TripOperationsModels.Series("目的地涉及车次","次",destinationPoints)),series(amounts,false),series(average,true));
}
private static List<TripOperationsModels.Point> points(Map<String,BigDecimal> values){return values.entrySet().stream().map(e->new TripOperationsModels.Point(e.getKey(),e.getValue())).toList();}
private static List<TripOperationsModels.Series> series(Map<String,Map<String,BigDecimal>> values,boolean average){return values.entrySet().stream().map(e->new TripOperationsModels.Series(e.getKey(),e.getKey()+(average?"/标准托位":""),points(e.getValue()))).toList();}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.trip.FleetTripCostModels;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class TripOperationsModels {
private TripOperationsModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@NotNull Instant from, @NotNull Instant to, @Positive Long warehouseId,
@Size(max=128) String keyword, @Pattern(regexp="OWN|EXTERNAL") String sourceType,
@Pattern(regexp="IN_TRANSIT|COMPLETED") String status) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Money(String currency, BigDecimal amount) { }
public record Destination(String key,String label) { }
public record PalletRate(String currency,long pricedPalletSpaces,BigDecimal agreedAmount) { }
public record Point(String label,BigDecimal value) { }
public record Series(String label,String unit,List<Point> points) { }
public record Charts(List<Series> physicalPallets,List<Series> destinationTrips,List<Series> externalAgreedAmounts,List<Series> externalPalletRates) { }
public record Row(String tripNo, String status, String vehiclePlate, String driverName,
String warehouseName, Instant departedAt, Instant completedAt, List<Destination> destinations,
long physicalPallets, long palletSpaces, long ownPackages, long externalPackages,
List<Money> externalAgreedAmounts, List<PalletRate> externalPalletRates, TripOperationsRecordModels.Details measured,
String financeStatus, String costStatus, FleetTripCostModels.Summary costs) {
Row withCosts(FleetTripCostModels.Summary value, String state) {
return new Row(tripNo,status,vehiclePlate,driverName,warehouseName,departedAt,completedAt,destinations,physicalPallets,palletSpaces,ownPackages,externalPackages,externalAgreedAmounts,externalPalletRates,measured,financeStatus,state,value);
}
}
public record CostTotal(String currency,BigDecimal directCost,BigDecimal ownCost,BigDecimal externalCost) { }
public record CostSummary(boolean visible,Long unrecordedTrips,Long unallocatedTrips,List<CostTotal> totals) { }
public record Summary(long trips, long inTransit, long completed, long physicalPallets, long palletSpaces,
long ownPackages, long externalPackages, List<Money> externalAgreedAmounts,CostSummary costs) { }
public record Result(PageResult<Row> page, Summary summary, Charts charts) { }
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.fleet.reporting;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
public final class TripOperationsRecordModels {
private TripOperationsRecordModels() { }
public record Details(
@DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualDistanceMiles,
@DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualFuelLitres,
@Size(max=1000) String remark) { }
public record Save(@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotNull @Valid Details details, @Size(max=1000) String reason) { }
public record Record(String tripNo, long recordVersion, Details details, Instant updatedAt) { }
public record History(long recordVersion, String reason, Instant occurredAt, Details details) { }
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.fleet.reporting;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class TripOperationsRecordRepository {
record Snapshot(long version,String details,String reason,String requestHash,LocalDateTime occurredAt) { }
private final JdbcTemplate jdbc;
private static final RowMapper<Snapshot> MAPPER=(r,i)->new Snapshot(r.getLong("record_version"),r.getString("details_json"),r.getString("reason"),r.getString("request_hash"),r.getTimestamp("occurred_at").toLocalDateTime());
public TripOperationsRecordRepository(JdbcTemplate jdbc) { this.jdbc=jdbc; }
boolean hasDeparted(String tripNo,boolean lock) {
return jdbc.query("SELECT departed_at FROM ll_fleet_trip WHERE trip_no=?"+(lock?" FOR UPDATE":""),
(r,i)->r.getTimestamp("departed_at")!=null,tripNo).stream().findFirst().orElse(false);
}
Snapshot latest(String tripNo) {
return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? ORDER BY record_version DESC LIMIT 1",MAPPER,tripNo).stream().findFirst().orElse(null);
}
Snapshot replay(String tripNo,String operator,String key) {
return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? AND operator_id=? AND request_key=?",MAPPER,tripNo,operator,key).stream().findFirst().orElse(null);
}
void append(long id,String tripNo,String operator,String key,Snapshot row) {
jdbc.update("INSERT INTO ll_fleet_trip_operations_record(id,trip_no,record_version,details_json,reason,operator_id,request_key,request_hash,occurred_at) VALUES(?,?,?,?,?,?,?,?,?)",
id,tripNo,row.version(),row.details(),row.reason(),operator,key,row.requestHash(),row.occurredAt());
}
long count(String tripNo) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_operations_record WHERE trip_no=?",Long.class,tripNo); }
List<Snapshot> history(String tripNo,int page,int size) {
return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? ORDER BY record_version DESC LIMIT ? OFFSET ?",MAPPER,tripNo,size,((long)page-1)*size);
}
}
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
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.*;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.*;
import java.util.HexFormat;
/** Append-only measured operating facts; never updates execution, vehicle odometers or costs. */
@Service
public class TripOperationsRecordService {
private final TripOperationsRecordRepository repository;
private final ObjectMapper json;
public TripOperationsRecordService(TripOperationsRecordRepository repository,ObjectMapper json) { this.repository=repository;this.json=json; }
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public TripOperationsRecordModels.Record get(String tripNo) {
requireDeparture(tripNo,false);return response(tripNo,repository.latest(tripNo));
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<TripOperationsRecordModels.History> history(String tripNo,int page,int size) {
requireDeparture(tripNo,false);long total=repository.count(tripNo);
var rows=repository.history(tripNo,page,size).stream().map(r->new TripOperationsRecordModels.History(r.version(),r.reason(),r.occurredAt().toInstant(ZoneOffset.UTC),read(r.details()))).toList();
return new PageResult<>(rows,page,size,total,(total+size-1)/size);
}
@Transactional(isolation=Isolation.READ_COMMITTED)
public TripOperationsRecordModels.Record save(String tripNo,String operator,TripOperationsRecordModels.Save request) {
requireDeparture(tripNo,true);
var d=request.details();var details=new TripOperationsRecordModels.Details(decimal(d.actualDistanceMiles()),decimal(d.actualFuelLitres()),trim(d.remark()));
String reason=trim(request.reason());if(reason==null)reason="补充或更正出车实测记录";
String contents=write(details);String hash=hash(request.recordVersion()+":"+contents+":"+reason);
var replay=repository.replay(tripNo,operator,request.idempotencyKey());
if(replay!=null) {if(!replay.requestHash().equals(hash))throw conflict("原请求键已用于其他实测内容");return response(tripNo,replay);}
var previous=repository.latest(tripNo);long version=previous==null?0:previous.version();
if(version!=request.recordVersion())throw conflict("实测记录已变化,请重新加载后更正");
if(previous!=null && previous.details().equals(contents))return response(tripNo,previous);
if(previous==null && details.actualDistanceMiles()==null && details.actualFuelLitres()==null && details.remark()==null)return response(tripNo,null);
var row=new TripOperationsRecordRepository.Snapshot(version+1,contents,reason,hash,LocalDateTime.now(ZoneOffset.UTC));
repository.append(IdWorker.getId(),tripNo,operator,request.idempotencyKey(),row);return response(tripNo,row);
}
private void requireDeparture(String tripNo,boolean lock) {if(!repository.hasDeparted(tripNo,lock))throw new ResponseStatusException(HttpStatus.CONFLICT,"仅实际已发车的车次可维护实测记录");}
private TripOperationsRecordModels.Record response(String tripNo,TripOperationsRecordRepository.Snapshot row) {
return row==null?new TripOperationsRecordModels.Record(tripNo,0,new TripOperationsRecordModels.Details(null,null,null),null)
:new TripOperationsRecordModels.Record(tripNo,row.version(),read(row.details()),row.occurredAt().toInstant(ZoneOffset.UTC));
}
private TripOperationsRecordModels.Details read(String value) {try{return json.readValue(value,TripOperationsRecordModels.Details.class);}catch(JsonProcessingException e){throw new IllegalStateException("实测记录无法读取",e);}}
private String write(Object value) {try{return json.writeValueAsString(value);}catch(JsonProcessingException e){throw new IllegalStateException(e);}}
private static String trim(String s){return s==null||s.isBlank()?null:s.trim();}
private static BigDecimal decimal(BigDecimal value){return value==null?null:value.stripTrailingZeros();}
private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}}
private static ResponseStatusException conflict(String s){return new ResponseStatusException(HttpStatus.CONFLICT,s);}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.fleet.reporting;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.time.*;
import java.util.*;
@Repository
public class TripOperationsRepository {
record Departure(String tripNo, String status, Instant completedAt, String snapshot, String measured) { }
private final JdbcTemplate jdbc;
public TripOperationsRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; }
List<Departure> departures(TripOperationsModels.Query query) {
StringBuilder sql = new StringBuilder("""
SELECT t.trip_no,t.status,t.completed_at,
(SELECT h.snapshot_json FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART'
ORDER BY h.record_version LIMIT 1) AS snapshot_json,
(SELECT r.details_json FROM ll_fleet_trip_operations_record r WHERE r.trip_no=t.trip_no
ORDER BY r.record_version DESC LIMIT 1) AS measured_json
FROM ll_fleet_trip t WHERE t.departed_at >= ? AND t.departed_at < ?
""");
var args = new ArrayList<Object>();
args.add(LocalDateTime.ofInstant(query.from(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.to(), ZoneOffset.UTC));
if (query.warehouseId() != null) { sql.append(" AND t.warehouse_id=?"); args.add(query.warehouseId()); }
if (query.status() != null && !query.status().isBlank()) { sql.append(" AND t.status=?"); args.add(query.status()); }
// Search by stable business number; vehicle/driver search is applied to the immutable departure snapshot.
sql.append(" ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001");
return jdbc.query(sql.toString(), (r, i) -> new Departure(r.getString("trip_no"), r.getString("status"),
r.getTimestamp("completed_at") == null ? null : r.getTimestamp("completed_at").toLocalDateTime().toInstant(ZoneOffset.UTC),
r.getString("snapshot_json"),r.getString("measured_json")), args.toArray());
}
}
@@ -0,0 +1,136 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.trip.*;
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.*;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
@Service
@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ)
public class TripOperationsService {
private final TripOperationsRepository repository;
private final ObjectMapper json;
private final FleetTripCostService costs;
public TripOperationsService(TripOperationsRepository repository, ObjectMapper json,FleetTripCostService costs) { this.repository=repository; this.json=json;this.costs=costs; }
public TripOperationsModels.Result report(TripOperationsModels.Query query,boolean costVisible) {
var rows = rows(query,costVisible); long total = rows.size();
int start = (int) Math.min(total, ((long) query.page()-1)*query.size());
return new TripOperationsModels.Result(new PageResult<>(rows.subList(start, Math.min(rows.size(),start+query.size())),
query.page(),query.size(),total,(total+query.size()-1)/query.size()), summary(rows,costVisible),TripOperationsCharts.from(rows));
}
List<TripOperationsModels.Row> rows(TripOperationsModels.Query query,boolean costVisible) {
if (query.from() == null || query.to() == null || !query.to().isAfter(query.from())
|| Duration.between(query.from(),query.to()).compareTo(Duration.ofDays(366))>0) throw bad("请选择不超过366天的实际发车时间范围");
var facts=repository.departures(query);
if (facts.size()>5000) throw bad("范围内超过5000车次,请缩小时间或仓库范围后查询或导出");
String keyword=query.keyword()==null?"":query.keyword().trim().toLowerCase(Locale.ROOT);
var rows=facts.stream().map(this::project)
.filter(r -> keyword.isEmpty() || contains(r.tripNo(),keyword) || contains(r.vehiclePlate(),keyword) || contains(r.driverName(),keyword) || contains(r.warehouseName(),keyword) || r.destinations().stream().anyMatch(s -> contains(s.label(),keyword)))
.filter(r -> query.sourceType()==null || query.sourceType().isBlank() || ("OWN".equals(query.sourceType())?r.ownPackages()>0:r.externalPackages()>0)).toList();
if(!costVisible)return rows.stream().map(r->r.withCosts(null,"FORBIDDEN")).toList();
var result=new ArrayList<TripOperationsModels.Row>();
for(int start=0;start<rows.size();start+=100) {
var chunk=rows.subList(start,Math.min(start+100,rows.size()));
var summaries=costs.summaries(chunk.stream().map(TripOperationsModels.Row::tripNo).toList());
for(var row:chunk) {
var summary=summaries.get(row.tripNo());
if(summary==null)throw new IllegalStateException("成本批量响应缺少车次"+row.tripNo());
result.add(row.withCosts(summary,summary.totals().isEmpty()?"NOT_RECORDED":"AVAILABLE"));
}
}
return List.copyOf(result);
}
TripOperationsModels.Row project(TripOperationsRepository.Departure fact) {
try {
if (fact.snapshot()==null) throw new IllegalStateException("实际发车缺少不可变历史,请核对车次"+fact.tripNo());
var snapshot=json.readValue(fact.snapshot(),FleetTripModels.AuditSnapshot.class);
var trip=snapshot.trip(); var load=snapshot.confirmedLoad();
if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty())
throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo());
var externalByPallet=new HashMap<String,Long>(); var money=new TreeMap<String,BigDecimal>(); var bookingNumbers=new HashSet<String>();
var rates=new ArrayList<TripOperationsModels.PalletRate>();
if (snapshot.externalBookings()!=null) for (var booking:snapshot.externalBookings()) {
if (Set.of("DRAFT","CANCELLED").contains(booking.status())) continue;
if (!"BOUND".equals(booking.status()) || !bookingNumbers.add(booking.bookingNo())) throw new IllegalStateException("发车外接记录未完成关联或重复");
if (booking.dispatchNo()==null || booking.agreedAmount()==null || booking.cargo()==null || booking.cargo().currency()==null)
throw new IllegalStateException("外接发车历史缺少约定报价或排车归属");
if(booking.receipt()==null || booking.receipt().source()==null || booking.receipt().source().pallets()==null)
throw new IllegalStateException("外接发车历史缺少实收来源");
long received=0;
for(var p:booking.receipt().source().pallets()) {
externalByPallet.merge(booking.dispatchNo()+":"+booking.customerId()+":"+p.warehousePalletId(),(long)p.packages(),Long::sum);
received+=p.packages();
}
if(received!=booking.receipt().source().receivedPackages())throw new IllegalStateException("外接实收来源数量未核平");
money.merge(booking.cargo().currency(),booking.agreedAmount(),BigDecimal::add);
if("PALLET".equals(booking.cargo().priceBasis())) {
if(booking.cargo().palletSpaces()==null || booking.cargo().palletSpaces()<1)throw new IllegalStateException("按托报价缺少确认计价托位");
rates.add(new TripOperationsModels.PalletRate(booking.cargo().currency(),booking.cargo().palletSpaces(),booking.agreedAmount()));
}
}
var pallets=new HashSet<String>(); long spaces=0;
for(var pallet:load.pallets()) {
if(!pallets.add(pallet.warehousePalletId())) throw new IllegalStateException("发车实物托盘重复");
spaces+=pallet.palletSpaces();
}
long own=0,external=0; var destinations=new TreeMap<String,String>(); var seen=new HashSet<String>();
var shippedByPallet=new HashMap<String,Long>();
for(var cargo:load.cargo()) {
if (!pallets.contains(cargo.warehousePalletId()) || !seen.add(cargo.dispatchNo()+":"+cargo.dispatchItemId()))
throw new IllegalStateException("发车装载货物未核平或重复");
shippedByPallet.merge(cargo.dispatchNo()+":"+cargo.customerId()+":"+cargo.warehousePalletId(),(long)cargo.packages(),Long::sum);
if(cargo.destinationKey()==null || cargo.destinationLabel()==null)throw new IllegalStateException("发车货物缺少目的地依据");
destinations.put(cargo.destinationKey(),cargo.destinationLabel());
}
for(var entry:externalByPallet.entrySet()) if(entry.getValue()>shippedByPallet.getOrDefault(entry.getKey(),0L))throw new IllegalStateException("外接实收超过实际发车货量");
external=externalByPallet.values().stream().mapToLong(Long::longValue).sum();
own=shippedByPallet.values().stream().mapToLong(Long::longValue).sum()-external;
return new TripOperationsModels.Row(fact.tripNo(),fact.status(),trip.vehiclePlate(),trip.driverName(),trip.warehouseName(),trip.departedAt(),fact.completedAt(),
destinations.entrySet().stream().map(e->new TripOperationsModels.Destination(e.getKey(),e.getValue())).toList(),pallets.size(),spaces,own,external,amounts(money),List.copyOf(rates),
fact.measured()==null?null:json.readValue(fact.measured(),TripOperationsRecordModels.Details.class),"NOT_QUERIED","NOT_QUERIED",null);
} catch(JsonProcessingException e) { throw new IllegalStateException("发车历史无法读取,请核对车次"+fact.tripNo(),e); }
}
static TripOperationsModels.Summary summary(List<TripOperationsModels.Row> rows,boolean costVisible) {
var money=new TreeMap<String,BigDecimal>();
rows.forEach(row -> row.externalAgreedAmounts().forEach(m -> money.merge(m.currency(),m.amount(),BigDecimal::add)));
return new TripOperationsModels.Summary(rows.size(),rows.stream().filter(r -> "IN_TRANSIT".equals(r.status())).count(),rows.stream().filter(r -> "COMPLETED".equals(r.status())).count(),
rows.stream().mapToLong(TripOperationsModels.Row::physicalPallets).sum(),rows.stream().mapToLong(TripOperationsModels.Row::palletSpaces).sum(),
rows.stream().mapToLong(TripOperationsModels.Row::ownPackages).sum(),rows.stream().mapToLong(TripOperationsModels.Row::externalPackages).sum(),amounts(money),costSummary(rows,costVisible));
}
private static TripOperationsModels.CostSummary costSummary(List<TripOperationsModels.Row> rows,boolean visible) {
if(!visible)return new TripOperationsModels.CostSummary(false,null,null,List.of());
var totals=new TreeMap<String,TripOperationsModels.CostTotal>();long unrecorded=0,unallocated=0;
for(var row:rows) {
if(row.costs().totals().isEmpty())unrecorded++;
else if(row.costs().allocationBlockReason()!=null)unallocated++;
for(var total:row.costs().totals()) {
var old=totals.get(total.currency());
totals.put(total.currency(),old==null?new TripOperationsModels.CostTotal(total.currency(),total.directCost(),total.ownCost(),total.externalCost())
:new TripOperationsModels.CostTotal(total.currency(),old.directCost().add(total.directCost()),addKnown(old.ownCost(),total.ownCost()),addKnown(old.externalCost(),total.externalCost())));
}
}
return new TripOperationsModels.CostSummary(true,unrecorded,unallocated,List.copyOf(totals.values()));
}
private static BigDecimal addKnown(BigDecimal a,BigDecimal b){return a==null||b==null?null:a.add(b);}
public byte[] exportCsv(TripOperationsModels.Query query,boolean costVisible) {
var csv=new StringBuilder("\uFEFF车次号,状态,车牌,司机,起运仓,实际发车UTC,货物结案UTC,目的地,实物托盘数,标准托位,自有箱数,外接箱数,外接约定金额(按币种),实际里程(mile),实际油耗(L),实测备注,应收与实收,直接成本\r\n");
for(var r:rows(query,costVisible)) csv.append(String.join(",",cell(r.tripNo()),cell(r.status()),cell(r.vehiclePlate()),cell(r.driverName()),cell(r.warehouseName()),cell(r.departedAt()),cell(r.completedAt()),
cell(r.destinations().stream().map(TripOperationsModels.Destination::label).collect(java.util.stream.Collectors.joining(" / "))),cell(r.physicalPallets()),cell(r.palletSpaces()),cell(r.ownPackages()),cell(r.externalPackages()),
cell(r.externalAgreedAmounts().stream().map(m -> m.currency()+" "+m.amount().toPlainString()).collect(java.util.stream.Collectors.joining(" / "))),
cell(r.measured()==null?null:r.measured().actualDistanceMiles()),cell(r.measured()==null?null:r.measured().actualFuelLitres()),cell(r.measured()==null?null:r.measured().remark()),cell("未查询财务"),cell(costText(r)))).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private static String costText(TripOperationsModels.Row row){return row.costs()==null?"无成本读取权限":row.costs().totals().isEmpty()?"未登记成本":row.costs().totals().stream().map(t->t.currency()+" "+t.directCost().toPlainString()).collect(java.util.stream.Collectors.joining(" / "));}
private static List<TripOperationsModels.Money> amounts(Map<String,BigDecimal> money) { return money.entrySet().stream().map(e -> new TripOperationsModels.Money(e.getKey(),e.getValue())).toList(); }
private static boolean contains(String value,String key) { return value!=null && value.toLowerCase(Locale.ROOT).contains(key); }
static String cell(Object value) { String s=value==null?"":value.toString(); if(s.matches("(?s)^[\\s]*[=+@-].*")) s="'"+s; return "\""+s.replace("\"","\"\"")+"\""; }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
}
@@ -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(),
@@ -17,7 +17,8 @@ public class UnloadCompletedOutboxRepository {
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
EventTypes.OVERSEAS_DELIVERY_COMPLETED,
EventTypes.WALLET_COMMAND_REQUESTED);
EventTypes.WALLET_COMMAND_REQUESTED,
EventTypes.COMPANY_VEHICLE_COST_SUBMITTED);
@Resource private LastlegEventOutboxMapper mapper;
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {