feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
+18
@@ -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) { }
|
||||
}
|
||||
+39
@@ -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, "客户目录暂不可用,请稍后重试"); }
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
+22
@@ -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, "司机档案已被修改,请刷新后重试"); }
|
||||
}
|
||||
+14
@@ -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) { }
|
||||
}
|
||||
+41
@@ -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, "仓库目录暂不可用,请重试");
|
||||
}
|
||||
}
|
||||
+89
@@ -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) { }
|
||||
}
|
||||
+119
@@ -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();
|
||||
}
|
||||
}
|
||||
+71
@@ -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) { }
|
||||
}
|
||||
+69
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+7
@@ -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> { }
|
||||
+14
@@ -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);
|
||||
}
|
||||
+42
@@ -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) { }
|
||||
}
|
||||
+181
@@ -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); }
|
||||
}
|
||||
+17
@@ -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) { }
|
||||
}
|
||||
+87
@@ -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);}
|
||||
}
|
||||
+24
@@ -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) { }
|
||||
}
|
||||
+50
@@ -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) { }
|
||||
}
|
||||
+144
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+318
@@ -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;
|
||||
}
|
||||
+7
@@ -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); }
|
||||
}
|
||||
+16
@@ -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() + " 中使用,请先在车次中改派或取消安排");
|
||||
}
|
||||
}
|
||||
+40
@@ -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();}
|
||||
}
|
||||
+120
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user