This commit is contained in:
2026-09-08 18:16:54 +08:00
parent 1a1a19dea7
commit fcb4c055e2
32 changed files with 1239 additions and 46 deletions
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
@@ -82,10 +83,8 @@ public class CustomerVisitService {
public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (Set.of("RETURN_PENDING", "REJECTED", "CANCELLED").contains(delivery.getStatus())) {
throw conflict("当前派送状态不能标记为派送完成");
}
boolean replayed = "DELIVERED".equals(delivery.getStatus());
if (!replayed) requireDeliveryTransition(delivery.getStatus(), "DELIVERED");
LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now();
if (!replayed) {
delivery.setStatus("DELIVERED");
@@ -116,18 +115,18 @@ public class CustomerVisitService {
String deliveryNo,
CustomerVisitRequests.DeliveryStatus request) {
String target = trim(request.status()).toUpperCase(Locale.ROOT);
if (!Set.of("WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED").contains(target)) {
if (!DeliveryStatusPolicy.isEditableTarget(target)) {
throw badRequest("不支持的派送状态");
}
if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo);
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if ("RETURN_PENDING".equals(delivery.getStatus())) throw conflict("退货处理中的派送单不能直接修改状态");
if (target.equals(delivery.getStatus())) {
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo,
delivery.getStatus(), existing == null ? null : existing.getStatus(), true);
}
requireDeliveryTransition(delivery.getStatus(), target);
String from = delivery.getStatus();
LocalDateTime now = LocalDateTime.now();
delivery.setStatus(target);
@@ -309,6 +308,11 @@ public class CustomerVisitService {
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static void requireDeliveryTransition(String from, String target) {
if (!DeliveryStatusPolicy.canTransition(from, target)) {
throw conflict("派送状态不允许由 " + from + " 变更为 " + target);
}
}
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -42,6 +42,15 @@ public class DeliveryTransferController {
return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request));
}
@GetMapping("/outbound-orders/one-click-dropship/candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
@Operation(summary = "List server-authoritative one-click dropship outbound candidates")
public ApiResponse<java.util.List<DeliveryResponses.OneClickCandidate>> oneClickCandidates(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@RequestParam(required = false) String warehouseId) {
return ApiResponse.success(service.oneClickCandidates(requiredCustomer(user, customerId), warehouseId));
}
@GetMapping("/outbound-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
@@ -24,7 +24,7 @@ public final class DeliveryResponses {
int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm,
LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt,
LocalDateTime expectedArrivalAt, LocalDateTime signedAt,
String remark, LocalDateTime createdAt) {}
String remark, LocalDateTime createdAt, List<String> allowedActions) {}
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo,
@@ -44,6 +44,9 @@ public final class DeliveryResponses {
public record OutboundPalletRow(String palletNo, String inboundCode, String destinationWarehouseCode,
String destinationWarehouseName, String skuCode, int packageCount) {}
public record OutboundDetail(OutboundRow outbound, List<OutboundPalletRow> pallets, DeliveryRow delivery) {}
public record OneClickCandidate(Long inventoryPositionId, String overseasWarehouseId,
String overseasWarehouseName, String palletNo, String inboundCode,
String skuCode, String productName, long availablePackages) {}
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {}
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.delivery.model;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class DeliveryStatusPolicy {
private static final Set<String> EDITABLE_TARGETS = Set.of(
"WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED");
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
"WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"),
"DELIVERING", Set.of("DELIVERED", "REJECTED"),
"DELIVERED", Set.of(),
"REJECTED", Set.of(),
"RETURN_PENDING", Set.of(),
"CANCELLED", Set.of());
private DeliveryStatusPolicy() { }
public static boolean isEditableTarget(String status) {
return EDITABLE_TARGETS.contains(status);
}
public static boolean canTransition(String from, String target) {
return TRANSITIONS.getOrDefault(from, Set.of()).contains(target);
}
public static List<String> allowedActions(String status) {
return switch (status == null ? "" : status) {
case "WAIT_DELIVERY" -> List.of(
"START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY", "MARK_RETURN");
case "DELIVERING" -> List.of("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN");
case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN");
default -> List.of();
};
}
}
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
@@ -39,6 +40,7 @@ import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
@@ -240,32 +242,41 @@ public class DeliveryTransferService {
return taskResult(outbound, task);
}
public List<DeliveryResponses.OneClickCandidate> oneClickCandidates(String customerId, String warehouseId) {
return dropshipProfiles.listOutboundCandidates(required(customerId, "Customer is required"),
StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream()
.map(this::oneClickCandidate).toList();
}
private String validateOneClickItems(String customerId, List<DeliveryRequests.OneClickItem> items) {
String warehouseId = null;
Map<String, Long> requestedPackages = new LinkedHashMap<>();
Map<String, DropshipOrderProfile> profiles = new HashMap<>();
Map<String, DropshipOutboundCandidate> candidates = new HashMap<>();
for (DeliveryRequests.OneClickItem input : items) {
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found");
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId());
if (profile == null) throw conflict("Selected pallet is not one-click dropship inventory");
if (warehouseId == null) warehouseId = position.getOverseasWarehouseId();
else if (!Objects.equals(warehouseId, position.getOverseasWarehouseId())) {
throw conflict("One outbound order must use inventory from the same overseas warehouse");
String sku = input.sku().trim();
DropshipOutboundCandidate candidate = dropshipProfiles.lockOutboundCandidate(customerId,
input.inventoryPositionId(), sku);
if (candidate == null) {
throw conflict("所选库存不是可用的一件代发库存,或已被其他作业占用");
}
if (value(position.getAvailablePackages()) < input.packages()) {
throw conflict("Insufficient available inventory for pallet " + position.getPalletNo());
if (warehouseId == null) warehouseId = candidate.getOverseasWarehouseId();
else if (!Objects.equals(warehouseId, candidate.getOverseasWarehouseId())) {
throw conflict("一张出库单只能选择同一海外仓的库存");
}
String key = profile.getId() + "\u0000" + input.sku().trim();
String key = candidate.getProfileId() + "\u0000" + sku;
requestedPackages.merge(key, input.packages(), Long::sum);
profiles.put(key, profile);
candidates.put(key, candidate);
}
for (Map.Entry<String, Long> requested : requestedPackages.entrySet()) {
String sku = requested.getKey().substring(requested.getKey().indexOf('\u0000') + 1);
DropshipOutboundCandidate candidate = candidates.get(requested.getKey());
if (requested.getValue() > value(candidate.getAvailablePackages())) {
throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku);
}
List<DropshipCarton> cartons = dropshipCartons.lockAvailable(customerId,
profiles.get(requested.getKey()).getId(), sku, requested.getValue());
candidate.getProfileId(), sku, requested.getValue());
if (cartons.size() != requested.getValue()) {
throw conflict("Insufficient available one-click dropship cartons for SKU " + sku);
throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku);
}
}
return warehouseId;
@@ -597,7 +608,13 @@ public class DeliveryTransferService {
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getSkuCode(),
value(item.getPackageCount()));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt()); }
private DeliveryResponses.OneClickCandidate oneClickCandidate(DropshipOutboundCandidate candidate) {
return new DeliveryResponses.OneClickCandidate(candidate.getInventoryPositionId(),
candidate.getOverseasWarehouseId(), candidate.getOverseasWarehouseName(), candidate.getPalletNo(),
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
value(candidate.getAvailablePackages()));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), DeliveryStatusPolicy.allowedActions(d.getStatus())); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
@@ -2,13 +2,53 @@ package com.baiyue.lastleg.dropship.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProfile> {
String OUTBOUND_CANDIDATE_SELECT = "SELECT pos.id inventory_position_id,p.id profile_id," +
"p.warehouse_pallet_id,pos.overseas_warehouse_id,b.overseas_warehouse_name," +
"p.pallet_no,b.inbound_code,s.sku_code,s.product_name," +
"LEAST(pos.available_packages,s.package_count,(SELECT COUNT(*) FROM ll_dropship_carton c " +
"WHERE c.customer_id=p.customer_id AND c.profile_id=p.id AND c.sku_code=s.sku_code " +
"AND c.status='AVAILABLE')) available_packages FROM ll_dropship_order_profile p " +
"JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id " +
"AND o.order_type='ONE_CLICK_DROPSHIP' AND COALESCE(o.detained,0)=0 " +
"JOIN ll_inventory_position pos ON pos.id=p.inventory_position_id AND pos.customer_id=p.customer_id " +
"AND pos.pallet_id=p.warehouse_pallet_id " +
"JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"JOIN ll_dropship_sku_inventory s ON s.customer_id=p.customer_id AND s.order_id=p.order_id " +
"AND s.profile_id=p.id ";
String OUTBOUND_CANDIDATE_WHERE = "WHERE p.customer_id=#{customerId} AND p.status='ACTIVE' " +
"AND pos.status='OCCUPIED' AND pos.available_packages>0 AND s.package_count>0 " +
"AND EXISTS (SELECT 1 FROM ll_dropship_carton c WHERE c.customer_id=p.customer_id " +
"AND c.profile_id=p.id AND c.sku_code=s.sku_code AND c.status='AVAILABLE') " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock dl WHERE dl.customer_id=p.customer_id " +
"AND dl.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id) AND dl.status='ACTIVE' " +
"AND (dl.expires_at IS NULL OR dl.expires_at>CURRENT_TIMESTAMP(3))) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"WHERE ta.customer_id=p.customer_id AND ta.inventory_position_id=pos.id AND ta.status='RESERVED') " +
"AND NOT EXISTS (SELECT 1 FROM ll_outbound_pallet_item oi JOIN ll_outbound_order oo " +
"ON oo.id=oi.outbound_order_id AND oo.customer_id=oi.customer_id " +
"WHERE oi.customer_id=p.customer_id AND oi.warehouse_pallet_id=p.warehouse_pallet_id " +
"AND oi.sku_code=s.sku_code AND oo.status IN ('WAIT_PICK','PICKING','PICKED')) ";
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no")
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1")
DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("<script>" + OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE +
"<if test='warehouseId != null and warehouseId != \"\"'>AND pos.overseas_warehouse_id=#{warehouseId} </if>" +
"ORDER BY b.overseas_warehouse_name,p.pallet_no,s.sku_code LIMIT 500</script>")
List<DropshipOutboundCandidate> listOutboundCandidates(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
@Select(OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE +
"AND pos.id=#{positionId} AND s.sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
DropshipOutboundCandidate lockOutboundCandidate(@Param("customerId") String customerId,
@Param("positionId") Long positionId,
@Param("skuCode") String skuCode);
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.dropship.model;
import lombok.Data;
/** Server-authoritative one-click dropship inventory dimension eligible for outbound creation. */
@Data
public class DropshipOutboundCandidate {
private Long inventoryPositionId;
private Long profileId;
private Long warehousePalletId;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String palletNo;
private String inboundCode;
private String skuCode;
private String productName;
private Long availablePackages;
}
@@ -45,6 +45,38 @@ public class ExceptionOrderController {
return ApiResponse.success(service.create(scope(user), operatorId(user), operatorName(user), request));
}
@GetMapping("/exception-orders/{exceptionNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')")
public ApiResponse<ExceptionOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String exceptionNo) {
return ApiResponse.success(service.detail(requiredScope(user, customerId), exceptionNo));
}
@PostMapping("/exception-orders/{exceptionNo}/start-processing")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.CommandResult> startProcessing(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.StartProcessing request) {
return ApiResponse.success(service.startProcessing(requiredScope(user, customerId), operatorId(user), exceptionNo, request));
}
@PostMapping("/exception-orders/{exceptionNo}/complete")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.CommandResult> complete(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.Complete request) {
return ApiResponse.success(service.complete(requiredScope(user, customerId), operatorId(user), exceptionNo, request));
}
@PostMapping("/exception-orders/{exceptionNo}/cancel")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.CommandResult> cancel(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.Cancel request) {
return ApiResponse.success(service.cancel(requiredScope(user, customerId), operatorId(user), exceptionNo, request));
}
@GetMapping("/delivery-orders/{deliveryNo}/return-inbound-options")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')")
public ApiResponse<List<ExceptionOrderResponses.Relation>> returnOptions(@AuthenticationPrincipal AuthenticatedUser user,
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class ExceptionOrderRequests {
private ExceptionOrderRequests() {}
@@ -29,6 +30,32 @@ public final class ExceptionOrderRequests {
@NotBlank @Size(max = 1000) String reason,
@NotBlank @Size(max = 32) String returnDirection,
@Size(max = 128) String returnLogisticsNo,
@Min(1) Integer packageCount,
@Size(max = 128) String skuCode) {}
@NotNull @Size(min = 1, max = 200) List<ReturnItem> items,
@NotBlank @Size(max = 255) String idempotencyKey) {}
public record ReturnItem(@NotNull Long outboundPalletItemId,
@NotNull Long inventoryPositionId,
@Min(1) long packageCount) {}
public record StartProcessing(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String handlingAction) {}
public record Complete(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotBlank @Size(max = 2000) String handlingResult,
@NotNull @Size(min = 1, max = 20) List<String> evidenceFileIds,
@Size(max = 200) List<ReturnReceipt> receipts) {}
public record ReturnReceipt(@NotNull Long outboundPalletItemId,
@NotNull Long inventoryPositionId,
@Min(1) long receivedPackages,
@NotNull Long targetPalletId,
@NotBlank @Size(max = 128) String targetPalletNo,
@NotNull Long targetLocationId,
@NotBlank @Size(max = 128) String targetLocationCode) {}
public record Cancel(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotBlank @Size(max = 1000) String reason) {}
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.exceptionorder.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class ExceptionOrderResponses {
private ExceptionOrderResponses() {}
@@ -13,13 +14,22 @@ public final class ExceptionOrderResponses {
Long deliveryOrderId, String deliveryNo, String outboundNo,
String skuCode, Integer packageCount, String returnDirection,
String returnLogisticsNo, String handlingAction,
String handlerName, LocalDateTime exceptionAt, LocalDateTime updatedAt) {}
String handlingResult, String handlerName, LocalDateTime exceptionAt,
LocalDateTime completedAt, LocalDateTime updatedAt, long version,
List<String> evidenceFileIds, List<String> allowedActions) {}
public record Relation(Long lastMileOrderId, String lastMileOrderNo,
Long inboundOrderId, String inboundCode,
String customerId, String customerName,
String overseasWarehouseId, String overseasWarehouseName,
String deliveryNo, String outboundNo, Integer availablePackages) {}
String deliveryNo, String outboundNo, Integer availablePackages,
List<ReturnSource> returnSources) {}
public record Created(Long id, String exceptionNo, String status) {}
public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId,
String palletNo, String skuCode, long maxReturnPackages) {}
public record Detail(Row order, List<ReturnSource> returnSources) {}
public record CommandResult(Long id, String exceptionNo, String status, long version,
Long inventoryAdjustmentId, long restoredPackages,
LocalDateTime completedAt, boolean replayed) {}
public record Created(Long id, String exceptionNo, String status, long version) {}
}
@@ -32,11 +32,16 @@ public class ExceptionOrder {
private String returnDirection;
private String returnLogisticsNo;
private String handlingAction;
private String handlingResult;
private String evidenceFileIdsJson;
private Long inventoryAdjustmentId;
private String handlerId;
private String handlerName;
private String sourceType;
private String createdBy;
private LocalDateTime exceptionAt;
private LocalDateTime completedAt;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.exceptionorder.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_exception_order_command")
public class ExceptionOrderCommand {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long exceptionOrderId;
private String action;
private String idempotencyKey;
private String requestHash;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.exceptionorder.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_exception_return_item")
public class ExceptionReturnItem {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long exceptionOrderId;
private Long outboundPalletItemId;
private Long inventoryPositionId;
private String palletNo;
private String skuCode;
private Long maxReturnPackages;
private Long approvedReturnPackages;
private LocalDateTime createdAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.exceptionorder.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ExceptionOrderCommandMapper extends BaseMapper<ExceptionOrderCommand> {
@Select("SELECT * FROM ll_exception_order_command WHERE customer_id=#{customerId} AND action=#{action} AND idempotency_key=#{key} LIMIT 1")
ExceptionOrderCommand findByKey(@Param("customerId") String customerId,
@Param("action") String action,
@Param("key") String key);
}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface ExceptionOrderMapper extends BaseMapper<ExceptionOrder> {
@@ -12,4 +13,10 @@ public interface ExceptionOrderMapper extends BaseMapper<ExceptionOrder> {
"AND exception_type='RETURN' LIMIT 1")
ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId);
@Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND exception_no=#{exceptionNo} LIMIT 1 FOR UPDATE")
ExceptionOrder lockByNo(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo);
@Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND exception_no=#{exceptionNo} LIMIT 1")
ExceptionOrder findByNo(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.exceptionorder.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ExceptionReturnItemMapper extends BaseMapper<ExceptionReturnItem> {
@Select("SELECT * FROM ll_exception_return_item WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionOrderId} ORDER BY id")
List<ExceptionReturnItem> findByException(@Param("customerId") String customerId,
@Param("exceptionOrderId") Long exceptionOrderId);
}
@@ -8,15 +8,31 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaPick;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import jakarta.annotation.Resource;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -26,8 +42,16 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class ExceptionOrderService {
@@ -37,6 +61,13 @@ public class ExceptionOrderService {
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private DeliveryOrderMapper deliveryOrders;
@Resource private DeliveryOperationMapper deliveryOperations;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPalletItems;
@Resource private OutboundPdaPickMapper outboundPdaPicks;
@Resource private ExceptionReturnItemMapper returnItems;
@Resource private ExceptionOrderCommandMapper commands;
@Resource private InventoryCommandService inventoryCommands;
@Resource private ObjectMapper objectMapper;
public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) {
String customerId = customerScope(scopedCustomerId, query.customerId());
@@ -65,6 +96,12 @@ public class ExceptionOrderService {
return relation(scopedCustomerId, requestedCustomerId, inboundCode, lastMileOrderNo);
}
public ExceptionOrderResponses.Detail detail(String customerId, String exceptionNo) {
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在");
return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order));
}
public List<ExceptionOrderResponses.Relation> returnCandidates(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
@@ -72,7 +109,8 @@ public class ExceptionOrderService {
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo)
.orderByAsc(LastlegOrder::getOrderNo)).stream()
.map(order -> relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()))
.map(order -> withReturnSources(
relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()), delivery))
.distinct().toList();
}
@@ -88,19 +126,40 @@ public class ExceptionOrderService {
@Transactional
public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo,
ExceptionOrderRequests.MarkReturn request) {
String requestHash = hash(request);
ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) return replayCreated(replay, requestHash);
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId());
if (existing != null) return new ExceptionOrderResponses.Created(existing.getId(), existing.getExceptionNo(), existing.getStatus());
if (existing != null) throw conflict("该派送单已存在退回异常单");
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(customerId, deliveryNo);
ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId()))
.findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单"));
List<ExceptionOrderResponses.ReturnSource> available = selected.returnSources();
List<ExceptionOrderRequests.ReturnItem> requested = request.items();
validateReturnItems(available, requested);
int packages = Math.toIntExact(requested.stream().mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).sum());
Set<String> skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item)))
.map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet());
String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED";
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
request.packageCount(), request.skuCode(), request.returnDirection(), request.returnLogisticsNo(),
packages, sku, request.returnDirection(), request.returnLogisticsNo(),
"DELIVERY_RETURN", operatorId, operatorName);
for (ExceptionOrderRequests.ReturnItem requestedItem : requested) {
ExceptionOrderResponses.ReturnSource source = available.stream()
.filter(candidate -> sameSource(candidate, requestedItem)).findFirst().orElseThrow();
ExceptionReturnItem item = new ExceptionReturnItem();
item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setExceptionOrderId(created.id());
item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId());
item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode());
item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount());
item.setCreatedAt(LocalDateTime.now()); returnItems.insert(item);
}
LocalDateTime now = LocalDateTime.now();
delivery.setStatus("RETURN_PENDING");
delivery.setUpdatedAt(now);
@@ -117,9 +176,84 @@ public class ExceptionOrderService {
operation.setOperationNode("MARK_RETURN"); operation.setOperatorId(operatorId); operation.setOperatorName(operatorName);
operation.setResult("已生成异常退货单 " + created.exceptionNo()); operation.setCreatedAt(now);
deliveryOperations.insert(operation);
recordCommand(customerId, created.id(), "MARK_RETURN", request.idempotencyKey(), requestHash, created);
return created;
}
@Transactional
public ExceptionOrderResponses.CommandResult startProcessing(String customerId, String operatorId,
String exceptionNo,
ExceptionOrderRequests.StartProcessing request) {
String requestHash = hash(request);
ExceptionOrderCommand replay = commands.findByKey(customerId, "START_PROCESSING", request.idempotencyKey().trim());
if (replay != null) return replayResult(replay, requestHash);
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING");
LocalDateTime now = LocalDateTime.now();
order.setStatus("PROCESSING"); order.setHandlingAction(trim(request.handlingAction()));
order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1);
exceptionOrders.updateById(order);
ExceptionOrderResponses.CommandResult result = commandResult(order, 0, false);
recordCommand(customerId, order.getId(), "START_PROCESSING", request.idempotencyKey(), requestHash, result);
return result;
}
@Transactional
public ExceptionOrderResponses.CommandResult cancel(String customerId, String operatorId,
String exceptionNo,
ExceptionOrderRequests.Cancel request) {
String requestHash = hash(request);
ExceptionOrderCommand replay = commands.findByKey(customerId, "CANCEL", request.idempotencyKey().trim());
if (replay != null) return replayResult(replay, requestHash);
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING");
LocalDateTime now = LocalDateTime.now();
order.setStatus("CANCELLED"); order.setHandlingResult(trim(request.reason()));
order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1);
exceptionOrders.updateById(order);
ExceptionOrderResponses.CommandResult result = commandResult(order, 0, false);
recordCommand(customerId, order.getId(), "CANCEL", request.idempotencyKey(), requestHash, result);
return result;
}
@Transactional
public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId,
String exceptionNo,
ExceptionOrderRequests.Complete request) {
String requestHash = hash(request);
ExceptionOrderCommand replay = commands.findByKey(customerId, "COMPLETE", request.idempotencyKey().trim());
if (replay != null) return replayResult(replay, requestHash);
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING");
ReturnedInventoryRestoreResponse restored = null;
if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) {
List<ExceptionReturnItem> stored = returnItems.findByException(customerId, order.getId());
if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细");
if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细");
restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId,
new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(),
order.getOverseasWarehouseId(), order.getInboundOrderId(),
stored.stream().map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(),
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(),
request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt(
receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(),
receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(),
receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(),
request.evidenceFileIds()));
} else if (request.receipts() != null && !request.receipts().isEmpty()) {
throw badRequest("非退回海外仓异常不能提交库存实收明细");
}
LocalDateTime now = restored == null ? LocalDateTime.now() : restored.completedAt();
order.setStatus("COMPLETED"); order.setHandlingResult(trim(request.handlingResult()));
order.setEvidenceFileIdsJson(write(request.evidenceFileIds()));
order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId());
order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now);
order.setVersion(version(order) + 1); exceptionOrders.updateById(order);
closeReturnedDelivery(order, operatorId, now);
long restoredPackages = restored == null ? 0 : restored.restoredPackages();
ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false);
recordCommand(customerId, order.getId(), "COMPLETE", request.idempotencyKey(), requestHash, result);
return result;
}
private ExceptionOrderResponses.Relation relation(String scopedCustomerId, String requestedCustomerId,
String inboundCode, String lastMileOrderNo) {
String customerId = customerScope(scopedCustomerId, requestedCustomerId);
@@ -150,7 +284,7 @@ public class ExceptionOrderService {
resolvedCustomer, tail == null ? null : tail.getCustomerName(),
tail == null ? null : tail.getOverseasWarehouseId(), tail == null ? null : tail.getOverseasWarehouseName(),
tail == null ? null : tail.getDeliveryOrderNo(), tail == null ? null : tail.getOutboundOrderNo(),
tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount());
tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount(), List.of());
}
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
@@ -180,10 +314,11 @@ public class ExceptionOrderService {
value.setHandlerId(operatorId); value.setHandlerName(operatorName);
}
value.setSourceType(sourceType); value.setCreatedBy(operatorId);
value.setVersion(0L);
value.setExceptionAt(now); value.setCreatedAt(now); value.setUpdatedAt(now);
try { exceptionOrders.insert(value); }
catch (DuplicateKeyException exception) { throw conflict("该关联单据已存在异常退货单"); }
return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus());
return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus(), version(value));
}
private LastlegOrder uniqueTail(String customerId, String orderNo, String inboundCode) {
@@ -227,8 +362,159 @@ public class ExceptionOrderService {
value.getOverseasWarehouseName(), value.getLastMileOrderId(), value.getLastMileOrderNo(),
value.getInboundOrderId(), value.getInboundCode(), value.getDeliveryOrderId(), value.getDeliveryNo(),
value.getOutboundNo(), value.getSkuCode(), value.getPackageCount(), value.getReturnDirection(),
value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlerName(),
value.getExceptionAt(), value.getUpdatedAt());
value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlingResult(), value.getHandlerName(),
value.getExceptionAt(), value.getCompletedAt(), value.getUpdatedAt(), version(value),
readStringList(value.getEvidenceFileIdsJson()), allowedActions(value));
}
private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation,
DeliveryOrder delivery) {
List<ExceptionOrderResponses.ReturnSource> sources = returnSources(delivery).stream()
.filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode()))
.map(Source::response).toList();
return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(),
relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(),
relation.overseasWarehouseId(), relation.overseasWarehouseName(), relation.deliveryNo(),
relation.outboundNo(), relation.availablePackages(), sources);
}
private List<Source> returnSources(DeliveryOrder delivery) {
OutboundOrder outbound = outboundOrders.findByNo(delivery.getCustomerId(), delivery.getOutboundNo());
if (outbound == null) throw conflict("派送单缺少原出库单据");
Map<Long, OutboundPalletItem> itemById = new LinkedHashMap<>();
outboundPalletItems.findByOutbound(delivery.getCustomerId(), outbound.getId())
.forEach(item -> itemById.put(item.getId(), item));
Map<String, Source> aggregated = new LinkedHashMap<>();
for (OutboundPdaPick pick : outboundPdaPicks.findByOutbound(delivery.getCustomerId(), outbound.getId())) {
OutboundPalletItem item = itemById.get(pick.getOutboundPalletItemId());
if (item == null || pick.getInventoryPositionId() == null) continue;
String key = item.getId() + ":" + pick.getInventoryPositionId();
aggregated.merge(key, new Source(item.getId(), pick.getInventoryPositionId(), item.getPalletNo(),
item.getSkuCode(), item.getInboundCode(), Objects.requireNonNullElse(pick.getPackages(), 0L)),
(left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages()));
}
return new ArrayList<>(aggregated.values());
}
private List<ExceptionOrderResponses.ReturnSource> storedReturnSources(ExceptionOrder order) {
return returnItems.findByException(order.getCustomerId(), order.getId()).stream()
.map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(),
item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(),
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList();
}
private void validateReturnItems(List<ExceptionOrderResponses.ReturnSource> available,
List<ExceptionOrderRequests.ReturnItem> requested) {
Set<String> unique = new java.util.HashSet<>();
for (ExceptionOrderRequests.ReturnItem item : requested) {
String key = item.outboundPalletItemId() + ":" + item.inventoryPositionId();
if (!unique.add(key)) throw badRequest("退回库存明细不能重复");
ExceptionOrderResponses.ReturnSource source = available.stream().filter(value -> sameSource(value, item))
.findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和入仓单"));
if (item.packageCount() > source.maxReturnPackages()) throw conflict("退回箱数超过原出库箱数");
}
}
private boolean sameSource(ExceptionOrderResponses.ReturnSource source, ExceptionOrderRequests.ReturnItem item) {
return Objects.equals(source.outboundPalletItemId(), item.outboundPalletItemId())
&& Objects.equals(source.inventoryPositionId(), item.inventoryPositionId());
}
private ExceptionOrder lock(String customerId, String exceptionNo, long expectedVersion, String expectedStatus) {
ExceptionOrder order = exceptionOrders.lockByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在");
if (version(order) != expectedVersion) throw conflict("异常订单版本已变化,请刷新后重试");
if (!expectedStatus.equals(order.getStatus())) throw conflict("当前异常状态不允许执行该操作");
return order;
}
private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
DeliveryOrder delivery = deliveryOrders.findByNo(order.getCustomerId(), order.getDeliveryNo());
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) {
throw conflict("关联派送单不是退货处理中状态");
}
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery);
for (LastlegOrder tail : lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, order.getCustomerId())
.eq(LastlegOrder::getDeliveryOrderNo, order.getDeliveryNo()))) {
tail.setAbnormal(false); tail.setSubOrderStatus("DELIVERY:RETURNED"); tail.setUpdatedAt(now);
lastMileOrders.updateById(tail);
}
DeliveryOperation operation = new DeliveryOperation();
operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(delivery.getId());
operation.setOperationNode("RETURN_COMPLETED"); operation.setOperatorId(operatorId);
operation.setOperatorName(operatorId); operation.setResult("异常退回处理完成:" + order.getExceptionNo());
operation.setCreatedAt(now); deliveryOperations.insert(operation);
}
private List<String> allowedActions(ExceptionOrder order) {
return switch (Objects.requireNonNullElse(order.getStatus(), "")) {
case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION");
case "PROCESSING" -> List.of("COMPLETE_EXCEPTION");
default -> List.of();
};
}
private ExceptionOrderResponses.CommandResult commandResult(ExceptionOrder order, long restoredPackages,
boolean replayed) {
return new ExceptionOrderResponses.CommandResult(order.getId(), order.getExceptionNo(), order.getStatus(),
version(order), order.getInventoryAdjustmentId(), restoredPackages, order.getCompletedAt(), replayed);
}
private void recordCommand(String customerId, Long orderId, String action, String idempotencyKey,
String requestHash, Object result) {
ExceptionOrderCommand command = new ExceptionOrderCommand();
command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setExceptionOrderId(orderId);
command.setAction(action); command.setIdempotencyKey(idempotencyKey.trim());
command.setRequestHash(requestHash); command.setResultJson(write(result)); command.setCreatedAt(LocalDateTime.now());
commands.insert(command);
}
private ExceptionOrderResponses.CommandResult replayResult(ExceptionOrderCommand command, String requestHash) {
requireReplayHash(command, requestHash);
try {
ExceptionOrderResponses.CommandResult result = objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.CommandResult.class);
return new ExceptionOrderResponses.CommandResult(result.id(), result.exceptionNo(), result.status(), result.version(),
result.inventoryAdjustmentId(), result.restoredPackages(), result.completedAt(), true);
} catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); }
}
private ExceptionOrderResponses.Created replayCreated(ExceptionOrderCommand command, String requestHash) {
requireReplayHash(command, requestHash);
try { return objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.Created.class); }
catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); }
}
private void requireReplayHash(ExceptionOrderCommand command, String requestHash) {
if (!Objects.equals(command.getRequestHash(), requestHash)) throw conflict("幂等键已用于不同请求");
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存异常处理结果", exception); }
}
private List<String> readStringList(String value) {
if (!StringUtils.hasText(value)) return List.of();
try { return objectMapper.readValue(value, new TypeReference<>() { }); }
catch (JsonProcessingException exception) { throw new IllegalStateException("异常处理证据数据损坏", exception); }
}
private static long version(ExceptionOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
private record Source(Long outboundPalletItemId, Long inventoryPositionId, String palletNo,
String skuCode, String inboundCode, long maxReturnPackages) {
Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages); }
ExceptionOrderResponses.ReturnSource response() {
return new ExceptionOrderResponses.ReturnSource(outboundPalletItemId, inventoryPositionId,
palletNo, skuCode, maxReturnPackages);
}
}
private String customerScope(String scopedCustomerId, String requestedCustomerId) {
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.inventory.command.dto;
import java.util.List;
public record ReturnedInventoryRestoreRequest(
String exceptionNo,
String outboundNo,
String overseasWarehouseId,
Long inboundOrderId,
List<SourceItem> sources,
List<Receipt> receipts,
String idempotencyKey,
List<String> evidenceFileIds
) {
public record SourceItem(Long outboundPalletItemId, Long inventoryPositionId,
String skuCode, long maxReturnPackages) { }
public record Receipt(Long outboundPalletItemId, Long inventoryPositionId, long receivedPackages,
Long targetPalletId, String targetPalletNo,
Long targetLocationId, String targetLocationCode) { }
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inventory.command.dto;
import java.time.LocalDateTime;
import java.util.List;
public record ReturnedInventoryRestoreResponse(
long adjustmentId,
List<Long> ledgerIds,
long beforePackages,
long restoredPackages,
long afterPackages,
boolean replayed,
LocalDateTime completedAt
) {
public ReturnedInventoryRestoreResponse asReplay() {
return new ReturnedInventoryRestoreResponse(adjustmentId, ledgerIds, beforePackages,
restoredPackages, afterPackages, true, completedAt);
}
}
@@ -47,6 +47,19 @@ public interface InventoryCommandMapper {
"AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)")
int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId);
@Select("SELECT COALESCE(-SUM(delta_packages),0) FROM ll_inventory_ledger " +
"WHERE customer_id=#{customerId} AND position_id=#{positionId} AND outbound_order_no=#{outboundNo} " +
"AND sku_code=#{skuCode} AND business_type='SHIP' AND delta_packages<0")
long shippedPackages(@Param("customerId") String customerId, @Param("positionId") Long positionId,
@Param("outboundNo") String outboundNo, @Param("skuCode") String skuCode);
@Select("SELECT COALESCE(SUM(delta_packages),0) FROM ll_inventory_ledger " +
"WHERE customer_id=#{customerId} AND source_event_id=#{sourceIdentity} " +
"AND sku_code=#{skuCode} AND business_type='RETURN_STOCK_IN' AND delta_packages>0")
long restoredPackages(@Param("customerId") String customerId,
@Param("sourceIdentity") String sourceIdentity,
@Param("skuCode") String skuCode);
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
"WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " +
"AND p.hold_packages>0 ORDER BY p.id FOR UPDATE")
@@ -32,6 +32,7 @@ public final class OverseasPermissions {
public static final String OUTBOUND_CANCEL = "platform:overseas-warehouse:outbound:cancel";
public static final String EXCEPTION_ORDER_LIST = "platform:overseas-warehouse:exception-order:list";
public static final String EXCEPTION_ORDER_CREATE = "platform:overseas-warehouse:exception-order:create";
public static final String EXCEPTION_ORDER_PROCESS = "platform:overseas-warehouse:exception-order:process";
public static final String CUSTOMER_VISIT_LIST = "platform:overseas-warehouse:customer-visit:list";
public static final String CUSTOMER_VISIT_EDIT = "platform:overseas-warehouse:customer-visit:edit";
public static final String MERCHANT_DELIVERY_LIST = "merchant:warehouse:delivery:list";