feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
+183
@@ -0,0 +1,183 @@
|
||||
package com.baiyue.lastleg.dispatch.fleet;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetCompletionModels;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.util.Objects;
|
||||
|
||||
@Component
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public class DispatchFleetAdapter implements FleetDispatchGateway {
|
||||
private final DispatchOrderMapper orders;
|
||||
private final DispatchPdaTaskMapper tasks;
|
||||
private final OutboundOrderMapper outbounds;
|
||||
private final DeliveryOrderMapper deliveries;
|
||||
private final DeliveryOperationMapper deliveryOperations;
|
||||
private final DispatchItemMapper items;
|
||||
private final DispatchPdaPickMapper picks;
|
||||
private final DispatchPdaWorkflowService workflow;
|
||||
|
||||
public DispatchFleetAdapter(DispatchOrderMapper orders, DispatchPdaTaskMapper tasks,
|
||||
OutboundOrderMapper outbounds, DeliveryOrderMapper deliveries, DispatchItemMapper items,
|
||||
DispatchPdaPickMapper picks, DispatchPdaWorkflowService workflow, DeliveryOperationMapper deliveryOperations) {
|
||||
this.orders = orders; this.tasks = tasks; this.outbounds = outbounds; this.deliveries = deliveries;
|
||||
this.items = items; this.picks = picks; this.workflow = workflow;
|
||||
this.deliveryOperations = deliveryOperations;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Snapshot lockForPlanning(String dispatchNo) {
|
||||
return lockBeforeDeparture(dispatchNo, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Snapshot lockForAssignment(String dispatchNo) {
|
||||
return lockBeforeDeparture(dispatchNo, true);
|
||||
}
|
||||
|
||||
private Snapshot lockBeforeDeparture(String dispatchNo, boolean allowPicking) {
|
||||
var dispatch = orders.lockGlobalByDispatchNo(dispatchNo);
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
|
||||
var allowedStatuses = allowPicking ? java.util.Set.of("PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART") : java.util.Set.of("PENDING_DEPARTURE");
|
||||
if (dispatch.getStatus() == null || !allowedStatuses.contains(dispatch.getStatus())
|
||||
|| (!allowPicking && Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0)
|
||||
|| Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0)
|
||||
throw conflict("排车单 " + dispatchNo + " 已开始执行,不能修改车次关联或改派");
|
||||
// Keep the same dispatch -> outbound -> PDA task lock order as departure.
|
||||
var outbound = outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId());
|
||||
if (outbound != null && !"WAIT_OUTBOUND".equals(outbound.getStatus()))
|
||||
throw conflict("排车单 " + dispatchNo + " 的出库状态不允许修改关联");
|
||||
var task = tasks.lockByDispatch(dispatch.getCustomerId(), dispatch.getId());
|
||||
var allowedTaskStatuses = allowPicking ? java.util.Set.of("PDA_PENDING", "PICKING", "COMPLETED") : java.util.Set.of("PDA_PENDING");
|
||||
if (task != null && (task.getStatus() == null || !allowedTaskStatuses.contains(task.getStatus())))
|
||||
throw conflict("排车单 " + dispatchNo + " 已开始拣货,不能修改车次关联或改派");
|
||||
if (task != null && !Objects.equals(task.getWarehouseId(), dispatch.getOverseasWarehouseId()))
|
||||
throw conflict("拣货任务所属仓不一致");
|
||||
if (task == null && Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0)
|
||||
throw conflict("排车单缺少已拣货的作业依据");
|
||||
if (dispatch.getPackageCount() == null || dispatch.getPackageCount() <= 0)
|
||||
throw conflict("排车单 " + dispatchNo + " 没有待发运货物");
|
||||
return new Snapshot(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), dispatch.getOverseasWarehouseId(), dispatch.getPackageCount());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void assign(Snapshot dispatch, Assignment assignment) {
|
||||
var now = LocalDateTime.now();
|
||||
// Explicit SET is needed when unbinding: MyBatis entity updates omit null fields.
|
||||
if (orders.update(null, new LambdaUpdateWrapper<DispatchOrder>().eq(DispatchOrder::getId, dispatch.dispatchId())
|
||||
.eq(DispatchOrder::getCustomerId, dispatch.customerId()).in(DispatchOrder::getStatus, "PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART")
|
||||
.set(DispatchOrder::getVehiclePlate, assignment.vehiclePlate()).set(DispatchOrder::getDriverName, assignment.driverName())
|
||||
.set(DispatchOrder::getDriverPhone, assignment.driverPhone()).set(DispatchOrder::getUpdatedAt, now)) != 1)
|
||||
throw conflict("排车单已变化,请刷新后重试");
|
||||
var outbound = outbounds.lockByDispatch(dispatch.customerId(), dispatch.dispatchId());
|
||||
if (outbound == null) return;
|
||||
for (var delivery : deliveries.lockByOutbound(dispatch.customerId(), outbound.getId())) {
|
||||
if (!"WAIT_DELIVERY".equals(delivery.getStatus()) || delivery.getDispatchedAt() != null || delivery.getSignedAt() != null)
|
||||
throw conflict("派送单 " + delivery.getDeliveryNo() + " 已执行,不能覆盖车辆司机");
|
||||
if (deliveries.update(null, new LambdaUpdateWrapper<DeliveryOrder>().eq(DeliveryOrder::getId, delivery.getId())
|
||||
.eq(DeliveryOrder::getCustomerId, dispatch.customerId()).eq(DeliveryOrder::getVersion, delivery.getVersion())
|
||||
.set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName())
|
||||
.set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, delivery.getVersion() + 1)
|
||||
.set(DeliveryOrder::getUpdatedAt, now)) != 1) throw conflict("派送单已变化,请刷新后重试");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock) {
|
||||
var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo);
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
|
||||
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
|
||||
var task = lock ? tasks.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : tasks.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
|
||||
var selected = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
|
||||
Map<Long, Long> picked = new HashMap<>();
|
||||
if (task != null) for (var pick : picks.listTask(dispatch.getCustomerId(), task.getId())) picked.merge(pick.getDispatchItemId(), pick.getPackages(), Math::addExact);
|
||||
var cargo = selected.stream().map(item -> new FleetExecutionModels.Cargo(dispatchNo, dispatch.getCustomerId(), item.getId().toString(),
|
||||
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0),
|
||||
destination(item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()),
|
||||
"PRIVATE".equals(item.getDestinationType()) ? item.getDeliveryAddress() : first(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()))).toList();
|
||||
String reason = null;
|
||||
if (!"READY_TO_DEPART".equals(dispatch.getStatus()) || task == null || !"COMPLETED".equals(task.getStatus())) reason = "请完成拣货并核平全部箱数";
|
||||
else if (!Objects.equals(dispatch.getOverseasWarehouseId(), task.getWarehouseId())) reason = "拣货任务所属仓不一致";
|
||||
else if (selected.isEmpty() || selected.stream().anyMatch(item -> item.getPackageCount() == null || item.getPackageCount() <= 0
|
||||
|| picked.getOrDefault(item.getId(), 0L).longValue() != item.getPackageCount().longValue())) reason = "每条货物须与实际拣货箱数核平";
|
||||
else if (!picked.keySet().equals(selected.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchItem::getId).collect(java.util.stream.Collectors.toSet()))) reason = "拣货记录包含不属于当前发运的货物";
|
||||
else if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) reason = "请先转派送并生成待出库单";
|
||||
else if (cargo.stream().anyMatch(item -> item.warehousePalletId() == null || item.palletNo() == null || item.destinationKey() == null)) reason = "实物托盘或目的地未确认";
|
||||
return new FleetExecutionModels.Manifest(dispatchNo, dispatch.getOverseasWarehouseId(), dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone(), cargo, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator,
|
||||
String idempotencyKey, String remark, Instant departedAt) {
|
||||
var result = workflow.depart(warehouseId, dispatchNo, operator, new DispatchPdaRequests.Depart(idempotencyKey, remark),
|
||||
LocalDateTime.ofInstant(departedAt, ZoneId.systemDefault()));
|
||||
return new FleetExecutionModels.DepartedDispatch(result.dispatchNo(), result.shippedPackages());
|
||||
}
|
||||
|
||||
private static String destination(String type, String code, String fba, String address, String postalCode) {
|
||||
return FleetDispatchGateway.destinationKey(type,code,fba,address,postalCode);
|
||||
}
|
||||
@Override public java.util.List<SourceCargo> sourceCargo(String dispatchNo) {
|
||||
var dispatch=orders.findGlobalByDispatchNo(dispatchNo);
|
||||
if(dispatch==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");
|
||||
return items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId()).stream().map(item->new SourceCargo(item.getInboundCode(),
|
||||
item.getWarehousePalletId()==null?null:item.getWarehousePalletId().toString(),item.getPalletNo(),Objects.requireNonNullElse(item.getPackageCount(),0),
|
||||
destination(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode()))).toList();
|
||||
}
|
||||
@Override
|
||||
public FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock) {
|
||||
var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo);
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
|
||||
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
|
||||
var rows = outbound == null ? java.util.List.<DeliveryOrder>of() : lock
|
||||
? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId());
|
||||
var outcomes = rows.stream().map(row -> new FleetCompletionModels.Outcome(dispatchNo, row.getDeliveryNo(), dispatch.getCustomerId(),
|
||||
"PRIVATE".equals(row.getDestinationType()) ? row.getDeliveryAddress() : first(row.getDestinationWarehouseCode(), row.getDestinationWarehouseName()),
|
||||
originallySigned(row) ? "DELIVERED" : row.getStatus(), Objects.requireNonNullElse(row.getPackageCount(), 0),
|
||||
originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus())
|
||||
? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null)).toList();
|
||||
String reason = null;
|
||||
if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) reason = "排车出库尚未完成";
|
||||
else if (outcomes.isEmpty()) reason = "缺少对应派送单";
|
||||
else if (outcomes.stream().mapToLong(FleetCompletionModels.Outcome::packages).sum() != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0)
|
||||
|| !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) reason = "派送单数量未覆盖本次全部实发货物";
|
||||
else if (outcomes.stream().anyMatch(row -> row.packages() <= 0 || !java.util.Set.of("DELIVERED", "RETURNED").contains(row.status()) || row.terminalAt() == null))
|
||||
reason = "所有派送单须已签收,或完成真实退回入仓";
|
||||
return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), outcomes, reason);
|
||||
}
|
||||
// Existing delivery commands persist LocalDateTime.now(); convert in that server zone, never the browser zone.
|
||||
private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); }
|
||||
private static boolean originallySigned(DeliveryOrder order) {
|
||||
return order.getSignedAt() != null && java.util.Set.of("DELIVERED", "RETURN_PENDING", "RETURNED").contains(order.getStatus());
|
||||
}
|
||||
private static String first(String a, String b) { return a != null && !a.isBlank() ? a : b == null || b.isBlank() ? null : b; }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
Reference in New Issue
Block a user