diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java index a496315f1..27250379d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java @@ -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 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 cancel( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java index 18754d6f9..2a0097abf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java @@ -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) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java index 882cee507..2b083f3d0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java @@ -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 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); } /** 查询原尾程单以冻结来源关系。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index ebd89a395..ae252e375 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -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 allowedActions(ExceptionOrder order) { - return switch (Objects.requireNonNullElse(order.getStatus(), "")) { + List 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, diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 58a7dd071..2cb0037f5 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java index ca1d65678..936e93907 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java @@ -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:",