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
+5 -1
View File
@@ -75,7 +75,11 @@
## 海外仓全流程开发路由
- 车辆履约只读投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同 `src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml`。车辆路径下`deliveries`同时要求vehicle:list与delivery:list,`assignment-history`同时要求vehicle:list与trip:list;不新增权限码或跨库查询。计划派送归当前分配车辆,实际运输归不可变DEPART快照;客户与排车双键关联。更换读取相邻版本ASSIGN快照,首次分配不算更换,旧车/新车/仅换司机分别呈现,不按当前车牌猜历史。列表分页在只读重复读事务内核对;内部ID及整份客户快照不直接返回。
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
- 车辆履约投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同`fleet-vehicle-fulfillment-v1.yaml`。派送同时要求vehicle:list与delivery:list,更换历史要求vehicle:list与trip:list;客户与排车双键关联,不跨库。按不可变DEPART/ASSIGN关联车辆,首次分配不算更换,不按当前车牌猜历史。只读重复读事务分页;不返回内部ID或整份客户快照。
- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。
- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。
@@ -29,7 +29,9 @@ test('fleet stays a business slice and its policies have no infrastructure depen
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0);
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)),
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController'
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'),
'dispatch must use fleet ports and guards, not its persistence mappers');
const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(incomingEdges.length > 0);
@@ -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) {
@@ -1212,6 +1212,138 @@ CREATE TABLE ll_fleet_trip_history (
CONSTRAINT fk_fleet_trip_history FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输车次操作历史';
CREATE TABLE ll_fleet_trip_operations_record (
id BIGINT NOT NULL COMMENT '出车实测历史主键',
trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',
record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增',
details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照',
reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明',
operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识',
request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键',
request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要',
occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_operations_version (trip_no,record_version),
UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录';
CREATE TABLE ll_fleet_trip_handover (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '所属车次',
trip_version BIGINT NOT NULL COMMENT '交接对应车次历史版本',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
handover_no VARCHAR(32) NOT NULL COMMENT '交接业务编号',
request_key VARCHAR(128) NOT NULL COMMENT '交接请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '交接请求摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '资源结束并发版本',
handed_over_at DATETIME(3) NOT NULL COMMENT '实际交接UTC时间',
snapshot_json JSON NOT NULL COMMENT '不可变资源及剩余货物交接快照',
vehicle_release_json JSON NULL COMMENT '原车辆任务结束及幂等结果',
driver_release_json JSON NULL COMMENT '原司机任务结束及幂等结果',
created_by VARCHAR(64) NOT NULL COMMENT '记录操作人',
created_at DATETIME(3) NOT NULL COMMENT '记录UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_handover_no (handover_no),
UNIQUE KEY uk_fleet_handover_request (trip_id, request_key),
UNIQUE KEY uk_fleet_handover_version (trip_id, trip_version),
KEY idx_fleet_handover_page (trip_id, id),
CONSTRAINT fk_fleet_handover_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在途车辆司机交接';
CREATE TABLE ll_fleet_finance_source (
id BIGINT UNSIGNED NOT NULL COMMENT '来源提交主键',
record_id BIGINT UNSIGNED NOT NULL COMMENT '车辆运维记录主键',
source_version BIGINT UNSIGNED NOT NULL COMMENT '独立财务来源版本,从1递增',
record_version BIGINT UNSIGNED NOT NULL COMMENT '提交时运维记录版本',
operator_id VARCHAR(64) NOT NULL COMMENT '提交操作人标识',
request_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键',
request_hash VARCHAR(71) NOT NULL COMMENT '规范请求摘要,含sha256前缀',
event_id VARCHAR(128) NOT NULL COMMENT '关联公共Outbox事件ID',
payload_json JSON NOT NULL COMMENT '不可变公司成本来源快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_finance_source_version (record_id,source_version),
UNIQUE KEY uk_fleet_finance_source_request (record_id,operator_id,request_key),
UNIQUE KEY uk_fleet_finance_source_event (event_id),
CONSTRAINT chk_fleet_finance_source_version CHECK (source_version>0)
) ENGINE=InnoDB COMMENT='维修救援公司成本提交来源,不保存付款推定';
CREATE TABLE ll_fleet_quote (
id BIGINT PRIMARY KEY COMMENT '报价记录主键',
quote_no VARCHAR(64) NOT NULL COMMENT '报价业务编号',
kind VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售',
party_id BIGINT NOT NULL COMMENT '供应商或客户主档标识',
party_name VARCHAR(160) NOT NULL COMMENT '往来单位名称快照',
origin_city VARCHAR(128) NOT NULL COMMENT '发货城市',
destination_code VARCHAR(64) NOT NULL COMMENT '目的仓业务编码',
valid_from DATE NOT NULL COMMENT '报价生效日期',
valid_to DATE NOT NULL COMMENT '报价失效日期含当天',
status VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态',
natural_key CHAR(64) NOT NULL COMMENT '报价组合去重摘要',
details_json JSON NOT NULL COMMENT '完整报价明细快照',
record_version BIGINT NOT NULL COMMENT '报价记录版本',
create_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
create_hash CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要',
created_by VARCHAR(64) NOT NULL COMMENT '创建操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC',
updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC',
UNIQUE KEY uk_fleet_quote_no (quote_no),
UNIQUE KEY uk_fleet_quote_natural (natural_key),
UNIQUE KEY uk_fleet_quote_create (created_by,create_key),
KEY idx_fleet_quote_search (kind,status,valid_from,valid_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价';
CREATE TABLE ll_fleet_quote_history (
id BIGINT PRIMARY KEY COMMENT '报价历史主键',
quote_id BIGINT NOT NULL COMMENT '报价记录标识',
record_version BIGINT NOT NULL COMMENT '报价记录版本',
action VARCHAR(24) NOT NULL COMMENT '报价操作类型',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人标识',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC',
snapshot_json JSON NOT NULL COMMENT '报价完整版本快照',
UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史';
CREATE TABLE ll_fleet_trip_cost (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '车次内部引用',
cost_no VARCHAR(32) NOT NULL COMMENT '车次成本业务编号',
category VARCHAR(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER',
currency VARCHAR(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总',
amount DECIMAL(14,2) NOT NULL COMMENT '运营登记金额,不代表付款',
incurred_at DATETIME(3) NOT NULL COMMENT '实际费用发生UTC时间',
remark VARCHAR(1000) NULL COMMENT '费用备注',
record_version BIGINT NOT NULL COMMENT '成本记录版本',
request_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
request_hash CHAR(64) NOT NULL COMMENT '创建请求摘要',
created_by VARCHAR(64) NOT NULL COMMENT '登记人',
updated_by VARCHAR(64) NOT NULL COMMENT '最近更正人',
created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '更正UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_trip_cost_no(cost_no),
UNIQUE KEY uk_fleet_trip_cost_request(trip_id,created_by,request_key),
KEY idx_fleet_trip_cost_page(trip_id,incurred_at,id),
CONSTRAINT fk_fleet_trip_cost_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT ck_fleet_trip_cost_amount CHECK(amount>=0),
CONSTRAINT ck_fleet_trip_cost_currency CHECK(currency IN ('CNY','USD')),
CONSTRAINT ck_fleet_trip_cost_category CHECK(category IN ('FUEL','DRIVER','TOLL','PARKING','OTHER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次直接运营成本,不生成客户账单或付款';
CREATE TABLE ll_fleet_trip_cost_history (
id BIGINT NOT NULL COMMENT '内部主键',
cost_id BIGINT NOT NULL COMMENT '成本记录内部引用',
record_version BIGINT NOT NULL COMMENT '成本更正后版本',
action VARCHAR(16) NOT NULL COMMENT '成本操作CREATE/UPDATE',
reason VARCHAR(1000) NULL COMMENT '登记或更正说明',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
snapshot_json JSON NOT NULL COMMENT '成本与当时整趟汇总及分摊快照',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version),
CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''投递租约获取时间'' AFTER published_at'));
PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''投递租约持有者'' AFTER locked_at'));
@@ -0,0 +1,290 @@
openapi: 3.0.3
info:
title: 车辆在途交接
version: 1.0.0
description: 从不可变DEPART及真实派送终态核对剩余货物。交接不重新出库或收费,不重写原始发车。旧车辆和司机分别确认实际任务结束后释放。
security:
- bearerAuth: []
paths:
/api/pda/v1/last-leg/dispatches/{dispatchNo}/handover-status:
get:
description: lastleg:pda:operate及ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR;只读,仓库来自Token。只返回所选排车的原发车资源、逐派送当前资源及涉及该排车的最近交接,不返回其他客户货物或内部ID。不提供PDA交接确认写接口。
parameters:
- { name: dispatchNo, in: path, required: true, schema: { type: string } }
responses:
'200':
description: 当前排车运输及交接状态
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
type: object
properties:
dispatchNo: { type: string }
tripNo: { type: string }
tripStatus: { type: string, enum: [IN_TRANSIT, COMPLETED] }
tripRecordVersion: { type: string }
originalDeparture: { $ref: '#/components/schemas/Transport' }
deliveries:
type: array
items:
type: object
properties:
deliveryNo: { type: string }
status: { type: string }
packages: { type: integer }
terminalAt: { type: string, format: date-time, nullable: true }
transport: { $ref: '#/components/schemas/Transport' }
latestHandover:
type: object
nullable: true
properties:
handoverNo: { type: string }
handedOverAt: { type: string, format: date-time }
location: { type: string }
previous: { $ref: '#/components/schemas/Transport' }
replacement: { $ref: '#/components/schemas/Transport' }
/api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers:
parameters:
- $ref: '#/components/parameters/TripNo'
get:
description: platform:fleet:trip:list;按车次分页读取交接及原资源任务结束记录。
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
responses:
'200':
description: 业务响应
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
type: object
required: [records, page, size, total, pages]
properties:
records: { type: array, items: { $ref: '#/components/schemas/Handover' } }
page: { type: integer }
size: { type: integer }
total: { type: integer, format: int64 }
pages: { type: integer }
post:
description: platform:fleet:trip:handover;同车次请求键唯一,异内容拒绝;先锁车次及新资源,再锁原客户执行单复核。原资源仍占用,车次保持IN_TRANSIT,仅未完成派送更新资源。失败整体回滚。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [selection, cargoHash, location, idempotencyKey]
properties:
selection: { $ref: '#/components/schemas/Selection' }
cargoHash: { type: string, pattern: '^[0-9a-f]{64}$' }
location: { type: string, minLength: 1, maxLength: 500 }
reason: { type: string, maxLength: 1000 }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
responses:
'200': { $ref: '#/components/responses/Handover' }
/api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers/preview:
parameters:
- $ref: '#/components/parameters/TripNo'
post:
description: platform:fleet:trip:list;只核对不写入。资格、容量、占用和派送事实变化使cargoHash失效。已签收后另退货使用原签收事实,未签收退回必须有RETURN_COMPLETED证据。部分同托仍用原整托实测,不按箱数比例猜重量体积。
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Selection' }
responses:
'200':
description: 交接预览
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
type: object
required: [tripNo, recordVersion, cargoHash, previous, replacement, capacity, cargo, driverQualification]
properties:
tripNo: { type: string }
recordVersion: { type: integer, format: int64 }
cargoHash: { type: string }
previous: { $ref: '#/components/schemas/Resource' }
replacement: { $ref: '#/components/schemas/Resource' }
capacity: { $ref: '#/components/schemas/Load' }
cargo: { $ref: '#/components/schemas/Inspection' }
driverQualification:
type: object
properties:
canDispatch: { type: boolean }
expiringSoon: { type: boolean }
reason: { type: string }
licenseExpiresOn: { type: string, format: date, nullable: true }
/api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers/{handoverNo}/resources/release:
parameters:
- $ref: '#/components/parameters/TripNo'
- { name: handoverNo, in: path, required: true, schema: { type: string } }
post:
description: platform:fleet:trip:release;分别结束被接替车辆或司机任务,可早于整车货物结案。未变化资源仍由原车次结案后释放。原请求重放不操作后续占用,车辆状态与结束记录同事务提交。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [recordVersion, resourceKind, actualEndAt, location, idempotencyKey]
properties:
recordVersion: { type: integer, format: int64, minimum: 0, description: 交接记录版本 }
resourceKind: { type: string, enum: [VEHICLE, DRIVER] }
actualEndAt: { type: string, format: date-time }
location: { type: string, minLength: 1, maxLength: 500 }
vehicleStatus: { type: string, nullable: true, enum: [IN_SERVICE, MAINTENANCE, INACTIVE, RETURNED], description: 结束原车辆必填;司机不得提交 }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
responses:
'200': { $ref: '#/components/responses/Handover' }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
parameters:
TripNo: { name: tripNo, in: path, required: true, schema: { type: string } }
responses:
Handover:
description: 已保存的交接事实及资源结束状态
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data: { $ref: '#/components/schemas/Handover' }
schemas:
Transport:
type: object
properties:
vehiclePlate: { type: string, nullable: true }
driverName: { type: string, nullable: true }
driverPhone: { type: string, nullable: true }
Envelope:
type: object
required: [code]
properties:
code: { type: string, example: '200' }
message: { type: string }
Selection:
type: object
required: [recordVersion, vehicleId, driverId, expectedDriverEndAt, handedOverAt]
properties:
recordVersion: { type: integer, format: int64, minimum: 0 }
vehicleId: { type: string, pattern: '^[1-9][0-9]*$' }
driverId: { type: string, pattern: '^[1-9][0-9]*$' }
expectedDriverEndAt: { type: string, format: date-time }
handedOverAt: { type: string, format: date-time }
Resource:
type: object
properties:
vehicleId: { type: string }
vehiclePlate: { type: string, nullable: true }
driverId: { type: string }
driverName: { type: string, nullable: true }
driverPhone: { type: string, nullable: true }
Load:
type: object
properties:
palletSpaces: { type: integer }
weightKg: { type: number }
volumeCbm: { type: number }
Cargo:
type: object
properties:
customerId: { type: string, description: 隔离键,不作客户名称展示 }
dispatchNo: { type: string }
itemId: { type: string, description: 内部核对键,不作业务编号展示 }
palletId: { type: string, description: 内部核对键,不作托盘码展示 }
destinationKey: { type: string, description: 匹配键,私人地址摘要不可作地址展示 }
packages: { type: integer }
Delivery:
type: object
properties:
customerId: { type: string }
dispatchNo: { type: string }
deliveryNo: { type: string }
destinationKey: { type: string }
packages: { type: integer }
terminalAt: { type: string, format: date-time, nullable: true }
Inspection:
type: object
properties:
remainingCargo: { type: array, items: { $ref: '#/components/schemas/Cargo' } }
completedDeliveries: { type: array, items: { $ref: '#/components/schemas/Delivery' } }
remainingPalletIds: { type: array, uniqueItems: true, items: { type: string } }
occupied: { $ref: '#/components/schemas/Load' }
Snapshot:
type: object
properties:
previous: { $ref: '#/components/schemas/Resource' }
replacement: { $ref: '#/components/schemas/Resource' }
handedOverAt: { type: string, format: date-time }
location: { type: string }
reason: { type: string }
expectedDriverEndAt: { type: string, format: date-time }
capacity: { $ref: '#/components/schemas/Load' }
cargo: { $ref: '#/components/schemas/Inspection' }
executionEvidence:
type: array
items:
type: object
properties:
dispatchNo: { type: string }
customerId: { type: string }
warehouseId: { type: string }
shippedPackages: { type: integer }
deliveries:
type: array
items:
type: object
properties:
cargo: { $ref: '#/components/schemas/Delivery' }
status: { type: string }
recordVersion: { type: integer, format: int64 }
transport: { $ref: '#/components/schemas/Transport' }
operatorId: { type: string, description: 审计身份,不作姓名展示 }
carriedDeliveries:
type: array
description: 当次实际交接的未完成派送身份,用于车辆运输区段查询
items:
type: object
properties:
customerId: { type: string }
deliveryNo: { type: string }
recordedAt: { type: string, format: date-time }
Released:
type: object
nullable: true
properties:
idempotencyKey: { type: string }
requestHash: { type: string }
actualEndAt: { type: string, format: date-time }
location: { type: string }
vehicleStatus: { type: string, nullable: true }
operatorId: { type: string }
recordedAt: { type: string, format: date-time }
Handover:
type: object
properties:
handoverNo: { type: string }
tripNo: { type: string }
recordVersion: { type: integer, format: int64 }
snapshot: { $ref: '#/components/schemas/Snapshot' }
vehicleRelease: { $ref: '#/components/schemas/Released' }
driverRelease: { $ref: '#/components/schemas/Released' }
@@ -0,0 +1,928 @@
{
"openapi": "3.0.3",
"info": {
"title": "真实出车与自有外接运营统计",
"version": "1.0.0",
"description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;财务未查询明确NOT_QUERIED;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
},
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/api/platform/v1/last-leg/fleet/operations": {
"get": {
"description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 1,
"minimum": 1
}
},
{
"name": "size",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 20,
"minimum": 1,
"maximum": 200
}
},
{
"name": "from",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date-time"
}
},
{
"name": "to",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date-time"
}
},
{
"name": "warehouseId",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1
}
},
{
"name": "keyword",
"in": "query",
"required": false,
"schema": {
"type": "string",
"maxLength": 128
}
},
{
"name": "sourceType",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"OWN",
"EXTERNAL"
]
}
},
{
"name": "status",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"IN_TRANSIT",
"COMPLETED"
]
}
}
],
"responses": {
"200": {
"description": "业务响应;成功code为200,业务失败不代表写入成功",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"page": {
"type": "object",
"properties": {
"records": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Row"
}
},
"page": {
"type": "integer",
"minimum": 0
},
"size": {
"type": "integer",
"minimum": 0
},
"total": {
"type": "integer",
"minimum": 0
},
"pages": {
"type": "integer",
"minimum": 0
}
},
"required": [
"records",
"page",
"size",
"total",
"pages"
]
},
"summary": {
"$ref": "#/components/schemas/Summary"
},
"charts": {
"$ref": "#/components/schemas/Charts"
}
},
"required": [
"page",
"summary",
"charts"
]
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/fleet/operations/export": {
"get": {
"description": "platform:fleet:operations:export;同筛选同事实来源的CSV",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 1,
"minimum": 1
}
},
{
"name": "size",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 20,
"minimum": 1,
"maximum": 200
}
},
{
"name": "from",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date-time"
}
},
{
"name": "to",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date-time"
}
},
{
"name": "warehouseId",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1
}
},
{
"name": "keyword",
"in": "query",
"required": false,
"schema": {
"type": "string",
"maxLength": 128
}
},
{
"name": "sourceType",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"OWN",
"EXTERNAL"
]
}
},
{
"name": "status",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"IN_TRANSIT",
"COMPLETED"
]
}
}
],
"responses": {
"200": {
"description": "UTF-8 BOM CSV",
"content": {
"text/csv": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/fleet/operations/{tripNo}/record": {
"parameters": [
{
"name": "tripNo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"description": "platform:fleet:operations:list;仅实际已发车车次;尚未登记返回版本0和空值,不伪造零里程/油耗",
"responses": {
"200": {
"description": "业务响应,成功code为200",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/MeasuredRecord"
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
},
"put": {
"description": "platform:fleet:operations:edit;父车次行锁、版本与原操作人请求键校验,原请求返回原快照,更正追加不可变历史。空值区别零,说明选填。不修改主档里程、车次执行、库存或费用。",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"recordVersion": {
"type": "integer",
"minimum": 0
},
"idempotencyKey": {
"type": "string",
"minLength": 8,
"maxLength": 128
},
"details": {
"$ref": "#/components/schemas/Measured"
},
"reason": {
"type": "string",
"maxLength": 1000,
"nullable": true
}
},
"required": [
"recordVersion",
"idempotencyKey",
"details",
"reason"
]
}
}
}
},
"responses": {
"200": {
"description": "业务响应,成功code为200",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/MeasuredRecord"
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/fleet/operations/{tripNo}/record/history": {
"parameters": [
{
"name": "tripNo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"description": "platform:fleet:operations:list;实测更正历史,按版本倒序分页",
"parameters": [
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
{
"name": "size",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
}
}
],
"responses": {
"200": {
"description": "业务响应,成功code为200",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"records": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MeasuredHistory"
}
},
"page": {
"type": "integer",
"minimum": 0
},
"size": {
"type": "integer",
"minimum": 0
},
"total": {
"type": "integer",
"minimum": 0
},
"pages": {
"type": "integer",
"minimum": 0
}
},
"required": [
"records",
"page",
"size",
"total",
"pages"
]
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"schemas": {
"Money": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"amount": {
"type": "number",
"minimum": 0
}
},
"required": [
"currency",
"amount"
]
},
"Row": {
"type": "object",
"properties": {
"tripNo": {
"type": "string"
},
"status": {
"type": "string"
},
"vehiclePlate": {
"type": "string",
"nullable": true
},
"driverName": {
"type": "string",
"nullable": true
},
"warehouseName": {
"type": "string",
"nullable": true
},
"departedAt": {
"type": "string",
"format": "date-time"
},
"completedAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"destinations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Destination"
}
},
"physicalPallets": {
"type": "integer",
"minimum": 0
},
"palletSpaces": {
"type": "integer",
"minimum": 0
},
"ownPackages": {
"type": "integer",
"minimum": 0
},
"externalPackages": {
"type": "integer",
"minimum": 0
},
"externalAgreedAmounts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Money"
}
},
"financeStatus": {
"type": "string",
"enum": [
"NOT_QUERIED"
]
},
"costStatus": {
"type": "string",
"enum": [
"FORBIDDEN",
"NOT_RECORDED",
"AVAILABLE"
]
},
"externalPalletRates": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PalletRate"
}
},
"measured": {
"allOf": [
{
"$ref": "#/components/schemas/Measured"
}
],
"nullable": true
},
"costs": {
"allOf": [
{
"$ref": "./fleet-trip-costs-v1.yaml#/components/schemas/Summary"
}
],
"nullable": true
}
},
"required": [
"tripNo",
"status",
"vehiclePlate",
"driverName",
"warehouseName",
"departedAt",
"completedAt",
"destinations",
"physicalPallets",
"palletSpaces",
"ownPackages",
"externalPackages",
"externalAgreedAmounts",
"financeStatus",
"costStatus",
"externalPalletRates",
"measured",
"costs"
]
},
"Summary": {
"type": "object",
"properties": {
"trips": {
"type": "integer",
"minimum": 0
},
"inTransit": {
"type": "integer",
"minimum": 0
},
"completed": {
"type": "integer",
"minimum": 0
},
"physicalPallets": {
"type": "integer",
"minimum": 0
},
"palletSpaces": {
"type": "integer",
"minimum": 0
},
"ownPackages": {
"type": "integer",
"minimum": 0
},
"externalPackages": {
"type": "integer",
"minimum": 0
},
"externalAgreedAmounts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Money"
}
},
"costs": {
"$ref": "#/components/schemas/CostSummary"
}
},
"required": [
"trips",
"inTransit",
"completed",
"physicalPallets",
"palletSpaces",
"ownPackages",
"externalPackages",
"externalAgreedAmounts",
"costs"
]
},
"Measured": {
"type": "object",
"properties": {
"actualDistanceMiles": {
"type": "number",
"minimum": 0,
"nullable": true,
"multipleOf": 0.1
},
"actualFuelLitres": {
"type": "number",
"minimum": 0,
"nullable": true,
"multipleOf": 0.1
},
"remark": {
"type": "string",
"nullable": true,
"maxLength": 1000
}
},
"required": [
"actualDistanceMiles",
"actualFuelLitres",
"remark"
]
},
"Destination": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"label": {
"type": "string"
}
},
"required": [
"key",
"label"
]
},
"PalletRate": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"pricedPalletSpaces": {
"type": "integer",
"minimum": 0
},
"agreedAmount": {
"type": "number",
"minimum": 0
}
},
"required": [
"currency",
"pricedPalletSpaces",
"agreedAmount"
]
},
"Series": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"unit": {
"type": "string"
},
"points": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"value": {
"type": "number",
"minimum": 0
}
},
"required": [
"label",
"value"
]
}
}
},
"required": [
"label",
"unit",
"points"
]
},
"Charts": {
"type": "object",
"properties": {
"physicalPallets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Series"
}
},
"destinationTrips": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Series"
}
},
"externalAgreedAmounts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Series"
}
},
"externalPalletRates": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Series"
}
}
},
"required": [
"physicalPallets",
"destinationTrips",
"externalAgreedAmounts",
"externalPalletRates"
]
},
"MeasuredRecord": {
"type": "object",
"properties": {
"tripNo": {
"type": "string"
},
"recordVersion": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"details": {
"$ref": "#/components/schemas/Measured"
},
"updatedAt": {
"type": "string",
"format": "date-time",
"nullable": true
}
},
"required": [
"tripNo",
"recordVersion",
"details",
"updatedAt"
]
},
"MeasuredHistory": {
"type": "object",
"properties": {
"recordVersion": {
"type": "integer",
"minimum": 0
},
"reason": {
"type": "string"
},
"occurredAt": {
"type": "string",
"format": "date-time"
},
"details": {
"$ref": "#/components/schemas/Measured"
}
},
"required": [
"recordVersion",
"reason",
"occurredAt",
"details"
]
},
"CostTotal": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"directCost": {
"type": "number",
"minimum": 0
},
"ownCost": {
"type": "number",
"minimum": 0,
"nullable": true
},
"externalCost": {
"type": "number",
"minimum": 0,
"nullable": true
}
},
"required": [
"currency",
"directCost",
"ownCost",
"externalCost"
]
},
"CostSummary": {
"type": "object",
"properties": {
"visible": {
"type": "boolean"
},
"unrecordedTrips": {
"type": "integer",
"minimum": 0,
"nullable": true
},
"unallocatedTrips": {
"type": "integer",
"minimum": 0,
"nullable": true
},
"totals": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CostTotal"
}
}
},
"required": [
"visible",
"unrecordedTrips",
"unallocatedTrips",
"totals"
]
}
}
},
"x-integration-database": {
"owner": "总控负责集成模块基线与海外统一初始化SQL,专项不执行SQL。新增权限platform:fleet:operations:edit。",
"ddl": "CREATE TABLE ll_fleet_trip_operations_record (\n id BIGINT NOT NULL COMMENT '出车实测历史主键',\n trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',\n record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增',\n details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照',\n reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明',\n operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识',\n request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键',\n request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要',\n occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC',\n PRIMARY KEY (id),\n UNIQUE KEY uk_fleet_operations_version (trip_no,record_version),\n UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录';"
}
}
@@ -0,0 +1,887 @@
{
"openapi": "3.0.3",
"info": {
"title": "车辆采购与外接销售报价库",
"version": "1.0.0",
"description": "采购价关联公共TRUCKING+OVERSEAS有效供应商,销售价关联有效且具备下单资格的客户。报价仅为价格库,不写入成本/应收/应付,不自动覆盖车次成交快照。整车自提与整车派送按单,散板派送按托。日期为路线业务日(含失效当天),未自动匹配成交;同维度同有效期禁止重复,重叠有效期须由运营明确选择具体报价版本。CSV导入界面解析后提交结构化行,整批原子写入;不支持部分成功。查询/导出最多5000条,导入最多200行。历史接口仅返回最新200版。"
},
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/api/platform/v1/last-leg/fleet/quotes": {
"get": {
"description": "platform:fleet:quote:list;包含详情只读",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
{
"name": "size",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 20
}
},
{
"name": "kind",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"PURCHASE",
"SALE"
]
}
},
{
"name": "keyword",
"in": "query",
"required": false,
"schema": {
"type": "string",
"maxLength": 128
}
},
{
"name": "status",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"ACTIVE",
"INACTIVE"
]
}
},
{
"name": "effectiveOn",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date"
}
}
],
"responses": {
"200": {
"description": "业务响应;成功code为200,业务失败不代表写入成功",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"records": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Quote"
}
},
"page": {
"type": "integer",
"minimum": 0
},
"size": {
"type": "integer",
"minimum": 0
},
"total": {
"type": "integer",
"minimum": 0
},
"pages": {
"type": "integer",
"minimum": 0
}
},
"required": [
"records",
"page",
"size",
"total",
"pages"
]
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
},
"post": {
"description": "platform:fleet:quote:create;原操作人+幂等键唯一,异内容拒绝。目录校验失败不写入。",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"idempotencyKey": {
"type": "string",
"minLength": 8,
"maxLength": 128
},
"details": {
"$ref": "#/components/schemas/Draft"
}
},
"required": [
"idempotencyKey",
"details"
]
}
}
}
},
"responses": {
"200": {
"description": "业务响应;成功code为200,业务失败不代表写入成功",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Quote"
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/fleet/quotes/{quoteNo}": {
"parameters": [
{
"name": "quoteNo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"description": "platform:fleet:quote:list",
"responses": {
"200": {
"description": "业务响应;成功code为200,业务失败不代表写入成功",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Quote"
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
},
"put": {
"description": "platform:fleet:quote:edit;行锁+版本,更正保留旧版,用途不可变",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"recordVersion": {
"type": "integer",
"minimum": 0
},
"details": {
"$ref": "#/components/schemas/Draft"
}
},
"required": [
"recordVersion",
"details"
]
}
}
}
},
"responses": {
"200": {
"description": "业务响应;成功code为200,业务失败不代表写入成功",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Quote"
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
},
"delete": {
"description": "platform:fleet:quote:delete;保存DELETED历史,退出报价库;不删除已成交业务快照",
"parameters": [
{
"name": "recordVersion",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"minimum": 0
}
}
],
"responses": {
"200": {
"description": "业务响应;成功code为200,业务失败不代表写入成功",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Quote"
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/fleet/quotes/{quoteNo}/status": {
"parameters": [
{
"name": "quoteNo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"patch": {
"description": "platform:fleet:quote:status;启用时重新校验目录",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"recordVersion": {
"type": "integer",
"minimum": 0
},
"status": {
"type": "string",
"enum": [
"ACTIVE",
"INACTIVE"
]
}
},
"required": [
"recordVersion",
"status"
]
}
}
}
},
"responses": {
"200": {
"description": "业务响应;成功code为200,业务失败不代表写入成功",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Quote"
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/fleet/quotes/{quoteNo}/history": {
"parameters": [
{
"name": "quoteNo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"description": "platform:fleet:quote:list;最新200个不可变版本",
"responses": {
"200": {
"description": "业务响应;成功code为200,业务失败不代表写入成功",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/History"
}
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/fleet/quotes/customer-options": {
"get": {
"description": "platform:fleet:quote:list;复用IAM有效客户目录。供应商候选由Admin调用现有/platform/v1/suppliers,须platform:supplier:list,非此接口扩权。",
"parameters": [
{
"name": "keyword",
"in": "query",
"required": false,
"schema": {
"type": "string",
"maxLength": 128
}
}
],
"responses": {
"200": {
"description": "业务响应;成功code为200,业务失败不代表写入成功",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"code": {
"type": "string"
},
"name": {
"type": "string"
},
"contactName": {
"type": "string",
"nullable": true
},
"contactPhone": {
"type": "string",
"nullable": true
}
},
"required": [
"id",
"code",
"name",
"contactName",
"contactPhone"
]
}
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/fleet/quotes/import": {
"post": {
"description": "platform:fleet:quote:import;最多200行,所有行与审计同事务提交;原批次键的行数或内容变化拒绝",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"idempotencyKey": {
"type": "string",
"minLength": 8,
"maxLength": 100
},
"rows": {
"type": "array",
"minItems": 1,
"maxItems": 200,
"items": {
"$ref": "#/components/schemas/Draft"
}
}
},
"required": [
"idempotencyKey",
"rows"
]
}
}
}
},
"responses": {
"200": {
"description": "业务响应;成功code为200,业务失败不代表写入成功",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"importedRows": {
"type": "integer",
"minimum": 0
},
"records": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Quote"
}
}
},
"required": [
"importedRows",
"records"
]
}
},
"required": [
"code",
"message",
"data"
]
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/fleet/quotes/export": {
"get": {
"description": "platform:fleet:quote:export;全筛选CSV,最多5000行,公式注入转义,空报价保留空值,币种不混合",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
{
"name": "size",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 20
}
},
{
"name": "kind",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"PURCHASE",
"SALE"
]
}
},
{
"name": "keyword",
"in": "query",
"required": false,
"schema": {
"type": "string",
"maxLength": 128
}
},
{
"name": "status",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"ACTIVE",
"INACTIVE"
]
}
},
{
"name": "effectiveOn",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date"
}
}
],
"responses": {
"200": {
"description": "UTF-8 BOM CSV",
"content": {
"text/csv": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"schemas": {
"Draft": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"PURCHASE",
"SALE"
]
},
"partyId": {
"type": "integer",
"format": "int64",
"minimum": 1
},
"originCity": {
"type": "string",
"maxLength": 128
},
"destinationCode": {
"type": "string",
"maxLength": 64
},
"distanceMiles": {
"type": "number",
"minimum": 0,
"multipleOf": 0.1
},
"currency": {
"type": "string",
"enum": [
"USD",
"CNY"
]
},
"selfPickupPrice": {
"type": "number",
"minimum": 0,
"nullable": true
},
"fullDeliveryPrice": {
"type": "number",
"minimum": 0,
"nullable": true
},
"looseDeliveryPrice": {
"type": "number",
"minimum": 0,
"nullable": true
},
"validFrom": {
"type": "string",
"format": "date"
},
"validTo": {
"type": "string",
"format": "date"
},
"remark": {
"type": "string",
"nullable": true,
"maxLength": 1000
}
},
"required": [
"kind",
"partyId",
"originCity",
"destinationCode",
"distanceMiles",
"currency",
"validFrom",
"validTo"
]
},
"Quote": {
"type": "object",
"properties": {
"quoteNo": {
"type": "string"
},
"details": {
"$ref": "#/components/schemas/Draft"
},
"partyName": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"ACTIVE",
"INACTIVE",
"DELETED"
]
},
"recordVersion": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"updatedAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"quoteNo",
"details",
"partyName",
"status",
"recordVersion",
"updatedAt"
]
},
"History": {
"type": "object",
"properties": {
"recordVersion": {
"type": "integer",
"minimum": 0
},
"action": {
"type": "string"
},
"occurredAt": {
"type": "string",
"format": "date-time"
},
"snapshot": {
"$ref": "#/components/schemas/Quote"
}
},
"required": [
"recordVersion",
"action",
"occurredAt",
"snapshot"
]
}
}
},
"x-integration-database": {
"owner": "总控集成:lastleg.sql及海外统一初始化SQL;本文件仅为字段与索引清单,不执行SQL",
"tables": {
"ll_fleet_quote": {
"comment": "车辆采购与外接销售报价",
"columns": {
"id": "BIGINT PRIMARY KEY COMMENT '报价记录主键'",
"quote_no": "VARCHAR(64) NOT NULL COMMENT '报价业务编号'",
"kind": "VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售'",
"party_id": "BIGINT NOT NULL COMMENT '供应商或客户主档标识'",
"party_name": "VARCHAR(160) NOT NULL COMMENT '往来单位名称快照'",
"origin_city": "VARCHAR(128) NOT NULL COMMENT '发货城市'",
"destination_code": "VARCHAR(64) NOT NULL COMMENT '目的仓业务编码'",
"valid_from": "DATE NOT NULL COMMENT '报价生效日期'",
"valid_to": "DATE NOT NULL COMMENT '报价失效日期含当天'",
"status": "VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态'",
"natural_key": "CHAR(64) NOT NULL COMMENT '报价组合去重摘要'",
"details_json": "JSON NOT NULL COMMENT '完整报价明细快照'",
"record_version": "BIGINT NOT NULL COMMENT '报价记录版本'",
"create_key": "VARCHAR(128) NOT NULL COMMENT '创建幂等键'",
"create_hash": "CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要'",
"created_by": "VARCHAR(64) NOT NULL COMMENT '创建操作人标识'",
"created_at": "DATETIME(3) NOT NULL COMMENT '创建时间UTC'",
"updated_at": "DATETIME(3) NOT NULL COMMENT '更新时间UTC'"
},
"indexes": [
"UNIQUE KEY uk_fleet_quote_no (quote_no)",
"UNIQUE KEY uk_fleet_quote_natural (natural_key)",
"UNIQUE KEY uk_fleet_quote_create (created_by,create_key)",
"KEY idx_fleet_quote_search (kind,status,valid_from,valid_to)"
]
},
"ll_fleet_quote_history": {
"comment": "车辆报价不可变版本历史",
"columns": {
"id": "BIGINT PRIMARY KEY COMMENT '报价历史主键'",
"quote_id": "BIGINT NOT NULL COMMENT '报价记录标识'",
"record_version": "BIGINT NOT NULL COMMENT '报价记录版本'",
"action": "VARCHAR(24) NOT NULL COMMENT '报价操作类型'",
"operator_id": "VARCHAR(64) NOT NULL COMMENT '操作人标识'",
"occurred_at": "DATETIME(3) NOT NULL COMMENT '操作时间UTC'",
"snapshot_json": "JSON NOT NULL COMMENT '报价完整版本快照'"
},
"indexes": [
"UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version)"
]
}
}
}
}
@@ -0,0 +1,183 @@
openapi: 3.0.3
info:
title: 车次直接运营成本
version: 1.0.0
description: 运营登记与分摊,不生成客户账单、财务应付或付款事实。不同币种分别汇总;仅从不可变DEPART实物装载和BOUND外接实收形成权重。DRAFT/CANCELLED预订不参与,其他未完成关联的有效预订阻止自动分摊,不得归为自有货。
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/fleet/trips/{tripNo}/costs:
parameters:
- $ref: '#/components/parameters/TripNo'
get:
description: platform:fleet:trip:list;分页记录和整趟汇总使用同一只读快照。没有登记成本时totals为空,不显示为已知零成本。
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/Size'
responses:
'200':
description: 业务响应,成功code为200
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data: { $ref: '#/components/schemas/Listing' }
post:
description: platform:fleet:trip:cost-create;按车次和操作人锁定创建幂等键,异载荷同键拒绝。金额末尾零与空白备注按同一内容核对;成本与当时汇总历史同事务保存。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [idempotencyKey, details]
properties:
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
details: { $ref: '#/components/schemas/Draft' }
responses:
'200': { $ref: '#/components/responses/Cost' }
/api/platform/v1/last-leg/fleet/trips/{tripNo}/costs/{costNo}:
parameters:
- $ref: '#/components/parameters/TripNo'
- $ref: '#/components/parameters/CostNo'
put:
description: platform:fleet:trip:cost-edit;核对成本版本与车次归属,更正保留旧记录及当时分摊快照。原因选填,缺省使用标准动作说明。内容未变化返回原版本,不追加历史;旧版本即使内容相同仍拒绝。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [recordVersion, details]
properties:
recordVersion: { type: integer, format: int64, minimum: 0 }
details: { $ref: '#/components/schemas/Draft' }
reason: { type: string, maxLength: 1000 }
responses:
'200': { $ref: '#/components/responses/Cost' }
/api/platform/v1/last-leg/fleet/trips/{tripNo}/costs/{costNo}/history:
parameters:
- $ref: '#/components/parameters/TripNo'
- $ref: '#/components/parameters/CostNo'
get:
description: platform:fleet:trip:list;只返回指定车次成本的不可变更正历史,金额为当时原始币种。
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/Size'
responses:
'200':
description: 历史分页,data包含records/page/size/total/pages
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
allOf:
- $ref: '#/components/schemas/Page'
- type: object
properties:
records: { type: array, items: { $ref: '#/components/schemas/History' } }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
parameters:
TripNo: { name: tripNo, in: path, required: true, schema: { type: string } }
CostNo: { name: costNo, in: path, required: true, schema: { type: string } }
Page: { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
Size: { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 10 } }
responses:
Cost:
description: 业务响应;A0404归属不存在,A0409版本或幂等冲突,A0422输入校验,实际发生时间不可为将来
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data: { $ref: '#/components/schemas/Cost' }
schemas:
Envelope:
type: object
properties:
code: { type: string }
message: { type: string }
requestId: { type: string }
traceId: { type: string }
Page:
type: object
properties:
page: { type: integer, format: int64 }
size: { type: integer, format: int64 }
total: { type: integer, format: int64 }
pages: { type: integer, format: int64 }
Draft:
type: object
required: [category, currency, amount, incurredAt]
properties:
category: { type: string, enum: [FUEL, DRIVER, TOLL, PARKING, OTHER] }
currency: { type: string, enum: [CNY, USD] }
amount: { type: number, minimum: 0, maximum: 999999999999.99, multipleOf: 0.01 }
incurredAt: { type: string, format: date-time, description: 实际发生时间,统一按毫秒精度保存和核对 }
remark: { type: string, nullable: true, maxLength: 1000 }
Cost:
type: object
properties:
costNo: { type: string }
tripNo: { type: string }
details: { $ref: '#/components/schemas/Draft' }
recordVersion: { type: integer, format: int64 }
updatedAt: { type: string, format: date-time }
Allocation:
type: object
properties:
businessNo: { type: string, description: 自有货排车单号或外接预订业务号 }
sourceType: { type: string, enum: [OWN, EXTERNAL] }
dispatchNo: { type: string }
palletSpaces: { type: integer, format: int64 }
amount: { type: number }
Total:
type: object
properties:
currency: { type: string }
directCost: { type: number }
allocations: { type: array, items: { $ref: '#/components/schemas/Allocation' } }
ownCost: { type: number, nullable: true }
externalCost: { type: number, nullable: true }
Summary:
type: object
properties:
departureVersion: { type: integer, format: int64, nullable: true }
manifestHash: { type: string, nullable: true }
allocationMethod: { type: string, nullable: true, enum: [SAME_ROUTE_PALLET_SPACES] }
allocationBlockReason: { type: string, nullable: true }
totals: { type: array, items: { $ref: '#/components/schemas/Total' } }
Listing:
type: object
properties:
page:
allOf:
- $ref: '#/components/schemas/Page'
- type: object
properties:
records: { type: array, items: { $ref: '#/components/schemas/Cost' } }
summary: { $ref: '#/components/schemas/Summary' }
Snapshot:
type: object
properties:
cost: { $ref: '#/components/schemas/Cost' }
summary: { $ref: '#/components/schemas/Summary' }
History:
type: object
properties:
recordVersion: { type: integer, format: int64 }
action: { type: string, enum: [CREATE, UPDATE] }
reason: { type: string }
occurredAt: { type: string, format: date-time }
snapshot: { $ref: '#/components/schemas/Snapshot' }
@@ -4,9 +4,9 @@ info:
version: 1.0.0
description: >-
平台按稳定车辆ID读取Lastleg本库履约事实,不按当前车牌文本反查,不修改单据或历史。
计划中的派送归当前分配车辆;实际发车后按不可变DEPART历史中的车辆身份归属。
计划中的派送归当前分配车辆;实际发车后按不可变DEPART及在途交接快照中的车辆身份归属。接替车辆仅关联交接时未完成且属于原客户的派送。
关联排车和派送时同时匹配客户归属,不返回财务、附件、联系人或内部数据库主键。
更换记录依据相邻车次版本的ASSIGN快照,首次分配和资源未变化的操作不算更换。
更换记录依据相邻车次版本的ASSIGN或HANDOVER快照;在途交接仅列实际剩余货物涉及的排车单,首次分配和资源未变化的操作不算更换。
旧车和新车均能读取同一更换事实;仅换司机也保留。返回历史车牌/姓名,不用当前主档覆盖。
不生成原型示例记录;没有正式车次关联的旧派送不通过车牌猜测归属。
security: [{bearerAuth: []}]
@@ -10,10 +10,70 @@ info:
照片最多20张,每张非空JPG/PNG且最多10MiB;File按平台0、FLEET_VEHICLE_RECORD_PHOTO和记录内部主键复合归属,验证内容签名。
维修和救援项目、地点独立保存;处理中或已完成须有处理人,异常须有明确分类。
费用只是运维申报金额,填金额则必须填有效币种;未填金额和零金额有区别,不生成应付或付款。
维修/救援paymentStatus当前固定UNLINKED表示未关联财务,不得由processingStatus推定为已付款,其他类型为空。
运维响应paymentStatus为空,不保存可变财务结论;维修/救援通过finance-status批量读取公司财务状态,不得由processingStatus推定付款。
显式提交公司成本时取已保存申报金额、服务端供应商与月份汇率,来源映射及Outbox同事务保存;不会自动入账或付款。
已提交费用须先在Finance作废才可修改费用项目/金额/币种或重新提交;处理进度、地点和处理人仍可独立更新。
不提供物理删除;编辑与上传历史同事务不可变保存。本接口不提供财务结算或车辆占用/状态自动变更。
security: [{bearerAuth: []}]
paths:
/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/finance-status:
parameters: [{$ref: '#/components/parameters/Vehicle'}]
post:
description: platform:overseas-master-data:vehicle:list;校验本车维修/救援记录,最多100条批量读取,不逐行调用Finance;依赖失败明确返回错误,不能伪造未关联或付款状态
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [recordNos]
properties:
recordNos: {type: array, minItems: 1, maxItems: 100, items: {type: string, minLength: 1, maxLength: 64}}
responses:
'200':
description: ApiResponse;data为每条请求记录的状态;Finance接收状态PENDING为待入账,APPROVED为已入账未付,不等于付款
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data: {type: array, items: {$ref: '#/components/schemas/FinanceStatus'}}
/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/{recordNo}/finance:
parameters:
- {$ref: '#/components/parameters/Vehicle'}
- {name: recordNo, in: path, required: true, schema: {type: string}}
post:
description: platform:overseas-master-data:vehicle:record-finance-submit;不复用普通运维编辑权限。前端选择供应商候选另需platform:supplier:list。首次提交或Finance已作废后才可登记新来源版本,未知结果保留原键重试
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [recordVersion, supplierId, businessDate, idempotencyKey]
properties:
recordVersion: {type: integer, format: int64, minimum: 0}
supplierId: {type: integer, format: int64, minimum: 1}
businessDate: {type: string, format: date, description: 明确选择实际费用业务日期,用于获取业务月份有效汇率}
idempotencyKey: {type: string, minLength: 8, maxLength: 128}
responses:
'200':
description: ApiResponse;仅表示提交请求已登记,PENDING_FINANCE不是已入账或付款
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: object
properties:
recordNo: {type: string}
sourceVersion: {type: integer, format: int64}
submittedRecordVersion: {type: integer, format: int64}
replayed: {type: boolean}
status: {type: string, enum: [PENDING_FINANCE]}
/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records:
parameters: [{$ref: '#/components/parameters/Vehicle'}]
get:
@@ -172,4 +232,12 @@ components:
recordVersion: {type: integer, format: int64, minimum: 0}
updatedBy: {type: string}
updatedAt: {type: string, format: date-time}
paymentStatus: {type: string, nullable: true, readOnly: true, enum: [UNLINKED], description: 维修/救援未关联财务时为UNLINKED,其余为空;不可用处理完成推定已付款}
paymentStatus: {type: string, nullable: true, readOnly: true, description: 运维快照不记录可变财务状态,当前为空;读取finance-status获取财务事实}
FinanceStatus:
type: object
properties:
recordNo: {type: string}
status: {type: string, enum: [UNLINKED, PENDING_FINANCE, FINANCE_ATTENTION, PENDING, APPROVED, PARTIALLY_PAID, PAID, VOIDED]}
sourceVersion: {type: integer, format: int64, nullable: true}
submittedRecordVersion: {type: integer, format: int64, nullable: true}
finance: {type: object, nullable: true, description: Finance公司费用Cost完整只读快照;来源版本及内容须与本地提交一致;未接收不虚构金额,具体结构见finance-vehicle-costs-v1.yaml的Cost}
@@ -34,6 +34,27 @@ class DirectEventClientTest {
verifyNoInteractions(firstleg);
}
@Test void companyVehicleCostsUseTheirOwnFinanceEndpoint() {
var event = event(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED);
var ack = ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED));
when(finance.vehicleCost("token", event)).thenReturn(ack);
assertThat(client.receive("token", event)).isSameAs(ack);
verify(finance).vehicleCost("token", event);
verifyNoMoreInteractions(finance);
verifyNoInteractions(firstleg);
}
@Test void companyCostValidationConflictsArePermanentButDependencyFailuresRetry() {
var event = event(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED);
for (String code : new String[]{"FIN_400", "FIN_404", "FIN_409", "FIN_503"}) {
when(finance.vehicleCost("token", event)).thenReturn(new ApiResponse<>(code, "rejected", null, null, null, Instant.now()));
assertThatThrownBy(() -> client.receive("token", event))
.isInstanceOf(com.baiyue.common.web.EventDeliveryFailure.class)
.satisfies(error -> assertThat(com.baiyue.common.web.EventDeliveryFailure.isPermanent((Exception) error)).isEqualTo(!"FIN_503".equals(code)));
}
verifyNoInteractions(firstleg);
}
@Test void fulfillmentEventsAreSentOnlyToFirstleg() {
for (String type : new String[]{EventTypes.OVERSEAS_CONTAINER_UNLOADED,
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED}) {
@@ -57,6 +78,8 @@ class DirectEventClientTest {
EventTypes.WALLET_COMMAND_REQUESTED);
assertEndpoint(FinanceBusinessEventClient.class, "fee", "FINANCE_SERVICE_URL", "finance",
EventTypes.BUSINESS_FEE_SOURCE_CHANGED);
assertEndpoint(FinanceBusinessEventClient.class, "vehicleCost", "FINANCE_SERVICE_URL", "finance",
EventTypes.COMPANY_VEHICLE_COST_SUBMITTED);
}
private void assertEndpoint(Class<?> type, String method, String env, String property, String eventType) throws Exception {
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.financefee.fleet;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.IllegalTransactionStateException;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class VehicleCareFinanceAdapterTest {
@Test void publicationCannotStartItsOwnTransactionAndConversionDelegatesToTheFeeOwner() {
var converter = mock(OverseasFeeCurrencyConverter.class);
var outbox = mock(LastlegEventOutboxMapper.class);
var jdbc = mock(JdbcTemplate.class);
var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-adapter-" + UUID.randomUUID(), "sa", "");
var adapter = new VehicleCareFinanceAdapter(converter, outbox, jdbc, new ObjectMapper());
var proxy = new ProxyFactory(adapter); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
var gateway = (VehicleCareFinanceGateway) proxy.getProxy();
assertThatThrownBy(() -> gateway.publish(null)).isInstanceOf(IllegalTransactionStateException.class);
verifyNoInteractions(outbox);
var date = LocalDate.of(2026, 1, 1);
when(converter.convert("USD", BigDecimal.TEN, date)).thenReturn(new OverseasFeeCurrencyConverter.Conversion("USD", new BigDecimal("10.00"), new BigDecimal("7.000000"), "2026-01", new BigDecimal("70.00")));
assertThat(gateway.convert("USD", BigDecimal.TEN, date)).isEqualTo(new VehicleCareFinanceGateway.Conversion("USD", new BigDecimal("10.00"), new BigDecimal("7.000000"), "2026-01", new BigDecimal("70.00")));
verify(converter).convert("USD", BigDecimal.TEN, date);
}
@Test void deliveryStatusReadsAreBoundedAndKeepActualOutboxState() {
var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-delivery-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_event_outbox(event_key VARCHAR(128),status VARCHAR(32))");
jdbc.update("INSERT INTO ll_event_outbox VALUES('one','PENDING'),('two','DEAD'),('other','PUBLISHED')");
var adapter = new VehicleCareFinanceAdapter(mock(OverseasFeeCurrencyConverter.class), mock(LastlegEventOutboxMapper.class), jdbc, new ObjectMapper());
assertThat(adapter.deliveryStates(List.of())).isEmpty();
assertThat(adapter.deliveryStates(List.of("one", "two"))).containsExactlyInAnyOrderEntriesOf(Map.of("one", "PENDING", "two", "DEAD"));
assertThatThrownBy(() -> adapter.deliveryStates(Collections.nCopies(101, "one"))).hasMessageContaining("最多100");
}
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.fleet.exception;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetHandoverContractTest {
@AfterEach void clearIdentity() { SecurityContextHolder.clearContext(); }
@Test void listPermissionCanInspectButCannotHandoverOrEndResources() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused",
List.of(new SimpleGrantedAuthority("platform:fleet:trip:list"))));
var controller = context.getBean(PlatformFleetHandoverController.class);
var service = context.getBean(FleetHandoverService.class);
controller.page("TR-1", new FleetHandoverModels.Query(1, 10));
controller.preview("TR-1", null);
verify(service).preview("TR-1", null);
assertThrows(AccessDeniedException.class, () -> controller.confirm("TR-1", null, null));
assertThrows(AccessDeniedException.class, () -> controller.release("TR-1", "HO-1", null, null));
}
}
@Test void everyPublicMethodHasAContractAndIndependentWriteAuthority() throws Exception {
Map<String, Object> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-handovers-v1.yaml")));
Map<?, ?> paths = (Map<?, ?>) document.get("paths");
String base = PlatformFleetHandoverController.class.getAnnotation(RequestMapping.class).value()[0];
int count = 0;
for (var method : PlatformFleetHandoverController.class.getDeclaredMethods()) {
String suffix, verb;
if (method.isAnnotationPresent(GetMapping.class)) { var pathsOnMethod = method.getAnnotation(GetMapping.class).value(); suffix = pathsOnMethod.length == 0 ? "" : pathsOnMethod[0]; verb = "get"; }
else if (method.isAnnotationPresent(PostMapping.class)) { var pathsOnMethod = method.getAnnotation(PostMapping.class).value(); suffix = pathsOnMethod.length == 0 ? "" : pathsOnMethod[0]; verb = "post"; }
else continue;
var path = (Map<?, ?>) paths.get(base + suffix); assertNotNull(path, method.getName());
var operation = (Map<?, ?>) path.get(verb); assertNotNull(operation, method.getName());
var authority = method.getAnnotation(PreAuthorize.class); assertNotNull(authority);
String permission = authority.value().split("'")[1]; assertTrue(operation.get("description").toString().contains(permission));
count++;
}
assertEquals(4, count);
}
@Configuration
@EnableMethodSecurity
static class Config {
@Bean FleetHandoverService service() { return mock(FleetHandoverService.class); }
@Bean PlatformFleetHandoverController controller(FleetHandoverService service) { return new PlatformFleetHandoverController(service); }
}
}
@@ -0,0 +1,74 @@
package com.baiyue.lastleg.fleet.exception;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FleetHandoverPdaQueryTest {
final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class);
final FleetTripMapper trips = mock(FleetTripMapper.class);
final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class);
final FleetDispatchGateway dispatches = mock(FleetDispatchGateway.class);
final FleetHandoverRepository handovers = mock(FleetHandoverRepository.class);
final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
final FleetHandoverPdaQuery service = new FleetHandoverPdaQuery(bindings, trips, history, dispatches, handovers, json);
@BeforeEach void setup() {
var binding = new FleetTripDispatch(); binding.setDispatchNo("PC-A"); binding.setCustomerId("CUSTOMER-A");
binding.setTripId(1L); binding.setTripNo("TR-ONE"); binding.setPackageCount(30);
var trip = new FleetTripEntity(); trip.setId(1L); trip.setTripNo("TR-ONE"); trip.setWarehouseId(92L);
trip.setStatus("IN_TRANSIT"); trip.setRecordVersion(10L);
when(bindings.findByDispatchNo("PC-A")).thenReturn(binding); when(trips.findByNo("TR-ONE")).thenReturn(trip);
var departed = new FleetTripHistory(); departed.setSnapshotJson("""
{"trip":{"tripNo":"TR-ONE","warehouseId":"92","vehiclePlate":"ORIGINAL","driverName":"原司机"}}
""");
when(history.selectList(any())).thenReturn(List.of(departed));
when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 30, List.of(
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z")),
"DELIVERED", 2, new FleetTransitEvidence.Transport("ORIGINAL", "原司机", null)),
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null),
"WAIT_DELIVERY", 3, new FleetTransitEvidence.Transport("REPLACEMENT", "接替司机", null)))));
}
@Test void crossWarehouseFailsBeforeAnyCargoOrHistoryQuery() {
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(93, "PC-A"));
verifyNoInteractions(dispatches, history, handovers);
}
@Test void selectedDispatchRetainsOriginalAndPerDeliveryResourcesWithoutInternalIdentifiers() throws Exception {
var result = service.status(92, "PC-A");
assertEquals("ORIGINAL", result.originalDeparture().vehiclePlate());
assertEquals("ORIGINAL", result.deliveries().get(0).transport().vehiclePlate());
assertEquals("REPLACEMENT", result.deliveries().get(1).transport().vehiclePlate());
String serialized = json.writeValueAsString(result);
assertFalse(serialized.contains("customerId")); assertFalse(serialized.contains("warehouseId")); assertFalse(serialized.contains("vehicleId"));
verify(handovers).latestForCargo(eq(1L), argThat(value -> value.contains("CUSTOMER-A") && value.contains("PC-A")));
verify(dispatches, never()).handover(any(), any());
}
@Test void staleBindingToAnotherCustomerCannotExposeDeliveryResources() {
when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-B", "92", 30, List.of()));
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(92, "PC-A"));
verifyNoInteractions(history, handovers);
}
@Test void controllerHasOnlyReadRouteAndRequiresThePdaIdentityPolicy() {
var policy = PdaFleetHandoverController.class.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value();
assertTrue(policy.contains("lastleg:pda:operate")); assertTrue(policy.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
// Coverage instrumentation may add synthetic methods. Count all HTTP mappings (including
// composed mappings), so a newly exposed write route still fails this read-only contract.
var routes = java.util.Arrays.stream(PdaFleetHandoverController.class.getDeclaredMethods())
.map(method -> org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation(
method, org.springframework.web.bind.annotation.RequestMapping.class))
.filter(java.util.Objects::nonNull).toList();
assertEquals(1, routes.size());
assertArrayEquals(new org.springframework.web.bind.annotation.RequestMethod[] { org.springframework.web.bind.annotation.RequestMethod.GET }, routes.get(0).method());
assertArrayEquals(new String[] { "/{dispatchNo}/handover-status" }, routes.get(0).path());
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> new PdaFleetHandoverController(service).status(null, "PC-A"));
}
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.fleet.exception;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import static com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.*;
import static org.junit.jupiter.api.Assertions.*;
class FleetHandoverPolicyTest {
private final Instant departure = Instant.parse("2026-10-03T01:00:00Z");
private final Instant handover = departure.plusSeconds(7200);
private final Load capacity = load(2, 200, 4);
private final Cargo a = new Cargo("A", "PC-A", "1", "P1", "FBA:ONE", 10);
private final Cargo b = new Cargo("B", "PC-B", "2", "P1", "FBA:ONE", 20);
private final List<Pallet> measured = List.of(new Pallet("P1", load(1, 100, 2)));
@Test void sharedPalletKeepsCustomerOwnershipAndCountsCapacityOnce() {
var result = inspectBoth(delivery(a, null), delivery(b, null));
assertEquals(List.of(a, b), result.remainingCargo());
assertEquals(load(1, 100, 2), result.occupied());
assertEquals(1, result.remainingPalletIds().size());
}
@Test void signedCustomersCargoIsExcludedWithoutGuessingResidualWeight() {
var signed = delivery(a, departure.plusSeconds(60));
var result = inspectBoth(signed, delivery(b, null));
assertEquals(List.of(b), result.remainingCargo());
assertEquals(List.of(signed), result.completedDeliveries());
assertEquals(load(1, 100, 2), result.occupied());
}
@Test void unresolvedRefusalRemainsCargoUntilActualReturnFact() {
var result = inspectBoth(delivery(a, null), delivery(b, departure.plusSeconds(60)));
assertEquals(List.of(a), result.remainingCargo());
}
@Test void allTerminalCargoRequiresCompletionRatherThanEmptyHandover() {
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, handover), delivery(b, handover)));
}
@Test void sameDestinationCannotMixCustomerOrDispatchFacts() {
var wrong = new Delivery("A", "PC-B", "D-B", "FBA:ONE", 20, null);
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong));
}
@Test void missingDuplicateAndChangedQuantityFactsAreRejected() {
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null)));
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), delivery(a, null), delivery(b, null)));
var wrong = new Delivery("B", "PC-B", "D-B", "FBA:ONE", 19, null);
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong));
}
@Test void cannotBackdateHandoverAcrossLaterSignatureOrBeforeDeparture() {
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, handover.plusSeconds(1)), delivery(b, null)));
assertThrows(IllegalArgumentException.class, () -> inspect(departure, departure.minusSeconds(1), handover,
List.of(a), List.of(delivery(a, null)), measured, capacity));
assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover.plusSeconds(1), handover,
List.of(a), List.of(delivery(a, null)), measured, capacity));
}
@Test void rejectsAllThreeCapacityOverflows() {
for (Load tooSmall : List.of(load(1, 99, 2), load(1, 100, 1), load(0, 100, 2)))
assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover,
List.of(a), List.of(delivery(a, null)), measured, tooSmall));
}
@Test void missingOrDuplicateMeasurementsAndMixedDestinationPalletAreRejected() {
assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover,
List.of(a), List.of(delivery(a, null)), List.of(), capacity));
assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover,
List.of(a), List.of(delivery(a, null)), List.of(measured.get(0), measured.get(0)), capacity));
var wrong = new Cargo("B", "PC-B", "2", "P1", "FBA:TWO", 20);
assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover,
List.of(a, wrong), List.of(delivery(a, null), delivery(wrong, null)), measured, capacity));
}
@Test void fullyUnloadedPalletDoesNotConsumeReplacementCapacity() {
var other = new Cargo("B", "PC-B", "2", "P2", "FBA:TWO", 20);
var result = inspect(departure, handover, handover, List.of(a, other),
List.of(delivery(a, handover), delivery(other, null)),
List.of(measured.get(0), new Pallet("P2", load(1, 50, 1))), load(1, 50, 1));
assertEquals(load(1, 50, 1), result.occupied());
assertEquals(List.of(other), result.remainingCargo());
}
private Inspection inspectBoth(Delivery... deliveries) {
return inspect(departure, handover, handover, List.of(a, b), List.of(deliveries), measured, capacity);
}
private static Delivery delivery(Cargo cargo, Instant terminalAt) {
return new Delivery(cargo.customerId(), cargo.dispatchNo(), "D-" + cargo.customerId(),
cargo.destinationKey(), cargo.packages(), terminalAt);
}
private static Load load(int pallets, int kg, int cbm) {
return new Load(pallets, BigDecimal.valueOf(kg), BigDecimal.valueOf(cbm));
}
}
@@ -0,0 +1,171 @@
package com.baiyue.lastleg.fleet.exception;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
import com.baiyue.lastleg.fleet.driver.*;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.math.BigDecimal;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.*;
import static com.baiyue.lastleg.fleet.exception.FleetHandoverModels.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real transaction/repository/resource locks; credential, cargo and existing trip-history ports are isolated. */
class FleetHandoverTransactionTest {
private JdbcTemplate jdbc;
private FleetHandoverService service;
private FleetHandoverRepository repository;
private FleetTripService history;
private FleetDispatchGateway dispatches;
private DriverService drivers;
private VehicleApplicationService vehicleService;
private Instant at, end;
private Selection selection;
@BeforeEach void setup() throws Exception {
at = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS); end = at.plusSeconds(3600);
var source = new DriverManagerDataSource("jdbc:h2:mem:handover-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE trip_state(id BIGINT PRIMARY KEY,vehicle_id BIGINT,driver_id BIGINT,record_version BIGINT)");
jdbc.update("INSERT INTO trip_state VALUES(1,11,21,7)");
jdbc.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(16),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(32),PRIMARY KEY(resource_kind,resource_id))");
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('VEHICLE',11,1,'TR-ONE'),('DRIVER',21,1,'TR-ONE')");
jdbc.execute("""
CREATE TABLE ll_fleet_trip_handover(
id BIGINT PRIMARY KEY,trip_id BIGINT NOT NULL,trip_version BIGINT NOT NULL,trip_no VARCHAR(32) NOT NULL,handover_no VARCHAR(32) NOT NULL UNIQUE,
request_key VARCHAR(128) NOT NULL,request_hash CHAR(64) NOT NULL,record_version BIGINT NOT NULL,
handed_over_at TIMESTAMP(3) NOT NULL,snapshot_json LONGTEXT NOT NULL,
vehicle_release_json LONGTEXT,driver_release_json LONGTEXT,created_by VARCHAR(64) NOT NULL,created_at TIMESTAMP(3) NOT NULL,
UNIQUE(trip_id,request_key),UNIQUE(trip_id,trip_version))
""");
var config = new MybatisConfiguration(); config.addMapper(FleetResourceMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config);
var resources = new SqlSessionTemplate(factory.getObject()).getMapper(FleetResourceMapper.class);
var trips = mock(FleetTripMapper.class);
when(trips.lockByNo("TR-ONE")).thenAnswer(call -> state()); when(trips.findByNo("TR-ONE")).thenAnswer(call -> state());
var vehicles = mock(VehicleRepository.class);
when(vehicles.lockById(anyLong())).thenAnswer(call -> Optional.of(new VehicleRepository.VehicleSnapshot(call.getArgument(0),
"PLATE-" + call.getArgument(0), "TRUCK", BigDecimal.ONE, 10, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null,
0, "1", LocalDateTime.now(), 2, BigDecimal.valueOf(200), BigDecimal.valueOf(4))));
drivers = mock(DriverService.class);
var qualification = new DriverQualificationPolicy.Qualification(true, true, DriverQualificationPolicy.Reason.ELIGIBLE, LocalDate.now().plusDays(15));
when(drivers.requireDispatchable(anyLong(), any())).thenAnswer(call -> new DriverModels.Response(Long.toString(call.getArgument(0)), "DR-NEW",
new DriverModels.Draft("接替司机", "phone", "OWNED", "OVERSEAS", "license", "C", null, LocalDate.now().plusDays(15), "UTC", null, null),
true, "APPROVED", "file", 0, "1", LocalDateTime.now(), qualification, "VALID", null, null));
var inspection = mock(FleetHandoverInspectionService.class);
var cargo = new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-A", "item", "pallet", "FBA:ONT8", 10);
var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null);
var evidence = new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 10,
List.of(new FleetTransitEvidence.Delivery(delivery, "WAIT_DELIVERY", 1, new FleetTransitEvidence.Transport("PLATE-11", "司机", "phone"))));
when(inspection.prepare(anyString(), any(), any(), eq(true))).thenAnswer(call -> new FleetHandoverInspectionService.Prepared(state().getRecordVersion(),
new FleetHandoverPolicy.Inspection(List.of(cargo), List.of(), Set.of("pallet"), new Load(1, BigDecimal.valueOf(100), BigDecimal.valueOf(2))), List.of(evidence)));
history = mock(FleetTripService.class);
doAnswer(call -> {
FleetTripEntity trip = call.getArgument(0);
jdbc.update("UPDATE trip_state SET vehicle_id=?,driver_id=?,record_version=record_version+1 WHERE id=1", trip.getVehicleId(), trip.getDriverId());
return null;
}).when(history).recordExecution(any(), anyLong(), anyString(), anyString(), anyString());
dispatches = mock(FleetDispatchGateway.class); vehicleService = mock(VehicleApplicationService.class);
repository = new FleetHandoverRepository(jdbc);
var target = new FleetHandoverService(trips, resources, history, dispatches, vehicles, vehicleService, drivers, inspection, repository,
new ObjectMapper().registerModule(new JavaTimeModule()));
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
service = (FleetHandoverService) proxy.getProxy();
selection = new Selection(7L, 12L, 22L, end, at);
}
@Test void handoverRetainsOldResourcesAndReplaysWithoutRepeatingDeliveryWrite() {
var command = command("handover-one");
var result = service.confirm("TR-ONE", "1", command);
assertEquals(4, countResources()); assertEquals(12L, state().getVehicleId()); assertEquals(22L, state().getDriverId());
assertEquals("11", result.snapshot().previous().vehicleId()); assertEquals("12", result.snapshot().replacement().vehicleId());
assertEquals(result.handoverNo(), service.confirm("TR-ONE", "another-operator", command).handoverNo());
verify(dispatches, times(1)).handover(any(), any());
verify(dispatches, never()).depart(anyString(), anyString(), anyString(), anyString(), any(), any());
assertEquals(1, repository.count(1));
}
@Test void failureAfterResourceAndHistoryWritesRollsBackEverything() {
var command = command("handover-fail");
doAnswer(call -> { jdbc.update("UPDATE trip_state SET vehicle_id=12 WHERE id=1"); throw new IllegalStateException("history failed"); })
.when(history).recordExecution(any(), anyLong(), eq("HANDOVER"), anyString(), anyString());
assertThrows(IllegalStateException.class, () -> service.confirm("TR-ONE", "1", command));
assertEquals(2, countResources()); assertEquals(0, repository.count(1)); assertEquals(11L, state().getVehicleId());
}
@Test void stalePreviewAndOccupiedReplacementDoNotWrite() {
var command = command("handover-stale");
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('VEHICLE',12,2,'TR-TWO')");
assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", command));
assertEquals(0, repository.count(1)); verifyNoInteractions(dispatches);
jdbc.update("DELETE FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=12");
var stale = new Confirm(selection, "0".repeat(64), "交接地点", null, "handover-stale");
assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", stale)); assertEquals(2, countResources());
}
@Test void sameKeyWithDifferentContentFailsBeforeAnySecondMutation() {
var command = command("handover-replay"); service.confirm("TR-ONE", "1", command);
var changed = new Confirm(selection, command.cargoHash(), "另一地点", null, command.idempotencyKey());
assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", changed));
verify(dispatches, times(1)).handover(any(), any());
}
@Test void oldResourceReleaseReplayCannotReleaseItsNextTrip() {
var row = service.confirm("TR-ONE", "1", command("handover-release"));
var release = new Release(0L, "DRIVER", at.plusSeconds(1), "任务结束地点", null, "release-driver");
var released = service.release("TR-ONE", row.handoverNo(), "1", release);
assertNotNull(released.driverRelease()); assertEquals(3, countResources());
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',21,2,'TR-TWO')");
service.release("TR-ONE", row.handoverNo(), "1", release);
assertEquals(2L, jdbc.queryForObject("SELECT trip_id FROM ll_fleet_resource_assignment WHERE resource_kind='DRIVER' AND resource_id=21", Long.class));
}
@Test void failedVehicleStatusChangeRestoresOriginalReservation() {
var row = service.confirm("TR-ONE", "1", command("handover-vehicle"));
when(vehicleService.changeStatus(anyLong(), anyString(), any())).thenThrow(new IllegalStateException("status failed"));
assertThrows(IllegalStateException.class, () -> service.release("TR-ONE", row.handoverNo(), "1",
new Release(0L, "VEHICLE", at.plusSeconds(1), "维修站", "MAINTENANCE", "release-vehicle")));
assertEquals(4, countResources()); assertNull(repository.find(1, row.handoverNo()).vehicleRelease());
}
@Test void currentUnchangedVehicleCannotBeReleasedAsOldResource() {
selection = new Selection(7L, 11L, 22L, end, at);
var row = service.confirm("TR-ONE", "1", command("handover-driver-only"));
assertEquals(3, countResources());
assertThrows(RuntimeException.class, () -> service.release("TR-ONE", row.handoverNo(), "1",
new Release(0L, "VEHICLE", at.plusSeconds(1), "原地", "IN_SERVICE", "release-unchanged")));
}
private Confirm command(String key) {
var preview = service.preview("TR-ONE", selection);
assertTrue(preview.driverQualification().expiringSoon());
return new Confirm(selection, preview.cargoHash(), "交接地点", null, key);
}
private int countResources() { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class); }
private FleetTripEntity state() {
return jdbc.queryForObject("SELECT * FROM trip_state WHERE id=1", (r, ignored) -> {
var trip = new FleetTripEntity(); trip.setId(1L); trip.setTripNo("TR-ONE"); trip.setStatus("IN_TRANSIT"); trip.setWarehouseId(92L);
trip.setVehicleId(r.getLong("vehicle_id")); trip.setVehiclePlate("PLATE-" + r.getLong("vehicle_id"));
trip.setDriverId(r.getLong("driver_id")); trip.setDriverName("司机"); trip.setDriverPhone("phone");
trip.setRecordVersion(r.getLong("record_version")); trip.setDepartedAt(LocalDateTime.ofInstant(at.minusSeconds(3600), ZoneOffset.UTC));
return trip;
});
}
}
@@ -0,0 +1,116 @@
package com.baiyue.lastleg.fleet.exception;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.dispatch.pda.mapper.*;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTransitEvidenceTest {
@BeforeAll static void initializeDeliveryMetadata() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new com.baomidou.mybatisplus.core.MybatisConfiguration(), "fleet-transit-evidence"),
DeliveryOrder.class);
}
final DispatchOrderMapper orders = mock(DispatchOrderMapper.class);
final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class);
final DeliveryOrderMapper deliveries = mock(DeliveryOrderMapper.class);
final DeliveryOperationMapper operations = mock(DeliveryOperationMapper.class);
final DispatchPdaWorkflowService workflow = mock(DispatchPdaWorkflowService.class);
final DispatchFleetAdapter adapter = new DispatchFleetAdapter(orders, mock(DispatchPdaTaskMapper.class),
outbounds, deliveries, mock(DispatchItemMapper.class), mock(DispatchPdaPickMapper.class), workflow, operations);
final DispatchOrder dispatch = new DispatchOrder();
final DeliveryOrder delivery = new DeliveryOrder();
FleetTransitEvidenceTest() {
dispatch.setId(11L); dispatch.setDispatchNo("PC-1"); dispatch.setCustomerId("CUSTOMER-A");
dispatch.setOverseasWarehouseId("21"); dispatch.setStatus("DEPARTED"); dispatch.setShippedPackageCount(10);
var outbound = new OutboundOrder(); outbound.setId(31L); outbound.setStatus("OUTBOUNDED"); outbound.setPackageCount(10);
delivery.setId(41L); delivery.setDeliveryNo("DL-1"); delivery.setCustomerId("CUSTOMER-A");
delivery.setDispatchOrderId(11L); delivery.setOutboundOrderId(31L); delivery.setOverseasWarehouseId("21");
delivery.setPackageCount(10); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
delivery.setDestinationType("FBA"); delivery.setDestinationWarehouseCode("ONT8");
when(orders.findGlobalByDispatchNo("PC-1")).thenReturn(dispatch);
when(orders.lockGlobalByDispatchNo("PC-1")).thenReturn(dispatch);
when(outbounds.findByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound);
when(outbounds.lockByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound);
when(deliveries.findByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery));
when(deliveries.lockByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery));
}
@Test void unresolvedReturnKeepsCargoAndUsesCustomerScopedLocks() {
delivery.setStatus("RETURN_PENDING");
var fact = adapter.transitEvidence("PC-1", true);
assertNull(fact.deliveries().get(0).cargo().terminalAt());
assertEquals("FBA:ONT8", fact.deliveries().get(0).cargo().destinationKey());
var order = inOrder(orders, outbounds, deliveries);
order.verify(orders).lockGlobalByDispatchNo("PC-1");
order.verify(outbounds).lockByDispatch("CUSTOMER-A", 11L);
order.verify(deliveries).lockByOutbound("CUSTOMER-A", 31L);
verifyNoInteractions(workflow);
}
@Test void priorSignatureRemainsTerminalAfterLaterReturnRequest() {
var signed = LocalDateTime.of(2026, 10, 3, 9, 0);
delivery.setStatus("RETURN_PENDING"); delivery.setSignedAt(signed);
var fact = adapter.transitEvidence("PC-1", false);
assertEquals(signed.atZone(ZoneId.systemDefault()).toInstant(), fact.deliveries().get(0).cargo().terminalAt());
verifyNoInteractions(operations, workflow);
}
@Test void unsignedReturnRequiresImmutableReturnOperation() {
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(LocalDateTime.now());
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
var returned = LocalDateTime.of(2026, 10, 3, 10, 0);
when(operations.returnedAt("CUSTOMER-A", 41L)).thenReturn(returned);
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),
adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt());
}
@Test void crossCustomerAndUnbalancedFactsFailClosed() {
delivery.setCustomerId("CUSTOMER-B");
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
delivery.setCustomerId("CUSTOMER-A"); delivery.setPackageCount(9);
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
verifyNoInteractions(workflow);
}
@Test void preDepartureAndCancelledDeliveryCannotBecomeHandoverCargo() {
dispatch.setStatus("READY_TO_DEPART");
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
dispatch.setStatus("DEPARTED"); delivery.setStatus("CANCELLED");
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
}
@Test void handoverUpdatesOnlyUnfinishedDeliveryAndNeverCallsDeparture() {
var evidence = adapter.transitEvidence("PC-1", true);
when(deliveries.lockByNo("CUSTOMER-A", "DL-1")).thenReturn(delivery);
when(deliveries.update(isNull(), any())).thenReturn(1);
adapter.handover(evidence, new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone"));
verify(deliveries).update(isNull(), any());
verify(orders, never()).update(any(), any());
verifyNoInteractions(workflow);
}
@Test void signedCargoStaysOnOriginalAssignmentAndStaleVersionCannotOverwrite() {
delivery.setStatus("DELIVERED"); delivery.setSignedAt(LocalDateTime.of(2026, 10, 3, 9, 0));
var evidence = adapter.transitEvidence("PC-1", true);
var assignment = new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone");
adapter.handover(evidence, assignment);
verify(deliveries, never()).update(any(), any());
delivery.setVersion(1L);
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.handover(evidence, assignment));
verifyNoInteractions(workflow);
}
}
@@ -0,0 +1,125 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class VehicleCareFinancePersistenceTest {
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
private JdbcTemplate jdbc;
private VehicleCareFinanceService service;
private VehicleCareMapper records;
private VehicleCareFinanceClient finance;
private VehicleCareSupplierClient suppliers;
private VehicleCareEntity row;
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:care-finance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_fleet_finance_source(id BIGINT PRIMARY KEY,record_id BIGINT NOT NULL,source_version BIGINT NOT NULL,record_version BIGINT NOT NULL,operator_id VARCHAR(64) NOT NULL,request_key VARCHAR(128) NOT NULL,request_hash VARCHAR(71) NOT NULL,event_id VARCHAR(128) NOT NULL,payload_json CLOB NOT NULL,created_at TIMESTAMP(3),UNIQUE(record_id,source_version),UNIQUE(record_id,operator_id,request_key),UNIQUE(event_id))");
jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(128),event_type VARCHAR(64),event_version INT,event_key VARCHAR(255) UNIQUE,payload_json CLOB,status VARCHAR(32),retry_count INT,next_retry_at TIMESTAMP(3),published_at TIMESTAMP(3),locked_at TIMESTAMP(3),locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP(3))");
var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
var outbox = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class);
records = mock(VehicleCareMapper.class); finance = mock(VehicleCareFinanceClient.class); suppliers = mock(VehicleCareSupplierClient.class);
row = new VehicleCareEntity(); row.setId(1L); row.setVehicleId(71L); row.setVehiclePlate("VEH-A"); row.setRecordNo("VR-ONE"); row.setRecordType("REPAIR"); row.setRecordVersion(0L); row.setDetailsJson(json.writeValueAsString(draft("10", "PENDING")));
when(records.lock(71, "VR-ONE")).thenReturn(row);
when(records.findFinanceRecords(71, List.of("VR-ONE"))).thenReturn(List.of(row));
when(finance.query(anyString(), any())).thenReturn(ApiResponse.success(List.of()));
when(suppliers.get("test-token", 8, true)).thenReturn(ApiResponse.success(new VehicleCareSupplierClient.Supplier(8, "维修供应商", null, "ACTIVE")));
var converter = mock(OverseasFeeCurrencyConverter.class);
when(converter.convert(eq("USD"), any(), eq(LocalDate.of(2026, 1, 1)))).thenAnswer(call -> {
BigDecimal amount = call.getArgument(1);
return new OverseasFeeCurrencyConverter.Conversion("USD", amount.setScale(2), new BigDecimal("7.000000"), "2026-01", amount.multiply(new BigDecimal("7")).setScale(2));
});
var adapterProxy = new ProxyFactory(new VehicleCareFinanceAdapter(converter, outbox, jdbc, json)); adapterProxy.setProxyTargetClass(true);
adapterProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
var accounting = (VehicleCareFinanceGateway) adapterProxy.getProxy();
var target = new VehicleCareFinanceService(records, jdbc, json, accounting, finance, suppliers, "test-token");
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
service = (VehicleCareFinanceService) proxy.getProxy();
}
private static VehicleCareModels.Draft draft(String amount, String processing) {
return new VehicleCareModels.Draft("REPAIR", Instant.parse("2026-01-01T00:00:00Z"), null, null, null, null, null, null, null, null,
new VehicleCareModels.Incident("轮胎损坏", "换轮胎", "维修站", "工作人员", processing, null, new BigDecimal(amount), "USD"));
}
private static VehicleCareFinanceModels.Submit command(String key) { return new VehicleCareFinanceModels.Submit(0L, 8L, LocalDate.of(2026, 1, 1), key); }
private VehicleCareFinanceModels.Source source() throws Exception {
return json.readValue(jdbc.queryForObject("SELECT payload_json FROM ll_fleet_finance_source ORDER BY source_version DESC LIMIT 1", String.class), VehicleCareFinanceModels.Source.class);
}
private void remote(String status) throws Exception {
var source = source();
when(finance.query(anyString(), any())).thenReturn(ApiResponse.success(List.of(new VehicleCareFinanceModels.Cost("VC-ONE", "PLATFORM_DEFAULT", source, "维修供应商", "CNY", status,
"PAID".equals(status) ? source.amount() : BigDecimal.ZERO, "PAID".equals(status) ? BigDecimal.ZERO : source.amount(), 1))));
}
@Test void submitWritesImmutableSourceAndOutboxTogetherAndUnknownResponseReplaysTheSameEvent() throws Exception {
var first = service.submit(71, "VR-ONE", "1", command("request-one"));
var replay = service.submit(71, "VR-ONE", "1", command("request-one"));
assertThat(first.status()).isEqualTo("PENDING_FINANCE"); assertThat(replay.replayed()).isTrue();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isEqualTo(1);
var envelope = json.readValue(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox", String.class), EventEnvelope.class);
assertThat(envelope.eventType()).isEqualTo(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED);
assertThat(envelope.tenantId()).isEqualTo("0"); assertThat(envelope.payload().has("customerId")).isFalse();
assertThat(source().amount()).isEqualByComparingTo("70.00");
verify(finance, times(1)).query(anyString(), any());
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", new VehicleCareFinanceModels.Submit(0L, 9L, LocalDate.of(2026,1,1), "request-one"))).hasMessageContaining("其他内容");
}
@Test void outboxFailureRollsBackTheSourceAndAllowsTheOriginalRequestToRetry() {
jdbc.execute("ALTER TABLE ll_event_outbox ADD CONSTRAINT reject_pending CHECK(status<>'PENDING')");
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-one"))).isInstanceOf(RuntimeException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isZero();
jdbc.execute("ALTER TABLE ll_event_outbox DROP CONSTRAINT reject_pending");
assertThat(service.submit(71, "VR-ONE", "1", command("request-one")).sourceVersion()).isEqualTo(1);
}
@Test void statusesDistinguishUnlinkedDeliveryFailureAndRealFinancePayment() throws Exception {
assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("UNLINKED");
verifyNoInteractions(finance);
service.submit(71, "VR-ONE", "1", command("request-one"));
assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("PENDING_FINANCE");
jdbc.update("UPDATE ll_event_outbox SET status='DEAD'");
assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("FINANCE_ATTENTION");
remote("PAID");
var status = service.statuses(71, List.of("VR-ONE")).get(0);
assertThat(status.status()).isEqualTo("PAID"); assertThat(status.finance().paidAmount()).isEqualByComparingTo("70");
}
@Test void submittedAmountsStayFrozenUntilFinancialVoidButProcessingUpdatesContinue() throws Exception {
service.submit(71, "VR-ONE", "1", command("request-one")); remote("PENDING");
assertThatThrownBy(() -> service.guardFinancialEdit(row, draft("20", "PENDING"))).hasMessageContaining("先在财务作废");
clearInvocations(finance);
service.guardFinancialEdit(row, draft("10", "COMPLETED")); verifyNoInteractions(finance);
remote("PAID"); assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-two"))).hasMessageContaining("不可改写");
remote("VOIDED"); service.guardFinancialEdit(row, draft("20", "PENDING"));
row.setDetailsJson(json.writeValueAsString(draft("20", "PENDING")));
assertThat(service.submit(71, "VR-ONE", "1", command("request-two")).sourceVersion()).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isEqualTo(2);
}
@Test void wrongVehicleStaleVersionAndFinanceFailureCannotPretendToSubmit() {
assertThatThrownBy(() -> service.submit(72, "VR-ONE", "1", command("request-one"))).hasMessageContaining("不存在");
assertThatThrownBy(() -> service.statuses(72, List.of("VR-ONE"))).hasMessageContaining("不存在");
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", new VehicleCareFinanceModels.Submit(1L,8L,LocalDate.of(2026,1,1),"request-one"))).hasMessageContaining("已变化");
when(finance.query(anyString(), any())).thenReturn(null);
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-one"))).hasMessageContaining("未确认入账或付款");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isZero();
}
}
@@ -50,7 +50,7 @@ class VehicleCarePersistenceTest {
files = mock(VehicleCareFileGateway.class);
when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8));
var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files,
new ObjectMapper().registerModule(new JavaTimeModule()));
new ObjectMapper().registerModule(new JavaTimeModule()), mock(VehicleCareFinanceService.class));
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
service = (VehicleCareService) proxy.getProxy();
@@ -128,7 +128,7 @@ class VehicleCarePersistenceTest {
if (List.of("REPAIR", "RESCUE").contains(type)) {
assertThat(row.details().incident().item()).isEqualTo("更换轮胎");
assertThat(row.details().incident().place()).isEqualTo("实际作业地点");
assertThat(row.paymentStatus()).isEqualTo("UNLINKED");
assertThat(row.paymentStatus()).isNull();
}
}
}
@@ -137,7 +137,7 @@ class VehicleCarePersistenceTest {
var started = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(0L, "已安排维修", incident("REPAIR", "IN_PROGRESS", "维修人员", new BigDecimal("120.50"), "USD")));
var completed = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(started.recordVersion(), "现场处理完成", incident("REPAIR", "COMPLETED", "维修人员", BigDecimal.ZERO, "USD")));
assertThat(completed.details().incident().reportedCost()).isEqualByComparingTo("0");
assertThat(completed.paymentStatus()).isEqualTo("UNLINKED");
assertThat(completed.paymentStatus()).isNull();
assertThat(jdbc.queryForObject("SELECT status FROM ll_vehicle WHERE id=?", String.class, vehicleA)).isEqualTo("IN_SERVICE");
var history = service.history(vehicleA, created.recordNo(), 1, 20).records();
assertThat(history).hasSize(3);
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.fleet.quote;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetQuoteDirectoryTest {
@Test void saleUsesCustomerAndPurchaseRequiresAnActiveOverseasTruckingSupplier() {
var customers=mock(FleetCustomerDirectory.class);var suppliers=mock(FleetQuoteSupplierClient.class);
var directory=new FleetQuoteDirectory(customers,suppliers,"test-only-token");
when(customers.required(1)).thenReturn(new FleetCustomerDirectory.Customer("1","C001","委托客户",null,null));
assertThat(directory.name("SALE",1)).isEqualTo("委托客户");verifyNoInteractions(suppliers);
when(suppliers.get("test-only-token",2,true,"TRUCKING")).thenReturn(ApiResponse.success(new FleetQuoteSupplierClient.Supplier(2,"国内拖车","TRUCKING","ACTIVE",List.of(),"DOMESTIC")));
assertThatThrownBy(()->directory.name("PURCHASE",2)).hasMessageContaining("海外车队");
when(suppliers.get("test-only-token",2,true,"TRUCKING")).thenReturn(ApiResponse.success(new FleetQuoteSupplierClient.Supplier(2,"海外车队","TRUCKING","ACTIVE",List.of(),"OVERSEAS")));
assertThat(directory.name("PURCHASE",2)).isEqualTo("海外车队");
}
@Test void unavailableDirectoryDoesNotCreateFakeNames() {
var suppliers=mock(FleetQuoteSupplierClient.class);var directory=new FleetQuoteDirectory(mock(FleetCustomerDirectory.class),suppliers,"test-only-token");
when(suppliers.get(anyString(),anyLong(),anyBoolean(),anyString())).thenThrow(new IllegalStateException("unavailable"));
assertThatThrownBy(()->directory.name("PURCHASE",2)).hasMessageContaining("目录暂不可用");
}
}
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.fleet.quote;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.LocalDate;
import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetQuotePersistenceTest {
private JdbcTemplate jdbc; private FleetQuoteService service;
@BeforeEach void setup() throws Exception {
var source=new DriverManagerDataSource("jdbc:h2:mem:quotes-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(source);
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_fleet_quote","ll_fleet_quote_history")) {
var match=Pattern.compile("(?s)CREATE TABLE "+table+" \\(.*?;").matcher(schema);
assertThat(match.find()).as("总控集成表结构 %s",table).isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT"));
}
var directory=mock(FleetQuoteDirectory.class);when(directory.name(anyString(),anyLong())).thenReturn("正式往来单位");
var target=new FleetQuoteService(new FleetQuoteRepository(jdbc),directory,new ObjectMapper().registerModule(new JavaTimeModule()));
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),new AnnotationTransactionAttributeSource()));
service=(FleetQuoteService)proxy.getProxy();
}
private FleetQuoteModels.Draft draft(String kind,String city,BigDecimal price) {
return new FleetQuoteModels.Draft(kind,1L,city,"ont8",new BigDecimal("10.0"),"USD",price,null,null,LocalDate.of(2026,1,1),LocalDate.of(2026,12,31),null);
}
@Test void nullPriceDiffersFromZeroAndSalesNeverAliasPurchase() {
assertThatThrownBy(()->service.create("1",new FleetQuoteModels.Create("invalid-key",draft("SALE","LA",null)))).hasMessageContaining("至少填写");
var sale=service.create("1",new FleetQuoteModels.Create("sale-key",draft("SALE","LA",BigDecimal.ZERO)));
var purchase=service.create("1",new FleetQuoteModels.Create("purchase-key",draft("PURCHASE","LA",BigDecimal.TEN)));
assertThat(sale.details().selfPickupPrice()).isEqualByComparingTo("0");assertThat(sale.details().fullDeliveryPrice()).isNull();
assertThat(purchase.quoteNo()).isNotEqualTo(sale.quoteNo());assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote",Integer.class)).isEqualTo(2);
}
@Test void replayAndCorrectionKeepVersionsAndRejectStaleWrites() {
var request=new FleetQuoteModels.Create("stable-key",draft("SALE","LA",BigDecimal.TEN));var first=service.create("1",request);
assertThat(service.create("1",request).quoteNo()).isEqualTo(first.quoteNo());
assertThatThrownBy(()->service.create("1",new FleetQuoteModels.Create("stable-key",draft("SALE","LA",BigDecimal.ONE)))).hasMessageContaining("不同报价");
service.update(first.quoteNo(),"1",new FleetQuoteModels.Update(0L,draft("SALE","LA",BigDecimal.ONE)));
assertThat(service.create("1",request).details().selfPickupPrice()).isEqualByComparingTo("10");
assertThat(service.history(first.quoteNo())).hasSize(2);assertThat(service.history(first.quoteNo()).get(1).snapshot().details().selfPickupPrice()).isEqualByComparingTo("10");
assertThatThrownBy(()->service.status(first.quoteNo(),"1",new FleetQuoteModels.Status(0L,"INACTIVE"))).hasMessageContaining("已变化");
}
@Test void failedAtomicImportLeavesNoRowsAndChangedBatchCannotReuseKey() {
var row=draft("SALE","LA",BigDecimal.ONE);
assertThatThrownBy(()->service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row,row)))).hasMessageContaining("重复");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote",Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote_history",Integer.class)).isZero();
service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row)));
assertThatThrownBy(()->service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row,draft("SALE","SF",BigDecimal.ONE))))).hasMessageContaining("不同报价");
}
@Test void deletePreservesHistoryAndExportEscapesSpreadsheetFormulas() {
var created=service.create("1",new FleetQuoteModels.Create("formula-key",draft("SALE","=2+2",BigDecimal.ZERO)));
assertThat(new String(service.exportCsv(new FleetQuoteModels.Query(1,20,null,null,null,null)),java.nio.charset.StandardCharsets.UTF_8)).contains("'=2+2");
service.delete(created.quoteNo(),"1",0);assertThat(service.page(new FleetQuoteModels.Query(1,20,null,null,null,null)).total()).isZero();
assertThat(service.history(created.quoteNo())).hasSize(2);
}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.fleet.reporting;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.math.BigDecimal;
import java.nio.file.*;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class TripOperationsRecordPersistenceTest {
private JdbcTemplate jdbc;private TripOperationsRecordRepository repository;private TripOperationsRecordService service;
@BeforeEach void setup() throws Exception {
var source=new DriverManagerDataSource("jdbc:h2:mem:trip-measured-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");jdbc=new JdbcTemplate(source);
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),departed_at DATETIME)");
jdbc.update("INSERT INTO ll_fleet_trip VALUES('TR-A','IN_TRANSIT','2026-10-01 00:00:00'),('TR-B','PLANNED',NULL)");
var match=Pattern.compile("(?s)CREATE TABLE ll_fleet_trip_operations_record \\(.*?;").matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
assertThat(match.find()).as("总控集成实测记录表").isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT"));
repository=spy(new TripOperationsRecordRepository(jdbc));var target=new TripOperationsRecordService(repository,new ObjectMapper().registerModule(new JavaTimeModule()));
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),new AnnotationTransactionAttributeSource()));service=(TripOperationsRecordService)proxy.getProxy();
}
private TripOperationsRecordModels.Save request(long version,String key,BigDecimal distance,BigDecimal fuel){return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Details(distance,fuel,null),null);}
@Test void onlyDepartedTripsCanBeMeasuredAndMissingValuesAreNotZero() {
assertThat(service.get("TR-A").details().actualDistanceMiles()).isNull();assertThat(service.get("TR-A").recordVersion()).isZero();
assertThatThrownBy(()->service.save("TR-B","1",request(0,"before-depart",BigDecimal.ONE,null))).hasMessageContaining("实际已发车");
var saved=service.save("TR-A","1",request(0,"save-zero",BigDecimal.ZERO,null));
assertThat(saved.details().actualDistanceMiles()).isEqualByComparingTo("0");assertThat(saved.details().actualFuelLitres()).isNull();
assertThat(jdbc.queryForObject("SELECT status FROM ll_fleet_trip WHERE trip_no='TR-A'",String.class)).isEqualTo("IN_TRANSIT");
}
@Test void replayReturnsOriginalSnapshotAndStaleUpdatesCannotOverwriteNewFacts() {
var first=request(0,"first-key",new BigDecimal("10.0"),BigDecimal.ONE);service.save("TR-A","1",first);
service.save("TR-A","1",request(1,"second-key",new BigDecimal("20"),BigDecimal.TEN));
assertThat(service.save("TR-A","1",first).details().actualDistanceMiles()).isEqualByComparingTo("10");
assertThatThrownBy(()->service.save("TR-A","1",request(1,"stale-key",BigDecimal.ONE,null))).hasMessageContaining("已变化");
assertThatThrownBy(()->service.save("TR-A","1",request(0,"first-key",BigDecimal.ONE,null))).hasMessageContaining("其他实测内容");
assertThat(service.history("TR-A",1,1).total()).isEqualTo(2);assertThat(service.get("TR-A").details().actualDistanceMiles()).isEqualByComparingTo("20");
}
@Test void unchangedFactsDoNotInventExtraHistoryAndExplicitClearIsAudited() {
service.save("TR-A","1",request(0,"create-key",new BigDecimal("10.0"),null));
assertThat(service.save("TR-A","1",request(1,"no-change",new BigDecimal("10.00"),null)).recordVersion()).isEqualTo(1);
service.save("TR-A","1",request(1,"clear-key",null,null));
assertThat(service.history("TR-A",1,20).total()).isEqualTo(2);assertThat(service.get("TR-A").details().actualDistanceMiles()).isNull();
}
@Test void failedAppendRollsBackAllMeasuredFacts() {
doAnswer(invocation->{invocation.callRealMethod();throw new IllegalStateException("audit-failure");}).when(repository).append(anyLong(),anyString(),anyString(),anyString(),any());
assertThatThrownBy(()->service.save("TR-A","1",request(0,"failure-key",BigDecimal.ONE,null))).hasMessageContaining("audit-failure");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_operations_record",Integer.class)).isZero();
}
}
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.fleet.reporting;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.baiyue.lastleg.fleet.trip.FleetTripCostService;
import com.baiyue.lastleg.fleet.trip.FleetTripCostModels;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class TripOperationsServiceTest {
static final String DEPARTURE="""
{"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"},
"confirmedLoad":{"cargo":[
{"dispatchNo":"D-A","customerId":"1","dispatchItemId":"I1","warehousePalletId":"P1","packages":10,"destinationKey":"FBA:ONT8","destinationLabel":"ONT8"},
{"dispatchNo":"D-B","customerId":"2","dispatchItemId":"I2","warehousePalletId":"P1","packages":5,"destinationKey":"FBA:LAX9","destinationLabel":"LAX9"}],
"pallets":[{"warehousePalletId":"P1","palletSpaces":2,"weightKg":100,"volumeCbm":2,"deliveryIndex":1}]},
"externalBookings":[{"bookingNo":"FB1","status":"BOUND","customerId":"1","dispatchNo":"D-A","agreedAmount":0,"cargo":{"currency":"USD"},
"receipt":{"source":{"receivedPackages":4,"pallets":[{"warehousePalletId":"P1","packages":4}]}}}]}
""";
@Test void mixedCustomersDestinationsAndCargoCountOneDepartureAndOnePhysicalPallet() {
var repo=mock(TripOperationsRepository.class);var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
var query=new TripOperationsModels.Query(1,1,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null);
when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","COMPLETED",Instant.parse("2026-10-02T00:00:00Z"),DEPARTURE,null)));
var result=service.report(query,false);assertThat(result.summary().trips()).isEqualTo(1);assertThat(result.summary().physicalPallets()).isEqualTo(1);assertThat(result.summary().palletSpaces()).isEqualTo(2);
assertThat(result.summary().externalPackages()).isEqualTo(4);assertThat(result.summary().ownPackages()).isEqualTo(11);
var row=result.page().records().get(0);assertThat(row.vehiclePlate()).isEqualTo("OLD-PLATE");assertThat(row.financeStatus()).isEqualTo("NOT_QUERIED");assertThat(row.costStatus()).isEqualTo("FORBIDDEN");
assertThat(row.externalAgreedAmounts().get(0).amount()).isEqualByComparingTo("0");verify(repo,times(1)).departures(query);
}
@Test void missingSnapshotAndOverstatedExternalQuantityFailInsteadOfInventingZero() {
var service=new TripOperationsService(mock(TripOperationsRepository.class),new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,null,null))).hasMessageContaining("不可变历史");
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE.replace("\"receivedPackages\":4","\"receivedPackages\":20").replace("\"packages\":4","\"packages\":20"),null))).hasMessageContaining("超过实际发车");
}
@Test void summaryUsesAllFilteredDeparturesNotCurrentPageAndSeparatesCurrencies() {
var repo=mock(TripOperationsRepository.class);var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
var query=new TripOperationsModels.Query(1,1,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,"EXTERNAL",null);
when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE,null),new TripOperationsRepository.Departure("TR-B","IN_TRANSIT",null,DEPARTURE.replace("USD","CNY").replace("TR-A","TR-B"),null)));
var result=service.report(query,false);assertThat(result.page().records()).hasSize(1);assertThat(result.summary().trips()).isEqualTo(2);assertThat(result.summary().externalAgreedAmounts()).hasSize(2);
assertThat(result.charts().physicalPallets().get(0).points().get(0).value()).isEqualByComparingTo("2");
assertThat(result.charts().destinationTrips().get(0).points()).hasSize(2);
assertThat(result.charts().externalPalletRates()).isEmpty();
}
@Test void perPalletChartUsesWeightedConfirmedQuantityAndExcludesOrderPricing() {
var service=new TripOperationsService(mock(TripOperationsRepository.class),new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
String a=DEPARTURE.replace("\"agreedAmount\":0","\"agreedAmount\":20").replace("\"currency\":\"USD\"","\"currency\":\"USD\",\"priceBasis\":\"PALLET\",\"palletSpaces\":2");
String b=a.replace("\"agreedAmount\":20","\"agreedAmount\":10").replace("\"palletSpaces\":2","\"palletSpaces\":8");
String order=DEPARTURE.replace("\"agreedAmount\":0","\"agreedAmount\":100").replace("\"currency\":\"USD\"","\"currency\":\"USD\",\"priceBasis\":\"ORDER\"");
var charts=TripOperationsCharts.from(List.of(service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,a,null)),
service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,b,null)),service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,order,null))));
assertThat(charts.externalPalletRates().get(0).points().get(0).value()).isEqualByComparingTo("3.00");
assertThat(charts.externalAgreedAmounts().get(0).points().get(0).value()).isEqualByComparingTo("130");
}
@Test void costsComeFromOneBatchAndUnknownAllocationsNeverBecomeZero() {
var repo=mock(TripOperationsRepository.class);var costs=mock(FleetTripCostService.class);
var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),costs);
var query=new TripOperationsModels.Query(1,20,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null);
when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE,null),new TripOperationsRepository.Departure("TR-B","IN_TRANSIT",null,DEPARTURE.replace("TR-A","TR-B"),null)));
var registered=new FleetTripCostModels.Summary(1L,"hash",null,"各票卸货站不同",List.of(new FleetTripCostModels.Total("USD",new java.math.BigDecimal("10.00"),List.of(),null,null)));
var empty=new FleetTripCostModels.Summary(1L,"hash",null,"各票卸货站不同",List.of());
when(costs.summaries(List.of("TR-A","TR-B"))).thenReturn(java.util.Map.of("TR-A",registered,"TR-B",empty));
var report=service.report(query,true);verify(costs,times(1)).summaries(List.of("TR-A","TR-B"));
assertThat(report.summary().costs().unrecordedTrips()).isEqualTo(1);assertThat(report.summary().costs().unallocatedTrips()).isEqualTo(1);
assertThat(report.summary().costs().totals().get(0).directCost()).isEqualByComparingTo("10.00");assertThat(report.summary().costs().totals().get(0).ownCost()).isNull();
assertThat(report.page().records().get(1).costStatus()).isEqualTo("NOT_RECORDED");
clearInvocations(costs);assertThat(service.report(query,false).summary().costs().visible()).isFalse();verifyNoInteractions(costs);
}
}
@@ -0,0 +1,77 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTripCostContractTest {
private static final String READ="platform:fleet:trip:list", CREATE="platform:fleet:trip:cost-create", EDIT="platform:fleet:trip:cost-edit";
private void login(String... permissions) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused",
Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList()));
}
@Test void readingDoesNotGrantCostWritesAndPlanEditingDoesNotGrantCostEditing() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformFleetTripCostController.class);
var service = context.getBean(FleetTripCostService.class);
var user = mock(AuthenticatedUser.class); when(user.userId()).thenReturn(8L);
var query = new FleetTripCostModels.Query(1, 10);
var create = new FleetTripCostModels.Create("test-key", null);
var update = new FleetTripCostModels.Update(0L, null, null);
login(READ, "platform:fleet:trip:edit");
controller.list("TR-ONE", query); controller.history("TR-ONE", "TC-ONE", query);
assertThatThrownBy(() -> controller.create("TR-ONE", user, create)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.update("TR-ONE", "TC-ONE", user, update)).isInstanceOf(AccessDeniedException.class);
login(CREATE); controller.create("TR-ONE", user, create);
assertThatThrownBy(() -> controller.update("TR-ONE", "TC-ONE", user, update)).isInstanceOf(AccessDeniedException.class);
login(EDIT); controller.update("TR-ONE", "TC-ONE", user, update);
assertThatThrownBy(() -> controller.create("TR-ONE", user, create)).isInstanceOf(AccessDeniedException.class);
login(); assertThatThrownBy(() -> controller.list("TR-ONE", query)).isInstanceOf(AccessDeniedException.class);
verify(service).list("TR-ONE", query); verify(service).history("TR-ONE", "TC-ONE", query);
verify(service).create("TR-ONE", "8", create); verify(service).update("TR-ONE", "TC-ONE", "8", update);
verifyNoMoreInteractions(service);
} finally { SecurityContextHolder.clearContext(); }
}
@Test void everyPublicOperationAndPayloadFieldHasAnExplicitContract() throws Exception {
Map<?,?> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trip-costs-v1.yaml")));
Map<?,?> paths = (Map<?,?>) document.get("paths");
String base = PlatformFleetTripCostController.class.getAnnotation(RequestMapping.class).value()[0];
for (var method : PlatformFleetTripCostController.class.getDeclaredMethods()) {
String verb, suffix;
if (method.isAnnotationPresent(GetMapping.class)) { verb="get"; var values=method.getAnnotation(GetMapping.class).value(); suffix=values.length==0 ? "" : values[0]; }
else if (method.isAnnotationPresent(PostMapping.class)) { verb="post"; suffix=""; }
else if (method.isAnnotationPresent(PutMapping.class)) { verb="put"; suffix=method.getAnnotation(PutMapping.class).value()[0]; }
else continue;
Map<?,?> operation = (Map<?,?>) ((Map<?,?>) paths.get(base+suffix)).get(verb);
String expression = method.getAnnotation(PreAuthorize.class).value();
for (String permission : List.of(READ, CREATE, EDIT)) if (expression.contains(permission))
assertThat(operation.get("description").toString()).contains(permission);
}
Map<?,?> schemas = (Map<?,?>) ((Map<?,?>) document.get("components")).get("schemas");
for (var type : List.of(FleetTripCostModels.Cost.class, FleetTripCostModels.Draft.class, FleetTripCostModels.Allocation.class,
FleetTripCostModels.Summary.class, FleetTripCostModels.Total.class, FleetTripCostModels.Listing.class,
FleetTripCostModels.Snapshot.class, FleetTripCostModels.History.class)) {
Map<?,?> properties = (Map<?,?>) ((Map<?,?>) schemas.get(type.getSimpleName())).get("properties");
assertThat(properties.keySet().stream().map(Object::toString).toList()).containsExactlyInAnyOrder(
Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toArray(String[]::new));
}
assertThat(FleetTripCostService.class.getMethod("list", String.class, FleetTripCostModels.Query.class).getAnnotation(Transactional.class).readOnly()).isTrue();
}
@Configuration @EnableMethodSecurity static class Config {
@Bean FleetTripCostService service() { return mock(FleetTripCostService.class); }
@Bean PlatformFleetTripCostController controller(FleetTripCostService service) { return new PlatformFleetTripCostController(service); }
}
}
@@ -0,0 +1,189 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class FleetTripCostPersistenceTest {
private JdbcTemplate jdbc;
private FleetTripCostRepository repository;
private FleetTripCostService service;
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
private static final FleetTripCostModels.Query PAGE = new FleetTripCostModels.Query(1, 10);
static final String DEPARTURE = """
{"confirmedLoad":{"manifestHash":"frozen-departure", "cargo":[
{"dispatchNo":"DS-A","customerId":"1","dispatchItemId":"I1","warehousePalletId":"P1","palletNo":"PAL-1","packages":3,"destinationKey":"FBA:A"},
{"dispatchNo":"DS-B","customerId":"2","dispatchItemId":"I2","warehousePalletId":"P2","palletNo":"PAL-2","packages":2,"destinationKey":"FBA:A"}],
"pallets":[{"warehousePalletId":"P1","palletSpaces":2,"weightKg":100,"volumeCbm":2,"deliveryIndex":1},
{"warehousePalletId":"P2","palletSpaces":1,"weightKg":50,"volumeCbm":1,"deliveryIndex":1}]},
"externalBookings":[{"bookingNo":"FB-B","customerId":"2","status":"BOUND","dispatchNo":"DS-B",
"receipt":{"source":{"receivedPackages":2,"pallets":[{"warehousePalletId":"P2","packages":2}]}}}]}
""";
@BeforeEach void setup() throws Exception {
var source = new DriverManagerDataSource("jdbc:h2:mem:trip-cost-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000", "sa", "");
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json LONGTEXT)");
jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-A','COMPLETED'),(2,'TR-B','PLANNED')");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,7,'DEPART',?)", DEPARTURE);
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for (String name : List.of("ll_fleet_trip_cost", "ll_fleet_trip_cost_history")) {
var match = Pattern.compile("(?s)CREATE TABLE " + name + " \\(.*?;").matcher(schema);
assertThat(match.find()).isTrue(); jdbc.execute(match.group().replace(" JSON", " LONGTEXT"));
}
var config = new MybatisConfiguration(); config.addMapper(FleetTripMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
repository = spy(new FleetTripCostRepository(jdbc));
var proxy = new ProxyFactory(new FleetTripCostService(mapper, repository, json)); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
service = (FleetTripCostService) proxy.getProxy();
}
private static FleetTripCostModels.Draft draft(String currency, String amount) {
return new FleetTripCostModels.Draft("FUEL", currency, new BigDecimal(amount), Instant.parse("2026-01-01T00:00:00Z"), null);
}
private FleetTripCostModels.Cost create(String trip, String key, String currency, String amount) {
return service.create(trip, "1", new FleetTripCostModels.Create(key, draft(currency, amount)));
}
@Test void departureOnlyWeightsConserveCentsAndNeverMixCurrencies() {
create("TR-A", "CNY-ONE", "CNY", "100.00"); create("TR-A", "USD-ONE", "USD", "0.01");
var listing = service.list("TR-A", PAGE);
assertThat(listing.summary().departureVersion()).isEqualTo(7);
assertThat(listing.summary().totals()).hasSize(2);
var cny = listing.summary().totals().get(0);
assertThat(cny.ownCost()).isEqualByComparingTo("66.67"); assertThat(cny.externalCost()).isEqualByComparingTo("33.33");
for (var total : listing.summary().totals()) assertThat(total.allocations().stream().map(FleetTripCostModels.Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo(total.directCost());
assertThat(cny.allocations()).extracting(FleetTripCostModels.Allocation::businessNo).containsExactly("DS-A", "FB-B");
assertThat(jdbc.queryForList("SELECT status FROM ll_fleet_trip WHERE id=1", String.class)).containsExactly("COMPLETED");
assertThat(jdbc.queryForObject("SELECT snapshot_json FROM ll_fleet_trip_history WHERE trip_id=1", String.class)).isEqualTo(DEPARTURE);
}
@Test void batchSummaryReusesTheSameAmountsWithoutPerTripQueriesAndDistinguishesMissingTrips() {
create("TR-A", "BATCH-COST", "CNY", "100.00");
var expected = service.list("TR-A", PAGE).summary();
clearInvocations(repository);
var result = service.summaries(List.of("TR-A", "TR-B", "MISSING", "TR-A"));
assertThat(result.keySet()).containsExactly("TR-A", "TR-B");
assertThat(result.get("TR-A")).isEqualTo(expected);
assertThat(result.get("TR-B").totals()).isEmpty();
verify(repository, never()).departure(anyLong());
verify(repository, never()).totals(anyLong());
verify(repository, times(1)).trips(anyCollection());
verify(repository, times(1)).departures(anyCollection());
verify(repository, times(1)).totals(anyCollection());
assertThat(service.summaries(List.of())).isEmpty();
assertThatThrownBy(() -> service.summaries(Collections.nCopies(101, "TR-A"))).hasMessageContaining("最多100");
assertThatThrownBy(() -> service.summaries(List.of(" "))).hasMessageContaining("有效车次");
}
@Test void createReplayAndConcurrentDuplicateCannotCreateTwoCosts() throws Exception {
var executor = Executors.newFixedThreadPool(2);
try {
var a = executor.submit(() -> create("TR-A", "SAME-KEY", "CNY", "5"));
var b = executor.submit(() -> create("TR-A", "SAME-KEY", "CNY", "5"));
assertThat(a.get(10, TimeUnit.SECONDS).costNo()).isEqualTo(b.get(10, TimeUnit.SECONDS).costNo());
} finally { executor.shutdownNow(); }
assertThat(service.list("TR-A", PAGE).page().total()).isEqualTo(1);
assertThatThrownBy(() -> create("TR-A", "SAME-KEY", "CNY", "6")).hasMessageContaining("其他成本内容");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost_history", Integer.class)).isEqualTo(1);
}
@Test void correctionRetainsOriginalAggregateAndRejectsOtherTripsAndStaleVersions() {
var original = create("TR-A", "COST-ONE", "USD", "30");
var request = new FleetTripCostModels.Update(0L, draft("USD", "60"), "核对实际费用");
assertThatThrownBy(() -> service.update("TR-B", original.costNo(), "1", request)).hasMessageContaining("不存在");
service.update("TR-A", original.costNo(), "1", request);
assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", request)).hasMessageContaining("已变化");
var history = service.history("TR-A", original.costNo(), PAGE).records();
assertThat(history).hasSize(2); assertThat(history.get(0).reason()).isEqualTo("核对实际费用");
assertThat(history.get(0).snapshot().summary().totals().get(0).directCost()).isEqualByComparingTo("60");
assertThat(history.get(1).snapshot().summary().totals().get(0).directCost()).isEqualByComparingTo("30");
}
@Test void equivalentMoneyAndEmptyRemarksReplayWithoutAnotherCostOrHistory() {
var original = create("TR-A", "EQUIVALENT-KEY", "USD", "30");
var equivalent = new FleetTripCostModels.Draft("FUEL", "USD", new BigDecimal("30.00"), original.details().incurredAt(), " ");
var replay = service.create("TR-A", "1", new FleetTripCostModels.Create("EQUIVALENT-KEY", equivalent));
assertThat(replay.costNo()).isEqualTo(original.costNo());
var unchanged = service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, equivalent, "仅说明不同"));
assertThat(unchanged.recordVersion()).isZero();
assertThat(service.history("TR-A", original.costNo(), PAGE).total()).isEqualTo(1);
assertThat(service.list("TR-A", PAGE).page().total()).isEqualTo(1);
service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("USD", "31"), null));
assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("USD", "31"), null)))
.hasMessageContaining("已变化");
}
@Test void millisecondStorageAndReplayingCreationAfterCorrectionPreserveTheCurrentCost() {
var input = new FleetTripCostModels.Draft("FUEL", "USD", new BigDecimal("12.50"), Instant.parse("2026-01-01T00:00:00.123456Z"), " 凭据说明 ");
var command = new FleetTripCostModels.Create("PRECISE-TIME-KEY", input);
var original = service.create("TR-A", "1", command);
assertThat(original.details().incurredAt()).isEqualTo(Instant.parse("2026-01-01T00:00:00.123Z"));
assertThat(original.details().remark()).isEqualTo("凭据说明");
var unchanged = service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, input, null));
assertThat(unchanged.recordVersion()).isZero();
service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("CNY", "20"), null));
var replay = service.create("TR-A", "1", command);
assertThat(replay.recordVersion()).isEqualTo(1);
assertThat(replay.details().currency()).isEqualTo("CNY");
assertThat(replay.details().amount()).isEqualByComparingTo("20");
assertThat(service.history("TR-A", original.costNo(), PAGE).total()).isEqualTo(2);
}
@Test void auditFailureRollsBackCostAndAmountCorrection() {
var original = create("TR-A", "ORIGINAL", "CNY", "30");
doThrow(new IllegalStateException("history unavailable")).when(repository).audit(anyLong(), any(), anyString(), anyString(), anyString(), anyString());
assertThatThrownBy(() -> create("TR-A", "REJECTED", "CNY", "90")).hasMessageContaining("history unavailable");
assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("CNY", "70"), null))).hasMessageContaining("history unavailable");
var list = service.list("TR-A", PAGE);
assertThat(list.page().total()).isEqualTo(1); assertThat(list.page().records().get(0).recordVersion()).isZero();
assertThat(list.summary().totals().get(0).directCost()).isEqualByComparingTo("30");
}
@Test void noDepartureAndNoCostsAreUnknownAndDifferentStopsPreventInventedAllocations() {
assertThat(service.list("TR-B", PAGE).summary().totals()).isEmpty();
create("TR-B", "BEFORE-DEPART", "USD", "0");
var before = service.list("TR-B", PAGE).summary();
assertThat(before.allocationBlockReason()).contains("尚无实际发车"); assertThat(before.totals().get(0).ownCost()).isNull();
jdbc.update("UPDATE ll_fleet_trip_history SET snapshot_json=? WHERE trip_id=1", DEPARTURE.replace("\"palletSpaces\":1,\"weightKg\":50,\"volumeCbm\":1,\"deliveryIndex\":1", "\"palletSpaces\":1,\"weightKg\":50,\"volumeCbm\":1,\"deliveryIndex\":2"));
create("TR-A", "MULTI-STOP", "USD", "50");
var summary = service.list("TR-A", PAGE).summary();
assertThat(summary.allocationBlockReason()).contains("卸货站不同"); assertThat(summary.totals().get(0).allocations()).isEmpty();
assertThat(summary.totals().get(0).directCost()).isEqualByComparingTo("50");
}
@Test void mismatchedExternalOwnerOrReceiptCannotBeClassifiedAsOwnCargo() throws Exception {
var wrongCustomer = json.readValue(DEPARTURE.replace("\"customerId\":\"2\",\"status\"", "\"customerId\":\"3\",\"status\""), FleetTripModels.AuditSnapshot.class);
assertThat(FleetTripCostBasis.from(wrongCustomer).blockReason()).contains("归属未核平");
var wrongQuantity = json.readValue(DEPARTURE.replace("\"receivedPackages\":2", "\"receivedPackages\":3"), FleetTripModels.AuditSnapshot.class);
assertThat(FleetTripCostBasis.from(wrongQuantity).blockReason()).contains("总量");
}
@Test void activeUnboundExternalCargoCannotSilentlyBecomeOwnCargo() throws Exception {
for (var status : List.of("CONFIRMED", "RECEIVED", "UNKNOWN")) {
var snapshot = json.readValue(DEPARTURE.replace("\"status\":\"BOUND\"", "\"status\":\"" + status + "\""), FleetTripModels.AuditSnapshot.class);
assertThat(FleetTripCostBasis.from(snapshot).blockReason()).contains("实收及排车依据");
}
}
@Test void boundExternalCargoRequiresIdentityAndUniqueReceiptPallets() throws Exception {
for (var malformed : List.of(
DEPARTURE.replace("\"bookingNo\":\"FB-B\"", "\"bookingNo\":null"),
DEPARTURE.replace("\"customerId\":\"2\",\"status\"", "\"customerId\":null,\"status\""),
DEPARTURE.replace("\"status\":\"BOUND\",\"dispatchNo\":\"DS-B\"", "\"status\":\"BOUND\",\"dispatchNo\":null"))) {
assertThat(FleetTripCostBasis.from(json.readValue(malformed, FleetTripModels.AuditSnapshot.class)).blockReason()).contains("实收及排车依据");
}
var duplicate = DEPARTURE.replace("\"warehousePalletId\":\"P2\",\"packages\":2}",
"\"warehousePalletId\":\"P2\",\"packages\":2},{\"warehousePalletId\":\"P2\",\"packages\":2}");
assertThat(FleetTripCostBasis.from(json.readValue(duplicate, FleetTripModels.AuditSnapshot.class)).blockReason()).contains("归属未核平");
}
}
@@ -476,6 +476,9 @@ class FleetTripPersistenceTest {
assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("OUTBOUNDED");
assertThat(jdbc.queryForObject("SELECT COUNT(DISTINCT depart_at) FROM ll_dispatch_order", Integer.class)).isEqualTo(1);
assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT");
assertThat(result.departedAt().getNano() % 1_000_000).isZero();
assertThat(service.detail(number).trip().departedAt()).isEqualTo(result.departedAt());
assertThat(service.history(number, 1, 20).records().get(0).snapshot().trip().departedAt()).isEqualTo(result.departedAt());
assertThat(execution.depart("92", "DP-12", "1", request).replayed()).isTrue();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'", Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isEqualTo(1);
@@ -13,7 +13,7 @@ import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** Exercises the production SQL with H2 adapters for MySQL's two JSON scalar functions. */
/** Exercises the production SQL with H2 adapters for MySQL JSON scalar functions. */
public class VehicleFulfillmentQueryTest {
private JdbcTemplate jdbc;
private VehicleFulfillmentMapper mapper;
@@ -22,19 +22,37 @@ public class VehicleFulfillmentQueryTest {
private static final ObjectMapper JSON = new ObjectMapper();
public static String jsonExtract(String source, String path) throws Exception {
if (source == null) return null;
if ("$".equals(path)) return source;
var value = JSON.readTree(source);
for (String field : path.substring(2).split("\\.")) value = value.path(field);
return value.isMissingNode() ? null : value.toString();
}
public static String jsonUnquote(String value) throws Exception { return value == null ? null : JSON.readTree(value).asText(); }
public static String jsonQuote(String value) throws Exception { return JSON.writeValueAsString(value); }
public static int jsonContains(String source, String candidate, String path) throws Exception {
String array = jsonExtract(source, path);
if (array == null) return 0;
var expected = JSON.readTree(candidate);
for (var entry : JSON.readTree(array)) if (entry.equals(expected)) return 1;
return 0;
}
public static String jsonSet(String source, String path, String value) throws Exception {
var document = (com.fasterxml.jackson.databind.node.ObjectNode) JSON.readTree(source);
document.set(path.substring(2), JSON.readTree(value));
return document.toString();
}
@BeforeEach void setup() throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(dataSource);
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonExtract'");
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonUnquote'");
jdbc.execute("CREATE ALIAS JSON_QUOTE FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonQuote'");
jdbc.execute("CREATE ALIAS JSON_CONTAINS FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonContains'");
jdbc.execute("CREATE ALIAS JSON_SET FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonSet'");
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),vehicle_id BIGINT,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT,customer_id VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_trip_history(id BIGINT PRIMARY KEY,trip_id BIGINT,record_version BIGINT,occurred_at TIMESTAMP,action VARCHAR(64),reason VARCHAR(255),snapshot_json CLOB,UNIQUE(trip_id,record_version))");
jdbc.execute("CREATE TABLE ll_fleet_trip_handover(id BIGINT PRIMARY KEY,trip_id BIGINT,trip_version BIGINT,snapshot_json CLOB,UNIQUE(trip_id,trip_version))");
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,delivery_no VARCHAR(64),dispatch_no VARCHAR(64),dispatch_order_id BIGINT,customer_id VARCHAR(32),customer_name VARCHAR(100),bol_no VARCHAR(64),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(100),delivery_address VARCHAR(255),planned_delivery_at TIMESTAMP,status VARCHAR(32),created_at TIMESTAMP)");
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); configuration.addMapper(VehicleFulfillmentMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration);
@@ -104,4 +122,24 @@ public class VehicleFulfillmentQueryTest {
assertThatThrownBy(()->service.deliveries(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在");
assertThatThrownBy(()->service.changes(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在");
}
@Test void handoverAssociatesOnlyActuallyCarriedDeliveriesAndScopesChangeHistory() throws Exception {
history(0,"DEPART","8","18"); history(1,"HANDOVER","9","19");
jdbc.update("UPDATE ll_fleet_trip SET vehicle_id=9,status='IN_TRANSIT'");
String snapshot = JSON.writeValueAsString(Map.of("replacement", Map.of("vehicleId", "9"),
"carriedDeliveries", List.of(Map.of("customerId", "CUSTOMER-B", "deliveryNo", "DL-22")),
"cargo", Map.of("remainingCargo", List.of(Map.of("dispatchNo", "DP-12"), Map.of("dispatchNo", "DP-12")))));
jdbc.update("INSERT INTO ll_fleet_trip_handover VALUES(1,1,1,?)", snapshot);
assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(1,10)).records()).hasSize(2);
assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).records())
.extracting(VehicleFulfillmentModels.Delivery::deliveryNo).containsExactly("DL-22");
assertThat(service.changes(9,new VehicleFulfillmentModels.Query(1,10)).records()).singleElement().satisfies(change -> {
assertThat(change.relationship()).isEqualTo("REPLACED_IN");
assertThat(change.dispatchNos()).containsExactly("DP-12");
});
jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED' WHERE id=22");
assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isEqualTo(1);
// Matching delivery numbers alone must never associate another customer's document.
jdbc.update("UPDATE ll_delivery_order SET customer_id='CUSTOMER-A' WHERE id=22");
assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isZero();
}
}
@@ -16,7 +16,9 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class OutboxLeaseDatabaseTest {
@Test void unloadCompletedOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"OverseasContainerUnloaded", "CompanyVehicleCostSubmitted"})
void unloadCompletedOutboxRepositoryUsesAtomicAuditAndBackoff(String eventType) throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(dataSource);
jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)");
@@ -30,9 +32,9 @@ class OutboxLeaseDatabaseTest {
ReflectionTestUtils.setField(repository, "mapper", mapper);
var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0);
jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "OverseasContainerUnloaded", now.minusSeconds(1), now.minusMinutes(3));
assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse();
assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue();
jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", eventType, now.minusSeconds(1), now.minusMinutes(3));
assertThat(journal.begin("old", "lastleg-service", eventType, "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse();
assertThat(journal.begin("old", "lastleg-service", eventType, "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue();
jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61));
assertThat(repository.claim(1L, "new", now)).isFalse();
var next = now.plusSeconds(10);
@@ -40,7 +42,7 @@ class OutboxLeaseDatabaseTest {
() -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue();
assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox", Integer.class)).isEqualTo(1);
assertThat(repository.claim(1L, "early", now)).isFalse();
assertThat(journal.begin("new", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", next,
assertThat(journal.begin("new", "lastleg-service", eventType, "1", null, "consumer", next,
() -> repository.claim(1L, "new", next))).isTrue();
assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null,
() -> repository.dead(1L, "old", 12, "late"))).isFalse();
@@ -55,6 +55,11 @@
"com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity"
],
"reviewedClassDependencies": [
{
"source": "com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter",
"target": "com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway",
"reason": "费用适配器仅实现Fleet公司成本换算与Outbox端口,Fleet不读取费用模块或Outbox持久化实现;同事务发布且不新增package循环。"
},
{
"source": "com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter",
"target": "com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway",