feat(fleet): integrate costs quotes operations and in-transit handovers
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+61
@@ -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());
|
||||
|
||||
+22
@@ -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));
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+94
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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; }
|
||||
}
|
||||
}
|
||||
+71
@@ -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, "当前仓库没有该排车交接记录"); }
|
||||
}
|
||||
+104
@@ -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); }
|
||||
}
|
||||
+49
@@ -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());
|
||||
}
|
||||
}
|
||||
+208
@@ -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); }
|
||||
}
|
||||
+10
@@ -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) { }
|
||||
}
|
||||
+44
@@ -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();
|
||||
}
|
||||
}
|
||||
+27
@@ -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));
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+22
@@ -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) { }
|
||||
}
|
||||
+149
@@ -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, "当前车辆维修救援记录不存在"); }
|
||||
}
|
||||
+4
@@ -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")
|
||||
|
||||
+5
-3
@@ -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);
|
||||
|
||||
+13
@@ -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) { }
|
||||
}
|
||||
+39
@@ -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);}
|
||||
}
|
||||
+14
@@ -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) { }
|
||||
}
|
||||
+41
@@ -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();}
|
||||
}
|
||||
+25
@@ -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");}
|
||||
}
|
||||
+28
@@ -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));
|
||||
}
|
||||
}
|
||||
+32
@@ -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();}
|
||||
}
|
||||
+38
@@ -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) { }
|
||||
}
|
||||
+19
@@ -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) { }
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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);}
|
||||
}
|
||||
+32
@@ -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());
|
||||
}
|
||||
}
|
||||
+136
@@ -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) { }
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.ArrayList;
|
||||
|
||||
@Repository
|
||||
public class FleetTripCostRepository {
|
||||
record Row(long id, long tripId, String costNo, FleetTripCostModels.Draft details, long version,
|
||||
String requestHash, LocalDateTime updatedAt) { }
|
||||
record Amount(String currency, BigDecimal amount) { }
|
||||
record Departure(long version, String snapshot) { }
|
||||
record Audit(long version, String action, String reason, Instant occurredAt, String snapshot) { }
|
||||
private static final RowMapper<Row> ROW = (r, ignored) -> new Row(r.getLong("id"), r.getLong("trip_id"), r.getString("cost_no"),
|
||||
new FleetTripCostModels.Draft(r.getString("category"), r.getString("currency"), r.getBigDecimal("amount"),
|
||||
r.getTimestamp("incurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), r.getString("remark")),
|
||||
r.getLong("record_version"), r.getString("request_hash"), r.getTimestamp("updated_at").toLocalDateTime());
|
||||
private final JdbcTemplate jdbc;
|
||||
public FleetTripCostRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; }
|
||||
Row find(long tripId, String number) {
|
||||
return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? AND cost_no=?", ROW, tripId, number).stream().findFirst().orElse(null);
|
||||
}
|
||||
Row created(long tripId, String operator, String key) {
|
||||
return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? AND created_by=? AND request_key=?", ROW, tripId, operator, key).stream().findFirst().orElse(null);
|
||||
}
|
||||
long count(long tripId) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost WHERE trip_id=?", Long.class, tripId); }
|
||||
List<Row> page(long tripId, FleetTripCostModels.Query query) {
|
||||
return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? ORDER BY incurred_at DESC,id DESC LIMIT ? OFFSET ?", ROW, tripId, query.size(), query.offset());
|
||||
}
|
||||
List<Amount> totals(long tripId) {
|
||||
return jdbc.query("SELECT currency,SUM(amount) total FROM ll_fleet_trip_cost WHERE trip_id=? GROUP BY currency ORDER BY currency",
|
||||
(r, ignored) -> new Amount(r.getString("currency"), r.getBigDecimal("total")), tripId);
|
||||
}
|
||||
Departure departure(long tripId) {
|
||||
var rows = jdbc.query("SELECT record_version,snapshot_json FROM ll_fleet_trip_history WHERE trip_id=? AND action='DEPART'",
|
||||
(r, ignored) -> new Departure(r.getLong("record_version"), r.getString("snapshot_json")), tripId);
|
||||
if (rows.size() > 1) throw new IllegalStateException("车次存在重复发车历史,不能计算成本分摊");
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
Map<String, Long> trips(Collection<String> numbers) {
|
||||
var result = new LinkedHashMap<String, Long>();
|
||||
jdbc.query("SELECT trip_no,id FROM ll_fleet_trip WHERE trip_no IN (" + placeholders(numbers.size()) + ")",
|
||||
(org.springframework.jdbc.core.RowCallbackHandler) r -> result.put(r.getString("trip_no"), r.getLong("id")), numbers.toArray());
|
||||
return result;
|
||||
}
|
||||
Map<Long, Departure> departures(Collection<Long> ids) {
|
||||
var result = new LinkedHashMap<Long, Departure>();
|
||||
if (ids.isEmpty()) return result;
|
||||
jdbc.query("SELECT trip_id,record_version,snapshot_json FROM ll_fleet_trip_history WHERE action='DEPART' AND trip_id IN (" + placeholders(ids.size()) + ")",
|
||||
(org.springframework.jdbc.core.RowCallbackHandler) r -> {
|
||||
if (result.put(r.getLong("trip_id"), new Departure(r.getLong("record_version"), r.getString("snapshot_json"))) != null)
|
||||
throw new IllegalStateException("车次存在重复发车历史,不能计算成本分摊");
|
||||
}, ids.toArray());
|
||||
return result;
|
||||
}
|
||||
Map<Long, List<Amount>> totals(Collection<Long> ids) {
|
||||
var result = new LinkedHashMap<Long, List<Amount>>();
|
||||
if (ids.isEmpty()) return result;
|
||||
jdbc.query("SELECT trip_id,currency,SUM(amount) total FROM ll_fleet_trip_cost WHERE trip_id IN (" + placeholders(ids.size()) + ") GROUP BY trip_id,currency ORDER BY trip_id,currency",
|
||||
(org.springframework.jdbc.core.RowCallbackHandler) r -> result.computeIfAbsent(r.getLong("trip_id"), ignored -> new ArrayList<>())
|
||||
.add(new Amount(r.getString("currency"), r.getBigDecimal("total"))), ids.toArray());
|
||||
return result;
|
||||
}
|
||||
private static String placeholders(int count) { return String.join(",", Collections.nCopies(count, "?")); }
|
||||
void insert(Row row, String operator, String key) {
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_cost(id,trip_id,cost_no,category,currency,amount,incurred_at,remark,record_version,request_key,request_hash,created_by,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
row.id(), row.tripId(), row.costNo(), row.details().category(), row.details().currency(), row.details().amount(),
|
||||
utc(row.details().incurredAt()), row.details().remark(), row.version(), key, row.requestHash(), operator, operator, row.updatedAt(), row.updatedAt());
|
||||
}
|
||||
int update(Row row, long expected, String operator) {
|
||||
return jdbc.update("UPDATE ll_fleet_trip_cost SET category=?,currency=?,amount=?,incurred_at=?,remark=?,record_version=?,updated_by=?,updated_at=? WHERE id=? AND record_version=?",
|
||||
row.details().category(), row.details().currency(), row.details().amount(), utc(row.details().incurredAt()), row.details().remark(), row.version(), operator, row.updatedAt(), row.id(), expected);
|
||||
}
|
||||
void audit(long id, Row row, String operator, String action, String reason, String snapshot) {
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_cost_history(id,cost_id,record_version,action,reason,operator_id,occurred_at,snapshot_json) VALUES(?,?,?,?,?,?,?,?)",
|
||||
id, row.id(), row.version(), action, reason, operator, row.updatedAt(), snapshot);
|
||||
}
|
||||
long historyCount(long id) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost_history WHERE cost_id=?", Long.class, id); }
|
||||
List<Audit> history(long id, FleetTripCostModels.Query query) {
|
||||
return jdbc.query("SELECT * FROM ll_fleet_trip_cost_history WHERE cost_id=? ORDER BY record_version DESC LIMIT ? OFFSET ?",
|
||||
(r, ignored) -> new Audit(r.getLong("record_version"), r.getString("action"), r.getString("reason"),
|
||||
r.getTimestamp("occurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), r.getString("snapshot_json")), id, query.size(), query.offset());
|
||||
}
|
||||
private static LocalDateTime utc(Instant instant) { return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); }
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.fleet.domain.TripCostAllocationPolicy;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class FleetTripCostService {
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetTripCostRepository costs;
|
||||
private final ObjectMapper json;
|
||||
public FleetTripCostService(FleetTripMapper trips, FleetTripCostRepository costs, ObjectMapper json) {
|
||||
this.trips = trips; this.costs = costs; this.json = json;
|
||||
}
|
||||
@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ)
|
||||
public FleetTripCostModels.Listing list(String tripNo, FleetTripCostModels.Query query) {
|
||||
var trip = required(tripNo, false);
|
||||
long count = costs.count(trip.getId());
|
||||
return new FleetTripCostModels.Listing(new PageResult<>(costs.page(trip.getId(), query).stream().map(row -> response(tripNo, row)).toList(),
|
||||
query.page(), query.size(), count, pages(count, query.size())), summary(trip));
|
||||
}
|
||||
/** At most 100 distinct trips. Missing trips are omitted; existing trips with no costs have empty totals. */
|
||||
@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ)
|
||||
public Map<String, FleetTripCostModels.Summary> summaries(Collection<String> tripNos) {
|
||||
if (tripNos == null || tripNos.size() > 100 || tripNos.stream().anyMatch(no -> no == null || no.isBlank()))
|
||||
throw error(HttpStatus.BAD_REQUEST, "批量成本查询最多100个有效车次号");
|
||||
if (tripNos.isEmpty()) return Map.of();
|
||||
var numbers = new LinkedHashSet<>(tripNos);
|
||||
var tripsByNo = costs.trips(numbers);
|
||||
var departures = costs.departures(tripsByNo.values());
|
||||
var amounts = costs.totals(tripsByNo.values());
|
||||
var result = new LinkedHashMap<String, FleetTripCostModels.Summary>();
|
||||
for (var number : numbers) {
|
||||
var id = tripsByNo.get(number);
|
||||
if (id != null) result.put(number, summary(departures.get(id), amounts.getOrDefault(id, List.of())));
|
||||
}
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ)
|
||||
public PageResult<FleetTripCostModels.History> history(String tripNo, String costNo, FleetTripCostModels.Query query) {
|
||||
var trip = required(tripNo, false); var row = cost(trip.getId(), costNo); long count = costs.historyCount(row.id());
|
||||
return new PageResult<>(costs.history(row.id(), query).stream().map(h -> new FleetTripCostModels.History(h.version(), h.action(), h.reason(), h.occurredAt(),
|
||||
read(h.snapshot(), FleetTripCostModels.Snapshot.class))).toList(), query.page(), query.size(), count, pages(count, query.size()));
|
||||
}
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public FleetTripCostModels.Cost create(String tripNo, String operator, FleetTripCostModels.Create request) {
|
||||
var trip = required(tripNo, true);
|
||||
String hash = digest(write(canonical(request.details())));
|
||||
var prior = costs.created(trip.getId(), operator, request.idempotencyKey());
|
||||
if (prior != null) {
|
||||
if (!hash.equals(prior.requestHash())) throw error(HttpStatus.CONFLICT, "请求标识已用于其他成本内容");
|
||||
return response(tripNo, prior);
|
||||
}
|
||||
validate(request.details());
|
||||
var row = new FleetTripCostRepository.Row(IdWorker.getId(), trip.getId(), "TC-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT),
|
||||
canonical(request.details()), 0, hash, LocalDateTime.now(ZoneOffset.UTC));
|
||||
costs.insert(row, operator, request.idempotencyKey());
|
||||
audit(trip, row, operator, "CREATE", "登记本趟直接成本");
|
||||
return response(tripNo, row);
|
||||
}
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public FleetTripCostModels.Cost update(String tripNo, String costNo, String operator, FleetTripCostModels.Update request) {
|
||||
var trip = required(tripNo, true); var prior = cost(trip.getId(), costNo);
|
||||
if (prior.version() != request.recordVersion()) throw error(HttpStatus.CONFLICT, "成本记录已变化,请刷新后重试");
|
||||
validate(request.details());
|
||||
if (canonical(prior.details()).equals(canonical(request.details()))) return response(tripNo, prior);
|
||||
var row = new FleetTripCostRepository.Row(prior.id(), prior.tripId(), prior.costNo(), canonical(request.details()), prior.version() + 1, prior.requestHash(), LocalDateTime.now(ZoneOffset.UTC));
|
||||
if (costs.update(row, prior.version(), operator) != 1) throw error(HttpStatus.CONFLICT, "成本记录已变化,请刷新后重试");
|
||||
audit(trip, row, operator, "UPDATE", request.reason() == null || request.reason().isBlank() ? "更正本趟直接成本" : request.reason().trim());
|
||||
return response(tripNo, row);
|
||||
}
|
||||
private void audit(FleetTripEntity trip, FleetTripCostRepository.Row row, String operator, String action, String reason) {
|
||||
costs.audit(IdWorker.getId(), row, operator, action, reason, write(new FleetTripCostModels.Snapshot(response(trip.getTripNo(), row), summary(trip))));
|
||||
}
|
||||
private FleetTripCostModels.Summary summary(FleetTripEntity trip) {
|
||||
return summary(costs.departure(trip.getId()), costs.totals(trip.getId()));
|
||||
}
|
||||
private FleetTripCostModels.Summary summary(FleetTripCostRepository.Departure departure, List<FleetTripCostRepository.Amount> amountsByCurrency) {
|
||||
var basis = FleetTripCostBasis.from(departure == null ? null : read(departure.snapshot(), FleetTripModels.AuditSnapshot.class));
|
||||
var totals = new ArrayList<FleetTripCostModels.Total>();
|
||||
for (var total : amountsByCurrency) {
|
||||
var allocations = new ArrayList<FleetTripCostModels.Allocation>();
|
||||
BigDecimal own = null, external = null;
|
||||
if (basis.blockReason() == null) {
|
||||
var amounts = new HashMap<String, BigDecimal>();
|
||||
TripCostAllocationPolicy.allocate(total.amount(), total.currency(), basis.shares().stream()
|
||||
.map(share -> new TripCostAllocationPolicy.Share(share.businessNo(), share.palletSpaces())).toList())
|
||||
.forEach(part -> amounts.put(part.bookingNo(), part.amount()));
|
||||
own = BigDecimal.ZERO.setScale(2); external = BigDecimal.ZERO.setScale(2);
|
||||
for (var share : basis.shares()) {
|
||||
var amount = amounts.get(share.businessNo());
|
||||
allocations.add(new FleetTripCostModels.Allocation(share.businessNo(), share.sourceType(), share.dispatchNo(), share.palletSpaces(), amount));
|
||||
if ("OWN".equals(share.sourceType())) own = own.add(amount); else external = external.add(amount);
|
||||
}
|
||||
}
|
||||
totals.add(new FleetTripCostModels.Total(total.currency(), total.amount(), allocations, own, external));
|
||||
}
|
||||
return new FleetTripCostModels.Summary(departure == null ? null : departure.version(), basis.manifestHash(),
|
||||
basis.blockReason() == null ? "SAME_ROUTE_PALLET_SPACES" : null, basis.blockReason(), totals);
|
||||
}
|
||||
private FleetTripEntity required(String number, boolean lock) {
|
||||
var trip = lock ? trips.lockByNo(number) : trips.findByNo(number);
|
||||
if (trip == null) throw error(HttpStatus.NOT_FOUND, "车次不存在"); return trip;
|
||||
}
|
||||
private FleetTripCostRepository.Row cost(long tripId, String number) {
|
||||
var row = costs.find(tripId, number); if (row == null) throw error(HttpStatus.NOT_FOUND, "本车次成本记录不存在"); return row;
|
||||
}
|
||||
private static FleetTripCostModels.Cost response(String tripNo, FleetTripCostRepository.Row row) {
|
||||
return new FleetTripCostModels.Cost(row.costNo(), tripNo, row.details(), row.version(), row.updatedAt().toInstant(ZoneOffset.UTC));
|
||||
}
|
||||
private static void validate(FleetTripCostModels.Draft draft) {
|
||||
if (draft.incurredAt().isAfter(Instant.now())) throw error(HttpStatus.BAD_REQUEST, "实际费用时间不能晚于当前时间");
|
||||
}
|
||||
private static FleetTripCostModels.Draft canonical(FleetTripCostModels.Draft draft) {
|
||||
String remark = draft.remark() == null || draft.remark().isBlank() ? null : draft.remark().trim();
|
||||
return new FleetTripCostModels.Draft(draft.category(), draft.currency(), draft.amount().stripTrailingZeros(),
|
||||
draft.incurredAt().truncatedTo(java.time.temporal.ChronoUnit.MILLIS), remark);
|
||||
}
|
||||
private String write(Object value) {
|
||||
try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException("成本快照无法保存", error); }
|
||||
}
|
||||
private <T> T read(String value, Class<T> type) {
|
||||
try { return json.readValue(value, type); } catch (JsonProcessingException error) { throw new IllegalStateException("成本依据无法读取", error); }
|
||||
}
|
||||
private static String digest(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
|
||||
}
|
||||
private static long pages(long count, int size) { return count / size + (count % size == 0 ? 0 : 1); }
|
||||
private static ResponseStatusException error(HttpStatus status, String text) { return new ResponseStatusException(status, text); }
|
||||
}
|
||||
+2
-1
@@ -209,7 +209,8 @@ public class FleetTripExecutionService {
|
||||
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
|
||||
if (reason == null) reason = loadingReviewBlockReason(trip);
|
||||
if (reason != null) throw conflict(reason);
|
||||
Instant now = Instant.now();
|
||||
// Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result.
|
||||
Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
|
||||
trip.setStatus("DEPARTING");
|
||||
if (trips.updateById(trip) != 1) throw conflict("车次状态已变化");
|
||||
var results = new ArrayList<FleetExecutionModels.DepartedDispatch>();
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.common.core.api.*;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/fleet/trips/{tripNo}/costs")
|
||||
public class PlatformFleetTripCostController {
|
||||
private final FleetTripCostService service;
|
||||
public PlatformFleetTripCostController(FleetTripCostService service) { this.service = service; }
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<FleetTripCostModels.Listing> list(@PathVariable String tripNo, @Valid FleetTripCostModels.Query query) {
|
||||
return ApiResponse.success(service.list(tripNo, query));
|
||||
}
|
||||
@GetMapping("/{costNo}/history")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<PageResult<FleetTripCostModels.History>> history(@PathVariable String tripNo, @PathVariable String costNo, @Valid FleetTripCostModels.Query query) {
|
||||
return ApiResponse.success(service.history(tripNo, costNo, query));
|
||||
}
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:cost-create')")
|
||||
public ApiResponse<FleetTripCostModels.Cost> create(@PathVariable String tripNo, @AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestBody @Valid FleetTripCostModels.Create request) {
|
||||
return ApiResponse.success(service.create(tripNo, user.userId().toString(), request));
|
||||
}
|
||||
@PutMapping("/{costNo}")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:cost-edit')")
|
||||
public ApiResponse<FleetTripCostModels.Cost> update(@PathVariable String tripNo, @PathVariable String costNo,
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestBody @Valid FleetTripCostModels.Update request) {
|
||||
return ApiResponse.success(service.update(tripNo, costNo, user.userId().toString(), request));
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -8,8 +8,12 @@ import java.util.List;
|
||||
/** Read projection in the Lastleg schema. Never derives vehicle identity from a mutable plate. */
|
||||
@Mapper
|
||||
public interface VehicleFulfillmentMapper {
|
||||
String DEPARTED = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' "
|
||||
String ORIGINAL_DEPARTURE = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' "
|
||||
+ "AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))=#{vehicleKey})";
|
||||
String HANDOVER = "EXISTS (SELECT 1 FROM ll_fleet_trip_handover ho WHERE ho.trip_id=t.id "
|
||||
+ "AND JSON_UNQUOTE(JSON_EXTRACT(ho.snapshot_json,'$.replacement.vehicleId'))=#{vehicleKey} "
|
||||
+ "AND JSON_CONTAINS(ho.snapshot_json,CONCAT('{\"customerId\":',JSON_QUOTE(d.customer_id),',\"deliveryNo\":',JSON_QUOTE(d.delivery_no),'}'),'$.carriedDeliveries')=1)";
|
||||
String DEPARTED = "(" + ORIGINAL_DEPARTURE + " OR " + HANDOVER + ")";
|
||||
String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b "
|
||||
+ "ON b.dispatch_id=d.dispatch_order_id AND CAST(b.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128)) "
|
||||
+ "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")";
|
||||
@@ -17,7 +21,8 @@ public interface VehicleFulfillmentMapper {
|
||||
String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))";
|
||||
String CHANGES = " FROM ll_fleet_trip_history h JOIN ll_fleet_trip t ON t.id=h.trip_id "
|
||||
+ "JOIN ll_fleet_trip_history p ON p.trip_id=h.trip_id AND p.record_version=h.record_version-1 "
|
||||
+ "WHERE h.action='ASSIGN' AND " + PREVIOUS_VEHICLE + " IS NOT NULL AND " + PREVIOUS_VEHICLE + " <> 'null' "
|
||||
+ "LEFT JOIN ll_fleet_trip_handover ho ON ho.trip_id=h.trip_id AND ho.trip_version=h.record_version "
|
||||
+ "WHERE (h.action='ASSIGN' OR (h.action='HANDOVER' AND ho.id IS NOT NULL)) AND " + PREVIOUS_VEHICLE + " IS NOT NULL AND " + PREVIOUS_VEHICLE + " <> 'null' "
|
||||
+ "AND (" + PREVIOUS_VEHICLE + "=#{vehicleKey} OR " + NEXT_VEHICLE + "=#{vehicleKey}) "
|
||||
+ "AND (" + PREVIOUS_VEHICLE + "<>" + NEXT_VEHICLE
|
||||
+ " OR JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.driverId'))<>JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.driverId')))";
|
||||
@@ -32,7 +37,8 @@ public interface VehicleFulfillmentMapper {
|
||||
@Param("offset") long offset, @Param("size") int size);
|
||||
@Select("SELECT COUNT(*)" + CHANGES)
|
||||
long countChanges(@Param("vehicleKey") String vehicleKey);
|
||||
@Select("SELECT t.trip_no,h.record_version,h.occurred_at,h.reason,p.snapshot_json AS before_snapshot,h.snapshot_json AS after_snapshot"
|
||||
@Select("SELECT t.trip_no,h.record_version,h.occurred_at,h.reason,p.snapshot_json AS before_snapshot,"
|
||||
+ "CASE WHEN h.action='HANDOVER' THEN JSON_SET(h.snapshot_json,'$.handover',JSON_EXTRACT(ho.snapshot_json,'$')) ELSE h.snapshot_json END AS after_snapshot"
|
||||
+ CHANGES + " ORDER BY h.occurred_at DESC,h.id DESC LIMIT #{size} OFFSET #{offset}")
|
||||
List<VehicleFulfillmentModels.ChangeRow> changes(@Param("vehicleKey") String vehicleKey, @Param("offset") long offset, @Param("size") int size);
|
||||
}
|
||||
|
||||
+3
-3
@@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
@@ -37,8 +36,9 @@ public class VehicleFulfillmentService {
|
||||
try {
|
||||
JsonNode before = json.readTree(row.beforeSnapshot()).path("trip"), snapshot = json.readTree(row.afterSnapshot()), after = snapshot.path("trip");
|
||||
if (!before.hasNonNull("vehicleId") || !after.hasNonNull("vehicleId")) throw new IllegalStateException("车辆更换历史缺少身份快照");
|
||||
var dispatches = new ArrayList<String>();
|
||||
for (JsonNode binding : snapshot.path("dispatches")) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText());
|
||||
var dispatches = new java.util.LinkedHashSet<String>();
|
||||
JsonNode associated = snapshot.hasNonNull("handover") ? snapshot.path("handover").path("cargo").path("remainingCargo") : snapshot.path("dispatches");
|
||||
for (JsonNode binding : associated) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText());
|
||||
String relationship = before.path("vehicleId").asText().equals(after.path("vehicleId").asText()) ? "DRIVER_CHANGED"
|
||||
: key.equals(before.path("vehicleId").asText()) ? "REPLACED_OUT" : "REPLACED_IN";
|
||||
return new VehicleFulfillmentModels.Change(row.tripNo(), row.recordVersion(), row.occurredAt().toInstant(ZoneOffset.UTC), row.reason(),
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user