fix(lastleg): enforce delivery and return workflows

This commit is contained in:
2026-09-08 18:49:52 +08:00
parent d42629d289
commit d1090671ce
23 changed files with 546 additions and 61 deletions
@@ -10,10 +10,12 @@ import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@@ -53,6 +55,14 @@ public class ExceptionOrderController {
return ApiResponse.success(service.detail(requiredScope(user, customerId), exceptionNo));
}
@PostMapping(value = "/exception-orders/{exceptionNo}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.UploadedEvidence> uploadEvidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String exceptionNo, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.uploadEvidence(requiredScope(user, customerId), operatorId(user), exceptionNo, file));
}
@PostMapping("/exception-orders/{exceptionNo}/start-processing")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.CommandResult> startProcessing(
@@ -26,15 +26,15 @@ public final class ExceptionOrderRequests {
@Min(1) Integer packageCount,
@Size(max = 128) String skuCode) {}
public record MarkReturn(@NotNull Long inboundOrderId,
public record MarkReturn(@NotNull @Min(1) Long inboundOrderId,
@NotBlank @Size(max = 1000) String reason,
@NotBlank @Size(max = 32) String returnDirection,
@Size(max = 128) String returnLogisticsNo,
@NotNull @Size(min = 1, max = 200) List<ReturnItem> items,
@NotNull @Size(min = 1, max = 200) List<@NotNull ReturnItem> items,
@NotBlank @Size(max = 255) String idempotencyKey) {}
public record ReturnItem(@NotNull Long outboundPalletItemId,
@NotNull Long inventoryPositionId,
public record ReturnItem(@NotNull @Min(1) Long outboundPalletItemId,
@NotNull @Min(1) Long inventoryPositionId,
@Min(1) long packageCount) {}
public record StartProcessing(@Min(0) long expectedVersion,
@@ -44,15 +44,15 @@ public final class ExceptionOrderRequests {
public record Complete(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotBlank @Size(max = 2000) String handlingResult,
@NotNull @Size(min = 1, max = 20) List<String> evidenceFileIds,
@Size(max = 200) List<ReturnReceipt> receipts) {}
@NotNull @Size(min = 1, max = 20) List<@NotNull @Min(1) Long> evidenceFileIds,
@Size(max = 200) List<@NotNull ReturnReceipt> receipts) {}
public record ReturnReceipt(@NotNull Long outboundPalletItemId,
@NotNull Long inventoryPositionId,
public record ReturnReceipt(@NotNull @Min(1) Long outboundPalletItemId,
@NotNull @Min(1) Long inventoryPositionId,
@Min(1) long receivedPackages,
@NotNull Long targetPalletId,
@NotNull @Min(1) Long targetPalletId,
@NotBlank @Size(max = 128) String targetPalletNo,
@NotNull Long targetLocationId,
@NotNull @Min(1) Long targetLocationId,
@NotBlank @Size(max = 128) String targetLocationCode) {}
public record Cancel(@Min(0) long expectedVersion,
@@ -16,7 +16,7 @@ public final class ExceptionOrderResponses {
String returnLogisticsNo, String handlingAction,
String handlingResult, String handlerName, LocalDateTime exceptionAt,
LocalDateTime completedAt, LocalDateTime updatedAt, long version,
List<String> evidenceFileIds, List<String> allowedActions) {}
List<Long> evidenceFileIds, List<String> allowedActions) {}
public record Relation(Long lastMileOrderId, String lastMileOrderNo,
Long inboundOrderId, String inboundCode,
@@ -31,5 +31,6 @@ public final class ExceptionOrderResponses {
public record CommandResult(Long id, String exceptionNo, String status, long version,
Long inventoryAdjustmentId, long restoredPackages,
LocalDateTime completedAt, boolean replayed) {}
public record Created(Long id, String exceptionNo, String status, long version) {}
public record UploadedEvidence(Long fileId, String fileName, String contentType, long sizeBytes) {}
public record Created(Long id, String exceptionNo, String status, long version, boolean replayed) {}
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.exceptionorder.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface ExceptionEvidenceFileGateway {
Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file);
void validate(String customerId, long exceptionOrderId, long fileId);
record Uploaded(long fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.exceptionorder.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@Component
public class HttpExceptionEvidenceFileGateway implements ExceptionEvidenceFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_EXCEPTION_EVIDENCE";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getCustomerVisitFile());
}
@Override
public Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file) {
MultipartBodyBuilder body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(file.getContentType()));
} catch (IOException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取异常处理证据", exception); }
MetadataEnvelope response = client.post()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
tenantId(customerId), BUSINESS_TYPE, exceptionOrderId)
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", operatorId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(
result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"异常处理证据上传失败"); }).body(MetadataEnvelope.class);
Metadata data = require(response);
return new Uploaded(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes());
}
@Override
public void validate(String customerId, long exceptionOrderId, long fileId) {
long tenantId = tenantId(customerId);
MetadataEnvelope response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
tenantId, BUSINESS_TYPE, exceptionOrderId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(
result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"异常处理证据不存在或归属不正确"); }).body(MetadataEnvelope.class);
Metadata data = require(response);
if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenantId
|| !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(exceptionOrderId).equals(data.businessId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "异常处理证据与当前异常单不匹配");
}
}
private static Metadata require(MetadataEnvelope response) {
if (response == null || !"200".equals(response.code()) || response.data() == null || response.data().fileId() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回异常处理证据元数据");
}
return response.data();
}
private static long tenantId(String value) {
try { return Long.parseLong(value); }
catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "客户ID格式无效", exception); }
}
private record MetadataEnvelope(String code, String message, Metadata data) { }
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) { }
}
@@ -5,7 +5,6 @@ import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface ExceptionOrderMapper extends BaseMapper<ExceptionOrder> {
@@ -14,6 +14,7 @@ import com.baiyue.lastleg.delivery.entity.OutboundPdaPick;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
@@ -21,6 +22,7 @@ import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper;
@@ -39,9 +41,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -67,6 +69,7 @@ public class ExceptionOrderService {
@Resource private ExceptionReturnItemMapper returnItems;
@Resource private ExceptionOrderCommandMapper commands;
@Resource private InventoryCommandService inventoryCommands;
@Resource private ExceptionEvidenceFileGateway evidenceFiles;
@Resource private ObjectMapper objectMapper;
public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) {
@@ -102,9 +105,28 @@ public class ExceptionOrderService {
return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order));
}
public ExceptionOrderResponses.UploadedEvidence uploadEvidence(String customerId, String operatorId,
String exceptionNo, MultipartFile file) {
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在");
if (file == null || file.isEmpty()) throw badRequest("请选择异常处理证据文件");
ExceptionEvidenceFileGateway.Uploaded uploaded = evidenceFiles.upload(customerId, order.getId(), operatorId, file);
return new ExceptionOrderResponses.UploadedEvidence(uploaded.fileId(), uploaded.fileName(),
uploaded.contentType(), uploaded.sizeBytes());
}
public List<ExceptionOrderResponses.Relation> returnCandidates(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
throw conflict("当前派送状态不允许标记退回");
}
return returnCandidates(delivery);
}
private List<ExceptionOrderResponses.Relation> returnCandidates(DeliveryOrder delivery) {
String customerId = delivery.getCustomerId();
String deliveryNo = delivery.getDeliveryNo();
return lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo)
@@ -129,12 +151,15 @@ public class ExceptionOrderService {
String requestHash = hash(request);
ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) return replayCreated(replay, requestHash);
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
throw conflict("当前派送状态不允许标记退回");
}
ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId());
if (existing != null) throw conflict("该派送单已存在退回异常单");
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(customerId, deliveryNo);
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId()))
.findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单"));
@@ -145,8 +170,12 @@ public class ExceptionOrderService {
Set<String> skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item)))
.map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet());
String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED";
String returnDirection = trim(request.returnDirection()).toUpperCase(java.util.Locale.ROOT);
if (!Set.of("OVERSEAS_WAREHOUSE", "CUSTOMER", "OTHER").contains(returnDirection)) {
throw badRequest("不支持的退回方向");
}
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
packages, sku, request.returnDirection(), request.returnLogisticsNo(),
packages, sku, returnDirection, request.returnLogisticsNo(),
"DELIVERY_RETURN", operatorId, operatorName);
for (ExceptionOrderRequests.ReturnItem requestedItem : requested) {
@@ -163,11 +192,12 @@ public class ExceptionOrderService {
LocalDateTime now = LocalDateTime.now();
delivery.setStatus("RETURN_PENDING");
delivery.setUpdatedAt(now);
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
deliveryOrders.updateById(delivery);
LastlegOrder tail = lastMileOrders.selectById(selected.lastMileOrderId());
if (tail != null) {
tail.setAbnormal(true);
tail.setSubOrderStatus("RETURN_PENDING");
tail.setSubOrderStatus("DELIVERY:RETURN_PENDING");
tail.setUpdatedAt(now);
lastMileOrders.updateById(tail);
}
@@ -222,6 +252,7 @@ public class ExceptionOrderService {
ExceptionOrderCommand replay = commands.findByKey(customerId, "COMPLETE", request.idempotencyKey().trim());
if (replay != null) return replayResult(replay, requestHash);
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING");
request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId));
ReturnedInventoryRestoreResponse restored = null;
if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) {
List<ExceptionReturnItem> stored = returnItems.findByException(customerId, order.getId());
@@ -237,7 +268,7 @@ public class ExceptionOrderService {
receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(),
receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(),
receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(),
request.evidenceFileIds()));
request.evidenceFileIds().stream().map(String::valueOf).toList()));
} else if (request.receipts() != null && !request.receipts().isEmpty()) {
throw badRequest("非退回海外仓异常不能提交库存实收明细");
}
@@ -318,7 +349,7 @@ public class ExceptionOrderService {
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(), version(value));
return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus(), version(value), false);
}
private LastlegOrder uniqueTail(String customerId, String orderNo, String inboundCode) {
@@ -364,7 +395,7 @@ public class ExceptionOrderService {
value.getOutboundNo(), value.getSkuCode(), value.getPackageCount(), value.getReturnDirection(),
value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlingResult(), value.getHandlerName(),
value.getExceptionAt(), value.getCompletedAt(), value.getUpdatedAt(), version(value),
readStringList(value.getEvidenceFileIdsJson()), allowedActions(value));
readEvidenceFileIds(value.getEvidenceFileIdsJson()), allowedActions(value));
}
private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation,
@@ -430,11 +461,13 @@ public class ExceptionOrderService {
private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
DeliveryOrder delivery = deliveryOrders.findByNo(order.getCustomerId(), order.getDeliveryNo());
DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo());
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) {
throw conflict("关联派送单不是退货处理中状态");
}
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery);
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now);
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
deliveryOrders.updateById(delivery);
for (LastlegOrder tail : lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, order.getCustomerId())
.eq(LastlegOrder::getDeliveryOrderNo, order.getDeliveryNo()))) {
@@ -482,7 +515,10 @@ public class ExceptionOrderService {
private ExceptionOrderResponses.Created replayCreated(ExceptionOrderCommand command, String requestHash) {
requireReplayHash(command, requestHash);
try { return objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.Created.class); }
try {
ExceptionOrderResponses.Created result = objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.Created.class);
return new ExceptionOrderResponses.Created(result.id(), result.exceptionNo(), result.status(), result.version(), true);
}
catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); }
}
@@ -500,7 +536,7 @@ public class ExceptionOrderService {
catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存异常处理结果", exception); }
}
private List<String> readStringList(String value) {
private List<Long> readEvidenceFileIds(String value) {
if (!StringUtils.hasText(value)) return List.of();
try { return objectMapper.readValue(value, new TypeReference<>() { }); }
catch (JsonProcessingException exception) { throw new IllegalStateException("异常处理证据数据损坏", exception); }