This commit is contained in:
2026-10-30 18:46:18 +08:00
parent 51a93a69af
commit edd4b1cd38
6 changed files with 79 additions and 3 deletions
@@ -90,6 +90,15 @@ public class ExceptionOrderController {
return ApiResponse.success(service.complete(requiredScope(user, customerId), operatorId(user), exceptionNo, request));
}
/** 选择其他并填写原因后人工完结,仅处理异常工单状态。 */
@PostMapping("/exception-orders/{exceptionNo}/force-close")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.CommandResult> forceClose(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.ForceClose request) {
return ApiResponse.success(service.forceClose(requiredScope(user, customerId), operatorId(user), exceptionNo, request));
}
@PostMapping("/exception-orders/{exceptionNo}/cancel")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.CommandResult> cancel(
@@ -59,4 +59,9 @@ public final class ExceptionOrderRequests {
public record Cancel(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotBlank @Size(max = 1000) String reason) {}
/** 其他处理方式必须保留人工完结原因,不提交库存实收或文件。 */
public record ForceClose(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotBlank @Size(max = 1000) String reason) {}
}
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.exceptionorder.repository;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionWorkflowMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
@@ -11,6 +13,7 @@ import java.util.List;
/** 异常退回尾程关系及仓库任务的数据访问边界。 */
@Repository
public class ExceptionWorkflowRepository {
@jakarta.annotation.Resource private ExceptionOrderCommandMapper commands;
private final ExceptionWorkflowMapper workflow;
private final ExceptionOrderMapper exceptions;
private final LastlegOrderMapper orders;
@@ -22,6 +25,14 @@ public class ExceptionWorkflowRepository {
public List<ExceptionWorkflowMapper.Delivery> deliveries(long id) { return workflow.deliveries(id); }
/** 在客户范围锁定异常。 */
public ExceptionOrder lock(String customer, String number) { return exceptions.lockByNo(customer,number); }
/** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */
public void updateException(ExceptionOrder order) { exceptions.updateById(order); }
/** 在客户及动作范围读取人工完结的幂等结果。 */
public ExceptionOrderCommand closeCommand(String customer, String key) {
return commands.findByKey(customer, "FORCE_CLOSE", key);
}
/** 同事务保存人工完结原因与原操作结果。 */
public void insertCommand(ExceptionOrderCommand command) { commands.insert(command); }
/** 在冻结仓范围锁定异常。 */
public ExceptionOrder lockWarehouse(String warehouse, String number) { return workflow.lockWarehouse(warehouse,number); }
/** 查询原尾程单以冻结来源关系。 */
@@ -330,6 +330,35 @@ public class ExceptionOrderService {
return result;
}
/** 其他处理方式只完结异常工单,不伪造退回、签收、库存或运输完成事实。 */
@Transactional
public ExceptionOrderResponses.CommandResult forceClose(String customerId, String operatorId,
String exceptionNo, ExceptionOrderRequests.ForceClose request) {
String reason = trim(request.reason());
if (reason == null || reason.length() > 1000) throw badRequest("请选择其他处理方式并填写完结原因(最多1000字)");
ExceptionOrder order = workflow.lock(customerId, exceptionNo);
if (order == null) throw notFound("异常单不存在");
String requestHash = hash(new ExceptionOrderRequests.ForceClose(request.expectedVersion(), request.idempotencyKey().trim(), reason));
// 同一异常父锁内先恢复已提交结果,避免重复点击推进版本或重复写入审计命令。
ExceptionOrderCommand replay = workflow.closeCommand(customerId, request.idempotencyKey().trim());
if (replay != null) {
if (!Objects.equals(replay.getExceptionOrderId(), order.getId())) throw conflict("幂等键已用于其他异常单");
return replayResult(replay, requestHash);
}
if (!java.util.Set.of("PENDING", "PROCESSING").contains(Objects.requireNonNullElse(order.getStatus(), ""))
|| version(order) != request.expectedVersion()) throw conflict("异常状态或版本已变化,请刷新");
LocalDateTime now = LocalDateTime.now();
order.setStatus("COMPLETED"); order.setHandlingAction("其他"); order.setHandlingResult(reason);
order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now); order.setVersion(version(order) + 1);
workflow.updateException(order);
var result = commandResult(order, 0, false);
var command = new ExceptionOrderCommand();
command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setExceptionOrderId(order.getId());
command.setAction("FORCE_CLOSE"); command.setIdempotencyKey(request.idempotencyKey().trim()); command.setRequestHash(requestHash);
command.setResultJson(write(result)); command.setCreatedAt(now); workflow.insertCommand(command);
return result;
}
/** 一次实收的普通库存、原箱账本及完成状态同事务提交,失败全部回滚。 */
@Transactional
public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId,
@@ -688,14 +717,17 @@ public class ExceptionOrderService {
}
}
/** 所有未结束异常均可选择其他方式,原退回及正常完成动作继续按资格开放。 */
private List<String> allowedActions(ExceptionOrder order) {
return switch (Objects.requireNonNullElse(order.getStatus(), "")) {
List<String> actions = new ArrayList<>(switch (Objects.requireNonNullElse(order.getStatus(), "")) {
case "PENDING" -> "RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())
? List.of("GENERATE_RETURN_INBOUND","CANCEL_EXCEPTION") : List.of("START_PROCESSING", "CANCEL_EXCEPTION");
case "PROCESSING" -> "RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection()) && workflow.returnOrder(order)==null
? List.of("GENERATE_RETURN_INBOUND") : List.of("COMPLETE_EXCEPTION");
default -> List.of();
};
});
if (java.util.Set.of("PENDING", "PROCESSING").contains(Objects.requireNonNullElse(order.getStatus(), ""))) actions.add("FORCE_CLOSE");
return List.copyOf(actions);
}
private ExceptionOrderResponses.CommandResult commandResult(ExceptionOrder order, long restoredPackages,
@@ -2309,6 +2309,16 @@ paths:
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}/force-close:
post:
summary: 选择其他方式人工完结异常
description: 权限 platform:overseas-warehouse:exception-order:process;所有类型的 PENDING/PROCESSING 异常均可填写原因后转 COMPLETED,无需文件及实收信息。只更新异常处理方式、原因、处理人及完成时间,保留原有实收、证据和库存事实,不推进关联退回单、派送或车辆资源。expectedVersion 校验并在异常父锁内保存幂等结果。
security: [{ bearerAuth: [] }]
parameters:
- { name: exceptionNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionForceClose' } } } }
responses: { '200': { description: 人工完结结果 }, '400': { description: 未填写原因 }, '409': { description: 版本、状态或幂等冲突 } }
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel:
post:
summary: 取消未处理异常订单
@@ -4193,6 +4203,13 @@ components:
expectedVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
reason: { type: string, minLength: 1, maxLength: 1000 }
ExceptionForceClose:
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, description: 其他处理方式的人工完结原因 }
ExceptionComplete:
type: object
required: [expectedVersion, idempotencyKey, handlingResult, evidenceFileIds]
@@ -4218,7 +4235,7 @@ components:
targetLocationCode: { type: string, minLength: 1, maxLength: 128 }
ExceptionAllowedAction:
type: string
enum: [START_PROCESSING, COMPLETE_EXCEPTION, CANCEL_EXCEPTION]
enum: [START_PROCESSING, COMPLETE_EXCEPTION, CANCEL_EXCEPTION, GENERATE_RETURN_INBOUND, FORCE_CLOSE]
DeliveryFeeInput:
type: object
required: [feeType, feeName, direction, currency, amount]
@@ -29,6 +29,7 @@ class Phase5AdminPermissionContractTest {
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("forceClose"));
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"));
@@ -69,6 +70,7 @@ class Phase5AdminPermissionContractTest {
"/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}/force-close:",
"/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:",