feat: complete tail order controls and terminal drayage

This commit is contained in:
2026-09-07 23:58:18 +08:00
parent f018ca85a9
commit e32fe65fa8
53 changed files with 2566 additions and 87 deletions
@@ -0,0 +1,240 @@
package com.baiyue.lastleg.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
import com.baiyue.lastleg.entity.LastlegOrderHoldHistory;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory;
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
import com.baiyue.lastleg.mapper.LastlegOrderHoldHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
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.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class LastlegOrderCommandService {
private static final String HOLD = "HOLD";
private static final String MANUAL_NODES = "MANUAL_NODES";
private static final List<String> NODE_CODES = List.of(
"DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL",
"OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED");
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderCommandRecordMapper commands;
@Resource private LastlegOrderHoldHistoryMapper holdHistory;
@Resource private LastlegOrderManualNodeMapper manualNodes;
@Resource private LastlegOrderManualNodeHistoryMapper manualNodeHistory;
@Resource private LastlegOrderOperationLogMapper operationLogs;
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
@Resource private ObjectMapper objectMapper;
@Transactional
public LastlegOrderCommandDtos.HoldResponse hold(String requestedCustomerId, long orderId,
String idempotencyKey,
LastlegOrderCommandDtos.HoldRequest request,
String operatorId, String operatorName) {
String key = key(idempotencyKey);
LastlegOrder order = lockOwned(requestedCustomerId, orderId);
String hash = hash(request);
LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, HOLD, operatorId, key);
if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.HoldResponse.class);
int version = version(order);
if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试");
boolean held = request.held();
if (Boolean.TRUE.equals(order.getDetained()) == held) {
throw conflict(held ? "尾程订单已暂扣" : "尾程订单未处于暂扣状态");
}
if (held && (positive(order.getPickedPackageCount()) || positive(order.getShippedPackageCount())
|| text(order.getOutboundOrderNo()) != null)) {
throw conflict("尾程订单已进入出库流程,不能新增暂扣");
}
LocalDateTime now = LocalDateTime.now();
boolean chargeable = held && request.chargeable();
String reason = request.reason().trim();
if (orders.updateHold(order.getCustomerId(), orderId, held, chargeable, reason, version, now) != 1) {
throw conflict("尾程订单版本已变化,请刷新后重试");
}
int nextVersion = version + 1;
LastlegOrderCommandDtos.HoldResponse response = new LastlegOrderCommandDtos.HoldResponse(
orderId, held, chargeable, held ? reason : null, nextVersion);
LastlegOrderHoldHistory history = new LastlegOrderHoldHistory();
history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId);
history.setHeld(held); history.setChargeable(chargeable); history.setReason(reason);
history.setOrderVersion(nextVersion); history.setOperatorId(operatorId); history.setOperatorName(operatorName);
history.setIdempotencyKey(key); history.setCreatedAt(now); holdHistory.insert(history);
operation(order, held ? "暂扣" : "解除暂扣", operatorName,
(chargeable ? "计费;" : "不计费;") + reason, now);
saveCommand(order, HOLD, operatorId, key, hash, null, response, nextVersion, now);
order.setDetained(held);
order.setHoldChargeable(chargeable);
order.setHoldReason(held ? reason : null);
order.setVersion(nextVersion);
if (text(order.getSourceInboundId()) != null) {
detainedOutbox.publish(order, held, reason, operatorId, operatorName,
now.toInstant(ZoneOffset.UTC), history.getId());
}
return response;
}
@Transactional
public LastlegOrderCommandDtos.ManualNodesResponse saveManualNodes(String requestedCustomerId, long orderId,
String idempotencyKey,
LastlegOrderCommandDtos.ManualNodesRequest request,
String operatorId, String operatorName) {
String key = key(idempotencyKey);
LastlegOrder order = lockOwned(requestedCustomerId, orderId);
List<LastlegOrderCommandDtos.ManualNode> normalized = normalizeNodes(request.nodes());
String hash = hash(new LastlegOrderCommandDtos.ManualNodesRequest(request.expectedVersion(), normalized));
LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, MANUAL_NODES, operatorId, key);
if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.ManualNodesResponse.class);
int version = version(order);
if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试");
Map<String, LocalDateTime> values = new HashMap<>();
normalized.forEach(node -> values.put(node.nodeCode(), node.nodeAt()));
LocalDateTime now = LocalDateTime.now();
if (orders.updateManualNodes(order.getCustomerId(), orderId,
values.get("ESTIMATED_ARRIVAL"), values.get("ACTUAL_ARRIVAL_WAITING_UNLOAD"),
null, version, now) != 1) {
throw conflict("尾程订单版本已变化,请刷新后重试");
}
int nextVersion = version + 1;
manualNodes.deleteCurrent(order.getCustomerId(), orderId);
List<LastlegOrderCommandDtos.ManualNodeView> views = new ArrayList<>(normalized.size());
for (LastlegOrderCommandDtos.ManualNode node : normalized) {
LastlegOrderManualNode entity = new LastlegOrderManualNode();
entity.setId(IdWorker.getId()); entity.setCustomerId(order.getCustomerId()); entity.setOrderId(orderId);
entity.setNodeCode(node.nodeCode()); entity.setNodeAt(node.nodeAt()); entity.setSource("MANUAL");
entity.setOrderVersion(nextVersion); entity.setUpdatedBy(operatorId); entity.setUpdatedAt(now);
manualNodes.insert(entity);
views.add(new LastlegOrderCommandDtos.ManualNodeView(node.nodeCode(), node.nodeAt(), "MANUAL"));
}
LastlegOrderCommandDtos.ManualNodesResponse response = new LastlegOrderCommandDtos.ManualNodesResponse(
orderId, true, List.copyOf(views), nextVersion);
LastlegOrderManualNodeHistory history = new LastlegOrderManualNodeHistory();
history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId);
history.setNodesJson(json(views)); history.setOrderVersion(nextVersion); history.setOperatorId(operatorId);
history.setOperatorName(operatorName); history.setIdempotencyKey(key); history.setCreatedAt(now);
manualNodeHistory.insert(history);
operation(order, "人工计划节点", operatorName, "保存" + views.size() + "个人工节点", now);
saveCommand(order, MANUAL_NODES, operatorId, key, hash, null, response, nextVersion, now);
return response;
}
@Transactional(readOnly = true)
public LastlegOrderCommandDtos.ManualNodesResponse manualNodes(String requestedCustomerId, long orderId) {
LastlegOrder order = findOwned(requestedCustomerId, orderId);
List<LastlegOrderCommandDtos.ManualNodeView> nodes = manualNodes.selectList(
new LambdaQueryWrapper<LastlegOrderManualNode>()
.eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId())
.eq(LastlegOrderManualNode::getOrderId, orderId)
.orderByAsc(LastlegOrderManualNode::getNodeCode))
.stream().map(node -> new LastlegOrderCommandDtos.ManualNodeView(
node.getNodeCode(), node.getNodeAt(), node.getSource()))
.sorted(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode()))).toList();
return new LastlegOrderCommandDtos.ManualNodesResponse(orderId,
Boolean.TRUE.equals(order.getManualNodeOverride()), nodes, version(order));
}
private List<LastlegOrderCommandDtos.ManualNode> normalizeNodes(List<LastlegOrderCommandDtos.ManualNode> nodes) {
Set<String> seen = new HashSet<>();
boolean anyTime = false;
List<LastlegOrderCommandDtos.ManualNode> result = new ArrayList<>();
for (LastlegOrderCommandDtos.ManualNode node : nodes) {
String code = node.nodeCode().trim().toUpperCase();
if (!NODE_CODES.contains(code)) throw badRequest("不支持的人工展示节点:" + code);
if (!seen.add(code)) throw badRequest("人工节点不能重复:" + code);
result.add(new LastlegOrderCommandDtos.ManualNode(code, node.nodeAt()));
anyTime |= node.nodeAt() != null;
}
if (!seen.equals(Set.copyOf(NODE_CODES))) throw badRequest("必须提交完整8行人工时间快照");
if (!anyTime) throw badRequest("人工时间快照至少填写一个节点时间");
result.sort(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode())));
return List.copyOf(result);
}
private LastlegOrder lockOwned(String requestedCustomerId, long orderId) {
LastlegOrder found = findOwned(requestedCustomerId, orderId);
LastlegOrder locked = orders.lockById(found.getCustomerId(), orderId);
if (locked == null) throw notFound();
return locked;
}
private LastlegOrder findOwned(String requestedCustomerId, long orderId) {
LastlegOrder order = orders.selectById(orderId);
if (order == null || (text(requestedCustomerId) != null
&& !requestedCustomerId.trim().equals(order.getCustomerId()))) throw notFound();
return order;
}
private void operation(LastlegOrder order, String node, String operatorName, String result, LocalDateTime now) {
LastlegOrderOperationLog log = new LastlegOrderOperationLog();
log.setId(IdWorker.getId()); log.setCustomerId(order.getCustomerId()); log.setOrderId(order.getId());
log.setOperationNode(node); log.setOperatorName(operatorName); log.setResult(result); log.setCreatedAt(now);
operationLogs.insert(log);
}
private void saveCommand(LastlegOrder order, String action, String operatorId, String key, String hash,
String resultRef, Object response, int resultVersion, LocalDateTime now) {
LastlegOrderCommandRecord record = new LastlegOrderCommandRecord();
record.setId(IdWorker.getId()); record.setCustomerId(order.getCustomerId()); record.setOrderId(order.getId());
record.setAction(action); record.setOperatorId(operatorId); record.setIdempotencyKey(key);
record.setRequestHash(hash); record.setResultRef(resultRef); record.setResultJson(json(response));
record.setResultVersion(resultVersion); record.setCreatedAt(now); commands.insert(record);
}
private <T> T replay(LastlegOrderCommandRecord record, String hash, Class<T> type) {
if (!MessageDigest.isEqual(record.getRequestHash().getBytes(StandardCharsets.UTF_8),
hash.getBytes(StandardCharsets.UTF_8))) throw conflict("同一幂等键不能用于不同请求");
try { return objectMapper.readValue(record.getResultJson(), type); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取已保存命令结果", exception); }
}
private String hash(Object request) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsBytes(request)));
} catch (NoSuchAlgorithmException | JsonProcessingException exception) {
throw new IllegalStateException("无法生成命令摘要", exception);
}
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存命令结果", exception); }
}
private String key(String value) {
String key = text(value);
if (key == null || key.length() > 128) throw badRequest("Idempotency-Key 必须为1至128个字符");
return key;
}
private int version(LastlegOrder order) { return order.getVersion() == null ? 0 : order.getVersion(); }
private boolean positive(Integer value) { return value != null && value > 0; }
private String text(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); }
private ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
@@ -15,6 +16,7 @@ import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
@@ -25,6 +27,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.model.LastlegOrderType;
import jakarta.annotation.Resource;
@@ -54,12 +57,16 @@ public class LastlegOrderService {
public static final String INBOUND_PLANNED = "INBOUND_PLANNED";
public static final String PALLET_MERGE_REQUIRED = "REQUIRED";
public static final String PALLET_MERGED = "MERGED";
private static final List<String> MANUAL_NODE_CODES = List.of(
"DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL",
"OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED");
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private LastlegOrderFeeMapper feeMapper;
@Resource private LastlegOrderOperationLogMapper operationLogMapper;
@Resource private LastlegOrderManualNodeMapper manualNodeMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private FbaWarehouseValidator fbaWarehouseValidator;
@Resource private InventoryCommandService inventory;
@@ -243,7 +250,15 @@ public class LastlegOrderService {
.eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId())
.eq(LastlegOrderOperationLog::getOrderId, orderId)
.orderByAsc(LastlegOrderOperationLog::getCreatedAt));
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), cargo, history.stream()
List<LastlegOrderCommandDtos.ManualNodeView> manualNodes = manualNodeMapper.selectList(
new LambdaQueryWrapper<LastlegOrderManualNode>()
.eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId())
.eq(LastlegOrderManualNode::getOrderId, orderId)
.orderByAsc(LastlegOrderManualNode::getId)).stream()
.map(value -> new LastlegOrderCommandDtos.ManualNodeView(
value.getNodeCode(), value.getNodeAt(), value.getSource()))
.sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList();
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
@@ -552,7 +567,9 @@ public class LastlegOrderService {
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(),
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),