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
+2 -2
View File
@@ -40,8 +40,8 @@
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`;平台维护拜访、附件上传、派送完成/状态 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`;异常订单建档、关联解析、派送退回候选与标记 |
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等,完成后生成回访记录 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口要求 `merchant:cross-border:domestic-trucking` |
@@ -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); }
@@ -282,6 +282,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
remark VARCHAR(1000) NULL COMMENT '派送备注',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
created_by VARCHAR(128) NULL COMMENT '创建人ID',
version BIGINT NOT NULL DEFAULT 0 COMMENT '派送单乐观锁版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
@@ -1919,9 +1920,12 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
delivery_order_id BIGINT NULL COMMENT '关联派送单ID', delivery_no VARCHAR(64) NULL COMMENT '关联派送单号', outbound_no VARCHAR(64) NULL COMMENT '关联出库单号',
customer_name VARCHAR(255) NULL COMMENT '客户名称快照', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照',
sku_code VARCHAR(128) NULL COMMENT '异常货物SKU', package_count INT NULL COMMENT '异常箱数', return_direction VARCHAR(32) NULL COMMENT '退回方向', return_logistics_no VARCHAR(128) NULL COMMENT '退回物流单号',
handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称',
handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handling_result VARCHAR(2000) NULL COMMENT '异常处理结果',
evidence_file_ids_json JSON NULL COMMENT '异常处理证据文件ID列表', inventory_adjustment_id BIGINT NULL COMMENT '退回库存恢复操作ID',
handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称',
source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '异常来源类型', created_by VARCHAR(128) NULL COMMENT '创建人ID',
exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', completed_at DATETIME(3) NULL COMMENT '异常处理完成时间',
version BIGINT NOT NULL DEFAULT 0 COMMENT '异常订单乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
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),
@@ -1929,6 +1933,25 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
KEY idx_ll_exception_inbound(customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='海外仓异常订单';
CREATE TABLE IF NOT EXISTS ll_exception_return_item (
id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID',
inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照',
sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限',
approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id),
KEY idx_ll_exception_return_order(customer_id,exception_order_id)
) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细';
CREATE TABLE IF NOT EXISTS ll_exception_order_command (
id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作',
idempotency_key VARCHAR(255) NOT NULL COMMENT '幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '请求内容SHA256哈希',
result_json JSON NOT NULL COMMENT '命令结果快照', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_command_key(customer_id,action,idempotency_key),
KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at)
) ENGINE=InnoDB COMMENT='异常处理命令幂等审计';
CREATE TABLE IF NOT EXISTS ll_customer_visit (
id BIGINT NOT NULL COMMENT '客户回访记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
@@ -643,6 +643,49 @@ paths:
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
responses: { '200': { description: 已创建异常订单 } }
/api/platform/v1/last-leg/exception-orders/{exceptionNo}:
get:
summary: 查询异常订单详情与冻结退回明细
description: 权限 platform:overseas-warehouse:exception-order:list;返回 version 和服务端 allowedActions。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 异常订单详情 }, '404': { description: 异常订单不存在 } }
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing:
post:
summary: 开始处理异常订单
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→PROCESSING,同版本与同幂等请求可重放。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionStartProcessing' } } } }
responses: { '200': { description: 异常处理命令结果 }, '409': { description: 版本、状态或幂等冲突 } }
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/evidence:
post:
summary: 上传异常处理证据
description: 权限 platform:overseas-warehouse:exception-order:process;文件按当前客户和异常订单绑定为 OVERSEAS_EXCEPTION_EVIDENCE。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
requestBody:
required: true
content:
multipart/form-data:
schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } }
responses: { '200': { description: 异常处理证据元数据 }, '400': { description: 文件或归属无效 } }
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete:
post:
summary: 完成异常处理并原子恢复退回库存
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PROCESSING→COMPLETED。退回海外仓必须提交证据和实收目标,库存 RETURN_STOCK_IN 成功后才完成异常单和派送退回。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionComplete' } } } }
responses: { '200': { description: 异常处理与库存恢复结果 }, '409': { description: 版本、状态、归属、数量或幂等冲突 } }
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel:
post:
summary: 取消未处理异常订单
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→CANCELLED。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionCancel' } } } }
responses: { '200': { description: 异常取消结果 }, '409': { description: 版本、状态或幂等冲突 } }
/api/platform/v1/last-leg/exception-orders/resolve:
get:
summary: 解析异常订单关联关系
@@ -652,17 +695,17 @@ paths:
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/return-inbound-options:
get:
summary: 查询派送退回候选入仓单
description: 权限 platform:overseas-warehouse:exception-order:list;属于异常订单列表只读辅助接口。
description: 权限 platform:overseas-warehouse:exception-order:list;返回按原出库拣货事实解析的 returnSources,页面不得自由填写 SKU 或猜测库存位置。
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 退回候选入仓单 } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return:
post:
summary: 将派送单标记为退回入仓
description: 权限 platform:overseas-warehouse:delivery:return。
description: 权限 platform:overseas-warehouse:delivery:return;items 必须来自 return-inbound-options,服务端冻结源出库明细与批准退回上限。
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryMarkReturn' } } } }
responses: { '200': { description: 已创建退回异常单 } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/complete:
post:
@@ -670,7 +713,8 @@ paths:
description: 权限 platform:overseas-warehouse:delivery:status。仅 WAIT_DELIVERY/DELIVERING 可完成;DELIVERED 同状态重放成功,其他终态或倒退返回 409。
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 派送状态与回访记录 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } }
responses: { '200': { description: 派送状态与回访记录 }, '409': { description: 版本或状态冲突 } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status:
put:
summary: 更新派送状态
@@ -1709,14 +1753,74 @@ components:
fees: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } }
DeliveryStatusChange:
type: object
required: [status]
required: [status, expectedVersion]
properties:
status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, DELIVERED, REJECTED, CANCELLED] }
expectedVersion: { type: integer, format: int64, minimum: 0 }
remark: { type: string, nullable: true, maxLength: 500 }
DeliveryAllowedAction:
type: string
enum: [START_DELIVERY, COMPLETE_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN]
description: START/COMPLETE/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口。
DeliveryMarkReturn:
type: object
required: [inboundOrderId, reason, returnDirection, items, idempotencyKey]
properties:
inboundOrderId: { type: string, pattern: '^[1-9][0-9]*$' }
reason: { type: string, minLength: 1, maxLength: 1000 }
returnDirection: { type: string, enum: [OVERSEAS_WAREHOUSE, CUSTOMER, OTHER] }
returnLogisticsNo: { type: string, nullable: true, maxLength: 128 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
items:
type: array
minItems: 1
maxItems: 200
items:
type: object
required: [outboundPalletItemId, inventoryPositionId, packageCount]
properties:
outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' }
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' }
packageCount: { type: integer, format: int64, minimum: 1 }
ExceptionStartProcessing:
type: object
required: [expectedVersion, idempotencyKey]
properties:
expectedVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
handlingAction: { type: string, nullable: true, maxLength: 1000 }
ExceptionCancel:
type: object
required: [expectedVersion, idempotencyKey, reason]
properties:
expectedVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
reason: { type: string, minLength: 1, maxLength: 1000 }
ExceptionComplete:
type: object
required: [expectedVersion, idempotencyKey, handlingResult, evidenceFileIds]
properties:
expectedVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
handlingResult: { type: string, minLength: 1, maxLength: 2000 }
evidenceFileIds: { type: array, minItems: 1, maxItems: 20, items: { type: string } }
receipts:
type: array
maxItems: 200
items:
type: object
required: [outboundPalletItemId, inventoryPositionId, receivedPackages, targetPalletId, targetPalletNo, targetLocationId, targetLocationCode]
properties:
outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' }
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' }
receivedPackages: { type: integer, format: int64, minimum: 1 }
targetPalletId: { type: string, pattern: '^[1-9][0-9]*$' }
targetPalletNo: { type: string, minLength: 1, maxLength: 128 }
targetLocationId: { type: string, pattern: '^[1-9][0-9]*$' }
targetLocationCode: { type: string, minLength: 1, maxLength: 128 }
ExceptionAllowedAction:
type: string
enum: [START_PROCESSING, COMPLETE_EXCEPTION, CANCEL_EXCEPTION]
DeliveryFeeInput:
type: object
required: [feeType, currency, amount]
@@ -6,6 +6,8 @@ import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper;
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.LastlegOrder;
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;
@@ -26,6 +28,7 @@ class CustomerVisitServiceTest {
@Mock private CustomerVisitMapper visits;
@Mock private DeliveryOrderMapper deliveryOrders;
@Mock private DeliveryOperationMapper deliveryOperations;
@Mock private LastlegOrderMapper lastMileOrders;
private CustomerVisitService service;
@BeforeEach
@@ -34,15 +37,16 @@ class CustomerVisitServiceTest {
ReflectionTestUtils.setField(service, "visits", visits);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
}
@Test
void rejectsRollbackFromDeliveredToWaitDeliveryWithoutWritingHistory() {
DeliveryOrder delivery = delivery("DELIVERED");
when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", "错误倒退")))
new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", 0L, "错误倒退")))
.isInstanceOf(ResponseStatusException.class)
.satisfies(error -> assertThat(((ResponseStatusException) error).getStatusCode().value()).isEqualTo(409))
.hasMessageContaining("DELIVERED").hasMessageContaining("WAIT_DELIVERY");
@@ -57,11 +61,11 @@ class CustomerVisitServiceTest {
CustomerVisit visit = new CustomerVisit();
visit.setId(33L);
visit.setStatus("PENDING");
when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", null));
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null));
assertThat(result.replayed()).isTrue();
assertThat(result.deliveryStatus()).isEqualTo("DELIVERED");
@@ -70,6 +74,39 @@ class CustomerVisitServiceTest {
verifyNoInteractions(deliveryOperations);
}
@Test
void rejectsForwardTransitionWithStaleVersion() {
DeliveryOrder delivery = delivery("WAIT_DELIVERY");
delivery.setVersion(3L);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERING", 2L, null)))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
verify(deliveryOrders, never()).updateById(delivery);
verifyNoInteractions(deliveryOperations);
}
@Test
void completingDeliveryUpdatesOnlySubOrderStatusAndKeepsMainFlowStatus() {
DeliveryOrder delivery = delivery("DELIVERING");
delivery.setVersion(1L);
LastlegOrder tail = new LastlegOrder();
tail.setId(21L); tail.setCustomerId("1001"); tail.setCurrentStatus("INBOUND_COMPLETED");
CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING");
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(lastMileOrders.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(tail));
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null));
assertThat(result.replayed()).isFalse();
assertThat(tail.getCurrentStatus()).isEqualTo("INBOUND_COMPLETED");
assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED");
}
private DeliveryOrder delivery(String status) {
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(11L);
@@ -66,6 +66,6 @@ class DeliveryLongIdJsonContractTest {
DELIVERY_ID,
null, null, "PS-1", null, null, null, null, "CREATED", null,
null, null, null, null, null, null, null, null, null, null, null,
0, 0, null, null, null, null, null, null, null, null, java.util.List.of());
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of());
}
}
@@ -8,7 +8,7 @@ class DeliveryStatusPolicyTest {
@Test
void exposesOnlyServerAuthorizedActionsForEachState() {
assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY"))
.containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY", "MARK_RETURN");
.containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING"))
.containsExactly("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN");
@@ -0,0 +1,159 @@
package com.baiyue.lastleg.exceptionorder.service;
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.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;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
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.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.List;
@ExtendWith(MockitoExtension.class)
class ExceptionOrderLifecycleTest {
@Mock private ExceptionOrderMapper exceptionOrders;
@Mock private ExceptionOrderCommandMapper commands;
@Mock private ExceptionReturnItemMapper returnItems;
@Mock private InventoryCommandService inventoryCommands;
@Mock private ExceptionEvidenceFileGateway evidenceFiles;
@Mock private DeliveryOrderMapper deliveryOrders;
@Mock private DeliveryOperationMapper deliveryOperations;
@Mock private LastlegOrderMapper lastMileOrders;
private ExceptionOrderService service;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
service = new ExceptionOrderService();
objectMapper = new ObjectMapper().findAndRegisterModules();
ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "returnItems", returnItems);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "evidenceFiles", evidenceFiles);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
}
@Test
void startsPendingExceptionWithVersionGateAndPersistsIdempotentResult() {
ExceptionOrder order = order("PENDING", 0);
when(commands.findByKey("1001", "START_PROCESSING", "start-1")).thenReturn(null);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
var result = service.startProcessing("1001", "9001", "EX-1",
new ExceptionOrderRequests.StartProcessing(0, "start-1", "联系客户并核对退件"));
assertThat(result.status()).isEqualTo("PROCESSING");
assertThat(result.version()).isEqualTo(1);
assertThat(result.replayed()).isFalse();
verify(exceptionOrders).updateById(order);
ArgumentCaptor<ExceptionOrderCommand> command = ArgumentCaptor.forClass(ExceptionOrderCommand.class);
verify(commands).insert(command.capture());
assertThat(command.getValue().getResultJson()).contains("PROCESSING");
}
@Test
void rejectsStaleVersionWithoutMutation() {
when(commands.findByKey("1001", "START_PROCESSING", "start-2")).thenReturn(null);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order("PENDING", 2));
assertThatThrownBy(() -> service.startProcessing("1001", "9001", "EX-1",
new ExceptionOrderRequests.StartProcessing(1, "start-2", null)))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
verify(exceptionOrders, never()).updateById(any(ExceptionOrder.class));
verify(commands, never()).insert(any(ExceptionOrderCommand.class));
}
@Test
void replaysSameCommandWithoutUpdatingAgain() throws Exception {
ExceptionOrderResponses.CommandResult saved = new ExceptionOrderResponses.CommandResult(
1L, "EX-1", "PROCESSING", 1, null, 0, null, false);
ExceptionOrderRequests.StartProcessing request =
new ExceptionOrderRequests.StartProcessing(0, "start-3", null);
ExceptionOrderCommand command = new ExceptionOrderCommand();
command.setRequestHash(hashFor(request));
command.setResultJson(objectMapper.writeValueAsString(saved));
when(commands.findByKey("1001", "START_PROCESSING", "start-3")).thenReturn(command);
var replayed = service.startProcessing("1001", "9001", "EX-1", request);
assertThat(replayed.replayed()).isTrue();
assertThat(replayed.status()).isEqualTo("PROCESSING");
verify(exceptionOrders, never()).lockByNo(any(), any());
}
@Test
void completesWarehouseReturnOnlyAfterInventoryRestoreSucceeds() {
ExceptionOrder order = order("PROCESSING", 1);
order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE");
order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("WH-1"); order.setInboundOrderId(44L);
order.setDeliveryNo("DL-1");
ExceptionReturnItem source = new ExceptionReturnItem();
source.setOutboundPalletItemId(51L); source.setInventoryPositionId(61L);
source.setSkuCode("SKU-1"); source.setApprovedReturnPackages(2L);
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(71L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1");
delivery.setStatus("RETURN_PENDING"); delivery.setVersion(2L);
LocalDateTime completedAt = LocalDateTime.of(2026, 9, 8, 18, 30);
when(commands.findByKey("1001", "COMPLETE", "complete-1")).thenReturn(null);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
when(returnItems.findByException("1001", 1L)).thenReturn(List.of(source));
when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)))
.thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, 2, 2, false, completedAt));
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(lastMileOrders.selectList(any())).thenReturn(List.of());
ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓",
List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L,
"PLT-201", 301L, "LOC-301")));
var result = service.complete("1001", "9001", "EX-1", request);
assertThat(result.status()).isEqualTo("COMPLETED");
assertThat(result.inventoryAdjustmentId()).isEqualTo(81L);
assertThat(result.restoredPackages()).isEqualTo(2);
assertThat(delivery.getStatus()).isEqualTo("RETURNED");
verify(evidenceFiles).validate("1001", 1L, 101L);
verify(inventoryCommands).restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class));
}
private String hashFor(Object request) {
return (String) ReflectionTestUtils.invokeMethod(service, "hash", request);
}
private ExceptionOrder order(String status, long version) {
ExceptionOrder order = new ExceptionOrder();
order.setId(1L); order.setCustomerId("1001"); order.setExceptionNo("EX-1");
order.setStatus(status); order.setVersion(version);
return order;
}
}
@@ -24,6 +24,11 @@ class Phase5AdminPermissionContractTest {
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("page"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("resolve"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_CREATE), permissions.get("create"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("detail"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("uploadEvidence"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("startProcessing"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("complete"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("cancel"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("returnOptions"));
assertEquals(authority(OverseasPermissions.DELIVERY_RETURN), permissions.get("markReturn"));
}
@@ -49,6 +54,7 @@ class Phase5AdminPermissionContractTest {
OverseasPermissions.DISPATCH_LIST,
OverseasPermissions.EXCEPTION_ORDER_LIST,
OverseasPermissions.EXCEPTION_ORDER_CREATE,
OverseasPermissions.EXCEPTION_ORDER_PROCESS,
OverseasPermissions.DELIVERY_RETURN,
OverseasPermissions.DELIVERY_STATUS,
OverseasPermissions.CUSTOMER_VISIT_LIST,
@@ -59,6 +65,10 @@ class Phase5AdminPermissionContractTest {
for (String path : new String[] {
"/api/platform/v1/last-leg/dispatches:",
"/api/platform/v1/last-leg/exception-orders:",
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}:",
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing:",
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete:",
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel:",
"/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return:",
"/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status:",
"/api/platform/v1/last-leg/customer-visits:",