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
@@ -68,8 +68,8 @@ public class CustomerVisitController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')")
public ApiResponse<CustomerVisitResponses.Completed> completeDelivery(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo) {
return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo));
@PathVariable String deliveryNo, @Valid @RequestBody CustomerVisitRequests.DeliveryStatus request) {
return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo, request));
}
@PutMapping("/delivery-orders/{deliveryNo}/status")
@@ -31,5 +31,6 @@ public final class CustomerVisitRequests {
@NotNull @Min(0) Long sizeBytes) { }
public record DeliveryStatus(@NotBlank @Size(max = 32) String status,
@NotNull @Min(0) Long expectedVersion,
@Size(max = 500) String remark) { }
}
@@ -15,10 +15,8 @@ import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -52,7 +50,6 @@ public class CustomerVisitService {
@Resource private DeliveryOperationMapper deliveryOperations;
@Resource private LastlegOrderMapper lastMileOrders;
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private CustomerVisitFileGateway files;
@Resource private ObjectMapper objectMapper;
@@ -80,29 +77,31 @@ public class CustomerVisitService {
}
@Transactional
public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo,
CustomerVisitRequests.DeliveryStatus request) {
if (!"DELIVERED".equals(trim(request.status()).toUpperCase(Locale.ROOT))) {
throw badRequest("派送完成接口只接受 DELIVERED 状态");
}
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
boolean replayed = "DELIVERED".equals(delivery.getStatus());
if (!replayed) requireDeliveryTransition(delivery.getStatus(), "DELIVERED");
if (!replayed) {
requireVersion(delivery, request.expectedVersion());
requireDeliveryTransition(delivery.getStatus(), "DELIVERED");
}
LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now();
if (!replayed) {
delivery.setStatus("DELIVERED");
delivery.setSignedAt(now);
delivery.setUpdatedAt(now);
delivery.setVersion(version(delivery) + 1);
deliveryOrders.updateById(delivery);
for (LastlegOrder order : lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo))) {
String from = order.getCurrentStatus();
order.setCurrentStatus("DELIVERED");
order.setSubOrderStatus("DELIVERY:DELIVERED");
order.setUpdatedAt(now);
lastMileOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from);
history.setToStatus("DELIVERED"); history.setEventId("DELIVERY_COMPLETED:" + deliveryNo);
history.setRemark("派送完成"); history.setCreatedAt(now); histories.insert(history);
}
recordOperation(delivery, operatorId, "COMPLETE_DELIVERY", "派送完成并生成客户回访记录", now);
}
@@ -118,20 +117,22 @@ public class CustomerVisitService {
if (!DeliveryStatusPolicy.isEditableTarget(target)) {
throw badRequest("不支持的派送状态");
}
if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo);
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo, request);
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (target.equals(delivery.getStatus())) {
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo,
delivery.getStatus(), existing == null ? null : existing.getStatus(), true);
}
requireVersion(delivery, request.expectedVersion());
requireDeliveryTransition(delivery.getStatus(), target);
String from = delivery.getStatus();
LocalDateTime now = LocalDateTime.now();
delivery.setStatus(target);
if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now);
delivery.setUpdatedAt(now);
delivery.setVersion(version(delivery) + 1);
deliveryOrders.updateById(delivery);
String result = "派送状态由 " + from + " 修改为 " + target;
if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark());
@@ -313,6 +314,10 @@ public class CustomerVisitService {
throw conflict("派送状态不允许由 " + from + " 变更为 " + target);
}
}
private static void requireVersion(DeliveryOrder delivery, long expectedVersion) {
if (version(delivery) != expectedVersion) throw conflict("派送单版本已变化,请刷新后重试");
}
private static long version(DeliveryOrder delivery) { return Objects.requireNonNullElse(delivery.getVersion(), 0L); }
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); }
@@ -24,7 +24,7 @@ public final class DeliveryResponses {
int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm,
LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt,
LocalDateTime expectedArrivalAt, LocalDateTime signedAt,
String remark, LocalDateTime createdAt, List<String> allowedActions) {}
String remark, LocalDateTime createdAt, long version, List<String> allowedActions) {}
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo,
@@ -47,6 +47,7 @@ public class DeliveryOrder {
private String remark;
private String idempotencyKey;
private String createdBy;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -10,6 +10,8 @@ import org.apache.ibatis.annotations.Select;
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1")
DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1 FOR UPDATE")
DeliveryOrder lockByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE")
@@ -13,6 +13,7 @@ public final class DeliveryStatusPolicy {
"DELIVERED", Set.of(),
"REJECTED", Set.of(),
"RETURN_PENDING", Set.of(),
"RETURNED", Set.of(),
"CANCELLED", Set.of());
private DeliveryStatusPolicy() { }
@@ -28,7 +29,7 @@ public final class DeliveryStatusPolicy {
public static List<String> allowedActions(String status) {
return switch (status == null ? "" : status) {
case "WAIT_DELIVERY" -> List.of(
"START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY", "MARK_RETURN");
"START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
case "DELIVERING" -> List.of("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN");
case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN");
default -> List.of();
@@ -170,7 +170,7 @@ public class DeliveryTransferService {
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName());
LastlegOrder firstOrder = firstTailOrder(customerId, items);
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY");
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim());
delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod()));
delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize()));
@@ -387,7 +387,7 @@ public class DeliveryTransferService {
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
LocalDateTime now = LocalDateTime.now();
DeliveryOrder delivery = existingDelivery;
if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); }
if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); }
OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId());
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); }
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
@@ -614,7 +614,7 @@ public class DeliveryTransferService {
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
value(candidate.getAvailablePackages()));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), DeliveryStatusPolicy.allowedActions(d.getStatus())); }
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus())); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
@@ -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); }