暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
@@ -0,0 +1,278 @@
package com.baiyue.lastleg.trucking.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
@Service
public class UsTruckOrderService {
public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
/** Kept for source compatibility; new orders use PENDING_REVIEW. */
@Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW;
public static final String WAITING_INBOUND = "WAITING_INBOUND";
public static final String RECEIVING = "RECEIVING";
public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION";
public static final String PALLET_OVERAGE_REVIEW = "PALLET_OVERAGE_REVIEW";
public static final String PARTIALLY_ALLOCATED = "PARTIALLY_ALLOCATED";
public static final String ALLOCATED = "ALLOCATED";
public static final String PENDING_DISPATCH = "PENDING_DISPATCH";
public static final String DISPATCHED = "DISPATCHED";
public static final String IN_TRANSIT = "IN_TRANSIT";
public static final String COMPLETED = "COMPLETED";
public static final String REJECTED = "REJECTED";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED,
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
private static final java.util.Map<String, Set<String>> TRANSITIONS = java.util.Map.ofEntries(
java.util.Map.entry(PENDING_REVIEW, Set.of(WAITING_INBOUND, REJECTED, CANCELLED)),
java.util.Map.entry(WAITING_INBOUND, Set.of(RECEIVING, CANCELLED)),
java.util.Map.entry(RECEIVING, Set.of(WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, CANCELLED)),
java.util.Map.entry(PALLET_OVERAGE_REVIEW, Set.of(WAITING_CONSOLIDATION, CANCELLED)),
java.util.Map.entry(WAITING_CONSOLIDATION, Set.of(PARTIALLY_ALLOCATED, ALLOCATED, CANCELLED)),
java.util.Map.entry(PARTIALLY_ALLOCATED, Set.of(ALLOCATED, CANCELLED)),
java.util.Map.entry(ALLOCATED, Set.of(PENDING_DISPATCH, CANCELLED)),
java.util.Map.entry(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)),
java.util.Map.entry(DISPATCHED, Set.of(IN_TRANSIT)),
java.util.Map.entry(IN_TRANSIT, Set.of(COMPLETED)));
private static final Set<String> TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR");
@Resource private UsTruckOrderMapper orders;
@Resource private UsTruckOrderPalletMapper pallets;
@Resource private UsTruckOrderStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@Transactional
public UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request) {
String key = request.idempotencyKey().trim();
String hash = hash(request);
UsTruckOrder replay = orders.findKey(customerId, key);
if (replay != null) return replay(customerId, replay, hash);
if (StringUtils.hasText(request.transportType())) validateTransport(request.transportType());
LocalDateTime now = LocalDateTime.now();
UsTruckOrder order = new UsTruckOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo("TK" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null);
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null);
order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount()));
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0);
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim());
order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_REVIEW);
order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash);
order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now);
try { orders.insert(order); }
catch (DuplicateKeyException e) {
UsTruckOrder concurrent = orders.findKey(customerId, key);
if (concurrent != null) return replay(customerId, concurrent, hash);
throw e;
}
history(order, null, PENDING_REVIEW, operatorId, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
public PageResult<UsTruckOrderResponses.Summary> page(String customerId, UsTruckOrderRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<UsTruckOrder> w = new LambdaQueryWrapper<UsTruckOrder>()
.eq(StringUtils.hasText(customerId), UsTruckOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), UsTruckOrder::getOrderNo, query.orderNo())
.and(StringUtils.hasText(query.routeKeyword()), x -> x.like(UsTruckOrder::getOrigin, query.routeKeyword())
.or().like(UsTruckOrder::getDestination, query.routeKeyword()))
.eq(StringUtils.hasText(query.status()), UsTruckOrder::getStatus,
StringUtils.hasText(query.status()) ? norm(query.status()) : null)
.eq(StringUtils.hasText(query.transportType()), UsTruckOrder::getTransportType,
StringUtils.hasText(query.transportType()) ? norm(query.transportType()) : null)
.eq(StringUtils.hasText(query.customerId()), UsTruckOrder::getCustomerId, query.customerId())
.ge(query.requestedFrom() != null, UsTruckOrder::getRequestedDate, query.requestedFrom())
.le(query.requestedTo() != null, UsTruckOrder::getRequestedDate, query.requestedTo())
.orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId);
Page<UsTruckOrder> result = orders.selectPage(Page.of(query.page(), query.size()), w);
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
public UsTruckOrderResponses.Detail detail(String customerId, String orderNo) {
UsTruckOrder order = orders.findNo(customerId, orderNo);
if (order == null) throw notFound();
return detail(order, histories.listOrder(customerId, order.getId()));
}
@Transactional
public UsTruckOrderResponses.Detail update(String customerId, String orderNo, UsTruckOrderRequests.Update request) {
UsTruckOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited");
if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); }
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress);
setText(request.containerType(), v -> order.setContainerType(norm(v)));
if (request.palletCount() != null) order.setPalletCount(request.palletCount());
if (request.packageCount() != null) order.setPackageCount(request.packageCount());
if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm());
if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency()));
setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo);
if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark);
touch(order); orders.updateById(order); return detail(customerId, orderNo);
}
@Transactional
public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ChangeStatus request) {
UsTruckOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported truck order status");
String from = order.getStatus();
if (!next.equals(from)) {
if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) {
throw conflict("Unsupported truck order transition: " + from + " -> " + next);
}
order.setStatus(next);
if (PENDING_REVIEW.equals(from) && WAITING_INBOUND.equals(next)) createPlannedPallets(order);
touch(order); orders.updateById(order);
history(order, from, next, operatorId, request.remark(), LocalDateTime.now());
}
return detail(customerId, orderNo);
}
@Transactional
public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ReceivePallets request) {
UsTruckOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (key.equals(order.getReceiptIdempotencyKey())) {
if (!requestHash.equals(order.getReceiptRequestHash())) {
throw conflict("Receipt idempotency key was reused with different pallet data");
}
return detail(customerId, orderNo);
}
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) {
throw conflict("Only waiting or receiving orders can receive pallets");
}
createPlannedPallets(order);
List<UsTruckOrderPallet> current = pallets.listOrder(customerId, order.getId());
int planned = value(order.getPalletCount());
int actual = request.actualPalletCount();
List<Integer> packageCounts = request.palletPackageCounts() == null ? List.of()
: request.palletPackageCounts();
if (!packageCounts.isEmpty() && packageCounts.size() != actual) {
throw badRequest("palletPackageCounts must be empty or contain one value per actual pallet");
}
if (!packageCounts.isEmpty() && request.packageCount() != null
&& packageCounts.stream().mapToInt(Integer::intValue).sum() != request.packageCount()) {
throw badRequest("Per-pallet package counts must equal the received package total");
}
LocalDateTime now = LocalDateTime.now();
for (int sequence = 1; sequence <= actual; sequence++) {
UsTruckOrderPallet pallet = sequence <= current.size() ? current.get(sequence - 1)
: newPallet(order, sequence, "PDA_OVERAGE", now);
pallet.setStatus(sequence > planned ? "OVERAGE_PENDING_REVIEW" : "RECEIVED");
if (!packageCounts.isEmpty()) pallet.setPackageCount(packageCounts.get(sequence - 1));
else if (actual == 1) pallet.setPackageCount(request.packageCount());
pallet.setReceivedAt(now);
pallet.setUpdatedAt(now);
if (pallet.getId() == null) {
pallet.setId(IdWorker.getId());
pallets.insert(pallet);
} else {
pallets.updateById(pallet);
}
}
order.setReceivedPalletCount(actual);
order.setReceiptIdempotencyKey(key);
order.setReceiptRequestHash(requestHash);
String from = order.getStatus();
String next = actual > planned ? PALLET_OVERAGE_REVIEW
: actual == planned ? WAITING_CONSOLIDATION : RECEIVING;
order.setStatus(next);
touch(order);
orders.updateById(order);
history(order, from, next, operatorId, request.remark(), now);
return detail(customerId, orderNo);
}
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {
List<UsTruckOrderResponses.Pallet> palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream()
.map(this::pallet)
.toList();
return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(),
o.getRemark(), o.getCreatedBy(), palletDetails,
hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(),
h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList());
}
private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) {
return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()),
p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt());
}
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), value(o.getShippedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); }
private void history(UsTruckOrder o, String from, String to, String operator, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setRemark(trim(remark)); h.setCreatedAt(at); histories.insert(h); }
private void createPlannedPallets(UsTruckOrder order) {
int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size();
LocalDateTime now = LocalDateTime.now();
for (int sequence = existing + 1; sequence <= value(order.getPalletCount()); sequence++) {
UsTruckOrderPallet pallet = newPallet(order, sequence, "ORDER_PLAN", now);
pallet.setId(IdWorker.getId());
pallets.insert(pallet);
}
}
private UsTruckOrderPallet newPallet(UsTruckOrder order, int sequence, String source, LocalDateTime now) {
UsTruckOrderPallet pallet = new UsTruckOrderPallet();
pallet.setOrderId(order.getId()); pallet.setCustomerId(order.getCustomerId());
pallet.setPalletNo(order.getOrderNo() + "-P" + String.format("%03d", sequence));
pallet.setSequenceNo(sequence); pallet.setSource(source); pallet.setStatus("EXPECTED");
pallet.setCreatedAt(now); pallet.setUpdatedAt(now);
return pallet;
}
private void validateQuery(UsTruckOrderRequests.Query q) { if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); if (StringUtils.hasText(q.transportType())) validateTransport(q.transportType()); if (q.requestedFrom() != null && q.requestedTo() != null && q.requestedFrom().isAfter(q.requestedTo())) throw badRequest("requestedFrom must not be after requestedTo"); }
private void validateTransport(String v) { if (!TRANSPORT_TYPES.contains(norm(v))) throw badRequest("Unsupported transport type"); }
private String hash(Object v) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(v))); } catch (JsonProcessingException | NoSuchAlgorithmException e) { throw new IllegalStateException("Cannot hash truck request", e); } }
private static void touch(UsTruckOrder o) { o.setVersion(value(o.getVersion()) + 1); o.setUpdatedAt(LocalDateTime.now()); }
private static void setText(String v, Consumer<String> c) { if (StringUtils.hasText(v)) c.accept(v.trim()); }
private static void setPresent(String v, Consumer<String> c) { if (v != null) c.accept(trim(v)); }
private static String trim(String v) { return v == null || v.isBlank() ? null : v.trim(); }
private static String norm(String v) { return v.trim().toUpperCase(); }
private static String defaultCurrency(String v) { return StringUtils.hasText(v) ? norm(v) : "USD"; }
private static int value(Integer v) { return v == null ? 0 : v; } private static long value(Long v) { return v == null ? 0 : v; }
private static ResponseStatusException badRequest(String m) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, m); }
private static ResponseStatusException conflict(String m) { return new ResponseStatusException(HttpStatus.CONFLICT, m); }
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "US truck order not found"); }
}