From e31fd8caa2b043360e7eba7755751b8efe088cda Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 26 Aug 2026 14:36:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 29 +++ .../controller/ExceptionOrderController.java | 75 ++++++ .../dto/ExceptionOrderRequests.java | 34 +++ .../dto/ExceptionOrderResponses.java | 25 ++ .../exceptionorder/entity/ExceptionOrder.java | 42 +++ .../mapper/ExceptionOrderMapper.java | 15 ++ .../service/ExceptionOrderService.java | 246 ++++++++++++++++++ .../src/main/resources/db/lastleg.sql | 17 ++ .../service/ExceptionOrderServiceTest.java | 74 ++++++ scripts/db/incremental.sql | 17 ++ 10 files changed, 574 insertions(+) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 982373ef2..3d6f4f558 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java new file mode 100644 index 000000000..b6ef71087 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java @@ -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> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid ExceptionOrderRequests.Query query) { + return ApiResponse.success(service.page(scope(user), query)); + } + + @GetMapping("/exception-orders/resolve") + public ApiResponse 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 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> 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 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()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java new file mode 100644 index 000000000..8af47a000 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java @@ -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) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java new file mode 100644 index 000000000..5847820c8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java @@ -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) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java new file mode 100644 index 000000000..b60908bb5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java new file mode 100644 index 000000000..39c25ca45 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java @@ -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 { + @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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java new file mode 100644 index 000000000..c2c1ca972 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -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 page(String scopedCustomerId, ExceptionOrderRequests.Query query) { + String customerId = customerScope(scopedCustomerId, query.customerId()); + Page result = exceptionOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper() + .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 returnCandidates(String customerId, String deliveryNo) { + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("派送单不存在"); + return lastMileOrders.selectList(new LambdaQueryWrapper() + .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 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 values = lastMileOrders.selectList(new LambdaQueryWrapper() + .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 values = inboundOrders.selectList(new LambdaQueryWrapper() + .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 values = lastMileOrders.selectList(new LambdaQueryWrapper() + .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 values = inboundOrders.selectList(new LambdaQueryWrapper() + .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); } +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e85e97965..6d969864d 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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='海外仓异常订单'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java new file mode 100644 index 000000000..6a9036e12 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java @@ -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; + } +} diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 5f5fe442e..c246f4826 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -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='海外仓异常订单';