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); }
|
||||
}
|
||||
+15
-16
@@ -9,6 +9,11 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchCandidateMapper {
|
||||
// Billing volume weight is never a substitute for physical cargo mass.
|
||||
String SOURCE_GROSS_WEIGHT =
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)>0 AND COUNT(*)=COUNT(d.gross_weight_kg) AND MIN(d.gross_weight_kg)>0 " +
|
||||
"THEN SUM(d.gross_weight_kg) END FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1))";
|
||||
@Select("<script>" +
|
||||
"SELECT DISTINCT customer_id FROM ll_inbound_plan WHERE id IN " +
|
||||
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
@@ -29,14 +34,11 @@ public interface DispatchCandidateMapper {
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
"COALESCE(NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)>0 THEN " +
|
||||
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
|
||||
"* COALESCE(a.allocated_volume_cbm,0) / l.volume_cbm ELSE " +
|
||||
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
|
||||
"/ NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0)," +
|
||||
"MIN(o.volume_weight_kg)) AS grossWeightKg," +
|
||||
"CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
" >0 AND (COALESCE(l.volume_cbm,0)<=0 OR COALESCE(a.allocated_volume_cbm,0)>0) THEN 1 ELSE 0 END)=1 THEN " +
|
||||
"NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)>0 THEN " + SOURCE_GROSS_WEIGHT +
|
||||
" * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
@@ -142,14 +144,11 @@ public interface DispatchCandidateMapper {
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
"COALESCE(NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)>0 THEN " +
|
||||
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
|
||||
"* COALESCE(a.allocated_volume_cbm,0) / l.volume_cbm ELSE " +
|
||||
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
|
||||
"/ NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0)," +
|
||||
"MIN(o.volume_weight_kg)) AS grossWeightKg," +
|
||||
"CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
" >0 AND (COALESCE(l.volume_cbm,0)<=0 OR COALESCE(a.allocated_volume_cbm,0)>0) THEN 1 ELSE 0 END)=1 THEN " +
|
||||
"NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)>0 THEN " + SOURCE_GROSS_WEIGHT +
|
||||
" * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
|
||||
+12
-5
@@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -19,19 +21,24 @@ import java.util.List;
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public class DispatchPdaController {
|
||||
@Resource private DispatchPdaWorkflowService service;
|
||||
@Resource private FleetTripExecutionService trips;
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<DispatchPdaResponses.Task>> list(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String status) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.list(String.valueOf(identity.overseasWarehouseId()), status));
|
||||
String warehouse = String.valueOf(identity.overseasWarehouseId());
|
||||
var tasks = service.list(warehouse, status);
|
||||
var gates = trips.gates(warehouse, tasks.stream().map(DispatchPdaResponses.Task::dispatchNo).toList());
|
||||
return ApiResponse.success(tasks.stream().map(task -> task.withGate(gates.get(task.dispatchNo()))).toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{dispatchNo}")
|
||||
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.task(String.valueOf(identity.overseasWarehouseId()), dispatchNo));
|
||||
String warehouse = String.valueOf(identity.overseasWarehouseId());
|
||||
return ApiResponse.success(service.task(warehouse, dispatchNo).withGate(trips.gate(warehouse, dispatchNo)));
|
||||
}
|
||||
|
||||
@PostMapping("/{dispatchNo}/items/{itemId}/pick")
|
||||
@@ -54,11 +61,11 @@ public class DispatchPdaController {
|
||||
}
|
||||
|
||||
@PostMapping("/{dispatchNo}/depart")
|
||||
public ApiResponse<DispatchPdaResponses.Departed> depart(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
public ApiResponse<FleetExecutionModels.Departed> depart(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,
|
||||
@Valid @RequestBody DispatchPdaRequests.Depart request) {
|
||||
@Valid @RequestBody FleetExecutionModels.Depart request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo,
|
||||
return ApiResponse.success(trips.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo,
|
||||
identity.operatorKey(), request));
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -1,11 +1,18 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.dto;
|
||||
|
||||
import java.util.List;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
|
||||
|
||||
public final class DispatchPdaResponses {
|
||||
private DispatchPdaResponses() {}
|
||||
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
|
||||
boolean canDepart, String departBlockReason, List<Item> items) {}
|
||||
boolean canDepart, String departBlockReason, List<Item> items, String tripNo, Long tripRecordVersion, int tripDispatchCount, String driverWarning,
|
||||
boolean canPick, String pickBlockReason) {
|
||||
public Task withGate(FleetExecutionModels.Gate gate) {
|
||||
return new Task(id, taskNo, dispatchNo, vehiclePlate, warehouseId, status, gate.canDepart(), gate.blockReason(), items,
|
||||
gate.tripNo(), gate.recordVersion(), gate.dispatchCount(), gate.driverWarning(), gate.canPick(), gate.pickBlockReason());
|
||||
}
|
||||
}
|
||||
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
|
||||
int scheduledPackages,
|
||||
|
||||
+11
-4
@@ -1,5 +1,7 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
@@ -36,6 +38,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class DispatchPdaWorkflowService {
|
||||
@Resource private FleetDispatchBindingGuard fleetBindings;
|
||||
@Resource private DispatchOrderMapper orders;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private OutboundPalletItemMapper outboundPalletItems;
|
||||
@@ -100,6 +103,7 @@ public class DispatchPdaWorkflowService {
|
||||
}
|
||||
requirePdaStatus(order);
|
||||
requireVehicle(order);
|
||||
fleetBindings.requirePickingApproved(order.getId());
|
||||
items.bindReceivedPallets(customerId, order.getId());
|
||||
if (!("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
|
||||
throw conflict("Dispatch PDA task is not available for picking");
|
||||
@@ -169,6 +173,7 @@ public class DispatchPdaWorkflowService {
|
||||
if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay();
|
||||
requireVehicle(order);
|
||||
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
|
||||
fleetBindings.requirePickingApproved(order.getId());
|
||||
if (task != null && "COMPLETED".equals(task.getStatus())) {
|
||||
List<DispatchItem> completedItems = items.findByDispatchOrder(customerId, order.getId());
|
||||
long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
|
||||
@@ -204,15 +209,16 @@ public class DispatchPdaWorkflowService {
|
||||
}
|
||||
|
||||
/** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */
|
||||
@Transactional
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public DispatchPdaResponses.Departed depart(String warehouseId, String dispatchNo, String operatorId,
|
||||
DispatchPdaRequests.Depart request) {
|
||||
DispatchPdaRequests.Depart request, LocalDateTime departedAt) {
|
||||
DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo);
|
||||
String customerId = found.getCustomerId();
|
||||
DispatchOrder order = orders.lockById(customerId, found.getId());
|
||||
if (order == null) throw notFound("Dispatch order not found");
|
||||
DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay();
|
||||
fleetBindings.requireCoordinatedDeparture(order.getId());
|
||||
requireVehicle(order);
|
||||
OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId());
|
||||
if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) {
|
||||
@@ -232,7 +238,7 @@ public class DispatchPdaWorkflowService {
|
||||
List<DispatchPdaPick> dispatchPicks = picks.listTask(customerId, task.getId());
|
||||
if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship");
|
||||
long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = Objects.requireNonNull(departedAt, "departedAt");
|
||||
pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
|
||||
if (outboundPalletItems != null) {
|
||||
for (var outboundItem : outboundPalletItems.findByOutbound(customerId, outbound.getId())) {
|
||||
@@ -292,7 +298,8 @@ public class DispatchPdaWorkflowService {
|
||||
OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId());
|
||||
String departBlockReason = departBlockReason(order, task, source, outbound);
|
||||
return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(),
|
||||
task.getWarehouseId(), task.getStatus(), departBlockReason == null, departBlockReason, values);
|
||||
task.getWarehouseId(), "DEPARTED".equals(order.getStatus()) ? "DEPARTED" : task.getStatus(),
|
||||
departBlockReason == null, departBlockReason, values, null, null, 0, null, false, "请先关联车次并通过调度审核");
|
||||
}
|
||||
|
||||
private String departBlockReason(DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source,
|
||||
|
||||
+7
-2
@@ -26,6 +26,7 @@ import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
||||
@@ -65,6 +66,7 @@ public class DispatchService {
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private LastlegOrderMapper tailOrders;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private FleetDispatchBindingGuard fleetBindings;
|
||||
@Resource private RelabelApplicationMapper relabelApplications;
|
||||
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
|
||||
|
||||
@@ -410,7 +412,7 @@ public class DispatchService {
|
||||
item.setContactName(candidate.contactName());
|
||||
item.setContactPhone(candidate.contactPhone());
|
||||
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
|
||||
item.setGrossWeightKg(Objects.requireNonNullElse(candidate.grossWeightKg(), BigDecimal.ZERO));
|
||||
item.setGrossWeightKg(candidate.grossWeightKg());
|
||||
item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO));
|
||||
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
|
||||
item.setSourceStage(candidate.sourceStage());
|
||||
@@ -459,7 +461,7 @@ public class DispatchService {
|
||||
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId);
|
||||
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
|
||||
dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
|
||||
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(Objects.requireNonNullElse(candidate.grossWeightKg(), BigDecimal.ZERO)); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
|
||||
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
|
||||
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
|
||||
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
|
||||
@@ -528,6 +530,7 @@ public class DispatchService {
|
||||
public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) {
|
||||
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
fleetBindings.requireUnbound(dispatch.getId());
|
||||
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Only a pending dispatch can be edited");
|
||||
@@ -585,6 +588,7 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
private void requirePalletEditingAllowed(DispatchOrder dispatch) {
|
||||
fleetBindings.requireUnbound(dispatch.getId());
|
||||
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())
|
||||
|| Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets");
|
||||
@@ -598,6 +602,7 @@ public class DispatchService {
|
||||
public void cancel(String dispatchNo) {
|
||||
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
fleetBindings.requireUnbound(dispatch.getId());
|
||||
if (CANCELLED.equals(dispatch.getStatus())) return;
|
||||
if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled");
|
||||
|
||||
Reference in New Issue
Block a user