This commit is contained in:
2026-08-26 14:36:04 +08:00
parent 394c690601
commit e31fd8caa2
10 changed files with 574 additions and 0 deletions
@@ -0,0 +1,75 @@
package com.baiyue.lastleg.exceptionorder.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
import com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg")
public class ExceptionOrderController {
@Resource private ExceptionOrderService service;
@GetMapping("/exception-orders")
public ApiResponse<PageResult<ExceptionOrderResponses.Row>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ExceptionOrderRequests.Query query) {
return ApiResponse.success(service.page(scope(user), query));
}
@GetMapping("/exception-orders/resolve")
public ApiResponse<ExceptionOrderResponses.Relation> resolve(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String inboundCode,
@RequestParam(required = false) String lastMileOrderNo) {
return ApiResponse.success(service.resolve(scope(user), customerId, inboundCode, lastMileOrderNo));
}
@PostMapping("/exception-orders")
public ApiResponse<ExceptionOrderResponses.Created> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ExceptionOrderRequests.Create request) {
return ApiResponse.success(service.create(scope(user), operator(user), request));
}
@GetMapping("/delivery-orders/{deliveryNo}/return-inbound-options")
public ApiResponse<List<ExceptionOrderResponses.Relation>> returnOptions(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String deliveryNo) {
return ApiResponse.success(service.returnCandidates(requiredScope(user, customerId), deliveryNo));
}
@PostMapping("/delivery-orders/{deliveryNo}/mark-return")
public ApiResponse<ExceptionOrderResponses.Created> markReturn(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String deliveryNo,
@Valid @RequestBody ExceptionOrderRequests.MarkReturn request) {
return ApiResponse.success(service.markReturn(requiredScope(user, customerId), operator(user), deliveryNo, request));
}
private String scope(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
return "PLATFORM".equals(user.appCode()) && user.tenantId() == 0L ? null : String.valueOf(user.tenantId());
}
private String requiredScope(AuthenticatedUser user, String requestedCustomer) {
String own = scope(user);
if (own != null) return own;
if (requestedCustomer == null || requestedCustomer.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择客户");
return requestedCustomer.trim();
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.exceptionorder.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
public final class ExceptionOrderRequests {
private ExceptionOrderRequests() {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String keyword, String exceptionType, String status,
String customerId, String warehouseId,
LocalDateTime createdFrom, LocalDateTime createdTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record Create(String customerId, @Size(max = 128) String inboundCode,
@Size(max = 64) String lastMileOrderNo,
@NotBlank @Size(max = 32) String exceptionType,
@NotBlank @Size(max = 1000) String reason,
@Min(1) Integer packageCount,
@Size(max = 128) String skuCode) {}
public record MarkReturn(@NotNull Long inboundOrderId,
@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) {}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.exceptionorder.dto;
import java.time.LocalDateTime;
public final class ExceptionOrderResponses {
private ExceptionOrderResponses() {}
public record Row(Long id, String exceptionNo, String exceptionType, String status,
String reason, String customerId, String customerName,
String overseasWarehouseId, String overseasWarehouseName,
Long lastMileOrderId, String lastMileOrderNo,
Long inboundOrderId, String inboundCode,
Long deliveryOrderId, String deliveryNo, String outboundNo,
String skuCode, Integer packageCount, String returnDirection,
String returnLogisticsNo, String handlingAction,
String handlerName, LocalDateTime exceptionAt, LocalDateTime updatedAt) {}
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) {}
public record Created(Long id, String exceptionNo, String status) {}
}
@@ -0,0 +1,42 @@
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")
public class ExceptionOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String customerId;
private String exceptionNo;
private String exceptionType;
private String status;
private String reason;
private Long lastMileOrderId;
private String lastMileOrderNo;
private Long inboundOrderId;
private String inboundCode;
private Long deliveryOrderId;
private String deliveryNo;
private String outboundNo;
private String customerName;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String skuCode;
private Integer packageCount;
private String returnDirection;
private String returnLogisticsNo;
private String handlingAction;
private String handlerId;
private String handlerName;
private String sourceType;
private String createdBy;
private LocalDateTime exceptionAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.exceptionorder.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
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;
@Mapper
public interface ExceptionOrderMapper extends BaseMapper<ExceptionOrder> {
@Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " +
"AND exception_type='RETURN' LIMIT 1")
ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId);
}
@@ -0,0 +1,246 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.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.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.mapper.ExceptionOrderMapper;
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Objects;
@Service
public class ExceptionOrderService {
@Resource private ExceptionOrderMapper exceptionOrders;
@Resource private LastlegOrderMapper lastMileOrders;
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private DeliveryOrderMapper deliveryOrders;
@Resource private DeliveryOperationMapper deliveryOperations;
public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) {
String customerId = customerScope(scopedCustomerId, query.customerId());
Page<ExceptionOrder> result = exceptionOrders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<ExceptionOrder>()
.eq(StringUtils.hasText(customerId), ExceptionOrder::getCustomerId, customerId)
.and(StringUtils.hasText(query.keyword()), wrapper -> wrapper
.like(ExceptionOrder::getExceptionNo, trim(query.keyword()))
.or().like(ExceptionOrder::getLastMileOrderNo, trim(query.keyword()))
.or().like(ExceptionOrder::getInboundCode, trim(query.keyword()))
.or().like(ExceptionOrder::getDeliveryNo, trim(query.keyword()))
.or().like(ExceptionOrder::getCustomerName, trim(query.keyword()))
.or().like(ExceptionOrder::getReason, trim(query.keyword())))
.eq(StringUtils.hasText(query.exceptionType()), ExceptionOrder::getExceptionType, trim(query.exceptionType()))
.eq(StringUtils.hasText(query.status()), ExceptionOrder::getStatus, trim(query.status()))
.eq(StringUtils.hasText(query.warehouseId()), ExceptionOrder::getOverseasWarehouseId, trim(query.warehouseId()))
.ge(query.createdFrom() != null, ExceptionOrder::getExceptionAt, query.createdFrom())
.le(query.createdTo() != null, ExceptionOrder::getExceptionAt, query.createdTo())
.orderByDesc(ExceptionOrder::getExceptionAt).orderByDesc(ExceptionOrder::getId));
return new PageResult<>(result.getRecords().stream().map(this::row).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
public ExceptionOrderResponses.Relation resolve(String scopedCustomerId, String requestedCustomerId,
String inboundCode, String lastMileOrderNo) {
return relation(scopedCustomerId, requestedCustomerId, inboundCode, lastMileOrderNo);
}
public List<ExceptionOrderResponses.Relation> returnCandidates(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
return lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo)
.orderByAsc(LastlegOrder::getOrderNo)).stream()
.map(order -> relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()))
.distinct().toList();
}
@Transactional
public ExceptionOrderResponses.Created create(String scopedCustomerId, String operatorId,
ExceptionOrderRequests.Create request) {
ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(),
request.inboundCode(), request.lastMileOrderNo());
return insert(relation, null, normalizeType(request.exceptionType()), request.reason(), request.packageCount(),
request.skuCode(), null, null, "MANUAL", operatorId);
}
@Transactional
public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String deliveryNo,
ExceptionOrderRequests.MarkReturn request) {
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());
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(customerId, deliveryNo);
ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId()))
.findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单"));
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
request.packageCount(), request.skuCode(), request.returnDirection(), request.returnLogisticsNo(),
"DELIVERY_RETURN", operatorId);
LocalDateTime now = LocalDateTime.now();
delivery.setStatus("RETURN_PENDING");
delivery.setUpdatedAt(now);
deliveryOrders.updateById(delivery);
LastlegOrder tail = lastMileOrders.selectById(selected.lastMileOrderId());
if (tail != null) {
tail.setAbnormal(true);
tail.setSubOrderStatus("RETURN_PENDING");
tail.setUpdatedAt(now);
lastMileOrders.updateById(tail);
}
DeliveryOperation operation = new DeliveryOperation();
operation.setCustomerId(customerId); operation.setDeliveryOrderId(delivery.getId());
operation.setOperationNode("MARK_RETURN"); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId);
operation.setResult("已生成异常退货单 " + created.exceptionNo()); operation.setCreatedAt(now);
deliveryOperations.insert(operation);
return created;
}
private ExceptionOrderResponses.Relation relation(String scopedCustomerId, String requestedCustomerId,
String inboundCode, String lastMileOrderNo) {
String customerId = customerScope(scopedCustomerId, requestedCustomerId);
String inbound = trim(inboundCode);
String tailNo = trim(lastMileOrderNo);
if (!StringUtils.hasText(inbound) && !StringUtils.hasText(tailNo)) {
throw badRequest("入仓码和尾程订单号至少填写一个");
}
LastlegOrder tail = StringUtils.hasText(tailNo) ? uniqueTail(customerId, tailNo) : null;
LastlegInboundOrder inboundOrder = StringUtils.hasText(inbound) ? uniqueInbound(customerId, inbound) : null;
if (tail != null && inboundOrder != null && !Objects.equals(tail.getInboundCode(), inboundOrder.getInboundCode())) {
throw conflict("尾程订单与入仓单不是同一关联关系");
}
if (tail == null && inboundOrder != null) tail = optionalTail(inboundOrder.getCustomerId(), inboundOrder.getInboundCode());
if (inboundOrder == null && tail != null) inboundOrder = optionalInbound(tail.getCustomerId(), tail.getInboundCode());
if (tail == null && inboundOrder == null) throw notFound("未找到对应单据");
String resolvedCustomer = tail != null ? tail.getCustomerId() : inboundOrder.getCustomerId();
if (tail != null && inboundOrder != null && !Objects.equals(tail.getCustomerId(), inboundOrder.getCustomerId())) {
throw conflict("尾程订单与入仓单不属于同一客户");
}
return new ExceptionOrderResponses.Relation(
tail == null ? null : tail.getId(), tail == null ? null : tail.getOrderNo(),
inboundOrder == null ? null : inboundOrder.getId(), inboundOrder == null ? null : inboundOrder.getInboundCode(),
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());
}
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
String type, String reason, Integer packages, String sku,
String direction, String logisticsNo, String sourceType,
String operatorId) {
if (packages != null && relation.availablePackages() != null && packages > relation.availablePackages()) {
throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages());
}
LocalDateTime now = LocalDateTime.now();
ExceptionOrder value = new ExceptionOrder();
value.setId(IdWorker.getId()); value.setCustomerId(relation.customerId());
value.setExceptionNo("EXC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + value.getId());
value.setExceptionType(type); value.setStatus("PENDING"); value.setReason(trim(reason));
value.setLastMileOrderId(relation.lastMileOrderId()); value.setLastMileOrderNo(relation.lastMileOrderNo());
value.setInboundOrderId(relation.inboundOrderId()); value.setInboundCode(relation.inboundCode());
value.setDeliveryOrderId(delivery == null ? null : delivery.getId());
value.setDeliveryNo(delivery == null ? relation.deliveryNo() : delivery.getDeliveryNo());
value.setOutboundNo(delivery == null ? relation.outboundNo() : delivery.getOutboundNo());
value.setCustomerName(relation.customerName());
value.setOverseasWarehouseId(delivery == null ? relation.overseasWarehouseId() : delivery.getOverseasWarehouseId());
value.setOverseasWarehouseName(delivery == null ? relation.overseasWarehouseName() : delivery.getOverseasWarehouseName());
value.setSkuCode(trim(sku)); value.setPackageCount(packages); value.setReturnDirection(trim(direction));
value.setReturnLogisticsNo(trim(logisticsNo));
if (StringUtils.hasText(direction)) {
value.setHandlingAction("OVERSEAS_WAREHOUSE".equals(direction) ? "退回海外仓" : "CUSTOMER".equals(direction) ? "退回客户" : "其他退货处理");
value.setHandlerId(operatorId); value.setHandlerName(operatorId);
}
value.setSourceType(sourceType); value.setCreatedBy(operatorId);
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());
}
private LastlegOrder uniqueTail(String customerId, String orderNo) {
List<LastlegOrder> values = lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOrderNo, orderNo).last("LIMIT 2"));
if (values.isEmpty()) throw notFound("尾程订单不存在");
if (values.size() > 1) throw conflict("尾程订单号不唯一,请先选择客户");
return values.get(0);
}
private LastlegInboundOrder uniqueInbound(String customerId, String inboundCode) {
List<LastlegInboundOrder> values = inboundOrders.selectList(new LambdaQueryWrapper<LastlegInboundOrder>()
.eq(StringUtils.hasText(customerId), LastlegInboundOrder::getCustomerId, customerId)
.eq(LastlegInboundOrder::getInboundCode, inboundCode).last("LIMIT 2"));
if (values.isEmpty()) throw notFound("入仓单不存在");
if (values.size() > 1) throw conflict("入仓码不唯一,请先选择客户");
return values.get(0);
}
private LastlegOrder optionalTail(String customerId, String inboundCode) {
List<LastlegOrder> values = lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getInboundCode, inboundCode).last("LIMIT 2"));
if (values.size() > 1) throw conflict("该入仓码关联了多个尾程订单,请修复关联关系后重试");
return values.size() == 1 ? values.get(0) : null;
}
private LastlegInboundOrder optionalInbound(String customerId, String inboundCode) {
if (!StringUtils.hasText(inboundCode)) return null;
List<LastlegInboundOrder> values = inboundOrders.selectList(new LambdaQueryWrapper<LastlegInboundOrder>()
.eq(LastlegInboundOrder::getCustomerId, customerId).eq(LastlegInboundOrder::getInboundCode, inboundCode).last("LIMIT 2"));
if (values.size() > 1) throw conflict("该尾程订单关联了多个入仓单,请修复关联关系后重试");
return values.size() == 1 ? values.get(0) : null;
}
private ExceptionOrderResponses.Row row(ExceptionOrder value) {
return new ExceptionOrderResponses.Row(value.getId(), value.getExceptionNo(), value.getExceptionType(), value.getStatus(),
value.getReason(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(),
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());
}
private String customerScope(String scopedCustomerId, String requestedCustomerId) {
if (StringUtils.hasText(scopedCustomerId)) {
if (StringUtils.hasText(requestedCustomerId) && !scopedCustomerId.equals(trim(requestedCustomerId))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "不能访问其他客户数据");
}
return scopedCustomerId;
}
return trim(requestedCustomerId);
}
private static String normalizeType(String value) {
String normalized = trim(value).toUpperCase();
if (!java.util.Set.of("RETURN", "REJECT", "DAMAGED", "OTHER").contains(normalized)) throw badRequest("不支持的异常类型");
return normalized;
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
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); }
}