Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
10 changed files with 574 additions and 0 deletions
Showing only changes of commit e31fd8caa2 - Show all commits
@@ -16569,6 +16569,35 @@ PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE
CREATE TABLE IF NOT EXISTS ll_delivery_fee (id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)) ENGINE=InnoDB COMMENT='派送单费用明细';
CREATE TABLE IF NOT EXISTS ll_delivery_operation (id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id)) ENGINE=InnoDB COMMENT='派送单操作记录';
CREATE TABLE IF NOT EXISTS ll_exception_order (
id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL, exception_no VARCHAR(64) NOT NULL,
exception_type VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING', reason VARCHAR(1000) NOT NULL,
last_mile_order_id BIGINT NULL, last_mile_order_no VARCHAR(64) NULL, inbound_order_id BIGINT NULL, inbound_code VARCHAR(128) NULL,
delivery_order_id BIGINT NULL, delivery_no VARCHAR(64) NULL, outbound_no VARCHAR(64) NULL,
customer_name VARCHAR(255) NULL, overseas_warehouse_id VARCHAR(128) NULL, overseas_warehouse_name VARCHAR(255) NULL,
sku_code VARCHAR(128) NULL, package_count INT NULL, return_direction VARCHAR(32) NULL, return_logistics_no VARCHAR(128) NULL,
handling_action VARCHAR(500) NULL, handler_id VARCHAR(128) NULL, handler_name VARCHAR(128) NULL,
source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL', created_by VARCHAR(128) NULL,
exception_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no),
UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type),
KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no),
KEY idx_ll_exception_inbound(customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='海外仓异常订单';
INSERT INTO baiyue_iam.iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0),
(5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0),
(5107072,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id,role_id,permission_id,created_at)
SELECT 0,role.id,permission.id,NOW(3) FROM baiyue_iam.iam_role role
JOIN baiyue_iam.iam_permission permission ON permission.id BETWEEN 5107070 AND 5107072 AND permission.tenant_id=0
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0;
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
@@ -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); }
}
@@ -1703,3 +1703,20 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation (
UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key),
KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账';
CREATE TABLE IF NOT EXISTS ll_exception_order (
id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL, exception_no VARCHAR(64) NOT NULL,
exception_type VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING', reason VARCHAR(1000) NOT NULL,
last_mile_order_id BIGINT NULL, last_mile_order_no VARCHAR(64) NULL, inbound_order_id BIGINT NULL, inbound_code VARCHAR(128) NULL,
delivery_order_id BIGINT NULL, delivery_no VARCHAR(64) NULL, outbound_no VARCHAR(64) NULL,
customer_name VARCHAR(255) NULL, overseas_warehouse_id VARCHAR(128) NULL, overseas_warehouse_name VARCHAR(255) NULL,
sku_code VARCHAR(128) NULL, package_count INT NULL, return_direction VARCHAR(32) NULL, return_logistics_no VARCHAR(128) NULL,
handling_action VARCHAR(500) NULL, handler_id VARCHAR(128) NULL, handler_name VARCHAR(128) NULL,
source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL', created_by VARCHAR(128) NULL,
exception_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no),
UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type),
KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no),
KEY idx_ll_exception_inbound(customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='海外仓异常订单';
@@ -0,0 +1,74 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ExceptionOrderServiceTest {
@Mock private ExceptionOrderMapper exceptionOrders;
@Mock private LastlegOrderMapper lastMileOrders;
@Mock private LastlegInboundOrderMapper inboundOrders;
private ExceptionOrderService service;
@BeforeEach
void setUp() {
service = new ExceptionOrderService();
ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders);
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders);
}
@Test
void resolvesTheOneToOneInboundAndLastMileRelation() {
LastlegOrder tail = tail("LM-1", "IN-1");
LastlegInboundOrder inbound = inbound("IN-1");
when(lastMileOrders.selectList(any())).thenReturn(List.of(tail));
when(inboundOrders.selectList(any())).thenReturn(List.of(inbound));
var relation = service.resolve(null, null, "IN-1", "LM-1");
assertThat(relation.lastMileOrderNo()).isEqualTo("LM-1");
assertThat(relation.inboundCode()).isEqualTo("IN-1");
assertThat(relation.customerId()).isEqualTo("1001");
}
@Test
void rejectsInputsThatExistButDoNotBelongTogether() {
when(lastMileOrders.selectList(any())).thenReturn(List.of(tail("LM-1", "IN-1")));
when(inboundOrders.selectList(any())).thenReturn(List.of(inbound("IN-2")));
assertThatThrownBy(() -> service.resolve(null, null, "IN-2", "LM-1"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("不是同一关联关系");
}
private LastlegOrder tail(String orderNo, String inboundCode) {
LastlegOrder value = new LastlegOrder();
value.setId(12L); value.setCustomerId("1001"); value.setOrderNo(orderNo); value.setInboundCode(inboundCode);
value.setCustomerName("测试客户"); value.setPackageCount(10);
return value;
}
private LastlegInboundOrder inbound(String inboundCode) {
LastlegInboundOrder value = new LastlegInboundOrder();
value.setId(11L); value.setCustomerId("1001"); value.setInboundCode(inboundCode); value.setPackageCount(10);
return value;
}
}
+17
View File
@@ -3848,3 +3848,20 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
CREATE TABLE IF NOT EXISTS ll_delivery_fee (id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)) ENGINE=InnoDB COMMENT='派送单费用明细';
CREATE TABLE IF NOT EXISTS ll_delivery_operation (id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id)) ENGINE=InnoDB COMMENT='派送单操作记录';
CREATE TABLE IF NOT EXISTS ll_exception_order (
id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL, exception_no VARCHAR(64) NOT NULL,
exception_type VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING', reason VARCHAR(1000) NOT NULL,
last_mile_order_id BIGINT NULL, last_mile_order_no VARCHAR(64) NULL, inbound_order_id BIGINT NULL, inbound_code VARCHAR(128) NULL,
delivery_order_id BIGINT NULL, delivery_no VARCHAR(64) NULL, outbound_no VARCHAR(64) NULL,
customer_name VARCHAR(255) NULL, overseas_warehouse_id VARCHAR(128) NULL, overseas_warehouse_name VARCHAR(255) NULL,
sku_code VARCHAR(128) NULL, package_count INT NULL, return_direction VARCHAR(32) NULL, return_logistics_no VARCHAR(128) NULL,
handling_action VARCHAR(500) NULL, handler_id VARCHAR(128) NULL, handler_name VARCHAR(128) NULL,
source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL', created_by VARCHAR(128) NULL,
exception_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no),
UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type),
KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no),
KEY idx_ll_exception_inbound(customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='海外仓异常订单';