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

This commit is contained in:
2026-10-03 04:18:52 +08:00
parent c50e9f97d1
commit 5e683da3b9
162 changed files with 9642 additions and 218 deletions
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
@Component
public class HttpVehicleCareFileGateway implements VehicleCareFileGateway {
private static final String TYPE = "FLEET_VEHICLE_RECORD_PHOTO";
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_VEHICLE_RECORD_PHOTO/{record}";
private static final long MAX_SIZE = 10L * 1024 * 1024;
private final RestClient client;
private final String token;
public HttpVehicleCareFileGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); this.token = token;
}
@Override public VehicleCareModels.File upload(long recordId, String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("运维照片须为非空 JPG 或 PNG,最大 10MiB");
var body = new MultipartBodyBuilder();
try { body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(MediaType.parseMediaType(file.getContentType())); }
catch (IOException error) { throw bad("无法读取运维照片"); }
var result = client.post().uri(PATH, recordId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class);
return verified(result, recordId, null);
}
@Override public VehicleCareModels.File metadata(long recordId, long fileId) {
return verified(client.get().uri(PATH + "/{file}", recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), recordId, fileId);
}
@Override public byte[] download(long recordId, long fileId) {
var response = client.get().uri(PATH + "/{file}/content", recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class);
return imageContent(response);
}
static byte[] imageContent(ResponseEntity<byte[]> response) {
byte[] content = response.getBody();
if (content == null || content.length == 0 || content.length > MAX_SIZE
|| !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
return content;
}
static VehicleCareModels.File verified(Envelope result, long recordId, Long fileId) {
if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
var data = result.data();
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|| !TYPE.equals(data.businessType()) || !Long.toString(recordId).equals(data.businessId())
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|| !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
throw bad("文件不属于当前运维记录或不是有效照片");
return new VehicleCareModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
}
private static boolean image(String type) { return Set.of("image/jpeg", "image/png").contains(Objects.toString(type, "")); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "运维照片不可用或归属校验失败"); }
record Envelope(String code, Metadata data) { }
record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records")
public class PlatformVehicleCareController {
private final VehicleCareService service;
public PlatformVehicleCareController(VehicleCareService service) { this.service = service; }
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ApiResponse<PageResult<VehicleCareModels.Response>> page(@PathVariable @Min(1) long vehicleId, @Valid VehicleCareModels.Query query) {
return ApiResponse.success(service.page(vehicleId, query));
}
@GetMapping("/{recordNo}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ApiResponse<VehicleCareModels.Response> detail(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo) {
return ApiResponse.success(service.detail(vehicleId, recordNo));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-create')")
public ApiResponse<VehicleCareModels.Response> create(@PathVariable @Min(1) long vehicleId, @AuthenticationPrincipal AuthenticatedUser user,
@RequestBody @Valid VehicleCareModels.Create request) {
return ApiResponse.success(service.create(vehicleId, operator(user), request));
}
@PutMapping("/{recordNo}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-edit')")
public ApiResponse<VehicleCareModels.Response> update(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
@AuthenticationPrincipal AuthenticatedUser user, @RequestBody @Valid VehicleCareModels.Update request) {
return ApiResponse.success(service.update(vehicleId, recordNo, operator(user), request));
}
@PostMapping(value = "/{recordNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-upload')")
public ApiResponse<VehicleCareModels.Response> upload(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long recordVersion,
@RequestParam @NotBlank @Size(max = 128) String idempotencyKey, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.upload(vehicleId, recordNo, recordVersion, idempotencyKey, operator(user), file));
}
@GetMapping("/{recordNo}/files/{fileId}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ResponseEntity<byte[]> download(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo, @PathVariable @Min(1) long fileId) {
var file = service.download(vehicleId, recordNo, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(file.metadata().fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content());
}
@GetMapping("/{recordNo}/history")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ApiResponse<PageResult<VehicleCareModels.History>> history(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
@RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return ApiResponse.success(service.history(vehicleId, recordNo, page, size));
}
private static String operator(AuthenticatedUser user) {
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
return user.userId().toString();
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_vehicle_record")
public class VehicleCareEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String recordNo;
private Long vehicleId;
private String vehiclePlate;
private String recordType;
private LocalDateTime occurredAt;
private String detailsJson;
private String filesJson;
private String idempotencyKey;
private String requestHash;
private Long recordVersion;
private String createdBy;
private String updatedBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.fleet.maintenance;
import org.springframework.web.multipart.MultipartFile;
public interface VehicleCareFileGateway {
VehicleCareModels.File upload(long recordId, String operatorId, MultipartFile file);
VehicleCareModels.File metadata(long recordId, long fileId);
byte[] download(long recordId, long fileId);
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_vehicle_record_history")
public class VehicleCareHistory {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long recordId;
private Long recordVersion;
private String action;
private String reason;
private String operatorId;
private LocalDateTime occurredAt;
private String snapshotJson;
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface VehicleCareHistoryMapper extends BaseMapper<VehicleCareHistory> { }
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
@Mapper
public interface VehicleCareMapper extends BaseMapper<VehicleCareEntity> {
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo}")
VehicleCareEntity find(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo);
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo} FOR UPDATE")
VehicleCareEntity lock(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo);
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND created_by=#{operator} AND idempotency_key=#{key} FOR UPDATE")
VehicleCareEntity lockCreated(@Param("vehicleId") long vehicleId, @Param("operator") String operator, @Param("key") String key);
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.fleet.maintenance;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class VehicleCareModels {
private VehicleCareModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size,
@Pattern(regexp = "MAINTENANCE|FUEL|REPAIR|BREAKDOWN|RESCUE|EXCEPTION") String recordType) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Draft(@NotBlank @Pattern(regexp = "MAINTENANCE|FUEL|REPAIR|BREAKDOWN|RESCUE|EXCEPTION") String recordType,
@NotNull Instant occurredAt,
@Pattern(regexp = "KM|MI") String distanceUnit,
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal odometer,
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal lastServiceOdometer,
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal nextServiceOdometer,
@DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 3) BigDecimal fuelQuantity,
@Pattern(regexp = "L|US_GAL|IMP_GAL") String fuelUnit,
@Pattern(regexp = "PENDING|VERIFIED") String verificationStatus,
@Size(max = 1000) String remark, @Valid Incident incident) { }
public record Incident(@NotBlank @Size(max = 2000) String description,
@Size(max = 256) String item, @Size(max = 512) String place,
@Size(max = 128) String handler,
@NotBlank @Pattern(regexp = "PENDING|IN_PROGRESS|COMPLETED") String processingStatus,
@Pattern(regexp = "ROUTE|TIMEOUT|DELIVERY|VEHICLE_BREAKDOWN") String exceptionType,
@DecimalMin("0") @Digits(integer = 12, fraction = 2) BigDecimal reportedCost,
@Pattern(regexp = "[A-Z]{3}") String currency) { }
public record Create(@NotBlank @Size(max = 128) String idempotencyKey, @NotNull @Valid Draft details) { }
public record Update(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 1000) String reason,
@NotNull @Valid Draft details) { }
public record File(String fileId, String fileName, String contentType, long sizeBytes) { }
public record Attached(String uploadKey, String sha256, File file) { }
public record Response(String recordNo, String vehicleId, String vehiclePlate, Draft details,
List<File> files, long recordVersion, String updatedBy, Instant updatedAt, String paymentStatus) { }
public record History(long recordVersion, String action, String reason, String operatorId,
Instant occurredAt, Response snapshot) { }
public record Download(File metadata, byte[] content) { }
}
@@ -0,0 +1,181 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.*;
import java.util.*;
@Service
public class VehicleCareService {
private final VehicleCareMapper records;
private final VehicleCareHistoryMapper histories;
private final VehicleRepository vehicles;
private final VehicleCareFileGateway files;
private final ObjectMapper json;
public VehicleCareService(VehicleCareMapper records, VehicleCareHistoryMapper histories,
VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json) {
this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json;
}
public PageResult<VehicleCareModels.Response> page(long vehicleId, VehicleCareModels.Query query) {
vehicles.findById(vehicleId).orElseThrow(VehicleCareService::missing);
var page = records.selectPage(new Page<>(query.page(), query.size()), new LambdaQueryWrapper<VehicleCareEntity>()
.eq(VehicleCareEntity::getVehicleId, vehicleId)
.eq(query.recordType() != null, VehicleCareEntity::getRecordType, query.recordType())
.orderByDesc(VehicleCareEntity::getOccurredAt, VehicleCareEntity::getId));
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public VehicleCareModels.Response detail(long vehicleId, String number) { return response(required(vehicleId, number, false)); }
@Transactional
public VehicleCareModels.Response create(long vehicleId, String operator, VehicleCareModels.Create request) {
// Serialize creation per vehicle; no empty-range locks or duplicate creation after a lost response.
var vehicle = vehicles.lockById(vehicleId).orElseThrow(VehicleCareService::missing);
String fingerprint = digest(write(request.details()).getBytes(StandardCharsets.UTF_8));
var prior = records.lockCreated(vehicleId, operator, request.idempotencyKey());
if (prior != null) {
if (!prior.getRequestHash().equals(fingerprint)) throw conflict("请求标识已用于其他运维记录内容");
return response(prior);
}
validate(request.details());
var row = new VehicleCareEntity(); row.setId(IdWorker.getId());
row.setRecordNo("VR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
row.setVehicleId(vehicleId); row.setVehiclePlate(vehicle.plateNumber()); row.setRecordType(request.details().recordType());
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); row.setFilesJson("[]");
row.setIdempotencyKey(request.idempotencyKey()); row.setRequestHash(fingerprint); row.setRecordVersion(0L);
row.setCreatedBy(operator); row.setUpdatedBy(operator); row.setCreatedAt(utc(Instant.now())); row.setUpdatedAt(row.getCreatedAt());
if (records.insert(row) != 1) throw conflict("运维记录保存失败");
record(row, "CREATE", null, operator);
return response(row);
}
@Transactional
public VehicleCareModels.Response update(long vehicleId, String number, String operator, VehicleCareModels.Update request) {
var row = required(vehicleId, number, true); version(row, request.recordVersion()); validate(request.details());
if (!row.getRecordType().equals(request.details().recordType())) throw conflict("已保存记录不能改变运维类型");
if (request.reason() == null || request.reason().isBlank()) throw bad("请填写更正原因");
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details()));
persist(row, request.recordVersion(), "UPDATE", request.reason(), operator);
return response(row);
}
@Transactional
public VehicleCareModels.Response upload(long vehicleId, String number, long expectedVersion, String uploadKey, String operator, MultipartFile file) {
var row = required(vehicleId, number, true);
if (uploadKey == null || uploadKey.isBlank() || uploadKey.length() > 128) throw bad("附件请求标识无效");
if (file == null || file.isEmpty() || file.getSize() > 10L * 1024 * 1024
|| !Set.of("image/jpeg", "image/png").contains(Objects.toString(file.getContentType(), ""))) throw bad("请选择非空 JPG 或 PNG 图片,最大 10MiB");
String fingerprint;
try { fingerprint = digest(file.getBytes()); } catch (IOException error) { throw bad("无法读取附件"); }
var attached = new ArrayList<>(attached(row));
var replay = attached.stream().filter(item -> item.uploadKey().equals(uploadKey)).findFirst();
if (replay.isPresent()) {
if (!replay.get().sha256().equals(fingerprint)) throw conflict("附件请求标识已用于不同图片");
return response(row);
}
version(row, expectedVersion);
if (attached.size() >= 20) throw bad("每条记录最多保存 20 张图片");
var uploaded = files.upload(row.getId(), operator, file);
attached.add(new VehicleCareModels.Attached(uploadKey, fingerprint, uploaded)); row.setFilesJson(write(attached));
persist(row, expectedVersion, "UPLOAD", uploaded.fileName(), operator);
return response(row);
}
public VehicleCareModels.Download download(long vehicleId, String number, long fileId) {
var row = required(vehicleId, number, false);
if (attached(row).stream().noneMatch(item -> item.file().fileId().equals(Long.toString(fileId)))) throw missing();
var metadata = files.metadata(row.getId(), fileId);
return new VehicleCareModels.Download(metadata, files.download(row.getId(), fileId));
}
public PageResult<VehicleCareModels.History> history(long vehicleId, String number, int page, int size) {
var row = required(vehicleId, number, false);
var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper<VehicleCareHistory>()
.eq(VehicleCareHistory::getRecordId, row.getId()).orderByDesc(VehicleCareHistory::getRecordVersion));
return new PageResult<>(result.getRecords().stream().map(entry -> new VehicleCareModels.History(entry.getRecordVersion(),
entry.getAction(), entry.getReason(), entry.getOperatorId(), entry.getOccurredAt().toInstant(ZoneOffset.UTC),
read(entry.getSnapshotJson(), VehicleCareModels.Response.class))).toList(), result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
private void persist(VehicleCareEntity row, long expected, String action, String reason, String operator) {
row.setRecordVersion(expected + 1); row.setUpdatedBy(operator); row.setUpdatedAt(utc(Instant.now()));
if (records.update(row, new LambdaUpdateWrapper<VehicleCareEntity>().eq(VehicleCareEntity::getId, row.getId())
.eq(VehicleCareEntity::getRecordVersion, expected)) != 1) throw conflict("记录已变化,请刷新后重试");
record(row, action, reason, operator);
}
private void record(VehicleCareEntity row, String action, String reason, String operator) {
var history = new VehicleCareHistory(); history.setId(IdWorker.getId()); history.setRecordId(row.getId());
history.setRecordVersion(row.getRecordVersion()); history.setAction(action); history.setReason(reason);
history.setOperatorId(operator); history.setOccurredAt(row.getUpdatedAt()); history.setSnapshotJson(write(response(row)));
if (histories.insert(history) != 1) throw conflict("记录历史保存失败");
}
private VehicleCareModels.Response response(VehicleCareEntity row) {
return new VehicleCareModels.Response(row.getRecordNo(), row.getVehicleId().toString(), row.getVehiclePlate(),
read(row.getDetailsJson(), VehicleCareModels.Draft.class), attached(row).stream().map(VehicleCareModels.Attached::file).toList(),
row.getRecordVersion(), row.getUpdatedBy(), row.getUpdatedAt().toInstant(ZoneOffset.UTC),
Set.of("REPAIR", "RESCUE").contains(row.getRecordType()) ? "UNLINKED" : null);
}
private VehicleCareEntity required(long vehicleId, String number, boolean lock) {
var row = lock ? records.lock(vehicleId, number) : records.find(vehicleId, number);
if (row == null) throw missing(); return row;
}
private static void version(VehicleCareEntity row, long expected) { if (row.getRecordVersion() != expected) throw conflict("记录已变化,请刷新后重试"); }
static void validate(VehicleCareModels.Draft value) {
if (value.occurredAt() == null || value.occurredAt().isAfter(Instant.now())) throw bad("记录时间不能晚于当前时间");
if (value.occurredAt().atOffset(ZoneOffset.UTC).getYear() < 1900) throw bad("记录时间须为 1900 年及以后");
if (Set.of("REPAIR", "BREAKDOWN", "RESCUE", "EXCEPTION").contains(Objects.toString(value.recordType(), ""))) {
validateIncident(value); return;
}
if (value.incident() != null) throw bad("保养加油不能混入维修、故障、救援或异常字段");
if (value.odometer() == null || value.odometer().signum() < 0 || !Set.of("KM", "MI").contains(Objects.toString(value.distanceUnit(), ""))) throw bad("请填写有效里程及单位");
if (!Set.of("PENDING", "VERIFIED").contains(Objects.toString(value.verificationStatus(), ""))) throw bad("核验状态无效");
if ("MAINTENANCE".equals(value.recordType())) {
if (value.lastServiceOdometer() == null || value.nextServiceOdometer() == null || value.lastServiceOdometer().signum() < 0
|| value.lastServiceOdometer().compareTo(value.odometer()) > 0 || value.nextServiceOdometer().compareTo(value.lastServiceOdometer()) <= 0)
throw bad("上次保养里程须不大于记录时里程,下次保养里程须大于上次保养里程");
if (value.fuelQuantity() != null || value.fuelUnit() != null) throw bad("保养记录不能包含加油字段");
} else if ("FUEL".equals(value.recordType())) {
if (value.fuelQuantity() == null || value.fuelQuantity().signum() <= 0 || value.fuelUnit() == null
|| !Set.of("L", "US_GAL", "IMP_GAL").contains(value.fuelUnit())) throw bad("请填写正数加油量并明确升、美制加仑或英制加仑");
if (value.lastServiceOdometer() != null || value.nextServiceOdometer() != null) throw bad("加油记录不能包含保养里程字段");
} else throw bad("记录类型无效");
}
private static void validateIncident(VehicleCareModels.Draft value) {
if (value.distanceUnit() != null || value.odometer() != null || value.lastServiceOdometer() != null || value.nextServiceOdometer() != null
|| value.fuelQuantity() != null || value.fuelUnit() != null || value.verificationStatus() != null) throw bad("处理记录不能混入保养、加油或核验字段");
var incident = value.incident();
if (incident == null || blank(incident.description())) throw bad("请填写事件描述");
if (!Set.of("PENDING", "IN_PROGRESS", "COMPLETED").contains(Objects.toString(incident.processingStatus(), ""))) throw bad("处理状态无效");
if (!"PENDING".equals(incident.processingStatus()) && blank(incident.handler())) throw bad("处理中或已完成记录须填写处理人");
boolean costRecord = Set.of("REPAIR", "RESCUE").contains(value.recordType());
if (costRecord && (blank(incident.item()) || blank(incident.place()))) throw bad("请填写维修或救援项目及地点");
if (!costRecord && (incident.item() != null || incident.place() != null || incident.reportedCost() != null || incident.currency() != null))
throw bad("故障和异常记录不接受维修救援费用字段");
if ("EXCEPTION".equals(value.recordType())) {
if (!Set.of("ROUTE", "TIMEOUT", "DELIVERY", "VEHICLE_BREAKDOWN").contains(Objects.toString(incident.exceptionType(), ""))) throw bad("请选择异常类型");
} else if (incident.exceptionType() != null) throw bad("非异常记录不能设置异常类型");
if (incident.reportedCost() != null) {
if (incident.reportedCost().signum() < 0 || blank(incident.currency())) throw bad("费用须为非负数并填写币种");
try { Currency.getInstance(incident.currency()); } catch (IllegalArgumentException error) { throw bad("费用币种无效"); }
} else if (incident.currency() != null) throw bad("未登记费用时不能填写币种");
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private List<VehicleCareModels.Attached> attached(VehicleCareEntity row) {
try { return json.readValue(row.getFilesJson(), new TypeReference<>() { }); } catch (JsonProcessingException error) { throw new IllegalStateException(error); }
}
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
private static String digest(byte[] value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }
private static LocalDateTime utc(Instant instant) { return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); }
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆或当前车辆的记录不存在"); }
private static ResponseStatusException bad(String reason) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, reason); }
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); }
}