feat(fleet): enforce reviewed trips and original fee coverage for dispatch

This commit is contained in:
2026-10-03 04:18:52 +08:00
parent c50e9f97d1
commit 5e683da3b9
162 changed files with 9642 additions and 218 deletions
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.fleet.customer;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name = "iam-service", contextId = "fleetCustomerClient", url = "${baiyue.clients.iam.url:}")
public interface FleetCustomerClient {
@GetMapping("/internal/iam/finance-customers")
ApiResponse<PageResult<Customer>> customers(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("page") long page, @RequestParam("size") long size, @RequestParam("keyword") String keyword,
@RequestParam("status") String status, @RequestParam("customerId") Long customerId);
@GetMapping("/internal/iam/tenants/{customerId}/order-eligibility")
ApiResponse<Eligibility> eligibility(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("customerId") long customerId);
record Customer(Long id, String tenantCode, String tenantName, String status, String contactName, String contactPhone) { }
record Eligibility(boolean eligible, String tenantStatus, String reason) { }
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.fleet.customer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@Component
public class FleetCustomerDirectory {
public record Customer(String id, String code, String name, String contactName, String contactPhone) { }
private final FleetCustomerClient client;
private final String token;
public FleetCustomerDirectory(FleetCustomerClient client, @Value("${baiyue.security.internal-service-token}") String token) {
this.client = client; this.token = token;
}
public List<Customer> options(String keyword) { return load(keyword, null); }
public Customer required(long id) {
var customer = load(null, id).stream().filter(row -> row.id().equals(Long.toString(id))).findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择有效客户"));
try {
var result = client.eligibility(token, id);
if (result == null || !result.successful() || result.data() == null) throw unavailable();
if (!result.data().eligible()) throw new ResponseStatusException(HttpStatus.CONFLICT, "客户尚不具备下单资格,请先完成客户资料审核");
} catch (ResponseStatusException error) { throw error; }
catch (RuntimeException error) { throw unavailable(); }
return customer;
}
private List<Customer> load(String keyword, Long id) {
try {
var result = client.customers(token, 1, 30, keyword == null || keyword.isBlank() ? null : keyword.trim(), "ACTIVE", id);
if (result == null || !result.successful() || result.data() == null) throw unavailable();
return result.data().records().stream().filter(row -> row.id() != null && row.id() > 0 && "ACTIVE".equals(row.status()))
.map(row -> new Customer(row.id().toString(), row.tenantCode(), row.tenantName(), row.contactName(), row.contactPhone())).toList();
} catch (ResponseStatusException error) { throw error; }
catch (RuntimeException error) { throw unavailable(); }
}
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "客户目录暂不可用,请稍后重试"); }
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.fleet.domain;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
/** Re-evaluated by the server when planning, changing or departing a trip. */
public final class DriverQualificationPolicy {
private DriverQualificationPolicy() { }
public enum ReviewStatus { PENDING, APPROVED, REJECTED }
public enum Reason {
ELIGIBLE, INACTIVE, MISSING_LICENSE, MISSING_EXPIRY, PENDING_REVIEW, REJECTED,
EXPIRED, MISSING_TRIP_END, INVALID_TRIP_END, EXPIRES_DURING_TRIP
}
public record Driver(boolean active, ReviewStatus reviewStatus, boolean hasLicense,
LocalDate licenseExpiresOn, ZoneId licenseZone) {
public Driver {
Objects.requireNonNull(reviewStatus, "reviewStatus");
Objects.requireNonNull(licenseZone, "licenseZone");
}
}
public record Qualification(boolean canDispatch, boolean expiringSoon, Reason reason,
LocalDate licenseExpiresOn) { }
public static Qualification evaluate(Driver driver, Instant now, Instant expectedEnd) {
Objects.requireNonNull(driver, "driver");
Objects.requireNonNull(now, "now");
LocalDate today = now.atZone(driver.licenseZone()).toLocalDate();
LocalDate expiry = driver.licenseExpiresOn();
long days = expiry == null ? Long.MAX_VALUE : ChronoUnit.DAYS.between(today, expiry);
boolean expiring = days >= 0 && days <= 30;
Reason reason;
if (!driver.active()) reason = Reason.INACTIVE;
else if (!driver.hasLicense()) reason = Reason.MISSING_LICENSE;
else if (expiry == null) reason = Reason.MISSING_EXPIRY;
else if (driver.reviewStatus() == ReviewStatus.REJECTED) reason = Reason.REJECTED;
else if (driver.reviewStatus() != ReviewStatus.APPROVED) reason = Reason.PENDING_REVIEW;
else if (days < 0) reason = Reason.EXPIRED;
else if (expectedEnd == null) reason = Reason.MISSING_TRIP_END;
else if (!expectedEnd.isAfter(now)) reason = Reason.INVALID_TRIP_END;
// A date-only licence is valid through that calendar day in its recorded zone.
else if (!expectedEnd.isBefore(expiry.plusDays(1).atStartOfDay(driver.licenseZone()).toInstant())) {
reason = Reason.EXPIRES_DURING_TRIP;
} else reason = Reason.ELIGIBLE;
return new Qualification(reason == Reason.ELIGIBLE, expiring, reason, expiry);
}
}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.fleet.domain;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
/** Pure capacity calculation; the caller must lock the trip before confirming reservations. */
public final class TripCapacityPolicy {
private TripCapacityPolicy() { }
public record Load(int palletSpaces, BigDecimal weightKg, BigDecimal volumeCbm) {
public Load {
if (palletSpaces < 0 || weightKg == null || volumeCbm == null
|| weightKg.signum() < 0 || volumeCbm.signum() < 0) {
throw new IllegalArgumentException("托位、重量和体积须为非负数");
}
}
public Load plus(Load other) {
return new Load(Math.addExact(palletSpaces, other.palletSpaces),
weightKg.add(other.weightKg), volumeCbm.add(other.volumeCbm));
}
}
public record Booking(String bookingNo, int pickupIndex, int deliveryIndex, Load load) {
public Booking {
if (bookingNo == null || bookingNo.isBlank()) throw new IllegalArgumentException("缺少承运业务编号");
if (pickupIndex < 0 || deliveryIndex <= pickupIndex) throw new IllegalArgumentException("卸货站必须在提货站之后");
Objects.requireNonNull(load, "load");
if (load.palletSpaces() == 0 || load.weightKg().signum() == 0 || load.volumeCbm().signum() == 0) {
throw new IllegalArgumentException("承运货物须有有效托位、重量和体积");
}
}
}
public record Segment(int fromIndex, int toIndex, Load occupied, Load remaining) { }
public static List<Segment> calculate(int stopCount, Load capacity, List<Booking> bookings) {
Objects.requireNonNull(capacity, "capacity");
Objects.requireNonNull(bookings, "bookings");
if (stopCount < 2 || stopCount > 200) throw new IllegalArgumentException("车次须有2至200个站点");
if (capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) {
throw new IllegalArgumentException("车辆容量必须大于零");
}
var numbers = new HashSet<String>();
for (Booking booking : bookings) {
Objects.requireNonNull(booking, "booking");
if (!numbers.add(booking.bookingNo())) throw new IllegalArgumentException("承运明细重复分配");
if (booking.deliveryIndex() >= stopCount) throw new IllegalArgumentException("承运站点不属于当前路线");
}
List<Segment> segments = new ArrayList<>();
for (int index = 0; index < stopCount - 1; index++) {
Load occupied = new Load(0, BigDecimal.ZERO, BigDecimal.ZERO);
for (Booking booking : bookings) {
if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) occupied = occupied.plus(booking.load());
}
if (occupied.palletSpaces() > capacity.palletSpaces()
|| occupied.weightKg().compareTo(capacity.weightKg()) > 0
|| occupied.volumeCbm().compareTo(capacity.volumeCbm()) > 0) {
throw new IllegalArgumentException("第" + (index + 1) + "段路线超过车辆托位、重量或容积限制");
}
Load remaining = new Load(capacity.palletSpaces() - occupied.palletSpaces(),
capacity.weightKg().subtract(occupied.weightKg()), capacity.volumeCbm().subtract(occupied.volumeCbm()));
segments.add(new Segment(index, index + 1, occupied, remaining));
}
return List.copyOf(segments);
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.fleet.domain;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
/** Operating-cost allocation, not customer billing. One currency per calculation. */
public final class TripCostAllocationPolicy {
private TripCostAllocationPolicy() { }
public record Share(String bookingNo, long palletSpaces) {
public Share {
if (bookingNo == null || bookingNo.isBlank() || palletSpaces <= 0) {
throw new IllegalArgumentException("成本分摊须有承运编号及正数托位");
}
}
}
public record Allocation(String bookingNo, BigDecimal amount) { }
private record Fraction(String bookingNo, BigInteger cents, BigInteger remainder) { }
public static List<Allocation> allocate(BigDecimal total, String currency, List<Share> shares) {
Objects.requireNonNull(total, "total");
Objects.requireNonNull(shares, "shares");
if (!("USD".equals(currency) || "CNY".equals(currency))) throw new IllegalArgumentException("不支持的分摊币种");
if (total.signum() < 0) throw new IllegalArgumentException("直接成本不能为负数");
BigInteger cents;
try { cents = total.setScale(2, RoundingMode.UNNECESSARY).movePointRight(2).toBigIntegerExact(); }
catch (ArithmeticException e) { throw new IllegalArgumentException("金额最多两位小数", e); }
if (shares.isEmpty()) throw new IllegalArgumentException("没有可分摊的实运货物");
var numbers = new HashSet<String>();
BigInteger spaces = BigInteger.ZERO;
for (Share share : shares) {
if (!numbers.add(share.bookingNo())) throw new IllegalArgumentException("成本分摊承运编号重复");
spaces = spaces.add(BigInteger.valueOf(share.palletSpaces()));
}
List<Fraction> fractions = new ArrayList<>();
BigInteger assigned = BigInteger.ZERO;
for (Share share : shares) {
BigInteger[] result = cents.multiply(BigInteger.valueOf(share.palletSpaces())).divideAndRemainder(spaces);
fractions.add(new Fraction(share.bookingNo(), result[0], result[1]));
assigned = assigned.add(result[0]);
}
// Largest remainder with a stable business-number tie-break conserves every cent.
fractions.sort(Comparator.comparing(Fraction::remainder).reversed().thenComparing(Fraction::bookingNo));
int extraCents = cents.subtract(assigned).intValueExact();
List<Allocation> result = new ArrayList<>();
for (int i = 0; i < fractions.size(); i++) {
Fraction fraction = fractions.get(i);
BigInteger amount = fraction.cents().add(i < extraCents ? BigInteger.ONE : BigInteger.ZERO);
result.add(new Allocation(fraction.bookingNo(), new BigDecimal(amount, 2)));
}
result.sort(Comparator.comparing(Allocation::bookingNo));
return List.copyOf(result);
}
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.fleet.driver;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_driver")
public class DriverEntity {
@TableId(type = IdType.INPUT) private Long id;
private String driverNo;
private String name;
private String phone;
private String ownership;
private String region;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private Long warehouseId;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String warehouseCode;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String warehouseName;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String vehicleModels;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String languagesAndArea;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate medicalExpiresOn;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate trainingExpiresOn;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate insuranceExpiresOn;
private Boolean active;
private String reviewStatus;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String licenseNumber;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String licenseType;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate licenseIssuedOn;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate licenseExpiresOn;
private String licenseZone;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private Long licenseFileId;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
private Long recordVersion;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.fleet.driver;
import org.springframework.web.multipart.MultipartFile;
public interface DriverFileGateway {
DriverModels.File upload(long driverId, String operatorId, MultipartFile file);
DriverModels.File metadata(long driverId, long fileId);
byte[] download(long driverId, long fileId);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.fleet.driver;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_driver_history")
public class DriverHistoryEntity {
@TableId(type = IdType.INPUT) private Long id;
private Long driverId;
private Long recordVersion;
private String action;
private String reason;
private Long licenseFileId;
private String snapshotJson;
private String operatorId;
private LocalDateTime occurredAt;
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.fleet.driver;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DriverHistoryMapper extends BaseMapper<DriverHistoryEntity> { }
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.fleet.driver;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import java.time.LocalDate;
import java.util.List;
@Mapper
public interface DriverMapper extends BaseMapper<DriverEntity> {
@Select("SELECT * FROM ll_fleet_driver WHERE id = #{id} FOR UPDATE")
DriverEntity lockById(@Param("id") long id);
@Select("SELECT DISTINCT license_zone FROM ll_fleet_driver")
List<String> licenseZones();
@Select("SELECT license_zone, license_expires_on, ownership, COUNT(*) AS driver_count FROM ll_fleet_driver ${ew.customSqlSegment} GROUP BY license_zone, license_expires_on, ownership")
List<DateGroup> dateGroups(@Param(Constants.WRAPPER) Wrapper<DriverEntity> query);
record DateGroup(String licenseZone, LocalDate licenseExpiresOn, String ownership, long driverCount) { }
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
public final class DriverModels {
private DriverModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Size(max = 128) String keyword, Boolean active, String reviewStatus,
String ownership, String region, String licenseState,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Draft(@NotBlank @Size(max = 128) String name,
@NotBlank @Size(max = 64) String phone,
@NotBlank @Pattern(regexp = "OWNED|DISPATCHED") String ownership,
@NotBlank @Pattern(regexp = "DOMESTIC|OVERSEAS") String region,
@Size(max = 128) String licenseNumber, @Size(max = 64) String licenseType,
LocalDate licenseIssuedOn, LocalDate licenseExpiresOn,
@NotBlank @Size(max = 64) String licenseZone,
@Size(max = 1000) String remark, @Valid Profile profile) { }
public record Profile(@Positive Long warehouseId, @Size(max = 128) String vehicleModels,
@Size(max = 256) String languagesAndArea, LocalDate medicalExpiresOn,
LocalDate trainingExpiresOn, LocalDate insuranceExpiresOn) { }
public record WarehouseOption(String id, String code, String name, String region) { }
public record Summary(long total, long owned, long dispatched, long expiringSoon, long expired) { }
public record DriverPage(java.util.List<Response> records, long page, long size, long total, long pages, Summary summary) { }
public record Update(@NotNull @Min(0) Long recordVersion, @NotNull @Valid Draft details) { }
public record Submit(@NotNull @Min(0) Long recordVersion) { }
public record ChangeStatus(@NotNull @Min(0) Long recordVersion, @NotNull Boolean active,
@NotBlank @Size(max = 1000) String reason) { }
public record Review(@NotNull @Min(0) Long recordVersion,
@NotBlank @Pattern(regexp = "APPROVED|REJECTED") String decision,
@Size(max = 1000) String reason) { }
public record Response(String id, String driverNo, Draft details, boolean active, String reviewStatus,
String licenseFileId, long recordVersion, String updatedBy, LocalDateTime updatedAt,
DriverQualificationPolicy.Qualification qualification, String licenseState,
String warehouseCode, String warehouseName) { }
public record History(long recordVersion, String action, String reason, String operatorId,
LocalDateTime occurredAt, Response snapshot) { }
public record File(String fileId, String fileName, String contentType, long sizeBytes) { }
public record Download(File metadata, byte[] content) { }
}
@@ -0,0 +1,346 @@
package com.baiyue.lastleg.fleet.driver;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@Service
public class DriverService {
private final DriverMapper drivers;
private final DriverHistoryMapper history;
private final DriverFileGateway files;
private final ObjectMapper json;
private final Clock clock;
private final FleetWarehouseDirectory warehouses;
@Autowired
public DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, FleetWarehouseDirectory warehouses) {
this(drivers, history, files, json, Clock.systemUTC(), warehouses);
}
DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, Clock clock, FleetWarehouseDirectory warehouses) {
this.drivers = drivers;
this.history = history;
this.files = files;
this.json = json;
this.clock = clock;
this.warehouses = warehouses;
}
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public DriverModels.DriverPage page(DriverModels.Query query) {
Instant now = clock.instant();
var filter = filter(query, now);
var summary = summary(drivers.dateGroups(filter), now);
filter.orderByDesc(DriverEntity::getUpdatedAt, DriverEntity::getId);
var page = drivers.selectPage(new Page<>(query.page(), query.size()), filter);
return new DriverModels.DriverPage(page.getRecords().stream().map(row -> response(row, now, query.expectedEnd())).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), summary);
}
private LambdaQueryWrapper<DriverEntity> filter(DriverModels.Query query, Instant now) {
String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态");
String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属");
String region = optionalChoice(query.region(), Set.of("DOMESTIC", "OVERSEAS"), "区域");
String keyword = trim(query.keyword());
var filter = new LambdaQueryWrapper<DriverEntity>()
.eq(query.active() != null, DriverEntity::getActive, query.active())
.eq(review != null, DriverEntity::getReviewStatus, review)
.eq(ownership != null, DriverEntity::getOwnership, ownership)
.eq(region != null, DriverEntity::getRegion, region);
if (keyword != null) filter.and(w -> w.like(DriverEntity::getName, keyword)
.or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword));
String state = optionalChoice(query.licenseState(), Set.of("MISSING", "VALID", "EXPIRING", "EXPIRED"), "驾驶证有效期");
if ("MISSING".equals(state)) filter.isNull(DriverEntity::getLicenseExpiresOn);
else if (state != null) {
List<String> zones = drivers.licenseZones();
if (zones.isEmpty()) filter.eq(DriverEntity::getId, -1L);
else filter.and(group -> {
for (String zone : zones) {
LocalDate today = now.atZone(ZoneId.of(zone)).toLocalDate();
group.or(part -> {
part.eq(DriverEntity::getLicenseZone, zone);
if ("EXPIRED".equals(state)) part.lt(DriverEntity::getLicenseExpiresOn, today);
else if ("EXPIRING".equals(state)) part.between(DriverEntity::getLicenseExpiresOn, today, today.plusDays(30));
else part.gt(DriverEntity::getLicenseExpiresOn, today.plusDays(30));
});
}
});
}
return filter;
}
private DriverModels.Summary summary(List<DriverMapper.DateGroup> groups, Instant now) {
long total = 0, owned = 0, dispatched = 0, expiring = 0, expired = 0;
for (var group : groups) {
long count = group.driverCount(); total += count;
if ("OWNED".equals(group.ownership())) owned += count; else dispatched += count;
String state = licenseState(group.licenseExpiresOn(), group.licenseZone(), now);
if ("EXPIRING".equals(state)) expiring += count;
if ("EXPIRED".equals(state)) expired += count;
}
return new DriverModels.Summary(total, owned, dispatched, expiring, expired);
}
public List<DriverModels.WarehouseOption> warehouseOptions(String region) { return warehouses.options(region); }
public DriverModels.Response detail(long id, Instant expectedEnd) {
return response(required(id), clock.instant(), expectedEnd);
}
@Transactional
public DriverModels.Response create(String operator, DriverModels.Draft draft) {
validate(draft);
var row = new DriverEntity();
row.setId(IdWorker.getId());
row.setDriverNo("DR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(java.util.Locale.ROOT));
assign(row, draft);
row.setActive(true);
row.setReviewStatus("PENDING");
row.setRecordVersion(0L);
row.setCreatedBy(operator);
row.setCreatedAt(now());
row.setUpdatedBy(operator);
row.setUpdatedAt(row.getCreatedAt());
if (drivers.insert(row) != 1) throw conflict();
record(row, "CREATE", null, operator);
return response(row, clock.instant(), null);
}
@Transactional
public DriverModels.Response update(long id, String operator, DriverModels.Update request) {
validate(request.details());
DriverEntity row = locked(id, request.recordVersion());
DriverModels.Draft next = request.details();
boolean credentialChanged = !Objects.equals(row.getName(), trim(next.name()))
|| !Objects.equals(row.getLicenseNumber(), trim(next.licenseNumber()))
|| !Objects.equals(row.getLicenseType(), trim(next.licenseType()))
|| !Objects.equals(row.getLicenseIssuedOn(), next.licenseIssuedOn())
|| !Objects.equals(row.getLicenseExpiresOn(), next.licenseExpiresOn())
|| !Objects.equals(row.getLicenseZone(), next.licenseZone());
assign(row, next);
if (credentialChanged) row.setReviewStatus("PENDING");
persist(row, request.recordVersion(), "UPDATE", credentialChanged ? "证照或身份信息变化,重新待审" : null, operator);
return response(row, clock.instant(), null);
}
@Transactional
public DriverModels.Response changeStatus(long id, String operator, DriverModels.ChangeStatus request) {
DriverEntity row = locked(id, request.recordVersion());
if (trim(request.reason()) == null) throw bad("启停原因不能为空");
row.setActive(request.active());
persist(row, request.recordVersion(), request.active() ? "ACTIVATE" : "DEACTIVATE", trim(request.reason()), operator);
return response(row, clock.instant(), null);
}
@Transactional
public DriverModels.Response submit(long id, String operator, DriverModels.Submit request) {
DriverEntity row = locked(id, request.recordVersion());
if (!"REJECTED".equals(row.getReviewStatus())) throw bad("仅已打回的司机档案需要重新提交");
requireLicense(row);
files.metadata(id, row.getLicenseFileId());
row.setReviewStatus("PENDING");
persist(row, request.recordVersion(), "RESUBMIT", "重新提交审核", operator);
return response(row, clock.instant(), null);
}
@Transactional
public DriverModels.Response review(long id, String operator, DriverModels.Review request) {
DriverEntity row = locked(id, request.recordVersion());
if (!"PENDING".equals(row.getReviewStatus())) throw bad("仅待审核司机可通过或打回;修改证照后重新提交审核");
String decision = optionalChoice(request.decision(), Set.of("APPROVED", "REJECTED"), "审核结果");
if (decision == null) throw bad("审核结果不能为空");
if ("REJECTED".equals(decision) && trim(request.reason()) == null) throw bad("打回原因不能为空");
if ("APPROVED".equals(decision)) {
requireLicense(row);
if (row.getLicenseIssuedOn() != null && row.getLicenseIssuedOn().isAfter(LocalDate.now(clock.withZone(ZoneId.of(row.getLicenseZone())))))
throw bad("驾驶证尚未生效");
if (row.getLicenseExpiresOn().isBefore(LocalDate.now(clock.withZone(ZoneId.of(row.getLicenseZone())))))
throw bad("驾驶证已过期,不能通过审核");
files.metadata(id, row.getLicenseFileId());
}
row.setReviewStatus(decision);
persist(row, request.recordVersion(), decision, trim(request.reason()), operator);
return response(row, clock.instant(), null);
}
@Transactional
public DriverModels.Response uploadLicense(long id, long version, String operator, MultipartFile file) {
DriverEntity row = locked(id, version);
DriverModels.File uploaded = files.upload(id, operator, file);
row.setLicenseFileId(Long.parseLong(uploaded.fileId()));
row.setReviewStatus("PENDING");
persist(row, version, "UPLOAD_LICENSE", "驾驶证附件更新,重新待审", operator);
return response(row, clock.instant(), null);
}
public PageResult<DriverModels.History> history(long id, int page, int size) {
required(id);
var result = history.selectPage(new Page<>(page, size), new LambdaQueryWrapper<DriverHistoryEntity>()
.eq(DriverHistoryEntity::getDriverId, id).orderByDesc(DriverHistoryEntity::getRecordVersion));
List<DriverModels.History> records = result.getRecords().stream().map(event -> {
try {
return new DriverModels.History(event.getRecordVersion(), event.getAction(), event.getReason(), event.getOperatorId(),
event.getOccurredAt(), json.readValue(event.getSnapshotJson(), DriverModels.Response.class));
} catch (JsonProcessingException e) { throw new IllegalStateException("司机历史快照读取失败", e); }
}).toList();
return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
public DriverModels.Download download(long id, long fileId) {
DriverEntity row = required(id);
if (!Objects.equals(row.getLicenseFileId(), fileId) && history.selectCount(new LambdaQueryWrapper<DriverHistoryEntity>()
.eq(DriverHistoryEntity::getDriverId, id).eq(DriverHistoryEntity::getLicenseFileId, fileId)) == 0)
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "驾驶证附件未关联该司机");
var metadata = files.metadata(id, fileId);
return new DriverModels.Download(metadata, files.download(id, fileId));
}
/** Called by trip commands inside their transaction, so a concurrent credential edit cannot bypass the gate. */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public DriverModels.Response requireDispatchable(long id, Instant expectedEnd) {
DriverEntity row = drivers.lockById(id);
if (row == null) throw notFound();
var result = response(row, clock.instant(), expectedEnd);
if (!result.qualification().canDispatch()) throw bad(blockReason(result.qualification().reason()));
files.metadata(id, row.getLicenseFileId());
return result;
}
private DriverEntity required(long id) {
DriverEntity row = drivers.selectById(id);
if (row == null) throw notFound();
return row;
}
private DriverEntity locked(long id, long version) {
DriverEntity row = drivers.lockById(id);
if (row == null) throw notFound();
if (!Objects.equals(row.getRecordVersion(), version)) throw conflict();
return row;
}
private void persist(DriverEntity row, long version, String action, String reason, String operator) {
row.setRecordVersion(version + 1);
row.setUpdatedBy(operator);
row.setUpdatedAt(now());
if (drivers.update(row, new LambdaUpdateWrapper<DriverEntity>().eq(DriverEntity::getId, row.getId())
.eq(DriverEntity::getRecordVersion, version)) != 1) throw conflict();
record(row, action, reason, operator);
}
private void record(DriverEntity row, String action, String reason, String operator) {
var event = new DriverHistoryEntity();
event.setId(IdWorker.getId());
event.setDriverId(row.getId());
event.setRecordVersion(row.getRecordVersion());
event.setAction(action);
event.setReason(reason);
event.setLicenseFileId(row.getLicenseFileId());
event.setOperatorId(operator);
event.setOccurredAt(row.getUpdatedAt());
try { event.setSnapshotJson(json.writeValueAsString(response(row, clock.instant(), null))); }
catch (JsonProcessingException e) { throw new IllegalStateException("司机历史快照写入失败", e); }
if (history.insert(event) != 1) throw conflict();
}
private DriverModels.Response response(DriverEntity row, Instant now, Instant expectedEnd) {
var details = new DriverModels.Draft(row.getName(), row.getPhone(), row.getOwnership(), row.getRegion(),
row.getLicenseNumber(), row.getLicenseType(), row.getLicenseIssuedOn(), row.getLicenseExpiresOn(), row.getLicenseZone(), row.getRemark(),
new DriverModels.Profile(row.getWarehouseId(), row.getVehicleModels(), row.getLanguagesAndArea(), row.getMedicalExpiresOn(), row.getTrainingExpiresOn(), row.getInsuranceExpiresOn()));
var qualification = DriverQualificationPolicy.evaluate(new DriverQualificationPolicy.Driver(Boolean.TRUE.equals(row.getActive()),
DriverQualificationPolicy.ReviewStatus.valueOf(row.getReviewStatus()), row.getLicenseFileId() != null,
row.getLicenseExpiresOn(), ZoneId.of(row.getLicenseZone())), now, expectedEnd);
return new DriverModels.Response(row.getId().toString(), row.getDriverNo(), details, Boolean.TRUE.equals(row.getActive()),
row.getReviewStatus(), row.getLicenseFileId() == null ? null : row.getLicenseFileId().toString(),
row.getRecordVersion(), row.getUpdatedBy(), row.getUpdatedAt(), qualification,
licenseState(row.getLicenseExpiresOn(), row.getLicenseZone(), now), row.getWarehouseCode(), row.getWarehouseName());
}
private static void validate(DriverModels.Draft draft) {
if (trim(draft.name()) == null || trim(draft.phone()) == null) throw bad("司机姓名和电话不能为空");
if (optionalChoice(draft.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属") == null
|| optionalChoice(draft.region(), Set.of("DOMESTIC", "OVERSEAS"), "区域") == null) throw bad("司机归属和区域不能为空");
try { ZoneId.of(draft.licenseZone()); }
catch (DateTimeException | NullPointerException e) { throw bad("请选择有效的证照时区"); }
for (LocalDate date : new LocalDate[]{draft.licenseIssuedOn(), draft.licenseExpiresOn()}) {
if (date != null && (date.getYear() < 1900 || date.getYear() > 9998)) throw bad("证照日期超出允许范围");
}
if (draft.profile() != null) for (LocalDate date : new LocalDate[]{draft.profile().medicalExpiresOn(), draft.profile().trainingExpiresOn(), draft.profile().insuranceExpiresOn()}) {
if (date != null && (date.getYear() < 1900 || date.getYear() > 9998)) throw bad("其他资质日期超出允许范围");
}
if (draft.licenseIssuedOn() != null && draft.licenseExpiresOn() != null && draft.licenseIssuedOn().isAfter(draft.licenseExpiresOn()))
throw bad("驾驶证到期日期不能早于签发日期");
}
private void assign(DriverEntity row, DriverModels.Draft draft) {
row.setName(trim(draft.name())); row.setPhone(trim(draft.phone()));
row.setOwnership(draft.ownership()); row.setRegion(draft.region());
row.setLicenseNumber(trim(draft.licenseNumber())); row.setLicenseType(trim(draft.licenseType()));
row.setLicenseIssuedOn(draft.licenseIssuedOn()); row.setLicenseExpiresOn(draft.licenseExpiresOn());
row.setLicenseZone(draft.licenseZone()); row.setRemark(trim(draft.remark()));
var profile = draft.profile();
var warehouse = profile == null || profile.warehouseId() == null ? null : warehouses.required(profile.warehouseId(), draft.region());
row.setWarehouseId(warehouse == null ? null : Long.valueOf(warehouse.id()));
row.setWarehouseCode(warehouse == null ? null : warehouse.code());
row.setWarehouseName(warehouse == null ? null : warehouse.name());
row.setVehicleModels(profile == null ? null : trim(profile.vehicleModels()));
row.setLanguagesAndArea(profile == null ? null : trim(profile.languagesAndArea()));
row.setMedicalExpiresOn(profile == null ? null : profile.medicalExpiresOn());
row.setTrainingExpiresOn(profile == null ? null : profile.trainingExpiresOn());
row.setInsuranceExpiresOn(profile == null ? null : profile.insuranceExpiresOn());
}
private static String licenseState(LocalDate expiry, String zone, Instant now) {
if (expiry == null) return "MISSING";
LocalDate today = now.atZone(ZoneId.of(zone)).toLocalDate();
if (expiry.isBefore(today)) return "EXPIRED";
return expiry.isAfter(today.plusDays(30)) ? "VALID" : "EXPIRING";
}
private LocalDateTime now() { return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); }
private static void requireLicense(DriverEntity row) {
if (row.getLicenseFileId() == null || trim(row.getLicenseNumber()) == null || trim(row.getLicenseType()) == null
|| row.getLicenseExpiresOn() == null) throw bad("请补齐驾驶证编号、类型、有效期及附件");
}
private static String blockReason(DriverQualificationPolicy.Reason reason) {
return switch (reason) {
case INACTIVE -> "司机已停用";
case MISSING_LICENSE -> "请上传驾驶证附件";
case MISSING_EXPIRY -> "请补齐驾驶证到期日期";
case PENDING_REVIEW -> "司机证照尚未审核通过";
case REJECTED -> "司机证照已被打回";
case EXPIRED -> "驾驶证已过期";
case MISSING_TRIP_END -> "请填写司机预计任务结束时间";
case INVALID_TRIP_END -> "司机预计任务结束时间必须晚于当前时间";
case EXPIRES_DURING_TRIP -> "驾驶证将在预计任务结束前到期,请更换司机或调整计划";
case ELIGIBLE -> "司机可派车";
};
}
private static String trim(String text) { return text == null || text.isBlank() ? null : text.trim(); }
private static String optionalChoice(String value, Set<String> allowed, String label) {
if (trim(value) == null) return null;
if (!allowed.contains(value)) throw bad(label + "不合法");
return value;
}
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "司机档案不存在"); }
private static ResponseStatusException conflict() { return new ResponseStatusException(HttpStatus.CONFLICT, "司机档案已被修改,请刷新后重试"); }
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "fleetWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}")
public interface FleetWarehouseClient {
@GetMapping("/internal/warehouses/order-catalog")
ApiResponse<List<Warehouse>> activeWarehouses(@RequestHeader("X-Internal-Service-Token") String token);
record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { }
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.fleet.driver;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
@Component
public class FleetWarehouseDirectory {
private final FleetWarehouseClient client;
private final String token;
public FleetWarehouseDirectory(FleetWarehouseClient client, @Value("${baiyue.security.internal-service-token:}") String token) {
this.client = client; this.token = token;
}
public List<DriverModels.WarehouseOption> options(String region) {
if (!Set.of("DOMESTIC", "OVERSEAS").contains(region == null ? "" : region))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择国内或海外区域");
try {
var result = client.activeWarehouses(token);
if (result == null || !result.successful() || result.data() == null) throw unavailable();
return result.data().stream().filter(row -> row != null && row.id() != null && row.id() > 0
&& row.warehouseCode() != null && !row.warehouseCode().isBlank() && row.warehouseName() != null && !row.warehouseName().isBlank()
&& ("DOMESTIC".equals(region) ? "DOMESTIC".equals(row.warehouseType())
: Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(row.warehouseType() == null ? "" : row.warehouseType())))
.map(row -> new DriverModels.WarehouseOption(row.id().toString(), row.warehouseCode(), row.warehouseName(), region)).toList();
} catch (ResponseStatusException e) { throw e; }
catch (RuntimeException e) { throw unavailable(); }
}
public DriverModels.WarehouseOption required(long id, String region) {
return options(region).stream().filter(row -> row.id().equals(Long.toString(id))).findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "所属仓库不存在、已停用或与区域不符"));
}
private static ResponseStatusException unavailable() {
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库目录暂不可用,请重试");
}
}
@@ -0,0 +1,89 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
@Component
public class HttpDriverFileGateway implements DriverFileGateway {
static final String TYPE = "FLEET_DRIVER_LICENSE";
static final long MAX_SIZE = 10L * 1024 * 1024;
private static final Set<String> CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png");
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_DRIVER_LICENSE/{driver}";
private final RestClient client;
private final String token;
public HttpDriverFileGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.client = factory.build(builder, baseUrl, properties.getCustomerVisitFile());
this.token = token;
}
@Override
public DriverModels.File upload(long driverId, String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !CONTENT_TYPES.contains(Objects.toString(file.getContentType(), "")))
throw bad("驾驶证附件须为非空的 PDF、JPG 或 PNG,最大 10MiB");
var body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(MediaType.parseMediaType(file.getContentType()));
} catch (IOException e) { throw bad("无法读取驾驶证附件"); }
Envelope result = client.post().uri(PATH, driverId).header("X-Internal-Service-Token", token)
.header("X-Operator-Id", operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); })
.body(Envelope.class);
return verified(result, driverId, null);
}
@Override
public DriverModels.File metadata(long driverId, long fileId) {
Envelope result = client.get().uri(PATH + "/{file}", driverId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); })
.body(Envelope.class);
return verified(result, driverId, fileId);
}
@Override
public byte[] download(long driverId, long fileId) {
byte[] content = client.get().uri(PATH + "/{file}/content", driverId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(byte[].class);
if (content == null || content.length == 0 || content.length > MAX_SIZE) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
return content;
}
static DriverModels.File verified(Envelope result, long driverId, Long fileId) {
if (result == null) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
if (!"200".equals(result.code())) throw unavailable(result.code() != null && result.code().startsWith("A04")
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE);
Metadata data = result.data();
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|| !TYPE.equals(data.businessType()) || !Long.toString(driverId).equals(data.businessId())
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|| !CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null
|| data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
throw bad("文件不属于当前司机或不是有效驾驶证附件");
return new DriverModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
}
private static ResponseStatusException unavailable(HttpStatusCode status) {
return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"驾驶证文件不可用或归属校验失败");
}
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
record Envelope(String code, Metadata data) { }
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,119 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.format.annotation.DateTimeFormat;
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.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/drivers")
@Tag(name = "Fleet drivers / 司机管理")
public class PlatformDriverController {
private final DriverService service;
public PlatformDriverController(DriverService service) { this.service = service; }
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ApiResponse<DriverModels.DriverPage> page(@Valid DriverModels.Query query) {
return ApiResponse.success(service.page(query));
}
@GetMapping("/warehouse-options")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ApiResponse<java.util.List<DriverModels.WarehouseOption>> warehouseOptions(@RequestParam String region) {
return ApiResponse.success(service.warehouseOptions(region));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ApiResponse<DriverModels.Response> detail(@PathVariable @Min(1) long id,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) {
return ApiResponse.success(service.detail(id, expectedEnd));
}
@GetMapping("/{id}/qualification")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ApiResponse<DriverQualificationPolicy.Qualification> qualification(@PathVariable @Min(1) long id,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) {
return ApiResponse.success(service.detail(id, expectedEnd).qualification());
}
@GetMapping("/{id}/history")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ApiResponse<PageResult<DriverModels.History>> history(@PathVariable @Min(1) long id,
@RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) {
return ApiResponse.success(service.history(id, page, size));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:create')")
public ApiResponse<DriverModels.Response> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody DriverModels.Draft draft) {
return ApiResponse.success(service.create(operator(user), draft));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:edit')")
public ApiResponse<DriverModels.Response> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id,
@Valid @RequestBody DriverModels.Update request) {
return ApiResponse.success(service.update(id, operator(user), request));
}
@PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:status')")
public ApiResponse<DriverModels.Response> status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id,
@Valid @RequestBody DriverModels.ChangeStatus request) {
return ApiResponse.success(service.changeStatus(id, operator(user), request));
}
@PostMapping("/{id}/review")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:review')")
public ApiResponse<DriverModels.Response> review(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id,
@Valid @RequestBody DriverModels.Review request) {
return ApiResponse.success(service.review(id, operator(user), request));
}
@PostMapping("/{id}/submit")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:edit')")
public ApiResponse<DriverModels.Response> submit(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id,
@Valid @RequestBody DriverModels.Submit request) {
return ApiResponse.success(service.submit(id, operator(user), request));
}
@PostMapping(value = "/{id}/license", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:upload')")
public ApiResponse<DriverModels.Response> upload(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id,
@RequestParam @Min(0) long recordVersion, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.uploadLicense(id, recordVersion, operator(user), file));
}
@GetMapping("/{id}/license/{fileId}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ResponseEntity<byte[]> download(@PathVariable @Min(1) long id, @PathVariable @Min(1) long fileId) {
var file = service.download(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(file.metadata().fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content());
}
private static String operator(AuthenticatedUser user) {
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
return user.userId().toString();
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
@Component
public class HttpVehicleCareFileGateway implements VehicleCareFileGateway {
private static final String TYPE = "FLEET_VEHICLE_RECORD_PHOTO";
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_VEHICLE_RECORD_PHOTO/{record}";
private static final long MAX_SIZE = 10L * 1024 * 1024;
private final RestClient client;
private final String token;
public HttpVehicleCareFileGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); this.token = token;
}
@Override public VehicleCareModels.File upload(long recordId, String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("运维照片须为非空 JPG 或 PNG,最大 10MiB");
var body = new MultipartBodyBuilder();
try { body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(MediaType.parseMediaType(file.getContentType())); }
catch (IOException error) { throw bad("无法读取运维照片"); }
var result = client.post().uri(PATH, recordId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class);
return verified(result, recordId, null);
}
@Override public VehicleCareModels.File metadata(long recordId, long fileId) {
return verified(client.get().uri(PATH + "/{file}", recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), recordId, fileId);
}
@Override public byte[] download(long recordId, long fileId) {
var response = client.get().uri(PATH + "/{file}/content", recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class);
return imageContent(response);
}
static byte[] imageContent(ResponseEntity<byte[]> response) {
byte[] content = response.getBody();
if (content == null || content.length == 0 || content.length > MAX_SIZE
|| !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
return content;
}
static VehicleCareModels.File verified(Envelope result, long recordId, Long fileId) {
if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
var data = result.data();
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|| !TYPE.equals(data.businessType()) || !Long.toString(recordId).equals(data.businessId())
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|| !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
throw bad("文件不属于当前运维记录或不是有效照片");
return new VehicleCareModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
}
private static boolean image(String type) { return Set.of("image/jpeg", "image/png").contains(Objects.toString(type, "")); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "运维照片不可用或归属校验失败"); }
record Envelope(String code, Metadata data) { }
record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
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.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records")
public class PlatformVehicleCareController {
private final VehicleCareService service;
public PlatformVehicleCareController(VehicleCareService service) { this.service = service; }
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ApiResponse<PageResult<VehicleCareModels.Response>> page(@PathVariable @Min(1) long vehicleId, @Valid VehicleCareModels.Query query) {
return ApiResponse.success(service.page(vehicleId, query));
}
@GetMapping("/{recordNo}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ApiResponse<VehicleCareModels.Response> detail(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo) {
return ApiResponse.success(service.detail(vehicleId, recordNo));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-create')")
public ApiResponse<VehicleCareModels.Response> create(@PathVariable @Min(1) long vehicleId, @AuthenticationPrincipal AuthenticatedUser user,
@RequestBody @Valid VehicleCareModels.Create request) {
return ApiResponse.success(service.create(vehicleId, operator(user), request));
}
@PutMapping("/{recordNo}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-edit')")
public ApiResponse<VehicleCareModels.Response> update(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
@AuthenticationPrincipal AuthenticatedUser user, @RequestBody @Valid VehicleCareModels.Update request) {
return ApiResponse.success(service.update(vehicleId, recordNo, operator(user), request));
}
@PostMapping(value = "/{recordNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-upload')")
public ApiResponse<VehicleCareModels.Response> upload(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long recordVersion,
@RequestParam @NotBlank @Size(max = 128) String idempotencyKey, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.upload(vehicleId, recordNo, recordVersion, idempotencyKey, operator(user), file));
}
@GetMapping("/{recordNo}/files/{fileId}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ResponseEntity<byte[]> download(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo, @PathVariable @Min(1) long fileId) {
var file = service.download(vehicleId, recordNo, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(file.metadata().fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content());
}
@GetMapping("/{recordNo}/history")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ApiResponse<PageResult<VehicleCareModels.History>> history(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
@RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return ApiResponse.success(service.history(vehicleId, recordNo, page, size));
}
private static String operator(AuthenticatedUser user) {
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
return user.userId().toString();
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_vehicle_record")
public class VehicleCareEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String recordNo;
private Long vehicleId;
private String vehiclePlate;
private String recordType;
private LocalDateTime occurredAt;
private String detailsJson;
private String filesJson;
private String idempotencyKey;
private String requestHash;
private Long recordVersion;
private String createdBy;
private String updatedBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.fleet.maintenance;
import org.springframework.web.multipart.MultipartFile;
public interface VehicleCareFileGateway {
VehicleCareModels.File upload(long recordId, String operatorId, MultipartFile file);
VehicleCareModels.File metadata(long recordId, long fileId);
byte[] download(long recordId, long fileId);
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_vehicle_record_history")
public class VehicleCareHistory {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long recordId;
private Long recordVersion;
private String action;
private String reason;
private String operatorId;
private LocalDateTime occurredAt;
private String snapshotJson;
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface VehicleCareHistoryMapper extends BaseMapper<VehicleCareHistory> { }
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
@Mapper
public interface VehicleCareMapper extends BaseMapper<VehicleCareEntity> {
@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")
VehicleCareEntity lock(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo);
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND created_by=#{operator} AND idempotency_key=#{key} FOR UPDATE")
VehicleCareEntity lockCreated(@Param("vehicleId") long vehicleId, @Param("operator") String operator, @Param("key") String key);
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.fleet.maintenance;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class VehicleCareModels {
private VehicleCareModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size,
@Pattern(regexp = "MAINTENANCE|FUEL|REPAIR|BREAKDOWN|RESCUE|EXCEPTION") String recordType) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Draft(@NotBlank @Pattern(regexp = "MAINTENANCE|FUEL|REPAIR|BREAKDOWN|RESCUE|EXCEPTION") String recordType,
@NotNull Instant occurredAt,
@Pattern(regexp = "KM|MI") String distanceUnit,
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal odometer,
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal lastServiceOdometer,
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal nextServiceOdometer,
@DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 3) BigDecimal fuelQuantity,
@Pattern(regexp = "L|US_GAL|IMP_GAL") String fuelUnit,
@Pattern(regexp = "PENDING|VERIFIED") String verificationStatus,
@Size(max = 1000) String remark, @Valid Incident incident) { }
public record Incident(@NotBlank @Size(max = 2000) String description,
@Size(max = 256) String item, @Size(max = 512) String place,
@Size(max = 128) String handler,
@NotBlank @Pattern(regexp = "PENDING|IN_PROGRESS|COMPLETED") String processingStatus,
@Pattern(regexp = "ROUTE|TIMEOUT|DELIVERY|VEHICLE_BREAKDOWN") String exceptionType,
@DecimalMin("0") @Digits(integer = 12, fraction = 2) BigDecimal reportedCost,
@Pattern(regexp = "[A-Z]{3}") String currency) { }
public record Create(@NotBlank @Size(max = 128) String idempotencyKey, @NotNull @Valid Draft details) { }
public record Update(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 1000) String reason,
@NotNull @Valid Draft details) { }
public record File(String fileId, String fileName, String contentType, long sizeBytes) { }
public record Attached(String uploadKey, String sha256, File file) { }
public record Response(String recordNo, String vehicleId, String vehiclePlate, Draft details,
List<File> files, long recordVersion, String updatedBy, Instant updatedAt, String paymentStatus) { }
public record History(long recordVersion, String action, String reason, String operatorId,
Instant occurredAt, Response snapshot) { }
public record Download(File metadata, byte[] content) { }
}
@@ -0,0 +1,181 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.*;
import java.util.*;
@Service
public class VehicleCareService {
private final VehicleCareMapper records;
private final VehicleCareHistoryMapper histories;
private final VehicleRepository vehicles;
private final VehicleCareFileGateway files;
private final ObjectMapper json;
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;
}
public PageResult<VehicleCareModels.Response> page(long vehicleId, VehicleCareModels.Query query) {
vehicles.findById(vehicleId).orElseThrow(VehicleCareService::missing);
var page = records.selectPage(new Page<>(query.page(), query.size()), new LambdaQueryWrapper<VehicleCareEntity>()
.eq(VehicleCareEntity::getVehicleId, vehicleId)
.eq(query.recordType() != null, VehicleCareEntity::getRecordType, query.recordType())
.orderByDesc(VehicleCareEntity::getOccurredAt, VehicleCareEntity::getId));
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public VehicleCareModels.Response detail(long vehicleId, String number) { return response(required(vehicleId, number, false)); }
@Transactional
public VehicleCareModels.Response create(long vehicleId, String operator, VehicleCareModels.Create request) {
// Serialize creation per vehicle; no empty-range locks or duplicate creation after a lost response.
var vehicle = vehicles.lockById(vehicleId).orElseThrow(VehicleCareService::missing);
String fingerprint = digest(write(request.details()).getBytes(StandardCharsets.UTF_8));
var prior = records.lockCreated(vehicleId, operator, request.idempotencyKey());
if (prior != null) {
if (!prior.getRequestHash().equals(fingerprint)) throw conflict("请求标识已用于其他运维记录内容");
return response(prior);
}
validate(request.details());
var row = new VehicleCareEntity(); row.setId(IdWorker.getId());
row.setRecordNo("VR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
row.setVehicleId(vehicleId); row.setVehiclePlate(vehicle.plateNumber()); row.setRecordType(request.details().recordType());
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); row.setFilesJson("[]");
row.setIdempotencyKey(request.idempotencyKey()); row.setRequestHash(fingerprint); row.setRecordVersion(0L);
row.setCreatedBy(operator); row.setUpdatedBy(operator); row.setCreatedAt(utc(Instant.now())); row.setUpdatedAt(row.getCreatedAt());
if (records.insert(row) != 1) throw conflict("运维记录保存失败");
record(row, "CREATE", null, operator);
return response(row);
}
@Transactional
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("已保存记录不能改变运维类型");
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);
return response(row);
}
@Transactional
public VehicleCareModels.Response upload(long vehicleId, String number, long expectedVersion, String uploadKey, String operator, MultipartFile file) {
var row = required(vehicleId, number, true);
if (uploadKey == null || uploadKey.isBlank() || uploadKey.length() > 128) throw bad("附件请求标识无效");
if (file == null || file.isEmpty() || file.getSize() > 10L * 1024 * 1024
|| !Set.of("image/jpeg", "image/png").contains(Objects.toString(file.getContentType(), ""))) throw bad("请选择非空 JPG 或 PNG 图片,最大 10MiB");
String fingerprint;
try { fingerprint = digest(file.getBytes()); } catch (IOException error) { throw bad("无法读取附件"); }
var attached = new ArrayList<>(attached(row));
var replay = attached.stream().filter(item -> item.uploadKey().equals(uploadKey)).findFirst();
if (replay.isPresent()) {
if (!replay.get().sha256().equals(fingerprint)) throw conflict("附件请求标识已用于不同图片");
return response(row);
}
version(row, expectedVersion);
if (attached.size() >= 20) throw bad("每条记录最多保存 20 张图片");
var uploaded = files.upload(row.getId(), operator, file);
attached.add(new VehicleCareModels.Attached(uploadKey, fingerprint, uploaded)); row.setFilesJson(write(attached));
persist(row, expectedVersion, "UPLOAD", uploaded.fileName(), operator);
return response(row);
}
public VehicleCareModels.Download download(long vehicleId, String number, long fileId) {
var row = required(vehicleId, number, false);
if (attached(row).stream().noneMatch(item -> item.file().fileId().equals(Long.toString(fileId)))) throw missing();
var metadata = files.metadata(row.getId(), fileId);
return new VehicleCareModels.Download(metadata, files.download(row.getId(), fileId));
}
public PageResult<VehicleCareModels.History> history(long vehicleId, String number, int page, int size) {
var row = required(vehicleId, number, false);
var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper<VehicleCareHistory>()
.eq(VehicleCareHistory::getRecordId, row.getId()).orderByDesc(VehicleCareHistory::getRecordVersion));
return new PageResult<>(result.getRecords().stream().map(entry -> new VehicleCareModels.History(entry.getRecordVersion(),
entry.getAction(), entry.getReason(), entry.getOperatorId(), entry.getOccurredAt().toInstant(ZoneOffset.UTC),
read(entry.getSnapshotJson(), VehicleCareModels.Response.class))).toList(), result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
private void persist(VehicleCareEntity row, long expected, String action, String reason, String operator) {
row.setRecordVersion(expected + 1); row.setUpdatedBy(operator); row.setUpdatedAt(utc(Instant.now()));
if (records.update(row, new LambdaUpdateWrapper<VehicleCareEntity>().eq(VehicleCareEntity::getId, row.getId())
.eq(VehicleCareEntity::getRecordVersion, expected)) != 1) throw conflict("记录已变化,请刷新后重试");
record(row, action, reason, operator);
}
private void record(VehicleCareEntity row, String action, String reason, String operator) {
var history = new VehicleCareHistory(); history.setId(IdWorker.getId()); history.setRecordId(row.getId());
history.setRecordVersion(row.getRecordVersion()); history.setAction(action); history.setReason(reason);
history.setOperatorId(operator); history.setOccurredAt(row.getUpdatedAt()); history.setSnapshotJson(write(response(row)));
if (histories.insert(history) != 1) throw conflict("记录历史保存失败");
}
private VehicleCareModels.Response response(VehicleCareEntity row) {
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);
}
private VehicleCareEntity required(long vehicleId, String number, boolean lock) {
var row = lock ? records.lock(vehicleId, number) : records.find(vehicleId, number);
if (row == null) throw missing(); return row;
}
private static void version(VehicleCareEntity row, long expected) { if (row.getRecordVersion() != expected) throw conflict("记录已变化,请刷新后重试"); }
static void validate(VehicleCareModels.Draft value) {
if (value.occurredAt() == null || value.occurredAt().isAfter(Instant.now())) throw bad("记录时间不能晚于当前时间");
if (value.occurredAt().atOffset(ZoneOffset.UTC).getYear() < 1900) throw bad("记录时间须为 1900 年及以后");
if (Set.of("REPAIR", "BREAKDOWN", "RESCUE", "EXCEPTION").contains(Objects.toString(value.recordType(), ""))) {
validateIncident(value); return;
}
if (value.incident() != null) throw bad("保养加油不能混入维修、故障、救援或异常字段");
if (value.odometer() == null || value.odometer().signum() < 0 || !Set.of("KM", "MI").contains(Objects.toString(value.distanceUnit(), ""))) throw bad("请填写有效里程及单位");
if (!Set.of("PENDING", "VERIFIED").contains(Objects.toString(value.verificationStatus(), ""))) throw bad("核验状态无效");
if ("MAINTENANCE".equals(value.recordType())) {
if (value.lastServiceOdometer() == null || value.nextServiceOdometer() == null || value.lastServiceOdometer().signum() < 0
|| value.lastServiceOdometer().compareTo(value.odometer()) > 0 || value.nextServiceOdometer().compareTo(value.lastServiceOdometer()) <= 0)
throw bad("上次保养里程须不大于记录时里程,下次保养里程须大于上次保养里程");
if (value.fuelQuantity() != null || value.fuelUnit() != null) throw bad("保养记录不能包含加油字段");
} else if ("FUEL".equals(value.recordType())) {
if (value.fuelQuantity() == null || value.fuelQuantity().signum() <= 0 || value.fuelUnit() == null
|| !Set.of("L", "US_GAL", "IMP_GAL").contains(value.fuelUnit())) throw bad("请填写正数加油量并明确升、美制加仑或英制加仑");
if (value.lastServiceOdometer() != null || value.nextServiceOdometer() != null) throw bad("加油记录不能包含保养里程字段");
} else throw bad("记录类型无效");
}
private static void validateIncident(VehicleCareModels.Draft value) {
if (value.distanceUnit() != null || value.odometer() != null || value.lastServiceOdometer() != null || value.nextServiceOdometer() != null
|| value.fuelQuantity() != null || value.fuelUnit() != null || value.verificationStatus() != null) throw bad("处理记录不能混入保养、加油或核验字段");
var incident = value.incident();
if (incident == null || blank(incident.description())) throw bad("请填写事件描述");
if (!Set.of("PENDING", "IN_PROGRESS", "COMPLETED").contains(Objects.toString(incident.processingStatus(), ""))) throw bad("处理状态无效");
if (!"PENDING".equals(incident.processingStatus()) && blank(incident.handler())) throw bad("处理中或已完成记录须填写处理人");
boolean costRecord = Set.of("REPAIR", "RESCUE").contains(value.recordType());
if (costRecord && (blank(incident.item()) || blank(incident.place()))) throw bad("请填写维修或救援项目及地点");
if (!costRecord && (incident.item() != null || incident.place() != null || incident.reportedCost() != null || incident.currency() != null))
throw bad("故障和异常记录不接受维修救援费用字段");
if ("EXCEPTION".equals(value.recordType())) {
if (!Set.of("ROUTE", "TIMEOUT", "DELIVERY", "VEHICLE_BREAKDOWN").contains(Objects.toString(incident.exceptionType(), ""))) throw bad("请选择异常类型");
} else if (incident.exceptionType() != null) throw bad("非异常记录不能设置异常类型");
if (incident.reportedCost() != null) {
if (incident.reportedCost().signum() < 0 || blank(incident.currency())) throw bad("费用须为非负数并填写币种");
try { Currency.getInstance(incident.currency()); } catch (IllegalArgumentException error) { throw bad("费用币种无效"); }
} else if (incident.currency() != null) throw bad("未登记费用时不能填写币种");
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private List<VehicleCareModels.Attached> attached(VehicleCareEntity row) {
try { return json.readValue(row.getFilesJson(), new TypeReference<>() { }); } catch (JsonProcessingException error) { throw new IllegalStateException(error); }
}
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 digest(byte[] value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }
private static LocalDateTime utc(Instant instant) { return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); }
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆或当前车辆的记录不存在"); }
private static ResponseStatusException bad(String reason) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, reason); }
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); }
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data @TableName("ll_fleet_external_booking")
public class FleetBookingEntity {
@TableId(type=IdType.INPUT) private Long id;
private String bookingNo;
private Long tripId;
private String tripNo;
private String customerId;
private String customerCode;
private String customerName;
private String status;
private String cargoJson;
private String destinationKey;
private String destinationLabel;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private BigDecimal agreedAmount;
private String idempotencyKey;
private String requestHash;
private String confirmKey;
private String confirmHash;
private String cancelKey;
private String cancelHash;
private String incomingNo;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String dispatchNo;
private String receiptJson;
private String incomingKey;
private String incomingHash;
private String receiptKey;
private String receiptHash;
private String bindingKey;
private String bindingHash;
private Long recordVersion;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,90 @@
package com.baiyue.lastleg.fleet.trip;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
/** Confirmed commercial facts only; delivery owns fee publication in its transaction. */
@Component
public class FleetBookingFreight {
private final FleetBookingMapper bookings;
private final FleetTripDispatchMapper bindings;
private final FleetBookingReservations reservations;
private final ObjectMapper json;
public FleetBookingFreight(FleetBookingMapper bookings, FleetTripDispatchMapper bindings,
FleetBookingReservations reservations, ObjectMapper json) {
this.bookings = bookings; this.bindings = bindings; this.reservations = reservations; this.json = json;
}
public record Quote(String bookingNo, long recordVersion, String incomingNo, String inboundCode,
String customerName, String destinationKey, String currency, BigDecimal amount,
BigDecimal quantity, String quantityUnit, BigDecimal unitPrice) { }
public record Preview(String snapshotHash, boolean requiresDeliveryFee, List<Quote> quotes,
@com.fasterxml.jackson.annotation.JsonIgnore List<String> priorInboundCodes) { }
/** Caller serializes transfer with the dispatch lock. Never lock the parent trip here. */
public Preview preview(long dispatchId, String dispatchNo, String customerId,
List<FleetDispatchGateway.SourceCargo> sources) {
var quotes = new ArrayList<Quote>();
var priorInboundCodes = new TreeSet<String>();
var grouped = new TreeMap<String, List<FleetDispatchGateway.SourceCargo>>();
boolean requiresFee = false;
for (var source : sources) {
if (source.inboundCode() == null || source.inboundCode().isBlank()) requiresFee = true;
else grouped.computeIfAbsent(source.inboundCode(), key -> new ArrayList<>()).add(source);
}
for (var entry : grouped.entrySet()) {
var booking = bookings.incomingForFreight(entry.getKey());
if (booking == null) { requiresFee = true; continue; }
if (!customerId.equals(booking.getCustomerId())) throw conflict("来货来源与排车客户不一致");
var accepted = reservations.receipt(booking);
if (accepted != null && accepted.source().inboundCode() != null && !accepted.source().inboundCode().isBlank())
priorInboundCodes.add(accepted.source().inboundCode());
if ("CANCELLED".equals(booking.getStatus())) { requiresFee = true; continue; }
var binding = bindings.findByDispatchNo(dispatchNo);
if (!"BOUND".equals(booking.getStatus()) || !customerId.equals(booking.getCustomerId())
|| !dispatchNo.equals(booking.getDispatchNo()) || binding == null
|| !Objects.equals(binding.getDispatchId(), dispatchId)
|| !Objects.equals(binding.getTripId(), booking.getTripId()))
throw conflict("外接货须先在承诺车次中完成实收确认和排车关联");
if (accepted == null) throw conflict("外接货缺少实收与报价确认");
if (accepted.source().inboundCode() == null || accepted.source().inboundCode().isBlank())
throw conflict("外接实收缺少实际入仓码,请先核对来货来源");
var actual = new TreeMap<String, Integer>();
for (var source : entry.getValue()) {
if (source.warehousePalletId() == null || !Objects.equals(booking.getDestinationKey(), source.destinationKey()))
throw conflict("外接发运来源或目的地已变化,请重新核对");
actual.merge(source.warehousePalletId(), source.packages(), Math::addExact);
}
var confirmed = new TreeMap<String, Integer>();
for (var pallet : accepted.source().pallets()) confirmed.merge(pallet.warehousePalletId(), pallet.packages(), Math::addExact);
if (!actual.equals(confirmed)) throw conflict("外接发运数量与已确认实收不一致");
var cargo = reservations.cargo(booking);
BigDecimal quantity = "PALLET".equals(cargo.priceBasis()) ? BigDecimal.valueOf(cargo.palletSpaces()) : BigDecimal.ONE;
if (booking.getAgreedAmount() == null || booking.getAgreedAmount().signum() < 0 || cargo.unitPrice() == null
|| quantity.multiply(cargo.unitPrice()).compareTo(booking.getAgreedAmount()) != 0)
throw conflict("外接约定运费与实收计价依据不一致");
if (!Set.of("CNY", "USD").contains(cargo.currency())) throw conflict("外接运费入账仅支持CNY或USD,请先更正约定币种");
quotes.add(new Quote(booking.getBookingNo(), booking.getRecordVersion(), booking.getIncomingNo(),
accepted.source().inboundCode(), booking.getCustomerName(), booking.getDestinationKey(), cargo.currency(),
booking.getAgreedAmount(), quantity, "PALLET".equals(cargo.priceBasis()) ? "托位" : "单", cargo.unitPrice()));
}
if (sources.isEmpty()) requiresFee = true;
try {
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
json.writeValueAsString(List.of(dispatchNo, customerId, sources.stream().sorted(Comparator
.comparing(FleetDispatchGateway.SourceCargo::inboundCode, Comparator.nullsFirst(String::compareTo))
.thenComparing(FleetDispatchGateway.SourceCargo::warehousePalletId, Comparator.nullsFirst(String::compareTo))
.thenComparing(FleetDispatchGateway.SourceCargo::destinationKey, Comparator.nullsFirst(String::compareTo))
.thenComparingInt(FleetDispatchGateway.SourceCargo::packages)).toList(), quotes)).getBytes(StandardCharsets.UTF_8)));
return new Preview(hash, requiresFee, List.copyOf(quotes), List.copyOf(priorInboundCodes));
} catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) {
throw new IllegalStateException(error);
}
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.fleet.trip;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** The transfer slice owns coming-goods documents and physical receiving facts. */
public interface FleetBookingIncomingGateway {
record Incoming(String customerId,String warehouseId,String warehouseName,String bookingNo,FleetBookingModels.Cargo cargo,
LocalDateTime estimatedArrivalAt,String deliveryInstructions,String privateAddress,String postalCode,String recipientName,String recipientPhone) { }
record Pallet(String warehousePalletId,String palletNo,int packages,BigDecimal weightKg,BigDecimal volumeCbm) { }
record Evidence(String incomingNo,String inboundCode,String warehouseId,String destinationKey,String status,
String receiptHash,List<Pallet> pallets,int receivedPackages) { }
Evidence create(String operator, Incoming incoming);
Evidence inspect(String customerId,String incomingNo,boolean lock);
Evidence confirm(String customerId,String incomingNo,String operator,String key);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface FleetBookingMapper extends BaseMapper<FleetBookingEntity> {
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo} AND status <> 'CANCELLED'")
FleetBookingEntity incoming(@Param("incomingNo") String incomingNo);
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo}")
FleetBookingEntity incomingForFreight(@Param("incomingNo") String incomingNo);
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} ORDER BY id")
List<FleetBookingEntity> list(@Param("tripId") long tripId);
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} AND created_by=#{operator} AND idempotency_key=#{key}")
FleetBookingEntity created(@Param("tripId") long tripId,@Param("operator") String operator,@Param("key") String key);
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} AND booking_no=#{number} FOR UPDATE")
FleetBookingEntity lock(@Param("tripId") long tripId,@Param("number") String number);
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.fleet.trip;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class FleetBookingModels {
private FleetBookingModels() { }
public record Cargo(@NotBlank @Size(max=160) String destinationKey, @Size(max=255) String cargoName,
@Min(1) int packageCount, @Min(1) Integer palletSpaces, @DecimalMin("0.001") @Digits(integer=9, fraction=3) BigDecimal weightKg,
@DecimalMin("0.001") @Digits(integer=9, fraction=3) BigDecimal volumeCbm,
@Pattern(regexp="PALLET|ORDER") String priceBasis, @DecimalMin("0.00") @Digits(integer=10, fraction=2) BigDecimal unitPrice,
@Size(max=3) String currency, @Size(max=128) String contactName, @Size(max=128) String contactPhone, @Size(max=1000) String remark) { }
public record Create(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Positive Long customerId,
@NotNull @Valid Cargo cargo, @NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Update(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotNull @Valid Cargo cargo) { }
public record Command(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey, @Size(max=1000) String reason) { }
public record StartIncoming(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
Instant estimatedArrivalAt,@Size(max=1000) String deliveryInstructions,@Size(max=1000) String privateAddress,
@Size(max=32) String postalCode,@Size(max=128) String recipientName,@Size(max=64) String recipientPhone,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record PalletSpaces(@NotBlank String warehousePalletId,@Min(1) int palletSpaces) { }
public record AcceptReceipt(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
@NotBlank String receiptHash,@NotEmpty List<@Valid PalletSpaces> pallets,
@NotNull @DecimalMin("0.00") @Digits(integer=10,fraction=2) BigDecimal unitPrice,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record BindDispatch(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(max=64) String dispatchNo,@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record IncomingPreview(long tripRecordVersion,long recordVersion,FleetBookingIncomingGateway.Evidence incoming) { }
public record AcceptedReceipt(FleetBookingIncomingGateway.Evidence source,List<PalletSpaces> pallets,String operatorId,Instant confirmedAt) { }
public record Response(String bookingNo, String tripNo, String customerId, String customerCode, String customerName,
String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt) { }
public record Result(long tripRecordVersion, Response booking, boolean replayed) { }
public record ListResult(long tripRecordVersion, List<Response> records) { }
}
@@ -0,0 +1,87 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.time.ZoneOffset;
import java.util.*;
/** Read under the parent trip lock whenever changing reservations or capacity. */
@Component
public class FleetBookingReservations {
private final FleetBookingMapper bookings;
private final ObjectMapper json;
public FleetBookingReservations(FleetBookingMapper bookings, ObjectMapper json) { this.bookings=bookings; this.json=json; }
public boolean hasConfirmed(long tripId) { return bookings.list(tripId).stream().anyMatch(row -> unbound(row)); }
public List<FleetBookingModels.Response> responses(long tripId) { return bookings.list(tripId).stream().map(this::response).toList(); }
public FleetBookingModels.Cargo cargo(FleetBookingEntity row) { return read(row.getCargoJson(), FleetBookingModels.Cargo.class); }
public FleetBookingModels.Response response(FleetBookingEntity row) {
return new FleetBookingModels.Response(row.getBookingNo(),row.getTripNo(),row.getCustomerId(),row.getCustomerCode(),row.getCustomerName(),
row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row));
}
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
Long excludedId, FleetBookingEntity addition) {
var stops=stops(trip); var loads=new ArrayList<TripCapacityPolicy.Booking>();
for (var pallet:own.pallets()) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(),
new TripCapacityPolicy.Load(pallet.palletSpaces(),pallet.weightKg(),pallet.volumeCbm())));
for(var row:bookings.list(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row);
if(addition!=null) add(loads,stops,addition);
try { return TripCapacityPolicy.calculate(stops.size(),new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),loads); }
catch(IllegalArgumentException|ArithmeticException error){throw conflict(error.getMessage());}
}
public void requireReassignmentFits(FleetTripEntity trip, Integer spaces, java.math.BigDecimal weight, java.math.BigDecimal volume) {
if(!hasConfirmed(trip.getId())) return;
if(trip.getPlannedLoadJson()==null) throw conflict("已有外接承运,请先重新核对计划容量再改派");
var own=read(trip.getPlannedLoadJson(),FleetExecutionModels.PlannedLoad.class);
for(var segment:segments(trip,own,null,null)) if(segment.occupied().palletSpaces()>spaces
||segment.occupied().weightKg().compareTo(weight)>0||segment.occupied().volumeCbm().compareTo(volume)>0)
throw conflict("替换车辆无法容纳自有货及已确认外接预订");
}
public FleetBookingModels.AcceptedReceipt receipt(FleetBookingEntity row){return row.getReceiptJson()==null?null:read(row.getReceiptJson(),FleetBookingModels.AcceptedReceipt.class);}
private static boolean unbound(FleetBookingEntity row){return Set.of("CONFIRMED","RECEIVED").contains(row.getStatus());}
public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,List<FleetDispatchGateway.SourceCargo> sources,String operator){
var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode));
for(var entry:bySource.entrySet()){
var row=bookings.incoming(entry.getKey());if(row==null)continue;
if(!Objects.equals(row.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订");
if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车");
var accepted=receipt(row);if(accepted==null)throw conflict("外接货缺少实收确认");
var expected=new TreeMap<String,Integer>();
for(var pallet:accepted.source().pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact);
var selected=new TreeMap<String,Integer>();
for(var item:entry.getValue()){
if(item.warehousePalletId()==null||!Objects.equals(item.destinationKey(),row.getDestinationKey()))throw conflict("排车货物托盘或目的地与外接实收不一致");
selected.merge(item.warehousePalletId(),item.packages(),Math::addExact);
}
if(!selected.equals(expected))throw conflict("当前排车未完整包含此外接预订的实收货物");
row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,operator);
}
}
public void unbindIncoming(FleetTripEntity trip,String dispatchNo,String operator){
for(var row:bookings.list(trip.getId()))if("BOUND".equals(row.getStatus())&&Objects.equals(row.getDispatchNo(),dispatchNo)){
row.setStatus("RECEIVED");row.setDispatchNo(null);touch(row,operator);
}
}
private void touch(FleetBookingEntity row,String operator){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(java.time.LocalDateTime.now(ZoneOffset.UTC));if(bookings.updateById(row)!=1)throw conflict("外接关联已变化");}
public void requireRoute(FleetTripEntity trip,List<FleetTripModels.Stop> stops) {
for(var row:bookings.list(trip.getId())) if((unbound(row)||"BOUND".equals(row.getStatus())) && index(stops,row.getDestinationKey())<1)
throw conflict("路线仍须包含已确认外接预订的卸货站");
}
private void add(List<TripCapacityPolicy.Booking> loads,List<FleetTripModels.Stop> stops,FleetBookingEntity row){
var cargo=cargo(row); int index=index(stops,row.getDestinationKey());
if(index<1) throw conflict("外接预订卸货站不在当前路线");
if(cargo.palletSpaces()==null||cargo.weightKg()==null||cargo.volumeCbm()==null) throw conflict("确认承运前请补齐预计托位、重量和体积");
try {loads.add(new TripCapacityPolicy.Booking(row.getBookingNo(),0,index,new TripCapacityPolicy.Load(cargo.palletSpaces(),cargo.weightKg(),cargo.volumeCbm())));}
catch(IllegalArgumentException error){throw conflict(error.getMessage());}
}
public List<FleetTripModels.Stop> stops(FleetTripEntity trip){
try{return json.readValue(trip.getStopsJson(),new TypeReference<>(){});}catch(JsonProcessingException error){throw new IllegalStateException(error);}
}
public static int index(List<FleetTripModels.Stop> stops,String key){for(int i=1;i<stops.size();i++)if(Objects.equals(stops.get(i).destinationKey(),key))return i;return -1;}
private <T>T read(String value,Class<T> type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException(error);}}
private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);}
}
@@ -0,0 +1,175 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Currency;
import java.util.HexFormat;
import java.util.Objects;
@Service
public class FleetBookingService {
private final FleetTripMapper trips;
private final FleetBookingMapper bookings;
private final FleetBookingReservations reservations;
private final FleetTripService history;
private final FleetTripExecutionService execution;
private final FleetCustomerDirectory customers;
private final ObjectMapper json;
private final FleetBookingIncomingGateway incoming;
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming){
this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming;
}
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingModels.ListResult list(String tripNo){var trip=trip(tripNo,false);return new FleetBookingModels.ListResult(trip.getRecordVersion(),reservations.responses(trip.getId()));}
@Transactional
public FleetBookingModels.Result create(String tripNo,String operator,FleetBookingModels.Create request){
var trip=trip(tripNo,true);String hash=hash(request);
var existing=bookings.created(trip.getId(),operator,request.idempotencyKey());
if(existing!=null){if(!hash.equals(existing.getRequestHash()))throw conflict("同一登记请求不能变更客户或货物内容");return result(trip,existing,true);}
requireTrip(trip,request.tripRecordVersion());var customer=customers.required(request.customerId());
var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo("FB-"+row.getId());row.setTripId(trip.getId());row.setTripNo(tripNo);
row.setCustomerId(customer.id());row.setCustomerCode(customer.code());row.setCustomerName(customer.name());row.setStatus("DRAFT");
apply(trip,row,request.cargo());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L);
row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedBy(operator);row.setUpdatedAt(row.getCreatedAt());
bookings.insert(row);history.recordExecution(trip,request.tripRecordVersion(),"BOOKING_CREATE","登记外接询价",operator);
return result(trip,row,false);
}
@Transactional
public FleetBookingModels.Result update(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){
var trip=trip(tripNo,true);requireTrip(trip,request.tripRecordVersion());var row=booking(trip,bookingNo);requireVersion(row,request.recordVersion());
if(!"DRAFT".equals(row.getStatus()))throw conflict("已确认承运的货物请先取消预订后重新登记,原记录保留");
apply(trip,row,request.cargo());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false);
}
@Transactional
public FleetBookingModels.Result confirm(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getConfirmKey(),request.idempotencyKey())){if(!hash.equals(row.getConfirmHash()))throw conflict("承运确认请求内容不一致");return result(trip,row,true);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"DRAFT".equals(row.getStatus()))throw conflict("仅询价中的外接货可以确认承运");
customers.required(Long.parseLong(row.getCustomerId()));var cargo=reservations.cargo(row);
if(row.getAgreedAmount()==null||cargo.currency()==null)throw conflict("确认承运前请填写约定单价和币种");
if(!java.util.Set.of("CNY","USD").contains(cargo.currency()))throw conflict("首版运费入账支持CNY或USD,请调整约定币种");
var plan=execution.capacityPlan(tripNo);
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先核对本车自有货计划容量,再确认外接承运");
reservations.segments(trip,plan.capacityPlan(),null,row);
row.setStatus("CONFIRMED");row.setConfirmKey(request.idempotencyKey());row.setConfirmHash(hash);
save(trip,row,operator,"BOOKING_CONFIRM",reason(request.reason(),"确认外接承运并预留容量"));return result(trip,row,false);
}
@Transactional
public FleetBookingModels.Result cancel(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getCancelKey(),request.idempotencyKey())){if(!hash.equals(row.getCancelHash()))throw conflict("取消请求内容不一致");return result(trip,row,true);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!java.util.Set.of("DRAFT","CONFIRMED","RECEIVED").contains(row.getStatus()))throw conflict("请先解除尚未开工的排车关联,再取消预订;已拣货须走现场撤回流程");
row.setStatus("CANCELLED");row.setCancelKey(request.idempotencyKey());row.setCancelHash(hash);
save(trip,row,operator,"BOOKING_CANCEL",reason(request.reason(),"取消未执行外接预订并释放容量"));return result(trip,row,false);
}
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingModels.IncomingPreview incoming(String tripNo,String bookingNo){
var trip=trip(tripNo,false);var row=bookings.list(trip.getId()).stream().filter(value->value.getBookingNo().equals(bookingNo)).findFirst()
.orElseThrow(()->new ResponseStatusException(HttpStatus.NOT_FOUND,"外接预订不存在"));
return new FleetBookingModels.IncomingPreview(trip.getRecordVersion(),row.getRecordVersion(),row.getIncomingNo()==null?null:incoming.inspect(row.getCustomerId(),row.getIncomingNo(),false));
}
@Transactional
public FleetBookingModels.Result startIncoming(String tripNo,String bookingNo,String operator,FleetBookingModels.StartIncoming request){
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getIncomingKey(),request.idempotencyKey())){if(!hash.equals(row.getIncomingHash()))throw conflict("来货登记请求内容不一致");return result(trip,row,true);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()!=null)throw conflict("仅已确认且尚未登记来货的预订允许此操作");
customers.required(Long.parseLong(row.getCustomerId()));
var arrival=request.estimatedArrivalAt()==null?trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).minusSeconds(7200):request.estimatedArrivalAt();
var evidence=incoming.create(operator,new FleetBookingIncomingGateway.Incoming(row.getCustomerId(),trip.getWarehouseId().toString(),trip.getWarehouseName(),row.getBookingNo(),reservations.cargo(row),
LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),request.deliveryInstructions(),request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone()));
requireEvidence(trip,row,evidence);
row.setIncomingNo(evidence.incomingNo());row.setIncomingKey(request.idempotencyKey());row.setIncomingHash(hash);
save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false);
}
@Transactional
public FleetBookingModels.Result acceptReceipt(String tripNo,String bookingNo,String operator,FleetBookingModels.AcceptReceipt request){
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getReceiptKey(),request.idempotencyKey())){if(!hash.equals(row.getReceiptHash()))throw conflict("实收确认请求内容不一致");return result(trip,row,true);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货");
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
if(!java.util.Set.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(evidence.status())||evidence.pallets().isEmpty())throw conflict("仓库尚未完成实收及测量");
if(!Objects.equals(evidence.receiptHash(),request.receiptHash()))throw conflict("实收数据已变化,请刷新后重新核对");
var spaces=new java.util.HashMap<String,Integer>();
for(var entry:request.pallets())if(entry.palletSpaces()<1||spaces.put(entry.warehousePalletId(),entry.palletSpaces())!=null)throw conflict("请为每个实际托盘填写一次占用托位");
var physical=evidence.pallets().stream().map(FleetBookingIncomingGateway.Pallet::warehousePalletId).collect(java.util.stream.Collectors.toSet());
if(!spaces.keySet().equals(physical)||physical.contains(null))throw conflict("托位确认须与实际收货托盘一致");
BigDecimal weight=BigDecimal.ZERO,volume=BigDecimal.ZERO;int count=0;
for(var pallet:evidence.pallets()){
if(pallet.packages()<=0||pallet.weightKg()==null||pallet.volumeCbm()==null||pallet.weightKg().signum()<=0||pallet.volumeCbm().signum()<=0)throw conflict("实际托盘缺少有效箱数、重量或体积");
weight=weight.add(pallet.weightKg());volume=volume.add(pallet.volumeCbm());count=Math.addExact(count,pallet.packages());
}
int totalSpaces=spaces.values().stream().reduce(0,Math::addExact);var old=reservations.cargo(row);
apply(trip,row,new FleetBookingModels.Cargo(old.destinationKey(),old.cargoName(),count,totalSpaces,weight,volume,old.priceBasis(),request.unitPrice(),old.currency(),old.contactName(),old.contactPhone(),old.remark()));
var plan=execution.capacityPlan(tripNo);
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先重新核对自有货容量计划,再确认外接实收");
reservations.segments(trip,plan.capacityPlan(),row.getId(),row);
var confirmed=incoming.confirm(row.getCustomerId(),row.getIncomingNo(),operator,"FLEET:"+bookingNo+":RECEIPT");
if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对");
row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now())));
row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash);
save(trip,row,operator,"BOOKING_RECEIPT","确认外接实收货量及约定运费 "+row.getBookingNo());return result(trip,row,false);
}
@Transactional
public FleetBookingModels.Result bindDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.BindDispatch request){
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getBindingKey(),request.idempotencyKey())){if(!hash.equals(row.getBindingHash()))throw conflict("关联发运请求内容不一致");return result(trip,row,true);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认外接实收货量和运费");
row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row);
var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo));
var result=booking(trip,bookingNo);
if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物");
return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false);
}
private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){
if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致");
if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致");
}
private void apply(FleetTripEntity trip,FleetBookingEntity row,FleetBookingModels.Cargo input){
var stops=reservations.stops(trip);int index=FleetBookingReservations.index(stops,input.destinationKey());
if(index<1)throw conflict("请选择本车已有卸货站,外接货首版从本起运仓装车");
String basis=input.priceBasis()==null?"PALLET":input.priceBasis();String currency=trim(input.currency());
if(!basis.equals("PALLET")&&!basis.equals("ORDER"))throw bad("请选择按托或整单报价");
if(currency!=null){currency=currency.toUpperCase(java.util.Locale.ROOT);try{Currency.getInstance(currency);}catch(IllegalArgumentException error){throw bad("币种无效");}}
var cargo=new FleetBookingModels.Cargo(input.destinationKey(),trim(input.cargoName()),input.packageCount(),input.palletSpaces(),input.weightKg(),input.volumeCbm(),
basis,input.unitPrice(),currency,trim(input.contactName()),trim(input.contactPhone()),trim(input.remark()));
row.setCargoJson(write(cargo));row.setDestinationKey(cargo.destinationKey());row.setDestinationLabel(stops.get(index).name());
BigDecimal amount=null;
if(input.unitPrice()!=null && (basis.equals("ORDER")||input.palletSpaces()!=null)) amount=input.unitPrice().multiply(BigDecimal.valueOf(basis.equals("ORDER")?1:input.palletSpaces())).setScale(2);
if(amount!=null && amount.precision()>16)throw bad("约定金额超出支持范围");
row.setAgreedAmount(amount);
}
private void save(FleetTripEntity trip,FleetBookingEntity row,String operator,String action,String reason){
row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(now());
if(bookings.updateById(row)!=1)throw conflict("外接预订已变化,请刷新");
history.recordExecution(trip,trip.getRecordVersion(),action,reason,operator);
}
private FleetBookingModels.Result result(FleetTripEntity trip,FleetBookingEntity row,boolean replayed){return new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(row),replayed);}
private FleetTripEntity trip(String number,boolean lock){var value=lock?trips.lockByNo(number):trips.findByNo(number);if(value==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"车次不存在");return value;}
private FleetBookingEntity booking(FleetTripEntity trip,String number){var row=bookings.lock(trip.getId(),number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前车次无此外接预订");return row;}
private static void requireTrip(FleetTripEntity trip,long version){if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");}
private static void requireVersion(FleetBookingEntity row,long version){if(row.getRecordVersion()!=version)throw conflict("外接预订已被修改,请刷新");}
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}}
private String hash(Object value){try{ObjectNode node=json.valueToTree(value);node.remove("idempotencyKey");node.remove("tripRecordVersion");return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);}
private static String trim(String value){return value==null||value.isBlank()?null:value.trim();}
private static String reason(String value,String fallback){return trim(value)==null?fallback:trim(value);}
private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);}
private static ResponseStatusException bad(String value){return new ResponseStatusException(HttpStatus.BAD_REQUEST,value);}
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.fleet.trip;
import jakarta.validation.constraints.*;
import java.time.Instant;
import java.util.List;
public final class FleetCompletionModels {
private FleetCompletionModels() { }
public record Outcome(String dispatchNo, String deliveryNo, String customerId, String destination,
String status, int packages, Instant terminalAt) { }
public record DispatchCompletion(String dispatchNo, String warehouseId, List<Outcome> outcomes, String blockReason) { }
public record Inspection(String tripNo, long recordVersion, boolean canComplete, String blockReason, List<Outcome> outcomes) { }
public record Complete(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey,
@NotBlank @Size(max = 1000) String reason) { }
public record Completed(String idempotencyKey, String requestHash, Instant completedAt, Instant recordedAt,
String operatorId, List<Outcome> outcomes) { }
public record Release(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "VEHICLE|DRIVER") String resourceKind,
@NotNull @PastOrPresent Instant actualEndAt, @NotBlank @Size(max = 500) String endLocation,
@Pattern(regexp = "IN_SERVICE|MAINTENANCE|INACTIVE|RETURNED") String vehicleStatus,
@NotBlank @Size(min = 8, max = 128) String idempotencyKey, @NotBlank @Size(max = 1000) String reason) { }
public record Released(String idempotencyKey, String requestHash, Instant actualEndAt, String endLocation,
String vehicleStatus, String operatorId, Instant recordedAt) { }
public record ReleaseInfo(Instant actualEndAt, String endLocation, String vehicleStatus) { }
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.fleet.trip;
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;
@Service
public class FleetDispatchBindingGuard {
private final FleetTripDispatchMapper bindings;
private final com.fasterxml.jackson.databind.ObjectMapper json;
public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, com.fasterxml.jackson.databind.ObjectMapper json) { this.bindings = bindings; this.json = json; }
/** Dispatch -> task -> binding locks; never acquire the parent trip lock from a PDA pick. */
@Transactional(propagation = Propagation.MANDATORY)
public void requirePickingApproved(long dispatchId) {
var binding = bindings.lockByDispatch(dispatchId);
if (binding == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "请先关联车次并通过调度审核");
var review = FleetTripReview.read(json, binding.getReviewJson());
if (!"APPROVED".equals(review.status())) throw new ResponseStatusException(HttpStatus.CONFLICT,
"REJECTED".equals(review.status()) ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货");
}
@Transactional(propagation = Propagation.MANDATORY)
public void requireMatchingAssignment(long dispatchId, FleetDispatchGateway.Assignment actual, FleetDispatchGateway.Assignment requested) {
var binding = bindings.lockByDispatch(dispatchId);
if (binding != null && (changed(requested.vehiclePlate(), actual.vehiclePlate()) || changed(requested.driverName(), actual.driverName())
|| changed(requested.driverPhone(), actual.driverPhone())))
throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆和司机由车次 " + binding.getTripNo() + " 统一维护");
}
private static boolean changed(String requested, String actual) {
return requested != null && !requested.isBlank() && !requested.trim().equals(actual);
}
/** Must be called after locking the customer dispatch, which serializes binding and mutations. */
@Transactional(propagation = Propagation.MANDATORY)
public void requireUnbound(long dispatchId) {
var binding = bindings.lockByDispatch(dispatchId);
if (binding != null) throw new ResponseStatusException(HttpStatus.CONFLICT,
"排车单已关联车次 " + binding.getTripNo() + ",请先在车次中解除关联");
}
@Transactional(propagation = Propagation.MANDATORY)
public void requireCoordinatedDeparture(long dispatchId) {
var binding = bindings.lockByDispatch(dispatchId);
if (binding == null || !"DEPARTING".equals(bindings.tripStatus(dispatchId)))
throw new ResponseStatusException(HttpStatus.CONFLICT, "请通过车次统一发车");
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.fleet.trip;
import java.time.Instant;
/** Customer dispatches remain owned by dispatch; fleet only orchestrates their physical trip. */
public interface FleetDispatchGateway {
record Snapshot(long dispatchId, String dispatchNo, String customerId, String warehouseId, int packageCount) { }
record SourceCargo(String inboundCode,String warehousePalletId,String palletNo,int packages,String destinationKey) { }
java.util.List<SourceCargo> sourceCargo(String dispatchNo);
record Assignment(String vehiclePlate, String driverName, String driverPhone) { }
static String destinationKey(String type,String code,String fba,String address,String postalCode) {
if(!"PRIVATE".equals(type)){String value=code==null||code.isBlank()?fba:code;return value==null||value.isBlank()?null:"FBA:"+value.trim().toUpperCase(java.util.Locale.ROOT);}
if(address==null||address.isBlank())return null;
String key=address.strip().replaceAll("\\s+"," ").toUpperCase(java.util.Locale.ROOT)+"|"+java.util.Objects.requireNonNullElse(postalCode,"").strip().toUpperCase(java.util.Locale.ROOT);
try{return "PRIVATE:"+java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(key.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}
catch(java.security.NoSuchAlgorithmException error){throw new IllegalStateException(error);}
}
/** Locks the dispatch and rejects any picking or departure already performed. */
Snapshot lockForPlanning(String dispatchNo);
/** Allows pre-departure resource/time changes without changing cargo or recorded picking. */
Snapshot lockForAssignment(String dispatchNo);
/** Caller holds the dispatch lock for the entire transaction, including downstream snapshots. */
void assign(Snapshot dispatch, Assignment assignment);
FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock);
FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock);
FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator,
String idempotencyKey, String remark, Instant departedAt);
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class FleetExecutionModels {
private FleetExecutionModels() { }
public record Cargo(String dispatchNo, String customerId, String dispatchItemId, String warehousePalletId,
String palletNo, int packages, String destinationKey, String destinationLabel) { }
public record Manifest(String dispatchNo, String warehouseId, String vehiclePlate, String driverName, String driverPhone,
List<Cargo> cargo, String blockReason) { }
public record Measurement(@NotBlank String warehousePalletId, @Min(1) int palletSpaces,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal weightKg,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal volumeCbm,
@Min(1) int deliveryIndex) { }
public record Confirm(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "[0-9a-f]{64}") String manifestHash,
@NotEmpty @Size(max = 1000) List<@NotNull @Valid Measurement> pallets,
@NotBlank @Size(max = 1000) String reason) { }
public record ConfirmedLoad(String manifestHash, List<Cargo> cargo, List<Measurement> pallets,
List<TripCapacityPolicy.Segment> segments, String operatorId, Instant confirmedAt) { }
public record Loading(String tripNo, long recordVersion, List<Cargo> cargo, String manifestHash,
ConfirmedLoad confirmedLoad, String blockReason) { }
public record PlannedLoad(String manifestHash, List<Cargo> cargo, List<Measurement> pallets,
List<TripCapacityPolicy.Segment> segments, String operatorId, Instant confirmedAt) { }
public record Planning(String tripNo, long recordVersion, List<Cargo> cargo, String manifestHash,
PlannedLoad capacityPlan, boolean planValid, String blockReason, List<TripCapacityPolicy.Segment> availableSegments) { }
public record Depart(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey,
@Size(max = 1000) String remark) { }
public record DepartedDispatch(String dispatchNo, long shippedPackages) { }
public record Departed(String tripNo, String status, Instant departedAt, long shippedPackages,
List<DepartedDispatch> dispatches, boolean replayed) {
public Departed withReplay() { return new Departed(tripNo, status, departedAt, shippedPackages, dispatches, true); }
}
public record Gate(String tripNo, Long recordVersion, int dispatchCount, boolean canDepart, String blockReason, String driverWarning,
boolean canPick, String pickBlockReason) {
public Gate withPicking(boolean allowed, String reason) { return new Gate(tripNo, recordVersion, dispatchCount, canDepart, blockReason, driverWarning, allowed, reason); }
}
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.fleet.trip;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface FleetResourceMapper {
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id}")
Assignment find(@Param("kind") String kind, @Param("id") long id);
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE trip_id=#{tripId} ORDER BY resource_kind,resource_id")
List<Assignment> byTrip(@Param("tripId") long tripId);
@Insert("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES(#{kind},#{id},#{tripId},#{tripNo})")
int reserve(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId, @Param("tripNo") String tripNo);
@Delete("DELETE FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id} AND trip_id=#{tripId}")
int release(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId);
record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { }
}
@@ -0,0 +1,144 @@
package 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.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.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
@Service
public class FleetTripCompletionService {
private final FleetTripMapper trips;
private final FleetTripDispatchMapper bindings;
private final FleetResourceMapper resources;
private final FleetDispatchGateway dispatches;
private final FleetTripService history;
private final VehicleRepository vehicles;
private final VehicleApplicationService vehicleService;
private final ObjectMapper json;
public FleetTripCompletionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources,
FleetDispatchGateway dispatches, FleetTripService history, VehicleRepository vehicles,
VehicleApplicationService vehicleService, ObjectMapper json) {
this.trips = trips; this.bindings = bindings; this.resources = resources; this.dispatches = dispatches;
this.history = history; this.vehicles = vehicles; this.vehicleService = vehicleService; this.json = json;
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetCompletionModels.Inspection inspection(String tripNo) { return inspect(required(tripNo, false), false); }
@Transactional
public FleetTripModels.Detail complete(String tripNo, String operator, FleetCompletionModels.Complete request) {
var trip = required(tripNo, true);
String hash = hash(write(request));
if (trip.getCompletionJson() != null) {
var prior = read(trip.getCompletionJson(), FleetCompletionModels.Completed.class);
if (!prior.idempotencyKey().equals(request.idempotencyKey()) || !prior.requestHash().equals(hash))
throw conflict("车次已结案,请核对原结案请求");
return history.detail(tripNo);
}
requireVersion(trip, request.recordVersion());
var inspection = inspect(trip, true);
if (!inspection.canComplete()) throw conflict(inspection.blockReason());
Instant completedAt = inspection.outcomes().stream().map(FleetCompletionModels.Outcome::terminalAt).max(Instant::compareTo).orElseThrow();
trip.setStatus("COMPLETED"); trip.setCompletedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC));
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(), hash, completedAt, Instant.now(), operator, inspection.outcomes())));
// Completion freezes cargo outcomes. It deliberately does not release a driver still returning.
history.recordExecution(trip, request.recordVersion(), "COMPLETE", request.reason(), operator);
return history.detail(tripNo);
}
@Transactional
public FleetTripModels.Detail release(String tripNo, String operator, FleetCompletionModels.Release request) {
var trip = required(tripNo, true);
if (!Set.of("VEHICLE", "DRIVER").contains(request.resourceKind())) throw bad("请选择车辆或司机任务");
boolean vehicle = "VEHICLE".equals(request.resourceKind());
String previous = vehicle ? trip.getVehicleReleaseJson() : trip.getDriverReleaseJson();
String hash = hash(write(request));
if (previous != null) {
var released = read(previous, FleetCompletionModels.Released.class);
if (!released.idempotencyKey().equals(request.idempotencyKey()) || !released.requestHash().equals(hash))
throw conflict("该资源任务已结束,请核对原结束请求");
return history.detail(tripNo);
}
requireVersion(trip, request.recordVersion());
if (!"COMPLETED".equals(trip.getStatus()) || trip.getCompletionJson() == null || trip.getCompletedAt() == null)
throw conflict("请先核对全部货物并完成车次结案");
Instant now = Instant.now();
if (request.actualEndAt() == null || request.actualEndAt().isAfter(now)
|| request.actualEndAt().isBefore(trip.getCompletedAt().toInstant(ZoneOffset.UTC))) throw bad("实际任务结束时间须不早于本车货物履约完成且不晚于当前时间");
if (request.endLocation() == null || request.endLocation().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("结束司机任务不能修改车辆状态");
if (vehicle) {
var current = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在"));
requireRelease("VEHICLE", trip.getVehicleId(), trip.getId());
vehicleService.changeStatus(trip.getVehicleId(), operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), current.recordVersion()));
} else {
// A now-expired or disabled driver may end their task; this never re-enables their qualification.
requireRelease("DRIVER", trip.getDriverId(), trip.getId());
}
var released = new FleetCompletionModels.Released(request.idempotencyKey(), hash, request.actualEndAt(),
request.endLocation().trim(), request.vehicleStatus(), operator, now);
if (vehicle) trip.setVehicleReleaseJson(write(released)); else trip.setDriverReleaseJson(write(released));
history.recordExecution(trip, request.recordVersion(), vehicle ? "RELEASE_VEHICLE" : "RELEASE_DRIVER", request.reason(), operator);
return history.detail(tripNo);
}
private FleetCompletionModels.Inspection inspect(FleetTripEntity trip, boolean lock) {
if (trip.getCompletionJson() != null) {
var prior = read(trip.getCompletionJson(), FleetCompletionModels.Completed.class);
return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), false, "车次货物已结案", prior.outcomes());
}
var completions = bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.completion(binding.getDispatchNo(), lock)).toList();
var outcomes = completions.stream().flatMap(completion -> completion.outcomes().stream()).toList();
String reason = null;
if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getDepartedAt() == null) reason = "仅已发车的车次可以结案";
else if (completions.isEmpty()) reason = "缺少客户执行单据,不能结案";
else {
for (var completion : completions) {
if (!trip.getWarehouseId().toString().equals(completion.warehouseId())) { reason = "客户执行单据起运仓与车次不一致"; break; }
if (completion.blockReason() != null) { reason = completion.dispatchNo() + ":" + completion.blockReason(); break; }
}
Instant departure = trip.getDepartedAt().toInstant(ZoneOffset.UTC), now = Instant.now();
if (reason == null && outcomes.stream().anyMatch(outcome -> outcome.terminalAt() == null
|| outcome.terminalAt().isBefore(departure) || outcome.terminalAt().isAfter(now))) reason = "签收或退回时间须在发车之后且不晚于当前时间,请核对原单";
}
return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), reason == null, reason, outcomes);
}
private void requireRelease(String kind, Long resourceId, long tripId) {
if (resourceId == null || resources.release(kind, resourceId, 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 static void requireVersion(FleetTripEntity trip, long version) {
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试");
}
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 value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_trip_dispatch")
public class FleetTripDispatch {
@TableId(type = IdType.INPUT)
private Long dispatchId;
private String dispatchNo;
private String customerId;
private Long tripId;
private String tripNo;
private Integer packageCount;
private String boundBy;
private LocalDateTime boundAt;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String reviewJson;
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface FleetTripDispatchMapper extends BaseMapper<FleetTripDispatch> {
@Select("SELECT * FROM ll_fleet_trip_dispatch WHERE trip_id=#{tripId} ORDER BY dispatch_id")
List<FleetTripDispatch> listByTrip(@Param("tripId") long tripId);
@Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_no=#{dispatchNo}")
FleetTripDispatch findByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("<script>SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_no IN <foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach></script>")
List<FleetTripDispatch> findByDispatchNos(@Param("numbers") List<String> numbers);
// Locking read sees a concurrent binding committed while the caller waited for the dispatch row.
@Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_id=#{dispatchId} FOR UPDATE")
FleetTripDispatch lockByDispatch(@Param("dispatchId") long dispatchId);
@Select("SELECT t.status FROM ll_fleet_trip_dispatch b JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE b.dispatch_id=#{dispatchId}")
String tripStatus(@Param("dispatchId") long dispatchId);
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_trip")
public class FleetTripEntity {
@TableId(type = IdType.INPUT) private Long id;
private String tripNo;
private String idempotencyKey;
private String requestHash;
private String status;
private Long warehouseId;
private String warehouseCode;
private String warehouseName;
private Long vehicleId;
private String vehiclePlate;
private Long driverId;
private String driverName;
private String driverPhone;
private Integer maxPalletSpaces;
private BigDecimal maxLoadKg;
private BigDecimal maxVolumeCbm;
private LocalDateTime plannedDepartureAt;
private LocalDateTime expectedDriverEndAt;
private LocalDateTime departedAt;
private LocalDateTime completedAt;
private String stopsJson;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String plannedLoadJson;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String loadJson;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String loadingReviewJson;
private String departureKey;
private String departureHash;
private String departureResultJson;
private String completionJson;
private String vehicleReleaseJson;
private String driverReleaseJson;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String remark;
private Long recordVersion;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,318 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import com.baiyue.lastleg.fleet.driver.DriverModels;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
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.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
@Service
public class FleetTripExecutionService {
private final FleetTripMapper trips;
private final FleetTripDispatchMapper bindings;
private final FleetResourceMapper resources;
private final FleetDispatchGateway dispatches;
private final FleetTripService history;
private final DriverService drivers;
private final VehicleRepository vehicles;
private final ObjectMapper json;
private final FleetBookingReservations bookingReservations;
public FleetTripExecutionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources,
FleetDispatchGateway dispatches, FleetTripService history, DriverService drivers,
VehicleRepository vehicles, ObjectMapper json, FleetBookingReservations bookingReservations) {
this.trips = trips; this.bindings = bindings; this.resources = resources; this.dispatches = dispatches;
this.history = history; this.drivers = drivers; this.vehicles = vehicles; this.json = json; this.bookingReservations = bookingReservations;
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetExecutionModels.Planning capacityPlan(String tripNo) {
var trip = requireTrip(tripNo, false);
var cargo = cargo(manifests(trip, false));
return planning(trip, cargo);
}
@Transactional
public FleetExecutionModels.Planning confirmCapacityPlan(String tripNo, String operator, FleetExecutionModels.Confirm request) {
var trip = requireTrip(tripNo, true);
requireVersionAndPlan(trip, request.recordVersion());
var cargo = cargo(manifests(trip, true));
String reason = planningBlockReason(trip, cargo);
if (reason != null) throw conflict(reason);
String fingerprint = hash(write(cargo));
if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对计划容量");
var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList();
var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets), operator, Instant.now());
bookingReservations.segments(trip, plan, null, null);
trip.setPlannedLoadJson(write(plan));
history.recordExecution(trip, request.recordVersion(), "PLAN_CAPACITY", request.reason(), operator);
return planning(trip, cargo);
}
private FleetExecutionModels.Planning planning(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
String fingerprint = hash(write(cargo));
var plan = trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class);
boolean valid = plan != null && plan.manifestHash().equals(fingerprint);
return new FleetExecutionModels.Planning(trip.getTripNo(), trip.getRecordVersion(), cargo, fingerprint, plan, valid, planningBlockReason(trip, cargo), valid ? bookingReservations.segments(trip, plan, null, null) : List.of());
}
private String planningBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量";
if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积";
if (cargo.isEmpty()) return "请先关联本车自有货排车单";
if (cargo.stream().anyMatch(row -> row.warehousePalletId() == null || row.destinationKey() == null))
return "请先明确每票货物的实物托盘和卸货目的地";
return null;
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetExecutionModels.Loading loading(String tripNo) {
var trip = requireTrip(tripNo, false);
var manifest = manifests(trip, false);
var cargo = cargo(manifest);
return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, hash(write(cargo)), load(trip), blockReason(trip, manifest));
}
@Transactional
public FleetExecutionModels.Loading confirmLoad(String tripNo, String operator, FleetExecutionModels.Confirm request) {
var trip = requireTrip(tripNo, true);
requireVersionAndPlan(trip, request.recordVersion());
var manifest = manifests(trip, true);
String block = blockReason(trip, manifest);
if (block != null) throw conflict(block);
var cargo = cargo(manifest); String fingerprint = hash(write(cargo));
if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对装载");
var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList();
var segments = capacity(trip, cargo, pallets);
var confirmed = new FleetExecutionModels.ConfirmedLoad(fingerprint, cargo, pallets, segments, operator, Instant.now());
trip.setLoadJson(write(confirmed));
history.recordExecution(trip, request.recordVersion(), "CONFIRM_LOAD", request.reason(), operator);
return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, fingerprint, confirmed, null);
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetExecutionModels.Gate gate(String warehouseId, String dispatchNo) {
var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) return missingGate();
return pickingGate(gateForTrip(warehouseId, binding.getTripNo()), binding);
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public Map<String, FleetExecutionModels.Gate> gates(String warehouseId, List<String> numbers) {
if (numbers.isEmpty()) return Map.of();
var result = new HashMap<String, FleetExecutionModels.Gate>();
numbers.forEach(number -> result.put(number, missingGate()));
var byTrip = new HashMap<String, FleetExecutionModels.Gate>();
for (var binding : bindings.findByDispatchNos(numbers)) result.put(binding.getDispatchNo(), pickingGate(
byTrip.computeIfAbsent(binding.getTripNo(), number -> gateForTrip(warehouseId, number)), binding));
return result;
}
private FleetExecutionModels.Gate gateForTrip(String warehouseId, String tripNo) {
var trip = requireTrip(tripNo, false);
requireWarehouse(trip, warehouseId);
var manifest = manifests(trip, false);
String reason = blockReason(trip, manifest);
String warning = null;
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
if (reason == null) reason = loadingReviewBlockReason(trip);
if (reason == null) {
var driver = drivers.detail(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
if (!driver.qualification().canDispatch()) reason = "司机资格不满足本次任务,请在平台核对";
else reason = driverBlockReason(trip, driver);
if (reason == null && driver.qualification().expiringSoon()) warning = "司机驾驶证将于 " + driver.qualification().licenseExpiresOn() + " 到期;有效期覆盖本次预计任务,请留意延期风险";
}
boolean canPick = "PLANNED".equals(trip.getStatus()) && trip.getVehicleId() != null && trip.getDriverId() != null;
return new FleetExecutionModels.Gate(trip.getTripNo(), trip.getRecordVersion(), manifest.size(), reason == null, reason, warning,
canPick, canPick ? null : "当前车次不允许继续拣货");
}
private static FleetExecutionModels.Gate missingGate() {
return new FleetExecutionModels.Gate(null, null, 0, false, "请先在平台将排车单关联车次", null, false, "请先关联车次并通过调度审核");
}
private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) {
if (!gate.canPick()) return gate;
var review = FleetTripReview.read(json, binding.getReviewJson());
String reason = "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status())
? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货";
return gate.withPicking(reason == null, reason);
}
private String loadingReviewBlockReason(FleetTripEntity trip) {
var review = FleetTripReview.read(json, trip.getLoadingReviewJson());
return "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) ? "理货审核已打回:" + review.reason() : "请先通过理货审核再发车";
}
@Transactional
public FleetTripModels.Detail reviewLoading(String tripNo, String operator, FleetTripModels.Review request) {
var trip = requireTrip(tripNo, true);
requireVersionAndPlan(trip, request.recordVersion());
String reason = FleetTripReview.reason(request);
if ("APPROVED".equals(request.decision())) {
if (trip.getDriverId() == null) throw conflict("请先分配司机");
var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
String driverBlock = driverBlockReason(trip, driver);
if (driverBlock != null) throw conflict(driverBlock);
var manifest = manifests(trip, true);
String block = blockReason(trip, manifest);
if (block == null) block = loadBlockReason(trip, cargo(manifest));
if (block != null) throw conflict(block);
}
trip.setLoadingReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, Instant.now(), request.recordVersion())));
history.recordExecution(trip, request.recordVersion(), "LOADING_REVIEW", reason, operator);
return history.detail(tripNo);
}
@Transactional
public FleetExecutionModels.Departed depart(String warehouseId, String dispatchNo, String operator, FleetExecutionModels.Depart request) {
var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) throw conflict("请先在平台将排车单关联车次");
var trip = requireTrip(binding.getTripNo(), true);
requireWarehouse(trip, warehouseId);
// The binding may have changed while waiting for the trip lock. Never ship a stale parent.
var members = bindings.listByTrip(trip.getId());
if (members.stream().noneMatch(member -> member.getDispatchNo().equals(dispatchNo))) throw conflict("排车单所属车次已变化,请刷新");
String requestHash = hash(write(request));
if (trip.getDepartureKey() != null) {
if (!trip.getDepartureKey().equals(request.idempotencyKey()) || !trip.getDepartureHash().equals(requestHash))
throw conflict("车次已发车,发车请求标识或内容不一致");
return read(trip.getDepartureResultJson(), FleetExecutionModels.Departed.class).withReplay();
}
requireVersionAndPlan(trip, request.recordVersion());
// Resource lock order matches trip assignment: trip -> vehicle -> driver -> customer dispatches.
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在"));
if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())
|| !Objects.equals(vehicle.plateNumber(), trip.getVehiclePlate()) || !Objects.equals(vehicle.maxPalletSpaces(), trip.getMaxPalletSpaces())
|| !equalNumber(vehicle.maxLoadKg(), trip.getMaxLoadKg()) || !equalNumber(vehicle.maxVolumeCbm(), trip.getMaxVolumeCbm()))
throw conflict("车辆状态或容量已变化,请重新确认车次");
requireResource("VEHICLE", trip.getVehicleId(), trip.getId()); requireResource("DRIVER", trip.getDriverId(), trip.getId());
var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
String driverBlock = driverBlockReason(trip, driver);
if (driverBlock != null) throw conflict(driverBlock);
var manifest = manifests(trip, true);
String reason = blockReason(trip, manifest);
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
if (reason == null) reason = loadingReviewBlockReason(trip);
if (reason != null) throw conflict(reason);
Instant now = Instant.now();
trip.setStatus("DEPARTING");
if (trips.updateById(trip) != 1) throw conflict("车次状态已变化");
var results = new ArrayList<FleetExecutionModels.DepartedDispatch>();
for (var member : members) results.add(dispatches.depart(warehouseId, member.getDispatchNo(), operator,
trip.getTripNo() + ":" + member.getDispatchNo(), request.remark(), now));
trip.setStatus("IN_TRANSIT"); trip.setDepartedAt(LocalDateTime.ofInstant(now, ZoneOffset.UTC));
var result = new FleetExecutionModels.Departed(trip.getTripNo(), "IN_TRANSIT", now,
results.stream().mapToLong(FleetExecutionModels.DepartedDispatch::shippedPackages).sum(), List.copyOf(results), false);
trip.setDepartureKey(request.idempotencyKey()); trip.setDepartureHash(requestHash); trip.setDepartureResultJson(write(result));
history.recordExecution(trip, request.recordVersion(), "DEPART", request.remark(), operator);
return result;
}
private List<FleetExecutionModels.Manifest> manifests(FleetTripEntity trip, boolean lock) {
return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList();
}
private static List<FleetExecutionModels.Cargo> cargo(List<FleetExecutionModels.Manifest> manifests) {
return manifests.stream().flatMap(manifest -> manifest.cargo().stream())
.sorted(Comparator.comparing(FleetExecutionModels.Cargo::dispatchNo).thenComparing(FleetExecutionModels.Cargo::dispatchItemId)).toList();
}
private String blockReason(FleetTripEntity trip, List<FleetExecutionModels.Manifest> manifests) {
if (!"PLANNED".equals(trip.getStatus())) return "当前车次不是待发车计划";
if (trip.getVehicleId() == null || trip.getDriverId() == null) return "请先分配车辆和司机";
if (manifests.isEmpty()) return "车次尚未关联排车单";
if (bookingReservations.hasConfirmed(trip.getId())) return "已确认外接预订尚未关联实际发运货物,请先完成来货与排车关联";
for (var binding : bindings.listByTrip(trip.getId())) {
if (!FleetTripReview.approved(json, binding.getReviewJson())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核";
}
for (var manifest : manifests) {
if (!trip.getWarehouseId().toString().equals(manifest.warehouseId())) return "排车单起运仓与车次不一致";
if (!Objects.equals(trip.getVehiclePlate(), manifest.vehiclePlate()) || !Objects.equals(trip.getDriverName(), manifest.driverName())
|| !Objects.equals(trip.getDriverPhone(), manifest.driverPhone())) return "排车单 " + manifest.dispatchNo() + " 的车辆司机与车次不一致";
if (manifest.blockReason() != null) return manifest.dispatchNo() + ":" + manifest.blockReason();
}
return null;
}
private String loadBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
var load = load(trip);
if (load == null) return "请在平台确认本车实际装载";
if (!load.manifestHash().equals(hash(write(cargo)))) return "装载确认后货物发生变化,请重新确认";
try { capacity(trip, cargo, load.pallets()); return null; }
catch (ResponseStatusException error) { return error.getReason(); }
}
private List<TripCapacityPolicy.Segment> capacity(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo,
List<FleetExecutionModels.Measurement> pallets) {
List<FleetTripModels.Stop> stops;
try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); }
catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); }
var destinations = new HashMap<String, String>(); var palletNumbers = new HashMap<String, String>();
if (cargo.isEmpty()) throw conflict("没有待发运货物");
for (var item : cargo) {
if (item.warehousePalletId() == null || item.palletNo() == null || item.palletNo().isBlank() || item.destinationKey() == null || item.packages() <= 0)
throw conflict("货物实物托盘、数量或目的地尚未确认");
String previous = destinations.putIfAbsent(item.warehousePalletId(), item.destinationKey());
if (previous != null && !previous.equals(item.destinationKey())) throw conflict("同一实物托盘包含不同卸货目的地,请先拆托");
String oldNumber = palletNumbers.putIfAbsent(item.warehousePalletId(), item.palletNo());
if (oldNumber != null && !oldNumber.equals(item.palletNo())) throw conflict("实物托盘编号不一致,请核对");
}
var measured = new HashSet<String>(); var bookings = new ArrayList<TripCapacityPolicy.Booking>();
try {
for (var pallet : pallets) {
if (!measured.add(pallet.warehousePalletId())) throw conflict("同一实物托盘不能重复计量");
if (!destinations.containsKey(pallet.warehousePalletId())) throw conflict("装载包含不属于本车的实物托盘");
if (pallet.deliveryIndex() < 1 || pallet.deliveryIndex() >= stops.size()
|| !destinations.get(pallet.warehousePalletId()).equals(stops.get(pallet.deliveryIndex()).destinationKey()))
throw conflict("托盘卸货站与货物目的地不一致");
bookings.add(new TripCapacityPolicy.Booking(pallet.warehousePalletId(), 0, pallet.deliveryIndex(),
new TripCapacityPolicy.Load(pallet.palletSpaces(), pallet.weightKg(), pallet.volumeCbm())));
}
if (!measured.equals(destinations.keySet())) throw conflict("请确认每个实物托盘的托位、实际重量及体积");
if (trip.getMaxPalletSpaces() == null || trip.getMaxLoadKg() == null || trip.getMaxVolumeCbm() == null)
throw conflict("车辆容量未确认");
return TripCapacityPolicy.calculate(stops.size(), new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm()), bookings);
} catch (IllegalArgumentException | ArithmeticException error) { throw conflict(error.getMessage()); }
}
private void requireResource(String kind, long id, long tripId) {
var claim = resources.find(kind, id);
if (claim == null || claim.tripId() != tripId) throw conflict("车次车辆或司机占用已变化");
}
private static String driverBlockReason(FleetTripEntity trip, DriverModels.Response driver) {
if (!"OVERSEAS".equals(driver.details().region())) return "本车次须使用海外司机,请核对当前司机档案";
if (!Objects.equals(driver.details().name(), trip.getDriverName())) return "司机档案已变化,请核对车次司机快照";
return null;
}
private FleetTripEntity requireTrip(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 static void requireWarehouse(FleetTripEntity trip, String warehouseId) {
if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次");
}
private static void requireVersionAndPlan(FleetTripEntity trip, long version) {
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认");
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
}
private static boolean equalNumber(java.math.BigDecimal a, java.math.BigDecimal b) { return a != null && b != null && a.compareTo(b) == 0; }
private FleetExecutionModels.ConfirmedLoad load(FleetTripEntity trip) { return trip.getLoadJson() == null ? null : read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class); }
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 value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_trip_history")
public class FleetTripHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long tripId;
private Long recordVersion;
private String action;
private String reason;
private String operatorId;
private LocalDateTime occurredAt;
private String snapshotJson;
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FleetTripHistoryMapper extends BaseMapper<FleetTripHistory> { }
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
FleetTripEntity findByNo(@Param("tripNo") String tripNo);
@Select("SELECT * FROM ll_fleet_trip WHERE created_by=#{operator} AND idempotency_key=#{key}")
FleetTripEntity findCreated(@Param("operator") String operator, @Param("key") String key);
@Select("SELECT * FROM ll_fleet_trip WHERE created_by=#{operator} AND idempotency_key=#{key} FOR UPDATE")
FleetTripEntity lockCreated(@Param("operator") String operator, @Param("key") String key);
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class FleetTripModels {
private FleetTripModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword,
String status, @Positive Long warehouseId) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Stop(@NotBlank @Size(max = 128) String name, @Size(max = 500) String address,
@Size(max = 160) String destinationKey) { }
public record Create(@NotNull @Positive Long warehouseId, @NotNull Instant plannedDepartureAt,
@NotNull Instant expectedDriverEndAt, @NotNull @Size(min = 2, max = 200) List<@Valid Stop> stops,
@Size(max = 1000) String remark, @NotBlank @Size(min = 8, max = 128) String idempotencyKey) { }
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
@NotNull @Positive Long driverId, @NotBlank @Size(max = 1000) String reason) { }
public record Plan(@NotNull @Min(0) Long recordVersion, @NotNull Instant plannedDepartureAt,
@NotNull Instant expectedDriverEndAt, @NotNull @Size(min = 2, max = 200) List<@Valid Stop> stops,
@Size(max = 1000) String remark, @NotBlank @Size(max = 1000) String reason) { }
public record Cancel(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 1000) String reason) { }
public record BindingCommand(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 64) String dispatchNo,
@NotBlank @Size(max = 1000) String reason) { }
public record Review(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "APPROVED|REJECTED") String decision,
@Size(max = 1000) String reason) { }
public record ReviewState(String status, String reason, String reviewerId, Instant reviewedAt, Long recordVersion) {
public static ReviewState pending() { return new ReviewState("PENDING", null, null, null, null); }
}
public record DispatchBinding(String dispatchNo, String customerId, int packageCount, String boundBy, Instant boundAt, ReviewState review) { }
public record Response(String tripNo, String status, String warehouseId, String warehouseCode, String warehouseName,
String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone,
Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm,
Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
List<Stop> stops, String remark, long recordVersion, Instant updatedAt,
FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease) { }
public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List<DispatchBinding> dispatches, ReviewState loadingReview) { }
public record AuditSnapshot(Response trip, List<DispatchBinding> dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad,
FleetCompletionModels.Completed completion, ReviewState loadingReview,
FleetExecutionModels.PlannedLoad capacityPlan, List<FleetBookingModels.Response> externalBookings) { }
public record History(long recordVersion, String action, String reason, String operatorId, Instant occurredAt, AuditSnapshot snapshot) { }
}
@@ -0,0 +1,24 @@
package 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.web.server.ResponseStatusException;
final class FleetTripReview {
private FleetTripReview() { }
static FleetTripModels.ReviewState read(ObjectMapper json, String value) {
if (value == null) return FleetTripModels.ReviewState.pending();
try { return json.readValue(value, FleetTripModels.ReviewState.class); }
catch (JsonProcessingException error) { throw new IllegalStateException("审核记录读取失败", error); }
}
static String reason(FleetTripModels.Review request) {
if (!"APPROVED".equals(request.decision()) && !"REJECTED".equals(request.decision()))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择审核通过或打回");
String reason = request.reason() == null ? "" : request.reason().trim();
if ("REJECTED".equals(request.decision()) && reason.isEmpty())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请填写打回原因");
return reason.isEmpty() ? "审核通过" : reason;
}
static boolean approved(ObjectMapper json, String value) { return "APPROVED".equals(read(json, value).status()); }
}
@@ -0,0 +1,336 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Isolation;
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.*;
@Service
public class FleetTripService {
private final FleetTripMapper trips;
private final FleetResourceMapper resources;
private final FleetTripHistoryMapper histories;
private final FleetTripDispatchMapper bindings;
private final FleetDispatchGateway dispatches;
private final VehicleRepository vehicles;
private final DriverService drivers;
private final FleetWarehouseDirectory warehouses;
private final ObjectMapper json;
private final Clock clock;
private final FleetBookingReservations bookingReservations;
@Autowired
public FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories,
VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json,
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations) {
this(trips, resources, histories, vehicles, drivers, warehouses, json, bindings, dispatches, bookingReservations, Clock.systemUTC());
}
FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories,
VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json,
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations, Clock clock) {
this.trips = trips; this.resources = resources; this.histories = histories; this.vehicles = vehicles;
this.drivers = drivers; this.warehouses = warehouses; this.json = json; this.clock = clock;
this.bindings = bindings; this.dispatches = dispatches; this.bookingReservations = bookingReservations;
}
public PageResult<FleetTripModels.Response> page(FleetTripModels.Query query) {
if (query.status() != null && !query.status().isBlank() && !Set.of("PLANNED", "IN_TRANSIT", "COMPLETED", "CANCELLED").contains(query.status()))
throw bad("车次状态无效");
var filter = new LambdaQueryWrapper<FleetTripEntity>()
.eq(query.status() != null && !query.status().isBlank(), FleetTripEntity::getStatus, query.status())
.eq(query.warehouseId() != null, FleetTripEntity::getWarehouseId, query.warehouseId());
if (query.keyword() != null && !query.keyword().isBlank()) filter.and(part -> part.like(FleetTripEntity::getTripNo, query.keyword().trim())
.or().like(FleetTripEntity::getVehiclePlate, query.keyword().trim()).or().like(FleetTripEntity::getDriverName, query.keyword().trim()));
filter.orderByDesc(FleetTripEntity::getUpdatedAt, FleetTripEntity::getId);
var page = trips.selectPage(new Page<>(query.page(), query.size()), filter);
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetTripModels.Detail detail(String tripNo) {
FleetTripEntity trip = required(tripNo);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) {
String hash = hash(write(request));
var existing = trips.findCreated(operator, request.idempotencyKey());
if (existing != null) return replay(existing, hash);
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS");
var trip = new FleetTripEntity();
trip.setId(IdWorker.getId());
trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash);
trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id()));
trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name());
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark()));
trip.setRecordVersion(0L); trip.setCreatedBy(operator); trip.setUpdatedBy(operator);
trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt());
try { if (trips.insert(trip) != 1) throw conflict("车次创建失败"); }
catch (DuplicateKeyException duplicate) {
var concurrent = trips.lockCreated(operator, request.idempotencyKey());
if (concurrent == null) throw conflict("车次编号冲突,请重试");
return replay(concurrent, hash);
}
record(trip, "CREATE", null, operator);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
// All assigners serialize first on the vehicle, then on the driver. Resource uniqueness is the final safeguard.
var vehicle = vehicles.lockById(request.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
|| vehicle.maxPalletSpaces() <= 0 || vehicle.maxLoadKg().signum() <= 0 || vehicle.maxVolumeCbm().signum() <= 0)
throw bad("请先确认车辆的托位、载重和容积");
if (trip.getLoadJson() != null) {
var load = read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class);
if (load.segments().stream().anyMatch(segment -> segment.occupied().palletSpaces() > vehicle.maxPalletSpaces()
|| segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0
|| segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0))
throw bad("已确认装载超过替换车辆的托位、载重或容积,请先调整装载安排");
}
if (!Objects.equals(trip.getVehicleId(), vehicle.id()))
bookingReservations.requireReassignmentFits(trip, vehicle.maxPalletSpaces(), vehicle.maxLoadKg(), vehicle.maxVolumeCbm());
var driver = drivers.requireDispatchable(request.driverId(), instant(trip.getExpectedDriverEndAt()));
if (!"OVERSEAS".equals(driver.details().region())) throw bad("请选择海外司机");
reserve("VEHICLE", request.vehicleId(), trip);
reserve("DRIVER", request.driverId(), trip);
if (trip.getVehicleId() != null && !trip.getVehicleId().equals(request.vehicleId())) release("VEHICLE", trip.getVehicleId(), trip.getId());
if (trip.getDriverId() != null && !trip.getDriverId().equals(request.driverId())) release("DRIVER", trip.getDriverId(), trip.getId());
trip.setVehicleId(vehicle.id()); trip.setVehiclePlate(vehicle.plateNumber());
trip.setMaxPalletSpaces(vehicle.maxPalletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm());
trip.setDriverId(Long.valueOf(driver.id())); trip.setDriverName(driver.details().name()); trip.setDriverPhone(driver.details().phone());
for (var binding : bindings.listByTrip(trip.getId())) {
var dispatch = dispatches.lockForAssignment(binding.getDispatchNo());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw conflict("排车单与车次起运仓不一致");
dispatches.assign(dispatch, assignment(trip));
}
persist(trip, request.recordVersion(), "ASSIGN", request.reason(), operator);
return new FleetTripModels.Detail(response(trip), driver.qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson()));
}
@Transactional
public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
bookingReservations.requireRoute(trip, request.stops());
if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt());
boolean unchangedRoute = response(trip).stops().equals(request.stops());
boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt()))
&& trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt()));
for (var binding : bindings.listByTrip(trip.getId())) {
if (unchangedRoute) dispatches.lockForAssignment(binding.getDispatchNo());
else dispatches.lockForPlanning(binding.getDispatchNo());
}
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark()));
persist(trip, request.recordVersion(), unchangedPlan ? "REMARK" : "PLAN", request.reason(), operator);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail reviewDispatch(String tripNo, String dispatchNo, String operator, FleetTripModels.Review request) {
var trip = planned(tripNo, request.recordVersion());
String reason = FleetTripReview.reason(request);
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
if ("APPROVED".equals(request.decision())) {
var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> bad("车辆档案不存在"));
if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())) throw conflict("车辆当前不可派车");
var driver = drivers.requireDispatchable(trip.getDriverId(), instant(trip.getExpectedDriverEndAt()));
if (!"OVERSEAS".equals(driver.details().region())) throw conflict("请选择海外司机");
}
var dispatch = dispatches.lockForAssignment(dispatchNo);
var binding = bindings.lockByDispatch(dispatch.dispatchId());
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
binding.setReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, clock.instant(), request.recordVersion())));
if (bindings.updateById(binding) != 1) throw conflict("排车单审核保存失败");
trip.setLoadingReviewJson(null);
persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
if (!bindings.listByTrip(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次");
if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次");
if (trip.getVehicleId() != null) release("VEHICLE", trip.getVehicleId(), trip.getId());
if (trip.getDriverId() != null) release("DRIVER", trip.getDriverId(), trip.getId());
trip.setStatus("CANCELLED");
persist(trip, request.recordVersion(), "CANCEL", request.reason(), operator);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓");
var bound = bindings.lockByDispatch(dispatch.dispatchId());
if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo());
var binding = new FleetTripDispatch();
binding.setDispatchId(dispatch.dispatchId()); binding.setDispatchNo(dispatch.dispatchNo()); binding.setCustomerId(dispatch.customerId());
binding.setTripId(trip.getId()); binding.setTripNo(tripNo); binding.setPackageCount(dispatch.packageCount());
binding.setBoundBy(operator); binding.setBoundAt(now());
try { if (bindings.insert(binding) != 1) throw conflict("排车单关联失败"); }
catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); }
bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator);
dispatches.assign(dispatch, assignment(trip));
persist(trip, request.recordVersion(), "BIND_DISPATCH", request.reason(), operator);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
var binding = bindings.lockByDispatch(dispatch.dispatchId());
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null));
if (bindings.deleteById(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试");
bookingReservations.unbindIncoming(trip, dispatch.dispatchNo(), operator);
persist(trip, request.recordVersion(), "UNBIND_DISPATCH", request.reason(), operator);
return detail(trip);
}
public PageResult<FleetTripModels.History> history(String tripNo, int page, int size) {
var trip = required(tripNo);
var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper<FleetTripHistory>()
.eq(FleetTripHistory::getTripId, trip.getId()).orderByDesc(FleetTripHistory::getRecordVersion));
return new PageResult<>(result.getRecords().stream().map(event -> new FleetTripModels.History(event.getRecordVersion(), event.getAction(),
event.getReason(), event.getOperatorId(), instant(event.getOccurredAt()), read(event.getSnapshotJson(), FleetTripModels.AuditSnapshot.class))).toList(),
result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
private void reserve(String kind, long resourceId, FleetTripEntity trip) {
var current = resources.find(kind, resourceId);
if (current != null) {
if (current.tripId() == trip.getId()) return;
throw conflict(("VEHICLE".equals(kind) ? "车辆" : "司机") + "已被车次 " + current.tripNo() + " 占用");
}
try { if (resources.reserve(kind, resourceId, trip.getId(), trip.getTripNo()) != 1) throw conflict("车次资源预留失败"); }
catch (DuplicateKeyException duplicate) { throw conflict("车辆或司机已被其他车次占用,请刷新后重试"); }
}
private void release(String kind, long resourceId, long tripId) {
if (resources.release(kind, resourceId, tripId) != 1) throw conflict("车次资源占用不一致,操作未完成");
}
private FleetTripEntity required(String number) {
var trip = trips.findByNo(number);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
return trip;
}
private FleetTripEntity planned(String number, long version) {
var trip = trips.lockByNo(number);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试");
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
return trip;
}
private FleetTripModels.Detail replay(FleetTripEntity trip, String hash) {
if (!Objects.equals(trip.getRequestHash(), hash)) throw conflict("创建请求标识已用于不同车次内容");
return detail(trip);
}
private FleetTripModels.Detail detail(FleetTripEntity trip) {
return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null
: drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson()));
}
private List<FleetTripModels.DispatchBinding> bindingResponses(FleetTripEntity trip) {
return bindings.listByTrip(trip.getId()).stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(),
binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()))).toList();
}
private static FleetDispatchGateway.Assignment assignment(FleetTripEntity trip) {
return new FleetDispatchGateway.Assignment(trip.getVehiclePlate(), trip.getDriverName(), trip.getDriverPhone());
}
private FleetTripModels.Response response(FleetTripEntity trip) {
List<FleetTripModels.Stop> stops;
try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); }
catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); }
return new FleetTripModels.Response(trip.getTripNo(), trip.getStatus(), trip.getWarehouseId().toString(), trip.getWarehouseCode(), trip.getWarehouseName(),
text(trip.getVehicleId()), trip.getVehiclePlate(), text(trip.getDriverId()), trip.getDriverName(), trip.getDriverPhone(),
trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm(), instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()),
instant(trip.getDepartedAt()), instant(trip.getCompletedAt()), List.copyOf(stops), trip.getRemark(), trip.getRecordVersion(), instant(trip.getUpdatedAt()),
releaseInfo(trip.getVehicleReleaseJson()), releaseInfo(trip.getDriverReleaseJson()));
}
private void validatePlan(Instant departure, Instant end, List<FleetTripModels.Stop> stops) {
if (departure == null || end == null || !end.isAfter(departure) || !end.isAfter(clock.instant())) throw bad("司机预计任务结束时间须晚于计划发车及当前时间");
try {
if (utc(departure).getYear() < 1900 || utc(end).getYear() > 9998) throw bad("车次时间超出允许范围");
} catch (DateTimeException invalid) { throw bad("车次时间超出允许范围"); }
if (stops == null || stops.size() < 2 || stops.size() > 200 || stops.stream().anyMatch(stop -> stop == null || trim(stop.name()) == null))
throw bad("车次须包含起点和目的地,最多200个站点");
}
private void persist(FleetTripEntity trip, long version, String action, String reason, String operator) {
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setPlannedLoadJson(null);
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setLoadJson(null);
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL", "CONFIRM_LOAD").contains(action)) trip.setLoadingReviewJson(null);
if (Set.of("ASSIGN", "PLAN").contains(action)) bindings.update(null, new LambdaUpdateWrapper<FleetTripDispatch>()
.eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null));
trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator);
if (trips.update(trip, new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1)
throw conflict("车次已被修改,请刷新后重试");
record(trip, action, reason, operator);
}
private void record(FleetTripEntity trip, String action, String reason, String operator) {
var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion());
event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt());
event.setSnapshotJson(write(new FleetTripModels.AuditSnapshot(response(trip), bindingResponses(trip), trip.getLoadJson() == null ? null
: read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class), trip.getCompletionJson() == null ? null
: read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), FleetTripReview.read(json, trip.getLoadingReviewJson()),
trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId()))));
if (histories.insert(event) != 1) throw conflict("车次历史保存失败");
}
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("车次序列化失败", e); } }
private FleetCompletionModels.ReleaseInfo releaseInfo(String value) {
if (value == null) return null;
var released = read(value, FleetCompletionModels.Released.class);
return new FleetCompletionModels.ReleaseInfo(released.actualEndAt(), released.endLocation(), released.vehicleStatus());
}
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) {
persist(trip, version, action, reason, operator);
}
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 value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }
private LocalDateTime now() { return utc(clock.instant()); }
private static LocalDateTime utc(Instant value) { return LocalDateTime.ofInstant(value, ZoneOffset.UTC); }
private static Instant instant(LocalDateTime value) { return value == null ? null : value.toInstant(ZoneOffset.UTC); }
private static String text(Long value) { return value == null ? null : value.toString(); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static void requireReason(String reason) { if (trim(reason) == null) throw bad("请填写操作原因"); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard;
import org.springframework.stereotype.Component;
@Component
public class FleetVehicleAssignmentGuard implements VehicleAssignmentGuard {
private final FleetResourceMapper resources;
public FleetVehicleAssignmentGuard(FleetResourceMapper resources) { this.resources = resources; }
@Override public void requireUnassigned(long vehicleId) {
var assignment = resources.find("VEHICLE", vehicleId);
if (assignment != null) throw new VehicleApplicationService.VehicleFailure(VehicleApplicationService.FailureKind.CONFLICT,
"车辆正在车次 " + assignment.tripNo() + " 中使用,请先在车次中改派或取消安排");
}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
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 org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController @RequestMapping("/api/platform/v1/last-leg/fleet/trips")
public class PlatformFleetBookingController {
private final FleetBookingService service;
private final FleetCustomerDirectory customers;
public PlatformFleetBookingController(FleetBookingService service,FleetCustomerDirectory customers){this.service=service;this.customers=customers;}
@GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<List<FleetCustomerDirectory.Customer>> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(customers.options(keyword));}
@GetMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetBookingModels.ListResult> list(@PathVariable String tripNo){return ApiResponse.success(service.list(tripNo));}
@PostMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-create')")
public ApiResponse<FleetBookingModels.Result> create(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetBookingModels.Create request){return ApiResponse.success(service.create(tripNo,operator(user),request));}
@PutMapping("/{tripNo}/bookings/{bookingNo}") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')")
public ApiResponse<FleetBookingModels.Result> update(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Update request){return ApiResponse.success(service.update(tripNo,bookingNo,operator(user),request));}
@PostMapping("/{tripNo}/bookings/{bookingNo}/confirm") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-confirm')")
public ApiResponse<FleetBookingModels.Result> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.confirm(tripNo,bookingNo,operator(user),request));}
@PostMapping("/{tripNo}/bookings/{bookingNo}/cancel") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-cancel')")
public ApiResponse<FleetBookingModels.Result> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.cancel(tripNo,bookingNo,operator(user),request));}
@GetMapping("/{tripNo}/bookings/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetBookingModels.IncomingPreview> incoming(@PathVariable String tripNo,@PathVariable String bookingNo){return ApiResponse.success(service.incoming(tripNo,bookingNo));}
@PostMapping("/{tripNo}/bookings/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')")
public ApiResponse<FleetBookingModels.Result> incoming(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.startIncoming(tripNo,bookingNo,operator(user),request));}
@PostMapping("/{tripNo}/bookings/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-confirm')")
public ApiResponse<FleetBookingModels.Result> receipt(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(tripNo,bookingNo,operator(user),request));}
@PostMapping("/{tripNo}/bookings/{bookingNo}/dispatch") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetBookingModels.Result> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.BindDispatch request){return ApiResponse.success(service.bindDispatch(tripNo,bookingNo,operator(user),request));}
private static String operator(AuthenticatedUser user){if(user==null||user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");return user.userId().toString();}
}
@@ -0,0 +1,120 @@
package com.baiyue.lastleg.fleet.trip;
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 jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
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")
public class PlatformFleetTripController {
private final FleetTripService service;
private final FleetTripExecutionService execution;
private final FleetTripCompletionService completion;
public PlatformFleetTripController(FleetTripService service, FleetTripExecutionService execution, FleetTripCompletionService completion) {
this.service = service; this.execution = execution; this.completion = completion;
}
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetTripModels.Response>> page(@Valid FleetTripModels.Query query) { return ApiResponse.success(service.page(query)); }
@GetMapping("/{tripNo}")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetTripModels.Detail> detail(@PathVariable String tripNo) { return ApiResponse.success(service.detail(tripNo)); }
@PostMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:create')")
public ApiResponse<FleetTripModels.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetTripModels.Create request) {
return ApiResponse.success(service.create(operator(user), request));
}
@PutMapping("/{tripNo}/plan")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Plan request) {
return ApiResponse.success(service.updatePlan(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/assignment")
@PreAuthorize("hasAuthority('platform:fleet:trip:assign')")
public ApiResponse<FleetTripModels.Detail> assign(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Assign request) {
return ApiResponse.success(service.assign(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/cancel")
@PreAuthorize("hasAuthority('platform:fleet:trip:cancel')")
public ApiResponse<FleetTripModels.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) {
return ApiResponse.success(service.cancel(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/dispatches/bind")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetTripModels.BindingCommand request) {
return ApiResponse.success(service.bindDispatch(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/dispatches/unbind")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> unbind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetTripModels.BindingCommand request) {
return ApiResponse.success(service.unbindDispatch(tripNo, operator(user), request));
}
@GetMapping("/{tripNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetTripModels.History>> history(@PathVariable String tripNo,
@RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) {
return ApiResponse.success(service.history(tripNo, page, size));
}
@PostMapping("/{tripNo}/dispatches/{dispatchNo}/review")
@PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')")
public ApiResponse<FleetTripModels.Detail> reviewDispatch(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@PathVariable String dispatchNo, @Valid @RequestBody FleetTripModels.Review request) {
return ApiResponse.success(service.reviewDispatch(tripNo, dispatchNo, operator(user), request));
}
@PostMapping("/{tripNo}/loading-review")
@PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')")
public ApiResponse<FleetTripModels.Detail> reviewLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetTripModels.Review request) {
return ApiResponse.success(execution.reviewLoading(tripNo, operator(user), request));
}
@GetMapping("/{tripNo}/loading")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetExecutionModels.Loading> loading(@PathVariable String tripNo) { return ApiResponse.success(execution.loading(tripNo)); }
@GetMapping("/{tripNo}/capacity-plan")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetExecutionModels.Planning> capacityPlan(@PathVariable String tripNo) { return ApiResponse.success(execution.capacityPlan(tripNo)); }
@PostMapping("/{tripNo}/capacity-plan")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetExecutionModels.Planning> confirmCapacityPlan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetExecutionModels.Confirm request) {
return ApiResponse.success(execution.confirmCapacityPlan(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/loading")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetExecutionModels.Loading> confirmLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetExecutionModels.Confirm request) {
return ApiResponse.success(execution.confirmLoad(tripNo, operator(user), request));
}
private static String operator(AuthenticatedUser user) {
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
return user.userId().toString();
}
@GetMapping("/{tripNo}/completion")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetCompletionModels.Inspection> completion(@PathVariable String tripNo) { return ApiResponse.success(completion.inspection(tripNo)); }
@PostMapping("/{tripNo}/complete")
@PreAuthorize("hasAuthority('platform:fleet:trip:complete')")
public ApiResponse<FleetTripModels.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetCompletionModels.Complete request) {
return ApiResponse.success(completion.complete(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/resources/release")
@PreAuthorize("hasAuthority('platform:fleet:trip:release')")
public ApiResponse<FleetTripModels.Detail> release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetCompletionModels.Release request) {
return ApiResponse.success(completion.release(tripNo, operator(user), request));
}
}