Explorar el Código

feat(order): 实现平台订单状态受控调整

qmj hace 1 día
padre
commit
0128731a9e

+ 606 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java

@@ -0,0 +1,606 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.ruoyi.app.order.dto.AdminOrderActionRequest;
+import com.ruoyi.app.order.dto.AdminOrderStatusContext;
+import com.ruoyi.app.order.dto.AdminOrderStatusUpdateRequest;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.PointsTransaction;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderOmgPayment;
+import com.ruoyi.system.domain.PosOrderOmgRefund;
+import com.ruoyi.system.domain.UserWallet;
+import com.ruoyi.system.service.IPosOrderOmgPaymentService;
+import com.ruoyi.system.service.IPosOrderOmgRefundService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPointsTransactionService;
+import com.ruoyi.system.service.IUserWalletService;
+import com.ruoyi.system.utils.OrderLogHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+
+@Service
+public class OrderLifecycleService {
+
+    public static final String PAY_TYPE_OFFLINE = "1";
+    public static final String PAY_TYPE_OMG = "7";
+
+    private final IPosOrderService posOrderService;
+    private final OrderService billingService;
+    private final OrderLogHelper orderLogHelper;
+    private final IUserWalletService userWalletService;
+    private final IPointsTransactionService pointsTransactionService;
+    private final IPosOrderOmgPaymentService omgPaymentService;
+    private final IPosOrderOmgRefundService omgRefundService;
+
+    @Autowired
+    public OrderLifecycleService(IPosOrderService posOrderService,
+                                 OrderService billingService,
+                                 OrderLogHelper orderLogHelper,
+                                 IUserWalletService userWalletService,
+                                 IPointsTransactionService pointsTransactionService,
+                                 IPosOrderOmgPaymentService omgPaymentService,
+                                 IPosOrderOmgRefundService omgRefundService) {
+        this.posOrderService = posOrderService;
+        this.billingService = billingService;
+        this.orderLogHelper = orderLogHelper;
+        this.userWalletService = userWalletService;
+        this.pointsTransactionService = pointsTransactionService;
+        this.omgPaymentService = omgPaymentService;
+        this.omgRefundService = omgRefundService;
+    }
+
+    public AdminOrderStatusContext getStatusContext(Long id) {
+        return buildContext(requireOrder(id));
+    }
+
+    public AdminOrderStatusContext validateOmgReconcile(Long id, AdminOrderActionRequest request) {
+        requireReason(request);
+        PosOrder order = requireOrder(id);
+        validateSnapshot(order, request);
+        if (!PAY_TYPE_OMG.equals(order.getPayType()) || order.getPayStatus() != 0L
+                || order.getState() == 4L || order.getAfterSaleStatus() != 0L) {
+            throw new ServiceException("当前订单状态不允许 OMG 支付补单");
+        }
+        PosOrderOmgPayment payment = omgPaymentService.getLatestByDdId(String.valueOf(order.getDdId()));
+        if (payment == null || !Integer.valueOf(0).equals(payment.getPayStatus())) {
+            throw new ServiceException("没有可查询的 OMG 未支付流水");
+        }
+        return buildContext(order);
+    }
+
+    public AdminOrderStatusContext validateOmgRefund(Long id, AdminOrderActionRequest request) {
+        requireReason(request);
+        PosOrder order = requireOrder(id);
+        validateSnapshot(order, request);
+        if (!PAY_TYPE_OMG.equals(order.getPayType())) {
+            throw new ServiceException("该订单非 OMG 支付");
+        }
+        validateRefundableOrder(order);
+        PosOrderOmgPayment payment = omgPaymentService.getLatestByDdId(String.valueOf(order.getDdId()));
+        if (payment == null || (!Integer.valueOf(1).equals(payment.getPayStatus())
+                && !Integer.valueOf(3).equals(payment.getPayStatus()))) {
+            throw new ServiceException("OMG 支付流水状态不允许退款");
+        }
+        return buildContext(order);
+    }
+
+    public void auditExternalAction(Long id, AdminOrderActionRequest request,
+                                    Long operatorId, String operatorName, String action) {
+        PosOrder order = requireOrder(id);
+        logStatusChange(order, request, operatorId, operatorName,
+                request.getExpectedState(), request.getExpectedDeliveryStatus(),
+                request.getExpectedPayStatus(), request.getExpectedAfterSaleStatus(), action);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public AdminOrderStatusContext updateStatus(Long id, AdminOrderStatusUpdateRequest request,
+                                                Long operatorId, String operatorName) {
+        requireReason(request);
+        PosOrder order = requireOrder(id);
+        validateSnapshot(order, request);
+        rejectNormalAdjustmentWhenBlocked(order);
+
+        Long targetState = request.getTargetState();
+        Long targetDelivery = request.getTargetDeliveryStatus();
+        if (targetState == null && targetDelivery == null) {
+            throw new ServiceException("请选择需要调整的状态");
+        }
+
+        Long nextState = order.getState();
+        Long nextDelivery = order.getDeliveryStatus();
+        Date deliveredAt = order.getSdTime();
+
+        if (targetDelivery != null && !Objects.equals(targetDelivery, order.getDeliveryStatus())) {
+            validateDeliveryTransition(order, targetDelivery);
+            nextDelivery = targetDelivery;
+            if (targetDelivery == 3L) {
+                nextState = 3L;
+                deliveredAt = new Date();
+            }
+        }
+
+        if (targetState != null && !Objects.equals(targetState, order.getState())) {
+            validateOrderTransition(order, targetState, nextDelivery);
+            nextState = targetState;
+            if (order.getState() == 1L && targetState == 2L
+                    && order.getType() == 0L && nextDelivery == null) {
+                nextDelivery = 0L;
+            }
+        }
+
+        if (Objects.equals(nextState, order.getState())
+                && Objects.equals(nextDelivery, order.getDeliveryStatus())) {
+            return buildContext(order);
+        }
+        validateResultInvariants(order, nextState, nextDelivery, order.getPayStatus(), order.getAfterSaleStatus());
+
+        PosOrder update = new PosOrder();
+        update.setState(nextState);
+        update.setDeliveryStatus(nextDelivery);
+        if (nextState == 3L && order.getState() != 3L) {
+            update.setSdTime(deliveredAt == null ? new Date() : deliveredAt);
+        }
+        applyCas(order, update);
+
+        order.setState(nextState);
+        order.setDeliveryStatus(nextDelivery);
+        if (update.getSdTime() != null) {
+            order.setSdTime(update.getSdTime());
+        }
+        if (nextState == 3L) {
+            completeSideEffects(order);
+        }
+        logStatusChange(order, request, operatorId, operatorName,
+                request.getExpectedState(), request.getExpectedDeliveryStatus(),
+                request.getExpectedPayStatus(), request.getExpectedAfterSaleStatus(),
+                "平台调整[订单/配送]");
+        return buildContext(order);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public AdminOrderStatusContext confirmOfflinePayment(Long id, AdminOrderActionRequest request,
+                                                         Long operatorId, String operatorName) {
+        requireReason(request);
+        PosOrder order = requireOrder(id);
+        validateSnapshot(order, request);
+        if (!PAY_TYPE_OFFLINE.equals(order.getPayType())) {
+            throw new ServiceException("仅线下支付订单可以确认收款,OMG 订单请使用支付补单");
+        }
+        if (order.getPayStatus() != 0L || order.getState() == 3L || order.getState() == 4L
+                || order.getAfterSaleStatus() != 0L) {
+            throw new ServiceException("当前订单状态不允许确认线下收款");
+        }
+
+        PosOrder update = new PosOrder();
+        update.setPayStatus(1L);
+        applyCas(order, update);
+        order.setPayStatus(1L);
+        logStatusChange(order, request, operatorId, operatorName,
+                request.getExpectedState(), request.getExpectedDeliveryStatus(),
+                request.getExpectedPayStatus(), request.getExpectedAfterSaleStatus(),
+                "平台确认线下收款");
+        return buildContext(order);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public AdminOrderStatusContext confirmOfflineRefund(Long id, AdminOrderActionRequest request,
+                                                        Long operatorId, String operatorName) {
+        requireReason(request);
+        PosOrder order = requireOrder(id);
+        validateSnapshot(order, request);
+        if (!PAY_TYPE_OFFLINE.equals(order.getPayType())) {
+            throw new ServiceException("仅线下支付订单可以确认线下退款");
+        }
+        validateRefundableOrder(order);
+
+        PosOrder update = refundedOrderUpdate();
+        applyCas(order, update);
+        order.setState(4L);
+        order.setPayStatus(2L);
+        order.setAfterSaleStatus(3L);
+        refundPoints(order);
+        logStatusChange(order, request, operatorId, operatorName,
+                request.getExpectedState(), request.getExpectedDeliveryStatus(),
+                request.getExpectedPayStatus(), request.getExpectedAfterSaleStatus(),
+                "平台确认线下退款");
+        return buildContext(order);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public AdminOrderStatusContext finalizeOmgRefund(Long id, AdminOrderActionRequest request,
+                                                     Long operatorId, String operatorName,
+                                                     String action) {
+        requireReason(request);
+        PosOrder order = requireOrder(id);
+        validateSnapshot(order, request);
+        if (!PAY_TYPE_OMG.equals(order.getPayType())) {
+            throw new ServiceException("该订单非 OMG 支付");
+        }
+        validateRefundableOrder(order);
+
+        PosOrder update = refundedOrderUpdate();
+        applyCas(order, update);
+        order.setState(4L);
+        order.setPayStatus(2L);
+        order.setAfterSaleStatus(3L);
+        refundPoints(order);
+        logStatusChange(order, request, operatorId, operatorName,
+                request.getExpectedState(), request.getExpectedDeliveryStatus(),
+                request.getExpectedPayStatus(), request.getExpectedAfterSaleStatus(), action);
+        return buildContext(order);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public AdminOrderStatusContext finalizeSystemOmgRefund(Long id) {
+        PosOrder order = requireOrder(id);
+        if (order.getState() == 4L && order.getPayStatus() == 2L && order.getAfterSaleStatus() == 3L) {
+            return buildContext(order);
+        }
+        if (!PAY_TYPE_OMG.equals(order.getPayType()) || order.getPayStatus() != 1L
+                || order.getAfterSaleStatus() != 0L || order.getState() == 3L) {
+            throw new ServiceException("OMG 退款后的订单状态不允许同步");
+        }
+        PosOrder update = refundedOrderUpdate();
+        applyCas(order, update);
+        Long beforeState = order.getState();
+        order.setState(4L);
+        order.setPayStatus(2L);
+        order.setAfterSaleStatus(3L);
+        refundPoints(order);
+        orderLogHelper.logSync(String.valueOf(order.getDdId()), 0, null, "系统",
+                "OMG退款同步:state " + beforeState + "→4,pay 1→2,afterSale 0→3");
+        return buildContext(order);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public AdminOrderStatusContext completeDeliveryByRider(Long id, Long riderId, String riderName) {
+        return completeDeliveryByRider(id, riderId, riderName, null);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public AdminOrderStatusContext completeDeliveryByRider(Long id, Long riderId, String riderName,
+                                                           String riderImage) {
+        PosOrder order = requireOrder(id);
+        if (order.getType() != 0L || order.getState() != 2L || order.getDeliveryStatus() == null
+                || order.getDeliveryStatus() != 2L || order.getAfterSaleStatus() != 0L
+                || order.getQsId() == null || !order.getQsId().equals(riderId)) {
+            throw new ServiceException("当前订单状态不允许确认送达");
+        }
+        Long nextPayStatus = order.getPayStatus();
+        if (nextPayStatus != 1L) {
+            if (nextPayStatus == 0L && (PAY_TYPE_OFFLINE.equals(order.getPayType())
+                    || "1".equals(order.getCollectPayment()))) {
+                nextPayStatus = 1L;
+            } else {
+                throw new ServiceException("订单尚未支付,不能确认送达");
+            }
+        }
+
+        PosOrder update = new PosOrder();
+        update.setState(3L);
+        update.setDeliveryStatus(3L);
+        update.setPayStatus(nextPayStatus);
+        update.setSdTime(new Date());
+        if (riderImage != null && !riderImage.isBlank()) {
+            update.setQsImg(riderImage);
+        }
+        applyCas(order, update);
+        Long oldPayStatus = order.getPayStatus();
+        order.setState(3L);
+        order.setDeliveryStatus(3L);
+        order.setPayStatus(nextPayStatus);
+        order.setSdTime(update.getSdTime());
+        completeSideEffects(order);
+        orderLogHelper.logSync(String.valueOf(order.getDdId()), 3, riderId, safeName(riderName),
+                "骑手确认送达:state 2→3,delivery 2→3,pay " + oldPayStatus + "→" + nextPayStatus);
+        return buildContext(order);
+    }
+
+    private PosOrder refundedOrderUpdate() {
+        PosOrder update = new PosOrder();
+        update.setState(4L);
+        update.setPayStatus(2L);
+        update.setAfterSaleStatus(3L);
+        return update;
+    }
+
+    private void validateRefundableOrder(PosOrder order) {
+        if (order.getState() == 3L) {
+            throw new ServiceException("已完成订单需结算冲正,本期不支持退款");
+        }
+        if (order.getState() == 4L || order.getPayStatus() != 1L || order.getAfterSaleStatus() != 0L) {
+            throw new ServiceException("当前订单状态不允许退款");
+        }
+    }
+
+    private void rejectNormalAdjustmentWhenBlocked(PosOrder order) {
+        if (order.getState() == 3L || order.getState() == 4L || order.getPayStatus() == 2L) {
+            throw new ServiceException("终态订单不能重新调整状态");
+        }
+        if (order.getAfterSaleStatus() != 0L) {
+            throw new ServiceException("订单存在售后流程,请先处理售后");
+        }
+    }
+
+    private void validateOrderTransition(PosOrder order, Long targetState, Long effectiveDelivery) {
+        Long current = order.getState();
+        boolean valid = current == 0L && targetState == 1L
+                || current == 1L && targetState == 2L
+                || current == 2L && targetState == 3L
+                || (current == 0L || current == 1L) && targetState == 4L;
+        if (!valid) {
+            throw new ServiceException("订单状态只能按规则向前推进");
+        }
+        if (current == 0L && targetState == 1L
+                && PAY_TYPE_OMG.equals(order.getPayType()) && order.getPayStatus() != 1L) {
+            throw new ServiceException("OMG 订单支付成功后才能接单");
+        }
+        if (targetState == 4L && order.getPayStatus() != 0L) {
+            throw new ServiceException("已支付订单请使用退款操作");
+        }
+        if (targetState == 3L) {
+            if (order.getPayStatus() != 1L) {
+                throw new ServiceException("订单支付完成后才能完成");
+            }
+            if (order.getType() == 0L && !Long.valueOf(3L).equals(effectiveDelivery)) {
+                throw new ServiceException("外送订单送达后才能完成");
+            }
+        }
+    }
+
+    private void validateDeliveryTransition(PosOrder order, Long targetDelivery) {
+        if (order.getType() != 0L) {
+            throw new ServiceException("自取或堂食订单不能修改配送状态");
+        }
+        if (order.getState() != 2L) {
+            throw new ServiceException("订单出餐后才能推进配送状态");
+        }
+        Long current = order.getDeliveryStatus();
+        boolean valid = current == null && targetDelivery == 0L
+                || current != null && targetDelivery == current + 1L;
+        if (!valid) {
+            throw new ServiceException("配送状态只能逐步向前推进");
+        }
+        if (targetDelivery >= 1L && order.getQsId() == null) {
+            throw new ServiceException("订单尚未指派骑手");
+        }
+        if (targetDelivery == 3L && order.getPayStatus() != 1L) {
+            throw new ServiceException("订单支付完成后才能确认送达");
+        }
+    }
+
+    private void validateResultInvariants(PosOrder order, Long state, Long delivery,
+                                          Long payStatus, Long afterSaleStatus) {
+        if (order.getType() != 0L && delivery != null) {
+            throw new ServiceException("非外送订单不能存在配送状态");
+        }
+        if (delivery != null && delivery == 3L && state != 3L) {
+            throw new ServiceException("送达订单必须同步完成");
+        }
+        if (state == 3L && (payStatus != 1L || afterSaleStatus != 0L)) {
+            throw new ServiceException("订单状态组合不合法");
+        }
+    }
+
+    private void validateSnapshot(PosOrder order, AdminOrderActionRequest request) {
+        if (!Objects.equals(order.getState(), request.getExpectedState())
+                || !Objects.equals(order.getDeliveryStatus(), request.getExpectedDeliveryStatus())
+                || !Objects.equals(order.getPayStatus(), request.getExpectedPayStatus())
+                || !Objects.equals(order.getAfterSaleStatus(), request.getExpectedAfterSaleStatus())) {
+            throw conflict();
+        }
+    }
+
+    private void applyCas(PosOrder order, PosOrder update) {
+        LambdaUpdateWrapper<PosOrder> wrapper = new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, order.getId())
+                .eq(PosOrder::getState, order.getState())
+                .eq(PosOrder::getPayStatus, order.getPayStatus())
+                .eq(PosOrder::getAfterSaleStatus, order.getAfterSaleStatus());
+        if (order.getDeliveryStatus() == null) {
+            wrapper.isNull(PosOrder::getDeliveryStatus);
+        } else {
+            wrapper.eq(PosOrder::getDeliveryStatus, order.getDeliveryStatus());
+        }
+        if (!posOrderService.update(update, wrapper)) {
+            throw conflict();
+        }
+    }
+
+    private ServiceException conflict() {
+        return new ServiceException("订单状态已变化,请刷新后重试");
+    }
+
+    private void completeSideEffects(PosOrder order) {
+        billingService.setSanghuBilling(order);
+        if (order.getType() == 0L && order.getQsId() != null) {
+            billingService.setQishouBilling(order);
+        }
+    }
+
+    private void refundPoints(PosOrder order) {
+        if (order.getPoints() == null || order.getPoints() <= 0) {
+            return;
+        }
+        PointsTransaction existing = pointsTransactionService.getOne(
+                new LambdaQueryWrapper<PointsTransaction>()
+                        .eq(PointsTransaction::getDdId, String.valueOf(order.getDdId()))
+                        .eq(PointsTransaction::getType, "2"));
+        if (existing != null) {
+            return;
+        }
+        UserWallet wallet = userWalletService.getOne(new LambdaQueryWrapper<UserWallet>()
+                .eq(UserWallet::getUserId, order.getUserId())
+                .isNull(UserWallet::getStoreId));
+        if (wallet == null || wallet.getPointsWallet() == null) {
+            throw new ServiceException("用户积分钱包不存在");
+        }
+        int oldVersion = wallet.getVersion();
+        wallet.setPointsWallet(wallet.getPointsWallet() + order.getPoints());
+        wallet.setVersion(oldVersion + 1);
+        boolean updated = userWalletService.update(wallet, new LambdaQueryWrapper<UserWallet>()
+                .eq(UserWallet::getId, wallet.getId())
+                .eq(UserWallet::getVersion, oldVersion));
+        if (!updated) {
+            throw new ServiceException("积分返还冲突,请稍后重试");
+        }
+        PointsTransaction transaction = new PointsTransaction();
+        transaction.setUserId(order.getUserId());
+        transaction.setPointsChange("+" + order.getPoints());
+        transaction.setCurrentPoints(String.valueOf(wallet.getPointsWallet()));
+        transaction.setDdId(String.valueOf(order.getDdId()));
+        transaction.setType("2");
+        transaction.setCreateTime(new Date());
+        pointsTransactionService.save(transaction);
+    }
+
+    private void logStatusChange(PosOrder after, AdminOrderActionRequest request,
+                                 Long operatorId, String operatorName,
+                                 Long beforeState, Long beforeDelivery, Long beforePay, Long beforeAfterSale,
+                                 String action) {
+        String content = action + ":state " + value(beforeState) + "→" + value(after.getState())
+                + ",delivery " + value(beforeDelivery) + "→" + value(after.getDeliveryStatus())
+                + ",pay " + value(beforePay) + "→" + value(after.getPayStatus())
+                + ",afterSale " + value(beforeAfterSale) + "→" + value(after.getAfterSaleStatus())
+                + ";原因:" + request.normalizedReason();
+        orderLogHelper.logSync(String.valueOf(after.getDdId()), 1, operatorId,
+                safeName(operatorName), content);
+    }
+
+    private AdminOrderStatusContext buildContext(PosOrder order) {
+        AdminOrderStatusContext context = new AdminOrderStatusContext();
+        context.setId(order.getId());
+        context.setDdId(String.valueOf(order.getDdId()));
+        context.setType(order.getType());
+        context.setPayType(order.getPayType());
+        context.setState(order.getState());
+        context.setDeliveryStatus(order.getDeliveryStatus());
+        context.setPayStatus(order.getPayStatus());
+        context.setAfterSaleStatus(order.getAfterSaleStatus());
+        context.setSdTime(order.getSdTime());
+        context.setRiderAssigned(order.getQsId() != null);
+        context.setAllowedOrderStates(allowedOrderStates(order));
+        context.setAllowedDeliveryStatuses(allowedDeliveryStates(order));
+
+        boolean active = order.getState() != 3L && order.getState() != 4L
+                && order.getPayStatus() != 2L && order.getAfterSaleStatus() == 0L;
+        context.setCanConfirmOfflinePayment(active && PAY_TYPE_OFFLINE.equals(order.getPayType())
+                && order.getPayStatus() == 0L);
+        context.setCanConfirmOfflineRefund(active && PAY_TYPE_OFFLINE.equals(order.getPayType())
+                && order.getPayStatus() == 1L);
+
+        if (PAY_TYPE_OMG.equals(order.getPayType())) {
+            PosOrderOmgPayment payment = omgPaymentService.getLatestByDdId(String.valueOf(order.getDdId()));
+            if (payment != null) {
+                context.setOmgPaymentStatus(payment.getPayStatus());
+                List<PosOrderOmgRefund> refunds = omgRefundService.listByPayment(payment.getId());
+                boolean manualDone = refunds.stream().anyMatch(this::isManualRefundDone);
+                boolean manualPending = !manualDone && refunds.stream().anyMatch(this::isManualRefundPending);
+                context.setManualRefundPending(manualPending);
+                context.setRefundUnknown(Integer.valueOf(4).equals(payment.getPayStatus()));
+                context.setCanReconcileOmg(active && order.getPayStatus() == 0L
+                        && Integer.valueOf(0).equals(payment.getPayStatus()));
+                context.setCanRefundOmg(active && order.getPayStatus() == 1L
+                        && (Integer.valueOf(1).equals(payment.getPayStatus())
+                        || Integer.valueOf(3).equals(payment.getPayStatus()))
+                        && !manualPending && !manualDone);
+                context.setCanConfirmManualOmgRefund(active && order.getPayStatus() == 1L
+                        && (manualPending && (Integer.valueOf(1).equals(payment.getPayStatus())
+                        || Integer.valueOf(3).equals(payment.getPayStatus()))
+                        || manualDone && Integer.valueOf(3).equals(payment.getPayStatus())));
+            }
+        }
+        return context;
+    }
+
+    private List<Long> allowedOrderStates(PosOrder order) {
+        List<Long> allowed = new ArrayList<>();
+        allowed.add(order.getState());
+        if (order.getState() == 3L || order.getState() == 4L || order.getPayStatus() == 2L
+                || order.getAfterSaleStatus() != 0L) {
+            return allowed;
+        }
+        if (order.getState() == 0L) {
+            if (!PAY_TYPE_OMG.equals(order.getPayType()) || order.getPayStatus() == 1L) {
+                allowed.add(1L);
+            }
+            if (order.getPayStatus() == 0L) {
+                allowed.add(4L);
+            }
+        } else if (order.getState() == 1L) {
+            allowed.add(2L);
+            if (order.getPayStatus() == 0L) {
+                allowed.add(4L);
+            }
+        } else if (order.getState() == 2L && order.getPayStatus() == 1L
+                && (order.getType() != 0L || Long.valueOf(3L).equals(order.getDeliveryStatus()))) {
+            allowed.add(3L);
+        }
+        return allowed;
+    }
+
+    private List<Long> allowedDeliveryStates(PosOrder order) {
+        List<Long> allowed = new ArrayList<>();
+        if (order.getType() != 0L) {
+            return allowed;
+        }
+        if (order.getDeliveryStatus() != null) {
+            allowed.add(order.getDeliveryStatus());
+        }
+        if (order.getState() != 2L || order.getPayStatus() == 2L || order.getAfterSaleStatus() != 0L) {
+            return allowed;
+        }
+        Long current = order.getDeliveryStatus();
+        if (current == null) {
+            allowed.add(0L);
+        } else if (current < 2L && order.getQsId() != null) {
+            allowed.add(current + 1L);
+        } else if (current == 2L && order.getQsId() != null && order.getPayStatus() == 1L) {
+            allowed.add(3L);
+        }
+        return allowed;
+    }
+
+    private boolean isManualRefundPending(PosOrderOmgRefund refund) {
+        return refund.getAction() == null && refund.getRtnCode() == null;
+    }
+
+    private boolean isManualRefundDone(PosOrderOmgRefund refund) {
+        return refund.getAction() == null && Integer.valueOf(1).equals(refund.getRtnCode());
+    }
+
+    private PosOrder requireOrder(Long id) {
+        PosOrder order = id == null ? null : posOrderService.getById(id);
+        if (order == null) {
+            throw new ServiceException("订单不存在");
+        }
+        if (order.getState() == null || order.getPayStatus() == null || order.getAfterSaleStatus() == null
+                || order.getType() == null) {
+            throw new ServiceException("订单状态数据不完整");
+        }
+        return order;
+    }
+
+    private void requireReason(AdminOrderActionRequest request) {
+        if (request == null || request.normalizedReason().isEmpty()
+                || request.normalizedReason().length() > 200) {
+            throw new ServiceException("请填写1至200字的调整原因");
+        }
+    }
+
+    private String value(Object value) {
+        return value == null ? "NULL" : String.valueOf(value);
+    }
+
+    private String safeName(String name) {
+        return name == null ? "" : name;
+    }
+}

+ 153 - 23
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -14,6 +14,11 @@ import com.ruoyi.app.order.dto.OrderPositionInfo;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.dto.PositionDto;
 import com.ruoyi.app.order.dto.QsDto;
+import com.ruoyi.app.order.dto.AdminOrderActionRequest;
+import com.ruoyi.app.order.dto.AdminOrderStatusContext;
+import com.ruoyi.app.order.dto.AdminOrderStatusUpdateRequest;
+import com.ruoyi.app.pay.OmgPayController;
+import com.ruoyi.app.pay.dto.OmgRefundOutcome;
 import com.ruoyi.app.service.UserService;
 import com.ruoyi.app.service.WalletService;
 import com.ruoyi.app.user.dto.StoreOutput;
@@ -62,6 +67,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
 import org.springframework.web.bind.annotation.*;
 
 import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
 
 import java.math.BigDecimal;
 import java.text.DecimalFormat;
@@ -145,6 +151,10 @@ public class PosOrderController extends BaseController {
 
     @Autowired
     private OrderInvoiceService orderInvoiceService;
+    @Autowired
+    private OrderLifecycleService orderLifecycleService;
+    @Autowired
+    private OmgPayController omgPayController;
 
 
     //查询用户足迹
@@ -1467,6 +1477,122 @@ public class PosOrderController extends BaseController {
         return success(posOrderService.selectPosOrderById(id));
     }
 
+    @PreAuthorize("@ss.hasPermi('system:order:edit')")
+    @GetMapping("/{id}/status-context")
+    public AjaxResult getStatusContext(@PathVariable Long id) {
+        return success(orderLifecycleService.getStatusContext(id));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:order:edit')")
+    @RepeatSubmit(interval = 1000, message = "请求过于频繁")
+    @PutMapping("/{id}/status")
+    public AjaxResult adminUpdateStatus(@PathVariable Long id,
+                                        @Valid @RequestBody AdminOrderStatusUpdateRequest request) {
+        SysUser operator = currentAdmin();
+        return success("修改成功", orderLifecycleService.updateStatus(id, request,
+                operator == null ? null : operator.getUserId(), operatorName(operator)));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:order:edit')")
+    @RepeatSubmit(interval = 1000, message = "请求过于频繁")
+    @PostMapping("/{id}/offline-payment/confirm")
+    public AjaxResult adminConfirmOfflinePayment(@PathVariable Long id,
+                                                 @Valid @RequestBody AdminOrderActionRequest request) {
+        SysUser operator = currentAdmin();
+        return success("已确认线下收款", orderLifecycleService.confirmOfflinePayment(id, request,
+                operator == null ? null : operator.getUserId(), operatorName(operator)));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:order:edit')")
+    @RepeatSubmit(interval = 1000, message = "请求过于频繁")
+    @PostMapping("/{id}/offline-refund/confirm")
+    public AjaxResult adminConfirmOfflineRefund(@PathVariable Long id,
+                                                @Valid @RequestBody AdminOrderActionRequest request) {
+        SysUser operator = currentAdmin();
+        return success("已确认线下退款", orderLifecycleService.confirmOfflineRefund(id, request,
+                operator == null ? null : operator.getUserId(), operatorName(operator)));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:order:edit')")
+    @RepeatSubmit(interval = 2000, message = "查询过于频繁")
+    @PostMapping("/{id}/omg-payment/reconcile")
+    public AjaxResult adminReconcileOmgPayment(@PathVariable Long id,
+                                               @Valid @RequestBody AdminOrderActionRequest request) {
+        AdminOrderStatusContext before = orderLifecycleService.validateOmgReconcile(id, request);
+        int[] result = omgPayController.reconcileByQuery(before.getDdId(), "admin");
+        AdminOrderStatusContext after = orderLifecycleService.getStatusContext(id);
+        if (!Objects.equals(before.getPayStatus(), after.getPayStatus())) {
+            SysUser operator = currentAdmin();
+            orderLifecycleService.auditExternalAction(id, request,
+                    operator == null ? null : operator.getUserId(), operatorName(operator), "平台发起OMG补单");
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("payStatus", after.getPayStatus());
+        data.put("reconciled", result[1] == 1 && after.getPayStatus() == 1L);
+        data.put("context", after);
+        return success(after.getPayStatus() == 1L ? "OMG 支付核验成功" : "OMG 尚未确认支付", data);
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:order:edit')")
+    @RepeatSubmit(interval = 2000, message = "请求过于频繁")
+    @PostMapping("/{id}/omg-refund")
+    public AjaxResult adminRefundOmgPayment(@PathVariable Long id,
+                                            @Valid @RequestBody AdminOrderActionRequest request) {
+        AdminOrderStatusContext before = orderLifecycleService.validateOmgRefund(id, request);
+        OmgRefundOutcome outcome = omgPayController.refundOrderOutcome(posOrderService.getById(id));
+        if (outcome.getStatus() == OmgRefundOutcome.Status.REFUNDED
+                || outcome.getStatus() == OmgRefundOutcome.Status.IDEMPOTENT) {
+            SysUser operator = currentAdmin();
+            try {
+                AdminOrderStatusContext context = orderLifecycleService.finalizeOmgRefund(id, request,
+                        operator == null ? null : operator.getUserId(), operatorName(operator), "平台OMG退款成功");
+                return success(outcome.getMessage(), refundData(outcome, context));
+            } catch (Exception e) {
+                orderLogHelper.logSync(String.valueOf(before.getDdId()), 0,
+                        operator == null ? null : operator.getUserId(), operatorName(operator),
+                        "高优先级:OMG退款成功,但订单状态同步失败,请人工核对");
+                throw new ServiceException("OMG退款成功,本地订单状态待同步");
+            }
+        }
+        if (outcome.getStatus() == OmgRefundOutcome.Status.MANUAL_PENDING) {
+            if (!Boolean.TRUE.equals(before.getManualRefundPending())) {
+                SysUser operator = currentAdmin();
+                orderLifecycleService.auditExternalAction(id, request,
+                        operator == null ? null : operator.getUserId(), operatorName(operator),
+                        "平台发起OMG人工退款待办");
+            }
+            return success(outcome.getMessage(), refundData(outcome, orderLifecycleService.getStatusContext(id)));
+        }
+        return error(outcome.getMessage(), refundData(outcome, orderLifecycleService.getStatusContext(id)));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:order:edit')")
+    @RepeatSubmit(interval = 2000, message = "请求过于频繁")
+    @PostMapping("/{id}/omg-refund/manual-confirm")
+    public AjaxResult adminConfirmManualOmgRefund(@PathVariable Long id,
+                                                  @Valid @RequestBody AdminOrderActionRequest request) {
+        AdminOrderStatusContext before = orderLifecycleService.validateOmgRefund(id, request);
+        if (!Boolean.TRUE.equals(before.getCanConfirmManualOmgRefund())) {
+            throw new ServiceException("无可确认的 OMG 人工退款待办");
+        }
+        OmgRefundOutcome outcome = omgPayController.confirmManualRefundOutcome(posOrderService.getById(id));
+        if (outcome.getStatus() != OmgRefundOutcome.Status.REFUNDED
+                && outcome.getStatus() != OmgRefundOutcome.Status.IDEMPOTENT) {
+            return error(outcome.getMessage(), refundData(outcome, orderLifecycleService.getStatusContext(id)));
+        }
+        SysUser operator = currentAdmin();
+        try {
+            AdminOrderStatusContext context = orderLifecycleService.finalizeOmgRefund(id, request,
+                    operator == null ? null : operator.getUserId(), operatorName(operator), "平台确认OMG人工退款完成");
+            return success(outcome.getMessage(), refundData(outcome, context));
+        } catch (Exception e) {
+            orderLogHelper.logSync(String.valueOf(before.getDdId()), 0,
+                    operator == null ? null : operator.getUserId(), operatorName(operator),
+                    "高优先级:OMG人工退款已确认,但订单状态同步失败,请人工核对");
+            throw new ServiceException("OMG退款已确认,本地订单状态待同步");
+        }
+    }
+
     /**
      * 新增posorder
      */
@@ -1485,31 +1611,35 @@ public class PosOrderController extends BaseController {
     @Log(title = "posorder", businessType = BusinessType.UPDATE)
     @PutMapping
     public AjaxResult edit(@RequestBody PosOrder posOrder) {
-        //送达时间
-        if (posOrder.getState() == 12) {
-            posOrder.setSdTime(new Date());
+        if (posOrder == null || posOrder.getId() == null) {
+            return error("订单ID不能为空");
         }
-        if (posOrder.getState() == 7) {
-            QueryWrapper<UserBilling> wrapper = new QueryWrapper<>();
-            wrapper.eq("dd_id", posOrder.getId());
-            List<UserBilling> billing = userBillingService.list(wrapper);
-            for (int i = 0; i < billing.size(); i++) {
-                UserBilling user = new UserBilling();
-                user.setId(billing.get(i).getId());
-                user.setState("1");
-                userBillingService.saveOrUpdate(user);
-            }
-            //退款、作废退回积分
-            if ((posOrder.getState() == 7 || posOrder.getState() == 11) && posOrder.getPoints() != null && posOrder.getPoints() > 0) {
-                returnPoints(posOrder.getUserId(), Long.valueOf(posOrder.getDdId()), Long.valueOf(posOrder.getPoints()));
-            }
-            return toAjax(posOrderService.updatePosOrder(posOrder));
-        } else {
-            if (posOrder.getKefuState().intValue() == 1) {
-                posOrder.setState(5L);
-            }
-            return toAjax(posOrderService.updatePosOrder(posOrder));
+        if (posOrder.getState() != null || posOrder.getDeliveryStatus() != null
+                || posOrder.getPayStatus() != null || posOrder.getAfterSaleStatus() != null
+                || posOrder.getSdTime() != null) {
+            return error("订单状态请使用状态专用操作");
         }
+        return toAjax(posOrderService.updatePosOrder(posOrder));
+    }
+
+    private SysUser currentAdmin() {
+        return SecurityUtils.getLoginUser() == null ? null : SecurityUtils.getLoginUser().getUser();
+    }
+
+    private String operatorName(SysUser operator) {
+        return operator == null || operator.getNickName() == null ? "" : operator.getNickName();
+    }
+
+    private Map<String, Object> refundData(OmgRefundOutcome outcome, AdminOrderStatusContext context) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("outcome", outcome.getStatus().name());
+        data.put("state", context.getState());
+        data.put("payStatus", context.getPayStatus());
+        data.put("afterSaleStatus", context.getAfterSaleStatus());
+        data.put("manualRefundPending", context.getManualRefundPending());
+        data.put("refundUnknown", context.getRefundUnknown());
+        data.put("context", context);
+        return data;
     }
 
 

+ 16 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -63,6 +63,8 @@ public class PosOrderQsOprateController extends BaseController {
     private IPosStoreService posStoreService;
     @Autowired
     private OrderLogHelper orderLogHelper;
+    @Autowired
+    private OrderLifecycleService orderLifecycleService;
 
     /**
      * 骑手接单:校验 type=0 且 afterSaleStatus=0,设置 deliveryStatus=1
@@ -94,7 +96,7 @@ public class PosOrderQsOprateController extends BaseController {
         String qsName = qsUser != null ? qsUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 3, Long.valueOf(qsId), qsName, "骑手" + qsName + "接单成功");
 
-        return setOrderQsState(posOrder, qsId, push);
+        return setOrderQsState(posOrder, qsId, push, qsName);
     }
 
     /**
@@ -124,7 +126,7 @@ public class PosOrderQsOprateController extends BaseController {
         String qsName = qsUser != null ? qsUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 3, Long.valueOf(qsId), qsName, "骑手" + qsName + "已取餐");
 
-        return setOrderQsState(posOrder, qsId, push);
+        return setOrderQsState(posOrder, qsId, push, qsName);
     }
 
     /**
@@ -150,13 +152,11 @@ public class PosOrderQsOprateController extends BaseController {
 
         InfoUser qsUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(qsId)));
         String qsName = qsUser != null ? qsUser.getNickName() : "";
-        orderLogHelper.log(String.valueOf(order.getDdId()), 3, Long.valueOf(qsId), qsName, "骑手" + qsName + "已送达");
-
-        return setOrderQsState(posOrder, qsId, push);
+        return setOrderQsState(posOrder, qsId, push, qsName);
     }
 
     //骑手接单、取餐、送达
-    private AjaxResult setOrderQsState(PosOrder posOrder, String qsId, PayPush push) {
+    private AjaxResult setOrderQsState(PosOrder posOrder, String qsId, PayPush push, String qsName) {
         String lockKey = "order:qs:lock:" + posOrder.getId();
         RLock lock = redissonClient.getLock(lockKey);
         try {
@@ -188,7 +188,16 @@ public class PosOrderQsOprateController extends BaseController {
                         userMessage = MessageUtils.message("no.message.push.delivery.personnel.qsysd.order");
                     }
 
-                    boolean org = posOrderService.saveOrUpdate(posOrder);
+                    boolean delivered = posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 3L;
+                    boolean org;
+                    if (delivered) {
+                        orderLifecycleService.completeDeliveryByRider(posOrder.getId(), Long.valueOf(qsId),
+                                qsName, posOrder.getQsImg());
+                        posOrder.setState(3L);
+                        org = true;
+                    } else {
+                        org = posOrderService.saveOrUpdate(posOrder);
+                    }
                     if (org) {
                         //到付订单,骑手送达时设置用户账单为完成
                         if (posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 3L && "1".equals(orst.getCollectPayment())) {

+ 39 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/AdminOrderActionRequest.java

@@ -0,0 +1,39 @@
+package com.ruoyi.app.order.dto;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class AdminOrderActionRequest {
+
+    @NotNull(message = "订单状态快照不能为空")
+    @Min(value = 0, message = "订单状态无效")
+    @Max(value = 4, message = "订单状态无效")
+    private Long expectedState;
+
+    @Min(value = 0, message = "配送状态无效")
+    @Max(value = 3, message = "配送状态无效")
+    private Long expectedDeliveryStatus;
+
+    @NotNull(message = "支付状态快照不能为空")
+    @Min(value = 0, message = "支付状态无效")
+    @Max(value = 2, message = "支付状态无效")
+    private Long expectedPayStatus;
+
+    @NotNull(message = "售后状态快照不能为空")
+    @Min(value = 0, message = "售后状态无效")
+    @Max(value = 6, message = "售后状态无效")
+    private Long expectedAfterSaleStatus;
+
+    @NotBlank(message = "请填写调整原因")
+    @Size(max = 200, message = "调整原因不能超过200字")
+    private String reason;
+
+    public String normalizedReason() {
+        return reason == null ? "" : reason.trim();
+    }
+}

+ 36 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/AdminOrderStatusContext.java

@@ -0,0 +1,36 @@
+package com.ruoyi.app.order.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+@Data
+public class AdminOrderStatusContext {
+
+    private Long id;
+    private String ddId;
+    private Long type;
+    private String payType;
+    private Long state;
+    private Long deliveryStatus;
+    private Long payStatus;
+    private Long afterSaleStatus;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date sdTime;
+
+    private Boolean riderAssigned = false;
+    private List<Long> allowedOrderStates = new ArrayList<>();
+    private List<Long> allowedDeliveryStatuses = new ArrayList<>();
+    private Boolean canConfirmOfflinePayment = false;
+    private Boolean canConfirmOfflineRefund = false;
+    private Boolean canReconcileOmg = false;
+    private Boolean canRefundOmg = false;
+    private Boolean canConfirmManualOmgRefund = false;
+    private Integer omgPaymentStatus;
+    private Boolean manualRefundPending = false;
+    private Boolean refundUnknown = false;
+}

+ 19 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/AdminOrderStatusUpdateRequest.java

@@ -0,0 +1,19 @@
+package com.ruoyi.app.order.dto;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AdminOrderStatusUpdateRequest extends AdminOrderActionRequest {
+
+    @Min(value = 0, message = "目标订单状态无效")
+    @Max(value = 4, message = "目标订单状态无效")
+    private Long targetState;
+
+    @Min(value = 0, message = "目标配送状态无效")
+    @Max(value = 3, message = "目标配送状态无效")
+    private Long targetDeliveryStatus;
+}

+ 109 - 23
ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java

@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.alibaba.fastjson2.JSON;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.order.OrderLifecycleService;
+import com.ruoyi.app.pay.dto.OmgRefundOutcome;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.app.utils.omg.OmgCheckMacValue;
@@ -53,6 +55,7 @@ import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.Enumeration;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
@@ -101,6 +104,8 @@ public class OmgPayController extends BaseController {
     private OrderLogHelper orderLogHelper;
     @Autowired
     private IPosOrderOmgRefundService refundService;
+    @Autowired
+    private OrderLifecycleService orderLifecycleService;
 
     @Value("${omg.base-url}")
     private String baseUrl;
@@ -524,44 +529,88 @@ public class OmgPayController extends BaseController {
      * 返回 success=已退款;error=需人工或失败(refund 已落记录可重试)。
      */
     public AjaxResult refundOrder(PosOrder order) {
+        OmgRefundOutcome outcome = refundOrderOutcome(order);
+        if (outcome.getStatus() == OmgRefundOutcome.Status.REFUNDED
+                || outcome.getStatus() == OmgRefundOutcome.Status.IDEMPOTENT) {
+            try {
+                orderLifecycleService.finalizeSystemOmgRefund(order.getId());
+                return success(outcome.getMessage());
+            } catch (Exception e) {
+                log.error("OMG refund succeeded but order state synchronization failed: orderId={}, errorType={}",
+                        order == null ? null : order.getId(), e.getClass().getSimpleName());
+                return error("OMG 退款成功,本地订单状态待同步");
+            }
+        }
+        return error(outcome.getMessage());
+    }
+
+    /**
+     * 返回明确退款结果,供管理端根据资金事实同步订单状态。
+     */
+    public OmgRefundOutcome refundOrderOutcome(PosOrder order) {
         if (order == null) {
-            return error("订单不存在");
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "订单不存在");
         }
         if (!PAY_TYPE_OMG.equals(order.getPayType())) {
-            return error("该订单非 OMG 支付");
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "该订单非 OMG 支付");
         }
         if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
-            return error("订单未支付,无需退款");
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "订单未支付,无需退款");
         }
         String ddId = String.valueOf(order.getDdId());
-        PosOrderOmgPayment payment = paymentService.getLatestPaidByDdId(ddId);
+        PosOrderOmgPayment payment = paymentService.getLatestByDdId(ddId);
         if (payment == null) {
-            return error("无 OMG 支付流水");
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "无 OMG 支付流水");
+        }
+        if (Integer.valueOf(3).equals(payment.getPayStatus())) {
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.IDEMPOTENT, "OMG 已完成退款");
+        }
+        if (Integer.valueOf(4).equals(payment.getPayStatus())) {
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN,
+                    "OMG 退款结果待确认,请勿重复发起");
+        }
+        if (!Integer.valueOf(1).equals(payment.getPayStatus())) {
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 支付流水不是已支付状态");
         }
         int amount = payment.getAmount() == null ? 0 : payment.getAmount();
         String payType = payment.getPayType();
         if (amount <= 0 || payment.getTradeNo() == null || payment.getTradeNo().isEmpty()) {
-            return error("OMG 支付流水不完整");
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 支付流水不完整");
         }
         if (order.getAmount() == null || order.getAmount().intValue() != amount) {
-            return error("OMG 支付流水金额与订单不一致");
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 支付流水金额与订单不一致");
         }
 
         // ATM/超商/BarcodeATM 无退款 API → 记录待人工
         if (!"Credit_CreditCard".equals(payType)) {
-            refundService.record(payment.getId(), ddId, payment.getTradeNo(), null, amount, null,
-                    "延期支付方式无退款API,待人工在 OMG 后台处理", "");
-            return error("该支付方式需人工在 OMG 后台退款");
+            List<com.ruoyi.system.domain.PosOrderOmgRefund> records = refundService.listByPayment(payment.getId());
+            boolean pending = records != null && records.stream()
+                    .anyMatch(row -> row.getAction() == null && row.getRtnCode() == null);
+            boolean completed = records != null && records.stream()
+                    .anyMatch(row -> row.getAction() == null && Integer.valueOf(1).equals(row.getRtnCode()));
+            if (completed) {
+                return OmgRefundOutcome.of(OmgRefundOutcome.Status.IDEMPOTENT, "OMG 人工退款已确认完成");
+            }
+            if (!pending) {
+                refundService.record(payment.getId(), ddId, payment.getTradeNo(), null, amount, null,
+                        "延期支付方式无退款API,待人工在 OMG 后台处理", "");
+            }
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.MANUAL_PENDING,
+                    "该支付方式需在 OMG 后台人工退款,订单暂保持已支付");
         }
 
         if (paymentService.markRefunding(payment.getId()) == 0) {
-            return error("退款已处理或正在处理中");
+            PosOrderOmgPayment latest = paymentService.getLatestByDdId(ddId);
+            if (latest != null && Integer.valueOf(3).equals(latest.getPayStatus())) {
+                return OmgRefundOutcome.of(OmgRefundOutcome.Status.IDEMPOTENT, "OMG 已完成退款");
+            }
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "退款已处理或正在处理中");
         }
 
         PosStoreOmg cred = storeOmgService.getCredentialByMerchantId(payment.getMerchantId());
         if (cred == null || cred.getStoreId() == null || !cred.getStoreId().equals(payment.getStoreId())) {
             paymentService.restorePaidFromRefunding(payment.getId());
-            return error("门店 OMG 凭证不可用");
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "门店 OMG 凭证不可用");
         }
         OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
 
@@ -573,8 +622,11 @@ public class OmgPayController extends BaseController {
             resp = omgPay.doAction(baseUrl, cfg, payment.getMerchantTradeNo(), payment.getTradeNo(), "R", amount);
         } catch (Exception e) {
             refundService.record(payment.getId(), ddId, payment.getTradeNo(), "R", amount, null,
-                    "退款结果未知,需对账: " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()), "");
-            return error("OMG 退款结果待确认,请勿重复发起");
+                    "退款结果未知,需对账", "");
+            log.error("OMG refund outcome unknown: ddId={}, paymentId={}, errorType={}",
+                    ddId, payment.getId(), e.getClass().getSimpleName());
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN,
+                    "OMG 退款结果待确认,请勿重复发起");
         }
         int rtnCode = toInt(resp == null ? null : resp.get("RtnCode"), -1);
         String rtnMsg = resp == null ? "" : resp.get("RtnMsg");
@@ -584,17 +636,51 @@ public class OmgPayController extends BaseController {
         if (rtnCode == 1) {
             if (paymentService.markRefunded(payment.getId()) == 0) {
                 log.error("OMG refund succeeded but ledger transition failed: paymentId={}", payment.getId());
-                return error("OMG 退款成功,账务状态待核对");
+                return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "OMG 退款成功,账务状态待核对");
             }
-            PosOrder upd = new PosOrder();
-            upd.setId(order.getId());
-            upd.setPayStatus(2L);
-            posOrderService.saveOrUpdate(upd);
             orderLogHelper.logSync(ddId, 0, null, "系统", "OMG 信用卡退款成功");
-            return success("退款成功");
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.REFUNDED, "退款成功");
         }
         paymentService.restorePaidFromRefunding(payment.getId());
-        return error("OMG 退款失败:" + rtnMsg);
+        log.warn("OMG refund failed: ddId={}, paymentId={}, rtnCode={}", ddId, payment.getId(), rtnCode);
+        return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 退款失败,请核对后重试");
+    }
+
+    /**
+     * 管理员已在 OMG 后台核实完成延期支付退款后,收口支付流水。
+     */
+    public OmgRefundOutcome confirmManualRefundOutcome(PosOrder order) {
+        if (order == null || !PAY_TYPE_OMG.equals(order.getPayType())) {
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "该订单非 OMG 支付");
+        }
+        String ddId = String.valueOf(order.getDdId());
+        PosOrderOmgPayment payment = paymentService.getLatestByDdId(ddId);
+        if (payment == null || "Credit_CreditCard".equals(payment.getPayType())) {
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "无可确认的 OMG 人工退款待办");
+        }
+        List<com.ruoyi.system.domain.PosOrderOmgRefund> records = refundService.listByPayment(payment.getId());
+        boolean pending = records != null && records.stream()
+                .anyMatch(row -> row.getAction() == null && row.getRtnCode() == null);
+        boolean completed = records != null && records.stream()
+                .anyMatch(row -> row.getAction() == null && Integer.valueOf(1).equals(row.getRtnCode()));
+        if (!pending && !completed) {
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "无可确认的 OMG 人工退款待办");
+        }
+        if (Integer.valueOf(4).equals(payment.getPayStatus())) {
+            return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "OMG 退款状态正在处理中");
+        }
+        if (!Integer.valueOf(3).equals(payment.getPayStatus())) {
+            if (!Integer.valueOf(1).equals(payment.getPayStatus())
+                    || paymentService.markRefunding(payment.getId()) == 0
+                    || paymentService.markRefunded(payment.getId()) == 0) {
+                return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "OMG 人工退款状态发生变化,请刷新");
+            }
+        }
+        if (!completed) {
+            refundService.record(payment.getId(), ddId, payment.getTradeNo(), null, payment.getAmount(), 1,
+                    "管理员确认已在 OMG 后台完成人工退款", "");
+        }
+        return OmgRefundOutcome.of(OmgRefundOutcome.Status.REFUNDED, "已确认 OMG 人工退款完成");
     }
 
     // ============================ US6:漏单补单(回调可靠性 / callback-reconcile.md) ============================
@@ -707,13 +793,13 @@ public class OmgPayController extends BaseController {
                 log.error("[OMG-{}] 补单金额/字段校验失败: ddId={}, paymentAmt={}, orderAmt={}, queryAmt={}, tradeNo={}",
                         source, ddId, payment.getAmount(), order.getAmount(), respAmt, tradeNo);
                 orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单金额/字段不符,需人工核对");
-                return new int[]{1, 0};
+                return new int[]{0, 0};
             }
             Date payTime = parsePayTime(resp.get("PaymentDate"));
             Date tradeDate = parsePayTime(resp.get("TradeDate"));
             if (payTime == null || tradeDate == null) {
                 log.warn("[OMG-{}] 补单响应日期非法: ddId={}", source, ddId);
-                return new int[]{1, 0};
+                return new int[]{0, 0};
             }
             applyPaidResult(payment, order, tradeNo, paymentType, 1, resp.get("RtnMsg"),
                     null, payTime, tradeDate, auditJson(resp));

+ 24 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/OmgRefundOutcome.java

@@ -0,0 +1,24 @@
+package com.ruoyi.app.pay.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@Data
+@AllArgsConstructor
+public class OmgRefundOutcome {
+
+    public enum Status {
+        REFUNDED,
+        MANUAL_PENDING,
+        UNKNOWN,
+        FAILED,
+        IDEMPOTENT
+    }
+
+    private Status status;
+    private String message;
+
+    public static OmgRefundOutcome of(Status status, String message) {
+        return new OmgRefundOutcome(status, message);
+    }
+}

+ 342 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java

@@ -0,0 +1,342 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.order.dto.AdminOrderActionRequest;
+import com.ruoyi.app.order.dto.AdminOrderStatusContext;
+import com.ruoyi.app.order.dto.AdminOrderStatusUpdateRequest;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderOmgPayment;
+import com.ruoyi.system.domain.PosOrderOmgRefund;
+import com.ruoyi.system.service.IPosOrderOmgPaymentService;
+import com.ruoyi.system.service.IPosOrderOmgRefundService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPointsTransactionService;
+import com.ruoyi.system.service.IUserWalletService;
+import com.ruoyi.system.utils.OrderLogHelper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class OrderLifecycleServiceTest {
+
+    private IPosOrderService posOrderService;
+    private OrderService billingService;
+    private OrderLogHelper orderLogHelper;
+    private IUserWalletService userWalletService;
+    private IPointsTransactionService pointsTransactionService;
+    private IPosOrderOmgPaymentService omgPaymentService;
+    private IPosOrderOmgRefundService omgRefundService;
+    private OrderLifecycleService service;
+
+    @BeforeEach
+    void setUp() {
+        posOrderService = mock(IPosOrderService.class);
+        billingService = mock(OrderService.class);
+        orderLogHelper = mock(OrderLogHelper.class);
+        userWalletService = mock(IUserWalletService.class);
+        pointsTransactionService = mock(IPointsTransactionService.class);
+        omgPaymentService = mock(IPosOrderOmgPaymentService.class);
+        omgRefundService = mock(IPosOrderOmgRefundService.class);
+        service = new OrderLifecycleService(posOrderService, billingService, orderLogHelper,
+                userWalletService, pointsTransactionService, omgPaymentService, omgRefundService);
+    }
+
+    @Test
+    void exposesOnlyLegalNextOrderAndDeliveryStates() {
+        PosOrder order = deliveryOrder();
+        when(posOrderService.getById(10L)).thenReturn(order);
+        PosOrderOmgPayment payment = new PosOrderOmgPayment();
+        payment.setId(1L);
+        payment.setPayStatus(1);
+        when(omgPaymentService.getLatestByDdId(order.getDdId())).thenReturn(payment);
+        when(omgRefundService.listByPayment(1L)).thenReturn(List.of());
+
+        AdminOrderStatusContext context = service.getStatusContext(10L);
+
+        assertEquals(List.of(2L), context.getAllowedOrderStates());
+        assertEquals(List.of(2L, 3L), context.getAllowedDeliveryStatuses());
+        assertTrue(context.getCanRefundOmg());
+        assertFalse(context.getCanConfirmOfflinePayment());
+    }
+
+    @Test
+    void marksDeliveryAsDeliveredAndCompletesExactlyOnce() {
+        PosOrder order = deliveryOrder();
+        when(posOrderService.getById(10L)).thenReturn(order);
+        doAnswer(invocation -> {
+            PosOrder update = invocation.getArgument(0);
+            order.setState(update.getState());
+            order.setDeliveryStatus(update.getDeliveryStatus());
+            order.setPayStatus(update.getPayStatus());
+            order.setAfterSaleStatus(update.getAfterSaleStatus());
+            order.setSdTime(update.getSdTime());
+            return true;
+        }).when(posOrderService).update(any(PosOrder.class), any(Wrapper.class));
+
+        AdminOrderStatusUpdateRequest request = statusRequest(2L, 2L, 1L, 0L);
+        request.setTargetDeliveryStatus(3L);
+        request.setReason("骑手端漏操作");
+
+        AdminOrderStatusContext context = service.updateStatus(10L, request, 99L, "平台管理员");
+
+        assertEquals(3L, context.getState());
+        assertEquals(3L, context.getDeliveryStatus());
+        assertNotNull(context.getSdTime());
+        verify(billingService).setSanghuBilling(order);
+        verify(billingService).setQishouBilling(order);
+        verify(orderLogHelper).logSync(anyString(), anyInt(), anyLong(), anyString(), anyString());
+    }
+
+    @Test
+    void staleSnapshotHasNoSideEffects() {
+        PosOrder order = deliveryOrder();
+        order.setPayStatus(0L);
+        when(posOrderService.getById(10L)).thenReturn(order);
+        AdminOrderStatusUpdateRequest request = statusRequest(2L, 2L, 1L, 0L);
+        request.setTargetDeliveryStatus(3L);
+        request.setReason("陈旧页面提交");
+
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.updateStatus(10L, request, 99L, "平台管理员"));
+
+        assertTrue(error.getMessage().contains("状态已变化"));
+        verify(posOrderService, never()).update(any(PosOrder.class), any(Wrapper.class));
+        verify(billingService, never()).setSanghuBilling(any());
+        verify(orderLogHelper, never()).logSync(anyString(), anyInt(), any(), anyString(), anyString());
+    }
+
+    @Test
+    void casFailureAfterReadHasNoSideEffects() {
+        PosOrder order = deliveryOrder();
+        when(posOrderService.getById(10L)).thenReturn(order);
+        when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(false);
+        AdminOrderStatusUpdateRequest request = statusRequest(2L, 2L, 1L, 0L);
+        request.setTargetDeliveryStatus(3L);
+        request.setReason("并发提交");
+
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.updateStatus(10L, request, 99L, "平台管理员"));
+
+        assertTrue(error.getMessage().contains("状态已变化"));
+        verify(billingService, never()).setSanghuBilling(any());
+        verify(orderLogHelper, never()).logSync(anyString(), anyInt(), any(), anyString(), anyString());
+    }
+
+    @Test
+    void rejectsDeliveryRollback() {
+        PosOrder order = deliveryOrder();
+        when(posOrderService.getById(10L)).thenReturn(order);
+        AdminOrderStatusUpdateRequest request = statusRequest(2L, 2L, 1L, 0L);
+        request.setTargetDeliveryStatus(1L);
+        request.setReason("错误回退");
+
+        assertThrows(ServiceException.class,
+                () -> service.updateStatus(10L, request, 99L, "平台管理员"));
+        verify(posOrderService, never()).update(any(PosOrder.class), any(Wrapper.class));
+    }
+
+    @Test
+    void confirmsOnlyOfflinePayment() {
+        PosOrder order = pickupOrder("1", 0L);
+        when(posOrderService.getById(20L)).thenReturn(order);
+        doAnswer(invocation -> {
+            PosOrder update = invocation.getArgument(0);
+            order.setPayStatus(update.getPayStatus());
+            return true;
+        }).when(posOrderService).update(any(PosOrder.class), any(Wrapper.class));
+
+        AdminOrderStatusContext context = service.confirmOfflinePayment(20L,
+                actionRequest(2L, null, 0L, 0L, "门店已收现金"), 99L, "平台管理员");
+
+        assertEquals(1L, context.getPayStatus());
+        verify(orderLogHelper).logSync(anyString(), anyInt(), anyLong(), anyString(), anyString());
+    }
+
+    @Test
+    void rejectsOmgOrderFromOfflinePaymentPath() {
+        PosOrder order = pickupOrder("7", 0L);
+        when(posOrderService.getById(20L)).thenReturn(order);
+
+        assertThrows(ServiceException.class, () -> service.confirmOfflinePayment(20L,
+                actionRequest(2L, null, 0L, 0L, "不能伪造在线支付"), 99L, "平台管理员"));
+        verify(posOrderService, never()).update(any(PosOrder.class), any(Wrapper.class));
+    }
+
+    @Test
+    void offlineRefundSynchronizesAllBusinessStates() {
+        PosOrder order = pickupOrder("1", 1L);
+        order.setState(1L);
+        when(posOrderService.getById(20L)).thenReturn(order);
+        doAnswer(invocation -> {
+            PosOrder update = invocation.getArgument(0);
+            order.setState(update.getState());
+            order.setPayStatus(update.getPayStatus());
+            order.setAfterSaleStatus(update.getAfterSaleStatus());
+            return true;
+        }).when(posOrderService).update(any(PosOrder.class), any(Wrapper.class));
+
+        AdminOrderStatusContext context = service.confirmOfflineRefund(20L,
+                actionRequest(1L, null, 1L, 0L, "已现场退还现金"), 99L, "平台管理员");
+
+        assertEquals(4L, context.getState());
+        assertEquals(2L, context.getPayStatus());
+        assertEquals(3L, context.getAfterSaleStatus());
+    }
+
+    @Test
+    void completedOrderCannotBeRefunded() {
+        PosOrder order = pickupOrder("1", 1L);
+        order.setState(3L);
+        when(posOrderService.getById(20L)).thenReturn(order);
+
+        assertThrows(ServiceException.class, () -> service.confirmOfflineRefund(20L,
+                actionRequest(3L, null, 1L, 0L, "申请退款"), 99L, "平台管理员"));
+        verify(posOrderService, never()).update(any(PosOrder.class), any(Wrapper.class));
+    }
+
+    @Test
+    void blankReasonIsRejectedBeforeAnyWrite() {
+        PosOrder order = pickupOrder("1", 0L);
+        when(posOrderService.getById(20L)).thenReturn(order);
+
+        assertThrows(ServiceException.class, () -> service.confirmOfflinePayment(20L,
+                actionRequest(2L, null, 0L, 0L, "   "), 99L, "平台管理员"));
+
+        verify(posOrderService, never()).update(any(PosOrder.class), any(Wrapper.class));
+    }
+
+    @Test
+    void riderDeliveryCompletesCashOrderAndPreservesInvariant() {
+        PosOrder order = deliveryOrder();
+        order.setPayType("1");
+        order.setCollectPayment("1");
+        order.setPayStatus(0L);
+        when(posOrderService.getById(10L)).thenReturn(order);
+        doAnswer(invocation -> {
+            PosOrder update = invocation.getArgument(0);
+            order.setState(update.getState());
+            order.setDeliveryStatus(update.getDeliveryStatus());
+            order.setPayStatus(update.getPayStatus());
+            order.setSdTime(update.getSdTime());
+            return true;
+        }).when(posOrderService).update(any(PosOrder.class), any(Wrapper.class));
+
+        AdminOrderStatusContext context = service.completeDeliveryByRider(10L, 88L, "骑手甲");
+
+        assertEquals(3L, context.getState());
+        assertEquals(3L, context.getDeliveryStatus());
+        assertEquals(1L, context.getPayStatus());
+        verify(billingService).setSanghuBilling(order);
+        verify(billingService).setQishouBilling(order);
+    }
+
+    @Test
+    void synchronizesSystemOmgRefundToAllThreeBusinessStates() {
+        PosOrder order = pickupOrder("7", 1L);
+        order.setState(1L);
+        when(posOrderService.getById(20L)).thenReturn(order);
+        doAnswer(invocation -> {
+            PosOrder update = invocation.getArgument(0);
+            order.setState(update.getState());
+            order.setPayStatus(update.getPayStatus());
+            order.setAfterSaleStatus(update.getAfterSaleStatus());
+            return true;
+        }).when(posOrderService).update(any(PosOrder.class), any(Wrapper.class));
+
+        AdminOrderStatusContext context = service.finalizeSystemOmgRefund(20L);
+
+        assertEquals(4L, context.getState());
+        assertEquals(2L, context.getPayStatus());
+        assertEquals(3L, context.getAfterSaleStatus());
+        verify(orderLogHelper).logSync(anyString(), anyInt(), any(), anyString(), anyString());
+    }
+
+    @Test
+    void exposesManualRefundLocalCompensationAfterLedgerAlreadyCompleted() {
+        PosOrder order = pickupOrder("7", 1L);
+        order.setState(1L);
+        PosOrderOmgPayment payment = new PosOrderOmgPayment();
+        payment.setId(3L);
+        payment.setPayStatus(3);
+        payment.setPayType("ATM_TAISHIN");
+        PosOrderOmgRefund completed = new PosOrderOmgRefund();
+        completed.setRtnCode(1);
+        when(posOrderService.getById(20L)).thenReturn(order);
+        when(omgPaymentService.getLatestByDdId(order.getDdId())).thenReturn(payment);
+        when(omgRefundService.listByPayment(3L)).thenReturn(List.of(completed));
+
+        AdminOrderStatusContext context = service.getStatusContext(20L);
+
+        assertTrue(context.getCanConfirmManualOmgRefund());
+        assertFalse(context.getCanRefundOmg());
+        assertFalse(context.getManualRefundPending());
+    }
+
+    private PosOrder deliveryOrder() {
+        PosOrder order = new PosOrder();
+        order.setId(10L);
+        order.setDdId("202608100001");
+        order.setType(0L);
+        order.setPayType("7");
+        order.setState(2L);
+        order.setDeliveryStatus(2L);
+        order.setPayStatus(1L);
+        order.setAfterSaleStatus(0L);
+        order.setQsId(88L);
+        order.setShId(66L);
+        order.setMdId(77L);
+        order.setAmount(100);
+        order.setFood("[]");
+        order.setFreight(10D);
+        return order;
+    }
+
+    private PosOrder pickupOrder(String payType, Long payStatus) {
+        PosOrder order = new PosOrder();
+        order.setId(20L);
+        order.setDdId("202608100002");
+        order.setType(1L);
+        order.setPayType(payType);
+        order.setState(2L);
+        order.setPayStatus(payStatus);
+        order.setAfterSaleStatus(0L);
+        return order;
+    }
+
+    private AdminOrderStatusUpdateRequest statusRequest(Long state, Long delivery, Long pay, Long afterSale) {
+        AdminOrderStatusUpdateRequest request = new AdminOrderStatusUpdateRequest();
+        request.setExpectedState(state);
+        request.setExpectedDeliveryStatus(delivery);
+        request.setExpectedPayStatus(pay);
+        request.setExpectedAfterSaleStatus(afterSale);
+        return request;
+    }
+
+    private AdminOrderActionRequest actionRequest(Long state, Long delivery, Long pay, Long afterSale,
+                                                  String reason) {
+        AdminOrderActionRequest request = new AdminOrderActionRequest();
+        request.setExpectedState(state);
+        request.setExpectedDeliveryStatus(delivery);
+        request.setExpectedPayStatus(pay);
+        request.setExpectedAfterSaleStatus(afterSale);
+        request.setReason(reason);
+        return request;
+    }
+}

+ 56 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderAdminStatusControllerTest.java

@@ -0,0 +1,56 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.AdminOrderActionRequest;
+import com.ruoyi.app.order.dto.AdminOrderStatusUpdateRequest;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.domain.PosOrder;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PosOrderAdminStatusControllerTest {
+
+    @Test
+    void statusEndpointsRequireOrderEditPermission() {
+        for (Method method : PosOrderController.class.getDeclaredMethods()) {
+            if (method.getName().startsWith("admin") || method.getName().equals("getStatusContext")) {
+                PreAuthorize annotation = method.getAnnotation(PreAuthorize.class);
+                assertNotNull(annotation, method.getName() + " must require permission");
+                assertTrue(annotation.value().contains("system:order:edit"));
+            }
+        }
+    }
+
+    @Test
+    void dedicatedRequestsCannotCarryBusinessFields() {
+        assertFalse(hasField(AdminOrderActionRequest.class, "amount"));
+        assertFalse(hasField(AdminOrderActionRequest.class, "payType"));
+        assertFalse(hasField(AdminOrderStatusUpdateRequest.class, "payStatus"));
+        assertFalse(hasField(AdminOrderStatusUpdateRequest.class, "sdTime"));
+    }
+
+    @Test
+    void legacyEditRejectsProtectedStatusFields() {
+        PosOrder order = new PosOrder();
+        order.setId(1L);
+        order.setState(2L);
+
+        AjaxResult result = new PosOrderController().edit(order);
+
+        assertEquals(500, result.get(AjaxResult.CODE_TAG));
+        assertEquals("订单状态请使用状态专用操作", result.get(AjaxResult.MSG_TAG));
+    }
+
+    private boolean hasField(Class<?> type, String name) {
+        return Arrays.stream(type.getDeclaredFields()).anyMatch(field -> field.getName().equals(name))
+                || (type.getSuperclass() != null && type.getSuperclass() != Object.class
+                && hasField(type.getSuperclass(), name));
+    }
+}

+ 158 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/OmgPayControllerTest.java

@@ -1,13 +1,37 @@
 package com.ruoyi.app.pay;
 
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.ruoyi.app.order.OrderLifecycleService;
+import com.ruoyi.app.pay.dto.OmgRefundOutcome;
+import com.ruoyi.app.utils.omg.OmgPay;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderOmgPayment;
+import com.ruoyi.system.domain.PosOrderOmgRefund;
+import com.ruoyi.system.domain.PosStoreOmg;
+import com.ruoyi.system.service.IPosOrderOmgPaymentService;
+import com.ruoyi.system.service.IPosOrderOmgRefundService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreOmgService;
 import jakarta.servlet.http.HttpServletRequest;
 import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+import org.springframework.test.util.ReflectionTestUtils;
 
 import java.util.Collections;
+import java.util.List;
+import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 class OmgPayControllerTest {
@@ -30,4 +54,138 @@ class OmgPayControllerTest {
         assertThrows(IllegalArgumentException.class,
                 () -> new OmgPayController().collectForm(request));
     }
+
+    @Test
+    void createsOnlyOneManualRefundTodo() {
+        RefundFixture fixture = refundFixture("ATM_TAISHIN");
+        PosOrderOmgRefund pending = new PosOrderOmgRefund();
+        when(fixture.refundService.listByPayment(1L))
+                .thenReturn(List.of(), List.of(pending));
+
+        assertEquals(OmgRefundOutcome.Status.MANUAL_PENDING,
+                fixture.controller.refundOrderOutcome(fixture.order).getStatus());
+        assertEquals(OmgRefundOutcome.Status.MANUAL_PENDING,
+                fixture.controller.refundOrderOutcome(fixture.order).getStatus());
+
+        verify(fixture.refundService).record(1L, fixture.order.getDdId(), "T123", null,
+                100, null, "延期支付方式无退款API,待人工在 OMG 后台处理", "");
+    }
+
+    @Test
+    void reportsRefundedOnlyAfterGatewayAndLedgerSucceed() throws Exception {
+        RefundFixture fixture = refundFixture("Credit_CreditCard");
+        PosStoreOmg credential = credential();
+        when(fixture.paymentService.markRefunding(1L)).thenReturn(1);
+        when(fixture.storeService.getCredentialByMerchantId("M123")).thenReturn(credential);
+        when(fixture.omgPay.doAction(anyString(), any(), anyString(), anyString(), anyString(), anyInt()))
+                .thenReturn(Map.of("RtnCode", "1", "RtnMsg", "Succeeded"));
+        when(fixture.paymentService.markRefunded(1L)).thenReturn(1);
+
+        OmgRefundOutcome outcome = fixture.controller.refundOrderOutcome(fixture.order);
+
+        assertEquals(OmgRefundOutcome.Status.REFUNDED, outcome.getStatus());
+        verify(fixture.paymentService).markRefunded(1L);
+        verify(fixture.orderLifecycleService, never()).finalizeSystemOmgRefund(10L);
+    }
+
+    @Test
+    void keepsRefundingStateWhenGatewayOutcomeIsUnknown() throws Exception {
+        RefundFixture fixture = refundFixture("Credit_CreditCard");
+        when(fixture.paymentService.markRefunding(1L)).thenReturn(1);
+        when(fixture.storeService.getCredentialByMerchantId("M123")).thenReturn(credential());
+        when(fixture.omgPay.doAction(anyString(), any(), anyString(), anyString(), anyString(), anyInt()))
+                .thenThrow(new RuntimeException("timeout with sensitive gateway detail"));
+
+        Logger logger = (Logger) LoggerFactory.getLogger(OmgPayController.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+
+        OmgRefundOutcome outcome;
+        try {
+            outcome = fixture.controller.refundOrderOutcome(fixture.order);
+        } finally {
+            logger.detachAppender(appender);
+        }
+
+        assertEquals(OmgRefundOutcome.Status.UNKNOWN, outcome.getStatus());
+        assertFalse(appender.list.stream()
+                .map(ILoggingEvent::getFormattedMessage)
+                .anyMatch(message -> message.contains("sensitive gateway detail")));
+        verify(fixture.paymentService, never()).restorePaidFromRefunding(1L);
+    }
+
+    @Test
+    void manualConfirmationUsesLedgerCasAndRecordsCompletion() {
+        RefundFixture fixture = refundFixture("ATM_TAISHIN");
+        PosOrderOmgRefund pending = new PosOrderOmgRefund();
+        when(fixture.refundService.listByPayment(1L)).thenReturn(List.of(pending));
+        when(fixture.paymentService.markRefunding(1L)).thenReturn(1);
+        when(fixture.paymentService.markRefunded(1L)).thenReturn(1);
+
+        OmgRefundOutcome outcome = fixture.controller.confirmManualRefundOutcome(fixture.order);
+
+        assertEquals(OmgRefundOutcome.Status.REFUNDED, outcome.getStatus());
+        verify(fixture.paymentService).markRefunding(1L);
+        verify(fixture.paymentService).markRefunded(1L);
+        verify(fixture.refundService).record(1L, fixture.order.getDdId(), "T123", null,
+                100, 1, "管理员确认已在 OMG 后台完成人工退款", "");
+    }
+
+    private RefundFixture refundFixture(String paymentType) {
+        RefundFixture fixture = new RefundFixture();
+        fixture.controller = new OmgPayController();
+        fixture.paymentService = mock(IPosOrderOmgPaymentService.class);
+        fixture.refundService = mock(IPosOrderOmgRefundService.class);
+        fixture.storeService = mock(IPosStoreOmgService.class);
+        fixture.omgPay = mock(OmgPay.class);
+        fixture.orderLifecycleService = mock(OrderLifecycleService.class);
+        ReflectionTestUtils.setField(fixture.controller, "paymentService", fixture.paymentService);
+        ReflectionTestUtils.setField(fixture.controller, "refundService", fixture.refundService);
+        ReflectionTestUtils.setField(fixture.controller, "storeOmgService", fixture.storeService);
+        ReflectionTestUtils.setField(fixture.controller, "omgPay", fixture.omgPay);
+        ReflectionTestUtils.setField(fixture.controller, "orderLifecycleService", fixture.orderLifecycleService);
+        ReflectionTestUtils.setField(fixture.controller, "orderLogHelper",
+                mock(com.ruoyi.system.utils.OrderLogHelper.class));
+        ReflectionTestUtils.setField(fixture.controller, "baseUrl", "https://payment.example.test");
+
+        fixture.order = new PosOrder();
+        fixture.order.setId(10L);
+        fixture.order.setDdId("202608100003");
+        fixture.order.setPayType("7");
+        fixture.order.setPayStatus(1L);
+        fixture.order.setAmount(100);
+
+        PosOrderOmgPayment payment = new PosOrderOmgPayment();
+        payment.setId(1L);
+        payment.setDdId(fixture.order.getDdId());
+        payment.setPayStatus(1);
+        payment.setPayType(paymentType);
+        payment.setAmount(100);
+        payment.setTradeNo("T123");
+        payment.setMerchantTradeNo("MT123");
+        payment.setMerchantId("M123");
+        payment.setStoreId(77L);
+        when(fixture.paymentService.getLatestByDdId(fixture.order.getDdId())).thenReturn(payment);
+        return fixture;
+    }
+
+    private PosStoreOmg credential() {
+        PosStoreOmg credential = new PosStoreOmg();
+        credential.setStoreId(77L);
+        credential.setMerchantId("M123");
+        credential.setHashKey("hash-key");
+        credential.setHashIv("hash-iv");
+        return credential;
+    }
+
+    private static class RefundFixture {
+        private OmgPayController controller;
+        private IPosOrderOmgPaymentService paymentService;
+        private IPosOrderOmgRefundService refundService;
+        private IPosStoreOmgService storeService;
+        private OmgPay omgPay;
+        private OrderLifecycleService orderLifecycleService;
+        private PosOrder order;
+    }
 }

+ 421 - 0
specs/018-order-status-admin/contracts/admin-order-status-api.md

@@ -0,0 +1,421 @@
+# API Contract: 平台订单状态受控调整
+
+**Base path**: `/system/order`
+**Authentication**: RuoYi 管理后台 Bearer token
+**Permission**: `system:order:edit`
+**Content-Type**: `application/json`
+
+## 1. Common Conventions
+
+### 1.1 Response envelope
+
+沿用 `AjaxResult`:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {}
+}
+```
+
+业务校验、状态冲突或网关失败沿用项目约定返回非 200 的业务 `code` 与安全错误消息。前端不得只根据 HTTP 状态推断业务成功。
+
+### 1.2 Snapshot fields
+
+所有写操作必须携带弹窗打开时的四状态快照:
+
+```json
+{
+  "expectedState": 2,
+  "expectedDeliveryStatus": 2,
+  "expectedPayStatus": 1,
+  "expectedAfterSaleStatus": 0,
+  "reason": "骑手端漏操作"
+}
+```
+
+`reason` trim 后 1~200 字。`expectedDeliveryStatus` 可为 `null`,其他快照字段必填。
+
+### 1.3 Conflict behavior
+
+如果订单在上下文读取后被回调、骑手、定时任务或另一管理员更新:
+
+```json
+{
+  "code": 500,
+  "msg": "订单状态已变化,请刷新后重试"
+}
+```
+
+冲突请求不得产生订单、账单、积分、日志或推送副作用。
+
+## 2. Get Status Context
+
+### `GET /system/order/{id}/status-context`
+
+读取修改弹窗所需的最小数据和服务端能力。
+
+**Response**:
+
+```json
+{
+  "code": 200,
+  "data": {
+    "id": 123,
+    "ddId": "202608100001",
+    "type": 0,
+    "payType": "7",
+    "state": 2,
+    "deliveryStatus": 2,
+    "payStatus": 1,
+    "afterSaleStatus": 0,
+    "sdTime": null,
+    "riderAssigned": true,
+    "allowedOrderStates": [2],
+    "allowedDeliveryStatuses": [2, 3],
+    "canConfirmOfflinePayment": false,
+    "canConfirmOfflineRefund": false,
+    "canReconcileOmg": false,
+    "canRefundOmg": true,
+    "canConfirmManualOmgRefund": false,
+    "omgPaymentStatus": 1,
+    "manualRefundPending": false,
+    "refundUnknown": false
+  }
+}
+```
+
+规则:
+
+- `allowed*` 至少包含当前值,终态只有当前值;
+- 自取/堂食 `allowedDeliveryStatuses=[]`;
+- OMG 无流水时 `omgPaymentStatus=null` 且所有 OMG 写能力为 false;
+- 能力字段只控制显示,不能作为后端授权依据。
+
+## 3. Update Order or Delivery Status
+
+### `PUT /system/order/{id}/status`
+
+**Annotations**: `@PreAuthorize(system:order:edit)`、`@RepeatSubmit`
+
+**Request**:
+
+```json
+{
+  "expectedState": 2,
+  "expectedDeliveryStatus": 2,
+  "expectedPayStatus": 1,
+  "expectedAfterSaleStatus": 0,
+  "targetState": null,
+  "targetDeliveryStatus": 3,
+  "reason": "骑手已实际送达,客户端漏操作"
+}
+```
+
+至少一个目标字段非空且与当前值不同。未提交的目标字段保持不变。
+
+**Success response**: 返回最新状态上下文。
+
+```json
+{
+  "code": 200,
+  "msg": "修改成功",
+  "data": {
+    "id": 123,
+    "state": 3,
+    "deliveryStatus": 3,
+    "payStatus": 1,
+    "afterSaleStatus": 0,
+    "sdTime": "2026-08-10 11:30:00"
+  }
+}
+```
+
+### Validation
+
+- `targetState` 只能是 0~4;`targetDeliveryStatus` 只能是 0~3;
+- 只能执行 [data-model.md](../data-model.md) 中的下一步转换;
+- 非外送不得提交配送目标;
+- 配送到 1/2/3 要求已有骑手;
+- 完成要求已支付且无售后;
+- 已支付取消返回“请使用退款操作”;
+- 送达自动同步完成和送达时间;客户端不能提交 `sdTime`。
+
+## 4. Confirm Offline Payment
+
+### `POST /system/order/{id}/offline-payment/confirm`
+
+**Request**: Common snapshot fields only.
+
+```json
+{
+  "expectedState": 2,
+  "expectedDeliveryStatus": null,
+  "expectedPayStatus": 0,
+  "expectedAfterSaleStatus": 0,
+  "reason": "门店确认已收到现金"
+}
+```
+
+**Allowed**:
+
+- `payType="1"`;
+- `payStatus=0`;
+- `state` 不是 3/4;
+- `afterSaleStatus=0`。
+
+**Result**: 只把 `payStatus` 改为 1,不创建 OMG/历史在线支付流水,不自动完成订单。
+
+## 5. Confirm Offline Refund
+
+### `POST /system/order/{id}/offline-refund/confirm`
+
+此操作代表管理员已经在线下实际退回款项,前端必须先显示不可逆二次确认。
+
+**Request**:
+
+```json
+{
+  "expectedState": 1,
+  "expectedDeliveryStatus": null,
+  "expectedPayStatus": 1,
+  "expectedAfterSaleStatus": 0,
+  "reason": "门店无法履约,已现场退还现金"
+}
+```
+
+**Allowed**:
+
+- `payType="1"`;
+- `payStatus=1`;
+- `state!=3`;
+- `afterSaleStatus=0`。
+
+**Atomic result**:
+
+```json
+{
+  "state": 4,
+  "payStatus": 2,
+  "afterSaleStatus": 3
+}
+```
+
+订单使用积分时调用现有幂等积分返还逻辑。已完成订单返回“已完成订单需结算冲正,本期不支持退款”。
+
+## 6. Reconcile OMG Payment
+
+### `POST /system/order/{id}/omg-payment/reconcile`
+
+**Request**: Common snapshot fields.
+
+**Allowed**:
+
+- `payType="7"`;
+- `payStatus=0`;
+- `state!=4`;
+- 存在最新 OMG 支付流水且流水可查询。
+
+**Behavior**:
+
+1. 复用 `reconcileByQuery(ddId, "admin")`;
+2. 校验 OMG 返回交易状态、交易号、金额和日期;
+3. 只有真实已支付时通过流水 CAS 和订单 CAS 核销;
+4. 未支付、失败、未知或字段不符时不得写 `order.payStatus=1`;
+5. 回调和补单并发只执行一次支付副作用。
+
+**Paid response**:
+
+```json
+{
+  "code": 200,
+  "msg": "OMG 支付核验成功",
+  "data": {
+    "payStatus": 1,
+    "reconciled": true
+  }
+}
+```
+
+**Still unpaid response**:
+
+```json
+{
+  "code": 200,
+  "msg": "OMG 尚未确认支付",
+  "data": {
+    "payStatus": 0,
+    "reconciled": false
+  }
+}
+```
+
+金额/订单信息不一致返回错误并写系统核对日志,不向前端返回原始网关报文。
+
+## 7. Refund OMG Payment
+
+### `POST /system/order/{id}/omg-refund`
+
+前端必须显示不可逆二次确认。
+
+**Request**: Common snapshot fields.
+
+**Allowed**:
+
+- `payType="7"`;
+- `payStatus=1`;
+- `state!=3`;
+- `afterSaleStatus=0`;
+- 最新 OMG 流水为已支付,金额和订单一致。
+
+### Outcomes
+
+#### Automatic refund succeeded
+
+```json
+{
+  "code": 200,
+  "msg": "退款成功",
+  "data": {
+    "outcome": "REFUNDED",
+    "state": 4,
+    "payStatus": 2,
+    "afterSaleStatus": 3
+  }
+}
+```
+
+#### Manual refund required
+
+```json
+{
+  "code": 200,
+  "msg": "该支付方式需在 OMG 后台人工退款,订单暂保持已支付",
+  "data": {
+    "outcome": "MANUAL_PENDING",
+    "payStatus": 1,
+    "manualRefundPending": true
+  }
+}
+```
+
+重复调用不重复新增人工待办。
+
+#### Result unknown
+
+```json
+{
+  "code": 500,
+  "msg": "OMG 退款结果待确认,请勿重复发起",
+  "data": {
+    "outcome": "UNKNOWN",
+    "payStatus": 1,
+    "refundUnknown": true
+  }
+}
+```
+
+OMG 流水保持退款中,后续请求不得再次调用网关。
+
+#### Explicit failure
+
+```json
+{
+  "code": 500,
+  "msg": "OMG 退款失败,请核对后重试",
+  "data": {
+    "outcome": "FAILED",
+    "payStatus": 1
+  }
+}
+```
+
+错误消息不包含 HashKey、HashIV、完整响应或堆栈。
+
+## 8. Confirm Manual OMG Refund
+
+### `POST /system/order/{id}/omg-refund/manual-confirm`
+
+此接口只用于支付方式不支持 OMG 退款 API、平台管理员已经在 OMG 后台核实退款完成的场景。前端必须进行不可逆二次确认。
+
+**Request**: Common snapshot fields;`reason` 必须说明人工退款核实依据。
+
+**Allowed**:
+
+- `payType="7"`、订单 `payStatus=1`、`state!=3`、`afterSaleStatus=0`;
+- 最新 OMG 流水属于不支持自动退款的支付方式;
+- 同一支付流水存在 `action=NULL, rtnCode=NULL` 的未决人工待办;
+- 不存在 `action=NULL, rtnCode=1` 的人工完成记录。
+
+**Behavior**:
+
+1. 管理员二次确认;
+2. OMG 支付流水复用现有 CAS 执行 `1→4→3`,不调用网关;
+3. 插入 `action=NULL, rtnCode=1` 的人工退款完成记录,`rtnMsg` 标明管理员确认;
+4. 同步 `state=4, payStatus=2, afterSaleStatus=3`;
+5. 写管理员操作日志,包含管理员、前后状态和原因。
+
+**Response**:
+
+```json
+{
+  "code": 200,
+  "msg": "已确认 OMG 人工退款完成",
+  "data": {
+    "outcome": "REFUNDED",
+    "state": 4,
+    "payStatus": 2,
+    "afterSaleStatus": 3,
+    "manualRefundPending": false
+  }
+}
+```
+
+无待办、已完成订单、流水状态变化或重复确认均不得产生新的退款记录或业务副作用。
+
+## 9. Legacy Endpoint Hardening
+
+### `PUT /system/order`
+
+继续保留历史非状态编辑能力,但请求含以下任意字段时拒绝:
+
+- `state`
+- `deliveryStatus`
+- `payStatus`
+- `afterSaleStatus`
+- `sdTime`
+
+**Response**:
+
+```json
+{
+  "code": 500,
+  "msg": "订单状态请使用状态专用操作"
+}
+```
+
+旧 `state=7/12`、`kefuState→state=5` 等逻辑必须删除。
+
+## 10. Forbidden Payload Fields
+
+新状态接口 DTO 不声明并不得使用以下字段:
+
+```text
+amount, userId, shId, mdId, food, freight, points,
+payType, type, qsId, sdTime, payStatus, afterSaleStatus
+```
+
+其中 `expectedPayStatus`、`expectedAfterSaleStatus` 仅作为旧快照,不是目标值。服务端必须从数据库读取订单类型、支付类型、金额、骑手和业务标识。
+
+## 11. Audit Requirements
+
+每次实际改变数据的管理员操作写一条 `operatorType=1` 的同步订单日志,包含:
+
+- 管理员 ID/名称;
+- 动作类型;
+- 四状态前后值;
+- 管理员原因;
+- 服务端时间。
+
+相同目标的幂等请求不重复写成功日志。OMG 补单/退款还保留现有 `operatorType=0` 系统资金日志。

+ 219 - 0
specs/018-order-status-admin/data-model.md

@@ -0,0 +1,219 @@
+# Data Model: 平台订单状态受控调整
+
+**Date**: 2026-08-10
+**Database change**: None
+
+## 1. Existing Persistent Entities
+
+### 1.1 `PosOrder` / `pos_order`
+
+本功能只使用现有字段,不新增列。
+
+| Java 字段 | 数据库列 | 类型 | 本功能含义 |
+|---|---|---|---|
+| `id` | `id` | BIGINT | 内部订单主键,管理端接口路径参数 |
+| `ddId` | `dd_id` | VARCHAR | 业务订单号,日志和 OMG 流水关联键 |
+| `type` | `type` | BIGINT | 0 外送、1 自取、2 堂食 |
+| `state` | `state` | BIGINT | 0 待处理、1 已接单、2 已出餐、3 已完成、4 已取消 |
+| `deliveryStatus` | `delivery_status` | BIGINT NULL | 外送:0 待接单、1 骑手已接单、2 配送中、3 已送达 |
+| `payStatus` | `pay_status` | BIGINT | 0 未支付、1 已支付、2 已退款 |
+| `afterSaleStatus` | `after_sale_status` | BIGINT | 0 无售后、1~6 为售后流程状态 |
+| `payType` | `pay_type` | VARCHAR | 1 线下/到付,7 OMG;历史类型不在本功能启用 |
+| `collectPayment` | `collect_payment` | VARCHAR | 到付标识,保留给既有账单逻辑 |
+| `qsId` | `qs_id` | BIGINT NULL | 骑手 ID;本功能不提供指派能力 |
+| `sdTime` | `sd_time` | DATETIME NULL | 实际送达时间 |
+| `points` | `points` | INT NULL | 退款/取消时需要返还的积分 |
+
+### 1.2 `PosOrderLog` / `pos_order_log`
+
+| 字段 | 用途 |
+|---|---|
+| `ddId` | 关联业务订单号 |
+| `operatorType` | 管理员固定为 1;系统资金日志为 0 |
+| `operatorId` | 当前后台管理员 ID |
+| `operatorName` | 当前后台管理员名称 |
+| `content` | 状态前后值、动作和原因,最大 512 字 |
+| `logTime` | 服务端写入时间 |
+
+人工操作使用同步日志,保证状态事务回滚时不会残留“成功”日志。
+
+### 1.3 `PosOrderOmgPayment` / `pos_order_omg_payment`
+
+订单层 `payStatus` 只有 0/1/2;OMG 流水有更细的内部状态:
+
+| `pay_status` | 含义 | 管理端行为 |
+|---|---|---|
+| 0 | 未支付 | 允许查询补单 |
+| 1 | 已支付 | 未完成订单允许退款 |
+| 2 | 支付失败 | 不得人工改为已支付;需要重新发起支付 |
+| 3 | 已退款 | 订单层应补偿为 `payStatus=2` |
+| 4 | 退款中/结果未知 | 禁止重复退款,提示等待对账 |
+
+### 1.4 `PosOrderOmgRefund` / `pos_order_omg_refund`
+
+| 字段组合 | 含义 |
+|---|---|
+| `action=R, rtnCode=1` | OMG 自动退款成功 |
+| `action=R, rtnCode!=1` | OMG 明确退款失败 |
+| `action=R, rtnCode=NULL` | 请求处理中或结果未知,结合 `rtnMsg` 判断 |
+| `action=NULL, rtnCode=NULL` | 支付方式不支持退款 API,等待人工处理 |
+| `action=NULL, rtnCode=1` | 管理员确认已在 OMG 后台完成人工退款 |
+
+同一支付流水只保留一个未决人工待办;已存在时重复请求返回原状态。
+
+## 2. API-only Models
+
+### 2.1 `AdminOrderActionRequest`
+
+所有支付动作与状态动作共享的旧快照和审计字段。
+
+| 字段 | Java 类型 | 校验 |
+|---|---|---|
+| `expectedState` | `Long` | 必填,0~4 |
+| `expectedDeliveryStatus` | `Long` | 可空;非空时 0~3 |
+| `expectedPayStatus` | `Long` | 必填,0~2 |
+| `expectedAfterSaleStatus` | `Long` | 必填,0~6 |
+| `reason` | `String` | 必填,trim 后 1~200 字 |
+
+### 2.2 `AdminOrderStatusUpdateRequest`
+
+继承/组合 `AdminOrderActionRequest`,增加:
+
+| 字段 | Java 类型 | 校验 |
+|---|---|---|
+| `targetState` | `Long` | 可空,非空时 0~4 |
+| `targetDeliveryStatus` | `Long` | 可空,非空时 0~3 |
+
+规则:至少一个目标值与快照不同;未提交的目标保持当前值。外送送达可以只提交 `targetDeliveryStatus=3`,服务端自动同步 `state=3`。
+
+### 2.3 `AdminOrderStatusContext`
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `id`, `ddId` | Long/String | 订单标识 |
+| `type`, `payType` | Long/String | 类型判定依据 |
+| `state`, `deliveryStatus`, `payStatus`, `afterSaleStatus` | Long | 当前快照 |
+| `sdTime` | Date | 当前送达时间 |
+| `riderAssigned` | Boolean | `qsId != null` |
+| `allowedOrderStates` | List<Long> | 包含当前值及可选下一值 |
+| `allowedDeliveryStatuses` | List<Long> | 非外送为空 |
+| `canConfirmOfflinePayment` | Boolean | 可确认线下收款 |
+| `canConfirmOfflineRefund` | Boolean | 可确认线下退款 |
+| `canReconcileOmg` | Boolean | 可查询 OMG 补单 |
+| `canRefundOmg` | Boolean | 可发起 OMG 退款 |
+| `canConfirmManualOmgRefund` | Boolean | 已有待办且可确认人工退款完成 |
+| `omgPaymentStatus` | Integer NULL | 最新 OMG 流水状态 0~4 |
+| `manualRefundPending` | Boolean | 存在未决人工退款记录 |
+| `refundUnknown` | Boolean | OMG 流水为退款中/结果未知 |
+
+`allowed*` 和 `can*` 是 UI 提示,不替代写接口校验。
+
+### 2.4 `OmgRefundOutcome`
+
+| 状态 | 说明 | 订单层处理 |
+|---|---|---|
+| `REFUNDED` | OMG 明确退款成功 | 同步为已退款/已取消/售后已退款 |
+| `MANUAL_PENDING` | 无自动退款 API | 保持已支付,显示待人工处理和确认入口 |
+| `UNKNOWN` | 请求结果未知 | 保持已支付,OMG 流水保持退款中 |
+| `FAILED` | 网关明确失败 | 保持已支付,OMG 流水恢复已支付 |
+| `IDEMPOTENT` | 已退款或正在处理 | 返回当前最终状态,无重复副作用 |
+
+## 3. State Invariants
+
+以下不变量必须在每个写入口成立:
+
+1. `type IN (1,2) → deliveryStatus IS NULL`。
+2. `deliveryStatus=3 → type=0 AND state=3 AND sdTime IS NOT NULL`。
+3. `state=3 → payStatus=1 AND afterSaleStatus=0`(本期不允许完成后退款)。
+4. `payStatus=2 → state=4 AND afterSaleStatus=3`(本功能处理的全额退款)。
+5. `payType=7 AND payStatus=1` 必须存在 OMG 已支付流水。
+6. `payType=7 AND payStatus=2` 必须存在 OMG 自动退款成功记录或管理员明确确认人工退款完成;人工待办不提前置 2。
+7. `state IN (3,4)`、`payStatus=2` 为本功能终态,普通状态接口不能重新打开。
+8. `afterSaleStatus>0` 时,普通订单/配送状态接口不得变更数据。
+
+## 4. Order State Transitions
+
+### 4.1 Normal order state
+
+| Current | Target | Additional conditions | Result |
+|---|---|---|---|
+| 0 | 1 | OMG 订单必须已支付;无售后 | `state=1` |
+| 1 | 2 | 无售后 | `state=2`;外送若配送为空则初始化为 0 |
+| 2 | 3 | 已支付;外送已送达,自取/堂食无配送状态 | `state=3` + 完成副作用 |
+| 0 | 4 | 未支付、无售后 | `state=4` |
+| 1 | 4 | 未支付、无售后 | `state=4` |
+
+其他跳转全部拒绝。已支付订单取消必须使用退款动作。
+
+### 4.2 Delivery state
+
+| Current | Target | Conditions | Result |
+|---|---|---|---|
+| NULL | 0 | 外送,`state=2` | 等待骑手接单 |
+| 0 | 1 | 外送,已有 `qsId`,`state=2` | 骑手已接单 |
+| 1 | 2 | 外送,已有 `qsId`,`state=2` | 配送中 |
+| 2 | 3 | 外送,已有 `qsId`,已支付,无售后 | 配送已送达 + 订单完成 + 送达时间 + 账单 |
+
+配送状态不得跳级或回退。相同值视为无变化。
+
+## 5. Payment State Transitions
+
+### 5.1 Offline `payType="1"`
+
+```text
+0 --confirm-payment--> 1
+1 --confirm-refund--> 2
+```
+
+- 收款不自动完成订单;完成仍要满足订单/配送规则。
+- 退款原子同步 `state=4` 和 `afterSaleStatus=3`。
+
+### 5.2 OMG `payType="7"`
+
+```text
+order.payStatus 0 --callback/query + ledger CAS--> 1
+order.payStatus 1 --real refund success--> 2
+```
+
+管理员请求体不能携带目标 `payStatus`。OMG 流水状态 4 期间订单仍显示已支付,并额外显示“退款结果待核对”。
+
+对于无退款 API 的支付方式:创建未决人工记录时订单和支付流水保持已支付;管理员完成外部人工退款并二次确认后,支付流水通过既有 `1→4→3` CAS,新增 `action=NULL, rtnCode=1` 的完成记录,再同步订单三状态。
+
+## 6. Composite Snapshot CAS
+
+逻辑条件:
+
+```sql
+UPDATE pos_order
+SET ...
+WHERE id = :id
+  AND state = :expectedState
+  AND pay_status = :expectedPayStatus
+  AND after_sale_status = :expectedAfterSaleStatus
+  AND delivery_status <=> :expectedDeliveryStatus;
+```
+
+实现使用 MyBatis-Plus 条件构造器参数化生成;上面的 MySQL `<=>` 仅用于说明 NULL-safe 语义,不要求新增 XML SQL。
+
+CAS 失败时:
+
+- 不写订单日志;
+- 不生成账单、不返积分、不推送;
+- 返回业务冲突并要求重新加载 `status-context`。
+
+## 7. Audit Content
+
+日志内容保持可读且不超过 512 字:
+
+```text
+平台调整[订单/配送]:state 2→3,delivery 2→3,pay 1→1,afterSale 0→0;原因:骑手端漏操作
+平台确认线下收款:pay 0→1;原因:门店已收现金
+平台发起OMG补单:pay 0→1;原因:用户提供付款凭证
+平台OMG退款成功:state 1→4,pay 1→2,afterSale 0→3;原因:商家无法履约
+```
+
+不得记录 OMG HashKey/HashIV、token、完整回调原文或其他秘密。
+
+## 8. Database Migration
+
+无数据库迁移,不修改 `updatesql/sql.md`。如测试发现现有生产表 `pos_order_log.content` 不是 512 字,应暂停实现并单独提出迁移评审,不在本功能中自动执行 DDL。

+ 237 - 0
specs/018-order-status-admin/plan.md

@@ -0,0 +1,237 @@
+# Implementation Plan: 平台订单状态受控调整
+
+**Branch**: `test`(沿用当前分支,不创建新分支) | **Date**: 2026-08-10 | **Spec**: [spec.md](./spec.md)
+
+**Input**: Feature specification from `/specs/018-order-status-admin/spec.md`
+
+## Summary
+
+平台订单管理改为使用状态专用接口,不再把完整 `PosOrder` 对象提交给通用编辑接口。修改弹窗先读取服务端计算的状态上下文与允许操作,再通过旧状态快照执行 CAS 更新:订单状态和配送状态只允许合法前向流转;线下支付通过确认收款/确认退款维护;OMG 支付通过现有查询补单和真实退款流水维护。所有成功的人工操作同步记录管理员、前后状态和原因。
+
+本功能同时修复两个已经确认的状态不变量缺口:骑手送达必须同步 `deliveryStatus=3`、`state=3`、`sdTime` 并执行幂等完成结算;OMG 退款成功必须同步 `payStatus=2`、`afterSaleStatus=3`、`state=4`。不新增数据库表或字段。
+
+## Technical Context
+
+**Language/Version**: Java 21;JavaScript(Vue 2.6)
+**Primary Dependencies**: Spring Boot 3.3.5、RuoYi-Vue 3.8.5、Spring Security、MyBatis-Plus/MyBatis XML、Redisson、Vue 2.6、Element UI 2.15、Axios 0.24、Vue I18n 8
+**Storage**: MySQL;复用 `pos_order`、`pos_order_log`、`pos_order_omg_payment`、`pos_order_omg_refund`,无 DDL 变更
+**Testing**: JUnit 5、Mockito、Spring Boot Test;前端 ESLint、生产构建和人工界面验证
+**Target Platform**: RuoYi Java Web 服务及桌面浏览器管理后台
+**Project Type**: 后端多模块 Web 服务 + 独立 Vue 管理后台
+**Performance Goals**: 本地状态操作只读取单笔订单并执行单行条件更新;OMG 查询/退款不增加额外网关调用;订单列表查询不新增关联查询
+**Constraints**: 在线支付状态必须以 OMG 事实为准;禁止完整实体批量赋值;外部支付请求不得持有长数据库事务;状态/账单/日志必须幂等;四语言文案齐全;不创建分支
+**Scale/Scope**: 1 个订单管理页面、1 个现有订单 Controller、2 条现有履约/支付链路、7 个管理端状态接口;无数据库迁移
+
+## Constitution Check
+
+*GATE: Phase 0 前检查;Phase 1 设计完成后复查。*
+
+项目 `.specify/memory/constitution.md` 仍是未初始化的占位模板,无法提供可执行条款。本功能以根目录 `CLAUDE.md`、管理后台 `CLAUDE.md`、006 订单状态规格和 016 OMG 支付规格作为实际质量门槛。
+
+| Gate | 结论 | 处理 |
+|---|---|---|
+| 使用当前有效订单/支付代码 | PASS | 只修改 `PosOrderController`、`PosOrderQsOprateController`、`OmgPayController` 及其现有服务;不接入废弃支付通道 |
+| 支付与退款真实性 | PASS | OMG 只允许回调/查询核销和真实退款;线下操作必须显式确认并审计 |
+| 输入最小化与权限 | PASS | 新接口使用专用 DTO、`@Valid`、`system:order:edit`,不接收金额/用户/商品等字段 |
+| 并发与幂等 | PASS | 订单使用四状态旧快照 CAS;OMG 继续使用支付流水 CAS;完成账单只在成功进入完成态后执行 |
+| 数据库变更规范 | PASS | 无表结构变更,不修改 `updatesql/sql.md` |
+| 前端国际化 | PASS | 新增及本页触及的状态文案同步简中、繁中、英文、越南文 |
+| 测试优先 | PASS | 先写策略/服务/OMG 回归测试,再实现接口和页面 |
+| 变更范围 | PASS | 不创建分支,不修改与订单状态无关的功能 |
+
+Phase 1 复查:设计未引入新的数据库、支付渠道、权限体系或跨模块反向依赖,所有 Gate 继续通过。
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/018-order-status-admin/
+├── spec.md
+├── plan.md
+├── research.md
+├── data-model.md
+├── quickstart.md
+└── contracts/
+    └── admin-order-status-api.md
+```
+
+`tasks.md` 由下一阶段 `/speckit.tasks` 生成,本阶段不创建。
+
+### Source Code (repository root)
+
+```text
+foodie_server/
+├── ruoyi-admin/src/main/java/com/ruoyi/app/order/
+│   ├── PosOrderController.java                 # 管理端状态接口、旧 PUT 接口加固
+│   ├── PosOrderQsOprateController.java         # 骑手送达改走统一完成逻辑
+│   ├── OrderLifecycleService.java              # 新增:状态策略、CAS、完成/线下支付/退款
+│   └── dto/
+│       ├── AdminOrderActionRequest.java        # 新增:旧状态快照 + 原因
+│       ├── AdminOrderStatusUpdateRequest.java  # 新增:目标订单/配送状态
+│       └── AdminOrderStatusContext.java        # 新增:当前状态与允许操作
+├── ruoyi-admin/src/main/java/com/ruoyi/app/pay/
+│   ├── OmgPayController.java                   # 复用补单/退款核心,补齐退款状态不变量
+│   └── dto/
+│       └── OmgRefundOutcome.java               # 新增:成功/人工待办/结果未知/失败
+├── ruoyi-admin/src/test/java/com/ruoyi/app/order/
+│   ├── OrderLifecycleServiceTest.java          # 新增
+│   └── PosOrderAdminStatusControllerTest.java  # 新增
+└── ruoyi-admin/src/test/java/com/ruoyi/app/pay/
+    └── OmgPayControllerTest.java               # 扩展退款与补单回归
+
+foodie-admin-vue/
+└── src/
+    ├── api/system/order.js                     # 新增状态上下文和动作 API
+    ├── views/system/order/index.vue            # 受控修改弹窗与支付动作
+    └── api/language/
+        ├── language.zh_CN.js
+        ├── language.zh_TW.js
+        ├── language.en_US.js
+        └── language.vi.js
+```
+
+**Structure Decision**: 状态不变量和本地事务放在 `OrderLifecycleService`,Controller 仅做权限、参数绑定与响应转换。OMG 不复制网关逻辑,继续复用现有 `OmgPayController.reconcileByQuery` 和退款流水服务;本期用明确结果对象替代管理员侧解析错误字符串,不进行整套 OMG Controller 重构。
+
+## Phase 0: Research Outcome
+
+详细结论见 [research.md](./research.md)。核心决策如下:
+
+1. 增加 `GET /system/order/{id}/status-context`,由后端返回当前快照和允许操作,前端不自行复制完整状态机。
+2. 本地状态更新使用 `state + deliveryStatus + payStatus + afterSaleStatus` 旧值组成的无模式变更 CAS;更新行数为 0 时要求刷新。
+3. 配送/完成、线下收款和线下退款集中到 `OrderLifecycleService`,同步写 `OrderLogHelper.logSync`。
+4. OMG 补单继续复用 `reconcileByQuery` 的金额、交易号和流水 CAS;管理员不能直接提交 `payStatus=1`。
+5. OMG 退款继续先将支付流水从已支付 CAS 为退款中,再调用网关;成功后统一完成订单/售后状态,超时则保留退款中等待核对;人工待办经管理员核实后使用同一流水 CAS 完成。
+6. `payType="1"` 是本期唯一可人工确认的线下支付方式;`payType="7"` 是 OMG;历史在线类型不得按线下订单处理。
+7. 不新增审计表;原因限制 1~200 字,结构化前后值写入现有 512 字订单日志内容。
+
+## Phase 1: Design
+
+### 1. 状态上下文与能力判定
+
+状态弹窗不再调用通用订单详情作为可提交表单。新上下文接口返回:
+
+- 订单标识、类型、支付类型、当前四状态、送达时间、是否已有骑手;
+- 允许的下一订单状态和配送状态;
+- 是否允许确认线下收款、线下退款、OMG 补单、OMG 退款;
+- OMG 流水是否退款中或等待人工退款。
+
+能力字段只是界面提示,所有写接口仍重新读取订单并执行同样校验,不能依赖前端隐藏按钮实现安全控制。
+
+### 2. 专用请求模型
+
+所有写请求携带:
+
+- `expectedState`;
+- `expectedDeliveryStatus`(允许为 `null`);
+- `expectedPayStatus`;
+- `expectedAfterSaleStatus`;
+- `reason`(去首尾空格后 1~200 字)。
+
+订单/配送修改额外携带可空的 `targetState`、`targetDeliveryStatus`,至少一个目标值发生变化。支付操作不接收目标支付状态,目标由服务端动作固定决定。
+
+### 3. 状态策略
+
+- 订单普通流转:`0→1→2→3`;取消仅 `0/1→4`。
+- 已支付订单不能通过普通状态接口取消;必须走对应退款动作。
+- `state=3/4`、`payStatus=2` 或 `afterSaleStatus>0` 禁止普通状态调整。
+- 外送完成要求 `payStatus=1` 且 `deliveryStatus=3`;配送改为 3 时同步完成。
+- 自取/堂食完成要求 `payStatus=1`,配送状态始终为 `null`。
+- 配送普通流转:`null→0→1→2→3`;`null→0` 仅在外送且 `state=2`;进入 1/2 要求已有骑手,本期不提供指派骑手。
+- 相同目标视为幂等无变化,不重复生成日志、账单或推送。
+
+完整矩阵见 [data-model.md](./data-model.md)。
+
+### 4. 原子更新和完成副作用
+
+`OrderLifecycleService` 先根据数据库最新值校验请求快照,再使用 MyBatis-Plus 条件更新:
+
+```text
+WHERE id = :id
+  AND state = :expectedState
+  AND pay_status = :expectedPayStatus
+  AND after_sale_status = :expectedAfterSaleStatus
+  AND delivery_status <=> :expectedDeliveryStatus
+```
+
+Java 实现对可空配送状态分别使用 `isNull` 或 `eq`,不拼接 SQL。只有 CAS 成功进入 `state=3` 的调用才生成商家/骑手账单;账单继续使用现有按订单检查。状态、账单和同步审计日志处于同一 Spring 事务。
+
+骑手送达改调用同一完成方法,从而补齐 `state=3` 和完成账单,同时保留现有到付用户账单处理。原有骑手锁保留,CAS 作为支付回调、定时任务和其他节点并发时的最终保护。
+
+### 5. 支付和退款
+
+#### 线下
+
+- 确认收款:仅 `payType="1"`、`payStatus=0`、非取消/退款订单;CAS 为 `payStatus=1`。
+- 确认退款:仅 `payType="1"`、`payStatus=1`、`state!=3`、`afterSaleStatus=0`;二次确认后 CAS 为 `payStatus=2, afterSaleStatus=3, state=4`,并复用现有积分返还幂等能力。
+
+#### OMG
+
+- 补单:管理端先校验快照和 `payType="7"`,再调用 `reconcileByQuery(ddId, "admin")`;支付流水和订单核销仍由现有 CAS 决定。
+- 退款:已完成订单先拒绝。信用卡退款成功后同步三个业务状态;不支持 API 的支付方式只记录一次人工待办,订单仍保持已支付;管理员在 OMG 后台完成退款后,可基于该待办二次确认,流水通过 `1→4→3` CAS 后同步三个业务状态;网关超时保留 OMG 流水退款中并禁止重复发起。
+- 外部 HTTP 调用不包在长事务中。网关成功后业务状态同步失败时记录高优先级同步日志,后续只允许补偿状态,禁止再次退款。
+
+### 6. 旧接口加固
+
+`PUT /system/order` 删除旧状态值 7、12 和 `kefuState→state=5` 分支。请求只要包含 `state`、`deliveryStatus`、`payStatus`、`afterSaleStatus` 或 `sdTime` 即拒绝,并提示使用状态专用接口,防止旧页面或构造请求绕过规则。其他历史非状态编辑能力保持原样。
+
+### 7. 管理后台
+
+- 修改弹窗只保存状态上下文、目标值、旧快照和原因,不再把完整订单对象回传。
+- 订单/配送下拉框只展示服务端返回的允许值;终态只读。
+- 支付状态始终只读,根据能力显示“确认线下收款”“查询 OMG 支付”“确认线下退款”“发起 OMG 退款”“确认 OMG 人工退款完成”等按钮。
+- 退款使用二次确认;提交期间按钮 loading,阻止重复点击;成功后重新读取详情和列表。
+- 将本页订单、配送、支付、售后状态文案统一改为四语言 `$t()`,避免新弹窗出现中文硬编码。
+
+### 8. API Contract
+
+接口、请求和响应示例见 [contracts/admin-order-status-api.md](./contracts/admin-order-status-api.md)。所有写接口复用 `system:order:edit` 权限,并使用 `@RepeatSubmit` 限制短时间重复操作。
+
+## Implementation Sequence
+
+1. 先编写状态策略、CAS、完成副作用和 Controller 合约测试,确认失败。
+2. 实现 DTO、状态上下文和 `OrderLifecycleService`。
+3. 增加管理端状态/线下支付接口,并加固旧 `PUT /system/order`。
+4. 让骑手送达复用统一完成逻辑,补充状态和账单回归测试。
+5. 扩展 OMG 管理补单/退款结果处理,补齐退款后三状态一致性和人工待办幂等。
+6. 修改管理后台 API、弹窗、操作按钮和四语言文案。
+7. 执行后端测试、编译、前端 lint/build 及 [quickstart.md](./quickstart.md) 手工场景。
+
+## Verification Strategy
+
+### Automated
+
+- 状态策略单元测试覆盖全部合法/非法转移、类型限制、售后和终态。
+- 服务测试验证 CAS 条件、CAS 失败无副作用、完成只结算一次、原因日志内容。
+- Controller 测试验证权限注解、参数校验和额外业务字段无法进入请求 DTO。
+- OMG 测试覆盖补单金额不符、回调/补单幂等、退款成功、人工待办、超时未知和重复退款。
+- 执行 `mvn -pl ruoyi-admin -am test` 与前端 lint/build。
+
+### Manual
+
+- 外送 `2/2/1/0 → 3/3/1/0`(订单/配送/支付/售后)并检查送达时间、账单、日志。
+- 自取/堂食线下收款后完成。
+- OMG 漏回调补单;OMG 信用卡退款;延期支付人工退款待办。
+- 旧快照并发提交、非法回退、已完成退款、自取修改配送、空原因全部拒绝。
+- 四种语言检查无原始 key 和中文泄漏。
+
+## Rollout and Rollback
+
+- 后端状态接口与前端页面同批发布;先部署后端可保证旧前端仍能读取订单,但旧状态提交会收到明确拒绝。
+- 无数据库迁移,回滚只需回退后端和前端代码。
+- 发布后重点观察:CAS 冲突、OMG 人工待办、退款结果未知、退款成功但业务状态同步失败日志。
+
+## Risks
+
+| 风险 | 缓解措施 |
+|---|---|
+| OMG 网关成功但本地状态失败 | 流水先保留真实退款状态,记录高优先级日志,仅做本地补偿,不重复退款 |
+| 完成路径并发重复结算 | 状态 CAS 成功者才执行完成副作用,现有账单按订单幂等检查兜底 |
+| 管理员页面陈旧覆盖回调/骑手结果 | 四状态快照 CAS,冲突时强制刷新 |
+| 历史在线支付被误当线下 | 线下动作只允许 `payType="1"`,不是简单判断 `!=7` |
+| 无独立审计字段 | 使用同步订单日志,原因上限 200 字,内容包含四状态前后值 |
+| Constitution 未初始化 | 在本计划显式列出并执行 `CLAUDE.md` 与既有规格门槛 |
+
+## Complexity Tracking
+
+无 Constitution 违规需要豁免。新增状态上下文接口和生命周期服务是隔离完整实体更新、统一状态不变量所需的最小结构。

+ 167 - 0
specs/018-order-status-admin/quickstart.md

@@ -0,0 +1,167 @@
+# Quickstart & Verification: 平台订单状态受控调整
+
+本文供 `/speckit.tasks` 和实现阶段使用。当前阶段不执行数据库写入,也不创建分支。
+
+## 1. Prerequisites
+
+- JDK 21、Maven 可用;
+- 管理后台依赖已安装,Node 环境能运行 Vue CLI 4;
+- 测试账号拥有 `system:order:list/query/edit`;
+- OMG 自动退款测试使用 mock;stage 环境不支持真实 DoAction;
+- 正式小额退款必须另行取得授权,本地自动化不得调用真实退款端点。
+
+## 2. Automated Verification
+
+### Backend focused tests
+
+```powershell
+mvn -pl ruoyi-admin -am `
+  -Dtest=OrderLifecycleServiceTest,PosOrderAdminStatusControllerTest,OmgPayControllerTest `
+  -Dsurefire.failIfNoSpecifiedTests=false test
+```
+
+### Existing OMG service regressions
+
+```powershell
+mvn -pl ruoyi-system `
+  -Dtest=PosOrderOmgPaymentServiceImplTest,PosOrderOmgRefundServiceImplTest test
+```
+
+### Backend compile
+
+```powershell
+mvn -pl ruoyi-admin -am -DskipTests compile
+```
+
+### Admin frontend
+
+在 `E:\QtwCode\foodie\foodie-admin-vue` 执行:
+
+```powershell
+npm run lint
+npm run build:prod
+```
+
+如果全库 lint 存在与本功能无关的历史错误,必须同时记录:全库结果、仅本次修改文件的 ESLint 结果,以及确认未新增错误的证据;不得顺手改无关文件。
+
+## 3. Required Test Cases
+
+### 3.1 State policy
+
+- 订单只允许 `0→1→2→3` 和未支付的 `0/1→4`;
+- 终态、回退、跳级、非法范围拒绝;
+- `afterSaleStatus>0` 拒绝普通调整;
+- 自取/堂食配送目标拒绝;
+- 配送 `0→1→2→3`,无骑手不能进入 1/2/3;
+- 完成前必须已支付;外送完成前必须已送达。
+
+### 3.2 CAS and side effects
+
+- 四状态快照匹配时更新一行;
+- 任一快照已变化时更新零行并返回刷新提示;
+- CAS 失败不写日志、不生成账单、不返积分;
+- 相同目标重复请求不产生重复日志/账单;
+- 请求中的未知金额、用户、商品字段无法进入专用 DTO 的更新逻辑。
+
+### 3.3 Delivery completion
+
+- 骑手/管理员送达同步写 `deliveryStatus=3, state=3, sdTime`;
+- 外送生成商家和骑手账单;
+- 订单与配送完成并发时只有首次完成者产生副作用;
+- 到付订单保留既有用户账单处理;
+- 推送只在事务提交后执行。
+
+### 3.4 Offline payment
+
+- `payType=1, payStatus=0` 可确认收款;
+- `payType=7` 或历史在线支付类型不能确认线下收款;
+- 线下退款同步三状态并返还积分;
+- 已完成订单、重复退款、售后处理中拒绝。
+
+### 3.5 OMG
+
+- 补单成功必须校验金额、交易号、日期;
+- OMG 未支付/失败/未知不写订单已支付;
+- 回调与管理员补单并发只核销一次;
+- 信用卡退款成功同步三状态;
+- 人工退款方式保持订单已支付并只建一个待办;
+- 已在 OMG 后台完成的人工退款只有在存在待办并二次确认后才同步三状态;重复确认无副作用;
+- 超时结果未知保持流水退款中,重复请求不再次调用网关;
+- 明确失败恢复流水已支付;
+- 网关成功、本地同步失败只允许补偿本地状态。
+
+### 3.6 Security
+
+- 无 token 返回未认证;无 `system:order:edit` 返回无权限;
+- 原因空白、超长、非法状态值被后端拒绝;
+- 错误响应不含堆栈、凭证或原始网关报文;
+- 状态专用接口不能修改金额、用户、门店、商品、支付类型或骑手;
+- 旧 `PUT /system/order` 不能再写五个受保护状态/时间字段。
+
+## 4. Manual Scenario Matrix
+
+测试前准备可恢复的测试订单,不直接在生产库执行 SQL 写入。
+
+| Scenario | Initial `state/delivery/pay/afterSale` | Action | Expected |
+|---|---|---|---|
+| 外送配送中→送达 | `2/2/1/0` | 修改配送为 3 | `3/3/1/0`,有 `sdTime`、账单、管理员日志 |
+| 自取线下收款 | `2/NULL/0/0`, payType=1 | 确认收款 | `2/NULL/1/0`,无 OMG 流水 |
+| 自取完成 | `2/NULL/1/0` | 订单改为 3 | `3/NULL/1/0`,商家账单一次 |
+| 线下退款 | `1/NULL/1/0`, payType=1 | 二次确认退款 | `4/NULL/2/3`,积分幂等返还 |
+| OMG 漏单 | `0/NULL/0/0`, payType=7 | 查询 OMG | 仅 OMG 实付时变 `pay=1` |
+| OMG 信用卡退款 | `1/NULL/1/0`, payType=7 | 发起退款 | 网关成功后 `4/NULL/2/3` |
+| OMG 延期支付退款 | `1/NULL/1/0`, payType=7 | 发起退款 | 状态不变,显示人工待办 |
+| OMG 人工退款完成 | `1/NULL/1/0`, payType=7,已有待办 | 二次确认人工退款完成 | `4/NULL/2/3`,待办关闭并记录管理员 |
+| 陈旧页面 | 任意 | 其他链路先改状态后提交 | 提示刷新,最新数据不被覆盖 |
+| 已完成退款 | `3/*/1/0` | 任一退款 | 拒绝并说明需结算冲正 |
+
+## 5. UI Verification
+
+对 `zh_CN`、`zh_TW`、`en_US`、`vi` 逐一检查:
+
+1. 搜索区状态标签;
+2. 表格状态 tag;
+3. 订单详情状态;
+4. 修改弹窗当前值和允许下拉项;
+5. 修改原因校验;
+6. 确认收款、补单、退款按钮;
+7. 二次确认、成功、冲突、人工待办和结果未知提示。
+
+不得出现原始 i18n key、空白按钮或新增中文硬编码。
+
+## 6. Read-only Data Checks
+
+仅在测试/预发布环境进行只读核对:
+
+```sql
+SELECT id, dd_id, type, pay_type, state, delivery_status,
+       pay_status, after_sale_status, qs_id, sd_time
+FROM pos_order
+WHERE id = ?;
+
+SELECT operator_type, operator_id, operator_name, content, create_time
+FROM pos_order_log
+WHERE dd_id = ?
+ORDER BY id DESC;
+
+SELECT id, dd_id, trade_no, pay_type, amount, pay_status, update_time
+FROM pos_order_omg_payment
+WHERE dd_id = ?
+ORDER BY id DESC;
+
+SELECT payment_id, action, amount, rtn_code, rtn_msg, create_time
+FROM pos_order_omg_refund
+WHERE dd_id = ?
+ORDER BY id DESC;
+```
+
+## 7. Exit Criteria
+
+- 所有新增/修改后端测试通过;
+- 后端编译通过;
+- 前端 lint 与生产构建通过,或仅存在已记录的无关历史问题;
+- 手工矩阵全部符合契约;
+- 四语言无缺失;
+- `git diff --check` 通过;
+- Git 当前分支仍为 `test`,未创建新分支;
+- 没有数据库 DDL 或真实支付退款副作用。

+ 157 - 0
specs/018-order-status-admin/research.md

@@ -0,0 +1,157 @@
+# Phase 0 Research: 平台订单状态受控调整
+
+**Date**: 2026-08-10
+**Scope**: 平台订单状态、配送状态、线下支付确认、OMG 补单/退款、日志、并发和前端管理页面。
+
+## R1. 当前页面为何只能修改订单状态
+
+**Finding**: `foodie-admin-vue/src/views/system/order/index.vue` 的修改弹窗只有 `form.state`。页面虽然能筛选和展示 `payStatus`、`deliveryStatus`、`afterSaleStatus`,但没有对应编辑控件。提交时调用 `updateOrder(this.form)`,把从详情接口取得的完整订单对象发给 `PUT /system/order`。
+
+**Decision**: 不在现有完整表单上直接增加两个下拉框。改为状态上下文 DTO 和专用动作 API。
+
+**Rationale**: 完整对象提交会把金额、用户、门店、商品等字段一并带回,后端 Mapper 又会更新所有非空字段;为支付/配送增加下拉框会放大批量赋值和并发覆盖风险。
+
+## R2. 通用订单编辑接口已经不符合新状态机
+
+**Finding**: `PosOrderController.edit()` 仍判断旧 `state=12`(送达)、`state=7/11`(退款)和 `kefuState→state=5`,而当前订单状态只允许 0~4。`PosOrderMapper.xml` 的通用更新同时允许写 `state`、`delivery_status`、`pay_status`、`after_sale_status`。
+
+**Decision**: 删除旧状态分支;通用 `PUT /system/order` 遇到任何状态字段或 `sdTime` 时直接拒绝。状态只能走新接口。
+
+**Alternative rejected**: 在 Controller 里静默把状态字段设为 `null`。静默忽略会让调用者误以为修改成功,也不利于发现旧客户端。
+
+## R3. 用状态上下文代替前端复制状态机
+
+**Decision**: 新增 `GET /system/order/{id}/status-context`,返回当前四状态、OMG 处理态和允许动作。
+
+**Rationale**:
+
+- 前端只负责呈现,后端是唯一规则来源;
+- 可以区分 `pos_order.payStatus` 与 OMG 流水的退款中/失败/已退状态;
+- 页面打开后保留旧快照,写入时用于并发校验;
+- 后端写接口仍会重新校验,能力字段不是授权依据。
+
+## R4. 无数据库版本字段时的并发控制
+
+**Decision**: 以 `state`、`deliveryStatus`、`payStatus`、`afterSaleStatus` 四个旧值作为复合版本,执行单行条件更新。
+
+**Rationale**: 这些字段覆盖本功能会与之竞争的支付回调、骑手、售后和管理员写入;无需增加 `version` 字段,符合无 DDL 约束。
+
+**Implementation note**: `deliveryStatus` 可空,使用 MyBatis-Plus `isNull`/`eq` 构建条件,禁止字符串拼 SQL。更新行数为 0 时重新读取订单并返回“状态已变化,请刷新后重试”。
+
+**Alternative rejected**:
+
+- 只加 Redis 锁:不能覆盖支付回调和滚动发布期间不同锁键的写入;
+- 只在更新前查询:查询与更新之间仍有竞争窗口;
+- 新增版本列:本期没有必要的模式变更成本。
+
+## R5. 状态转换必须收敛到服务层
+
+**Finding**: 商家完成、骑手送达、用户确认、自动完成和平台旧编辑分别直接写订单。当前骑手送达只写配送状态和时间,未写 `state=3`;006 契约明确要求同步完成。
+
+**Decision**: 新增 `OrderLifecycleService` 处理:
+
+- 管理员订单/配送状态调整;
+- 骑手送达的统一完成;
+- 线下收款与退款;
+- OMG 退款后的订单状态收口;
+- CAS、账单、积分和同步订单日志。
+
+**Boundary**: 本期只把骑手送达和新增管理端动作接入统一服务;其他完成入口保持行为不变,除非测试证明会破坏新不变量。现有账单按订单检查继续作为幂等兜底。
+
+## R6. 完成态与账单
+
+**Finding**: `OrderService.setSanghuBilling()` 和 `setQishouBilling()` 在写账单前按用户、类型、订单号检查是否已存在。商家完成和定时自动完成会生成账单,骑手送达当前不会。
+
+**Decision**: 只有 CAS 首次把订单带入 `state=3` 的调用才执行完成副作用;外送生成商家和骑手账单,自取/堂食至少生成商家账单。状态、账单和同步日志放在同一事务。
+
+**Caveat**: 现有账单幂等依赖应用层 count,没有数据库唯一约束。本期不改表,CAS 决定唯一完成者,账单 count 作为第二层保护。
+
+## R7. 线下支付边界
+
+**Finding**: 当前有效约定中 `payType="1"` 表示到付/现金,`payType="7"` 表示 OMG;代码库仍保留其他历史在线支付类型,但 `CLAUDE.md` 明确不得重新启用。
+
+**Decision**: “线下支付”只认 `payType="1"`,不采用 `payType != "7"`。确认收款只允许未支付且未取消的线下订单;确认退款只允许已支付、未完成、无售后的线下订单。
+
+**Rationale**: 将历史在线类型当成线下会制造无法对账的人工已支付/已退款状态。
+
+## R8. OMG 补单复用现有事实链路
+
+**Finding**: `OmgPayController.reconcileByQuery()` 已实现:读取最新支付流水、调用 `QueryTradeInfo`、校验交易号/金额/日期、通过 `markSuccessIfUnpaid` CAS 核销,并与回调共用 `applyPaidResult()`。
+
+**Decision**: 管理端补单调用这一公开核心,来源标记为 `admin`;不新增“管理员直接设已支付”逻辑,也不新建支付表。
+
+**Additional guard**: 管理端调用前检查订单快照、`payType=7`、`payStatus=0` 和非取消态;调用后重新读取订单作为最终响应。
+
+## R9. OMG 退款结果需要显式类型
+
+**Finding**: 当前 `refundOrder()` 用 `AjaxResult` 同时表达成功、人工退款、失败和结果未知,不利于管理端根据结果刷新能力;退款成功只更新 `pos_order.payStatus=2`。
+
+**Decision**: 增加内部 `OmgRefundOutcome`,至少区分:
+
+- `REFUNDED`:网关成功,支付流水已退;
+- `MANUAL_PENDING`:支付方式不支持 API;
+- `UNKNOWN`:请求超时/结果未知,流水保持退款中;
+- `FAILED`:明确失败,流水恢复已支付;
+- `IDEMPOTENT`:此前已经完成或正在处理。
+
+外部 `/pay/omg/refund` 仍转换成兼容的 `AjaxResult`;管理端使用明确结果展示提示。
+
+## R10. OMG 退款事务边界
+
+**Decision**:
+
+1. 校验订单、支付流水和金额;
+2. 支付流水 `1→4`(退款中)CAS;
+3. 在数据库事务外调用 OMG;
+4. 成功后流水 `4→3`,再通过生命周期服务同步 `payStatus=2, afterSaleStatus=3, state=4`;
+5. 明确失败时流水 `4→1`;
+6. 超时/未知时保持 4,禁止重复发起,等待人工对账;
+7. 网关成功但订单同步失败时记录高优先级日志,后续只补偿本地状态。
+
+**Rationale**: 数据库事务无法覆盖外部网关,长事务还会扩大锁等待;支付流水是不可丢失的资金事实。
+
+## R11. 人工退款待办的幂等
+
+**Finding**: ATM/CVS/BarcodeATM 当前每次调用都会向 `pos_order_omg_refund` 插入 `action=null, rtn_code=null` 的人工记录。
+
+**Decision**: 写入前查询该支付流水的退款记录;已有未决人工记录时直接返回 `MANUAL_PENDING`,不重复插入。订单 `payStatus` 保持 1。
+
+**Constraint**: 现有表没有独立待办状态,本期通过支付流水和最新退款记录计算上下文,不新增字段。
+
+人工退款在 OMG 后台完成后,管理员通过专用二次确认接口收口:必须存在同一支付流水的未决人工记录,先复用支付流水 `1→4→3` CAS,再插入 `action=NULL, rtnCode=1` 的人工完成记录并同步订单三状态。`action` 保持 NULL 是因为现有列为 `CHAR(1)` 且只表示 OMG DoAction 的 C/R/E/N,不能伪造网关动作。
+
+## R12. 审计记录
+
+**Finding**: `pos_order_log.content` 为 `VARCHAR(512)`,已有操作者类型、ID、名称和时间字段。`OrderLogHelper.log()` 异步写入,`logSync()` 可参与当前事务。
+
+**Decision**: 人工状态操作使用 `logSync()`,原因限制 200 字。内容采用固定、可读格式:
+
+```text
+平台调整[配送状态]:2→3;订单状态:2→3;支付状态:1→1;售后状态:0→0;原因:骑手端漏操作
+```
+
+支付补单/退款保留系统资金日志,同时再写一条管理员发起/结果日志,确保责任主体完整。
+
+## R13. 权限和输入安全
+
+**Decision**:
+
+- 所有上下文和动作接口使用 `@PreAuthorize("@ss.hasPermi('system:order:edit')")`;
+- 写接口使用 `@Valid` 和白名单 DTO;状态范围、原因长度、目标互斥/必填在服务端二次校验;
+- 支付动作使用 `@RepeatSubmit`,但幂等最终依赖 CAS;
+- 不向管理端返回 HashKey、HashIV、原始回调或其他支付秘密;
+- 错误消息只说明业务冲突,不回传堆栈或网关敏感报文。
+
+## R14. 前端策略
+
+**Decision**: 修改弹窗显示三部分:订单状态、配送状态、支付状态与动作。支付状态为只读;所有状态名称和操作文案使用 `dingdanorder` 四语言 key。退款使用 Element UI 二次确认,提交期间禁用按钮;任一成功或冲突后重新读取服务端。
+
+**Alternative rejected**: 前端直接维护转换矩阵。它容易与骑手/支付规则漂移,也无法安全判断 OMG 流水退款中或人工待办。
+
+## R15. 数据库与分支
+
+**Decision**: 不新增表、字段、索引,不修改 `updatesql/sql.md`;全程保留当前 `test` 分支,不执行 Spec Kit 的建分支脚本。
+
+## Resolved Questions
+
+本阶段没有阻塞实现的未决问题。已完成订单退款和结算冲正明确排除;历史在线支付不得按线下处理;人工退款待办及完成确认复用现有 OMG 退款记录。

+ 238 - 0
specs/018-order-status-admin/spec.md

@@ -0,0 +1,238 @@
+# 018 - 平台订单状态受控调整
+
+**创建日期**:2026-08-10
+**状态**:Implemented(代码与自动化验证完成,预发布手工验收待执行)
+**工作方式**:沿用当前工作分支,不创建新分支
+**输入**:平台订单管理当前只能修改订单状态,需要兼容配送状态和支付状态,同时避免人工修改破坏配送、支付、退款及结算链路。
+
+## 背景
+
+平台订单列表已经展示 `state`、`deliveryStatus`、`payStatus` 和 `afterSaleStatus`,但修改弹窗目前只提供订单状态。后端通用更新接口直接接收完整订单对象,并残留旧订单状态值的处理逻辑;如果只在页面增加两个下拉框,会产生越权字段更新、并发覆盖、虚假支付、虚假退款及状态组合矛盾等风险。
+
+本功能为平台管理员提供受约束的订单状态调整能力。配送状态可按订单履约规则推进;支付状态通过“确认线下收款、支付补单、发起退款”等业务操作维护,不提供不受约束的任意修改。
+
+## 锁定的业务决策
+
+1. 平台管理员可以调整订单状态和配送状态,但所有变化必须满足订单类型、当前状态和售后状态约束。
+2. 支付状态不提供可任意选择“未支付、已支付、已退款”的普通下拉框。
+3. 线下支付通过“确认收款”变更为已支付;线下退款通过“确认线下退款”变更为已退款。
+4. OMG 在线支付只能由支付回调或向 OMG 查询补单后变更为已支付,只能在真实退款成功后变更为已退款。
+5. OMG 不支持自动退款的支付方式进入“等待人工退款”状态,在人工退款得到确认前不得标记为已退款。
+6. 配送状态改为“已送达”时,必须同步完成订单并记录送达时间。
+7. 已完成、已取消、已退款等终态不得通过普通状态调整重新打开。
+8. 已完成订单的退款涉及商家、骑手结算冲正,本期不支持直接人工退款,必须留待完整售后与结算冲正能力处理。
+9. 每次人工调整必须填写原因,并记录管理员、调整前后状态和调整时间。
+10. 状态更新必须防止覆盖支付回调、骑手操作或其他管理员刚刚写入的新状态。
+
+## 状态定义
+
+### 订单状态 `state`
+
+| 值 | 含义 |
+|---|---|
+| 0 | 待处理 |
+| 1 | 已接单 |
+| 2 | 已出餐 |
+| 3 | 已完成 |
+| 4 | 已取消 |
+
+### 配送状态 `deliveryStatus`
+
+仅适用于 `type=0` 的外送订单。
+
+| 值 | 含义 |
+|---|---|
+| 0 | 待接单 |
+| 1 | 骑手已接单 |
+| 2 | 配送中 |
+| 3 | 已送达 |
+
+### 支付状态 `payStatus`
+
+| 值 | 含义 |
+|---|---|
+| 0 | 未支付 |
+| 1 | 已支付 |
+| 2 | 已退款 |
+
+## 状态操作矩阵
+
+| 操作 | 允许条件 | 结果 | 禁止情况 |
+|---|---|---|---|
+| 调整订单状态 | 目标值为 0~4,当前订单不是终态,组合状态合法 | 更新订单状态并记录原因 | 完成/取消后回退;已支付在线订单直接取消但未退款 |
+| 推进配送状态 | 外送订单、无处理中售后、目标状态不早于当前状态 | 更新配送状态 | 自取/堂食;状态回退;售后处理中 |
+| 标记已送达 | 外送订单,履约条件满足 | `deliveryStatus=3`、`state=3`、记录送达时间,并执行幂等完成处理 | 非外送订单;订单已取消/退款 |
+| 确认线下收款 | 线下支付订单、`payStatus=0`、订单未取消/退款 | `payStatus=1` | OMG 在线支付;终态订单 |
+| 确认线下退款 | 线下支付订单、`payStatus=1`、订单尚未完成结算 | `payStatus=2`、`afterSaleStatus=3`、`state=4` | 已完成订单;OMG 在线支付;重复退款 |
+| OMG 查询补单 | `payType=7`、`payStatus=0`、订单未取消 | 查询 OMG 真实交易;确认成功后更新为已支付 | 查询失败、金额不符、交易未支付时不得改单 |
+| OMG 发起退款 | `payType=7`、`payStatus=1`、订单尚未完成结算 | 真实退款成功后同步支付、售后和订单状态 | 未支付、已退款、已完成结算订单 |
+| OMG 人工退款待办 | OMG 支付方式不支持退款 API | 保留已支付状态并提示等待人工处理 | 人工退款未确认前不得标记已退款 |
+| 确认 OMG 人工退款完成 | 已存在人工退款待办,管理员已在 OMG 后台核实退款完成 | 记录人工确认并同步支付、售后和订单状态 | 无待办、退款结果未核实、已完成结算订单 |
+
+## User Scenarios & Testing
+
+### User Story 1 - 平台调整外送配送状态(Priority: P1)
+
+平台管理员可以在订单修改弹窗查看并推进外送订单的配送状态,用于处理骑手端漏操作或运营纠错。
+
+**Why this priority**:这是当前页面明确缺失的履约管理能力,且不涉及外部资金事实。
+
+**Independent Test**:选择一笔外送订单,将配送状态从“配送中”调整为“已送达”,确认订单同时完成并记录送达时间和操作日志。
+
+**Acceptance Scenarios**:
+
+1. **Given** 外送订单处于配送中,**When** 管理员改为已送达并填写原因,**Then** 配送状态为已送达、订单状态为已完成、送达时间有值。
+2. **Given** 自取或堂食订单,**When** 管理员打开修改弹窗,**Then** 不提供配送状态修改入口。
+3. **Given** 已送达订单,**When** 管理员尝试回退为配送中,**Then** 系统拒绝且数据不变。
+4. **Given** 订单存在处理中售后,**When** 管理员尝试推进配送状态,**Then** 系统拒绝并提示先处理售后。
+
+---
+
+### User Story 2 - 平台确认线下收款(Priority: P1)
+
+平台管理员可以为实际已经线下收款但系统仍显示未支付的现金订单确认收款。
+
+**Why this priority**:自取、堂食现金订单在完成前可能保持未支付,需要平台具备异常兜底能力。
+
+**Independent Test**:选择一笔未支付的线下现金订单,执行确认收款,确认支付状态更新且不会生成在线支付流水。
+
+**Acceptance Scenarios**:
+
+1. **Given** 未支付的线下现金订单,**When** 管理员确认收款并填写原因,**Then** 支付状态变为已支付并记录操作日志。
+2. **Given** 未支付的 OMG 订单,**When** 管理员尝试直接确认收款,**Then** 系统拒绝并引导使用支付补单。
+3. **Given** 已支付或已退款订单,**When** 管理员重复确认收款,**Then** 系统拒绝且不产生重复副作用。
+
+---
+
+### User Story 3 - 平台处理 OMG 漏单(Priority: P1)
+
+平台管理员可以对仍显示未支付的 OMG 订单发起支付状态查询,只有 OMG 返回真实支付成功且金额、订单号等信息校验通过后,系统才补记为已支付。
+
+**Why this priority**:在线支付状态是资金事实,必须以支付平台为准,同时平台需要处理回调丢失场景。
+
+**Independent Test**:构造一笔回调丢失但 OMG 查询为成功的订单,执行平台补单后确认支付流水和订单只核销一次。
+
+**Acceptance Scenarios**:
+
+1. **Given** OMG 实际支付成功但订单仍未支付,**When** 管理员查询补单,**Then** 订单更新为已支付并记录补单日志。
+2. **Given** OMG 返回未支付或交易失败,**When** 管理员查询补单,**Then** 订单不得被标记为已支付。
+3. **Given** OMG 返回金额或订单信息不一致,**When** 管理员查询补单,**Then** 系统拒绝核销并记录需人工核对的日志。
+4. **Given** 回调和平台补单并发到达,**When** 两条链路同时处理,**Then** 只允许一次支付核销和一次业务副作用。
+
+---
+
+### User Story 4 - 平台发起受控退款(Priority: P2)
+
+平台管理员可以对尚未完成结算的已支付订单发起退款。OMG 订单必须调用真实退款能力;线下订单必须由管理员确认已经完成线下退款。
+
+**Why this priority**:退款能力重要但风险高,必须在支付确认和配送纠错能力之后实现。
+
+**Independent Test**:分别验证 OMG 信用卡退款成功、OMG 不支持自动退款、线下退款确认及已完成订单拒绝退款。
+
+**Acceptance Scenarios**:
+
+1. **Given** 尚未完成结算的 OMG 信用卡订单,**When** 管理员发起退款且 OMG 返回成功,**Then** 支付状态为已退款、售后状态为已退款、订单状态为已取消。
+2. **Given** OMG 支付方式不支持退款 API,**When** 管理员发起退款,**Then** 系统生成/保留人工退款待办,不提前修改为已退款。
+3. **Given** 尚未完成结算的线下已支付订单,**When** 管理员确认已线下退款并二次确认,**Then** 系统同步退款和取消状态并记录原因。
+4. **Given** 已完成并产生结算的订单,**When** 管理员发起退款,**Then** 系统拒绝并提示需要完整售后与结算冲正流程。
+5. **Given** OMG 延期支付订单已有人工退款待办且管理员已在 OMG 后台完成退款,**When** 管理员二次确认人工退款完成,**Then** 系统记录确认并同步支付、售后和订单状态。
+
+---
+
+### User Story 5 - 可审计且防并发覆盖(Priority: P1)
+
+平台管理员的每次人工调整都可追溯;如果订单在管理员打开弹窗后被支付回调、骑手或其他管理员更新,本次提交不得覆盖新状态。
+
+**Why this priority**:状态调整涉及资金和履约事实,审计及并发保护是上线前提。
+
+**Independent Test**:管理员打开订单后模拟支付回调改变状态,再提交旧页面数据,确认提交失败且回调结果保留。
+
+**Acceptance Scenarios**:
+
+1. **Given** 管理员未填写修改原因,**When** 提交状态调整,**Then** 前后端均拒绝提交。
+2. **Given** 页面加载后订单状态已被其他链路改变,**When** 管理员提交旧状态快照,**Then** 系统提示刷新且不覆盖新数据。
+3. **Given** 状态调整成功,**When** 查看订单操作日志,**Then** 可以看到管理员、调整前后值、原因和时间。
+4. **Given** 请求携带金额、用户、门店或商品等额外字段,**When** 提交状态调整,**Then** 这些字段不得被更新。
+
+## Edge Cases
+
+- 配送状态从 `NULL` 开始时,只能在外送订单进入可配送阶段后初始化为待接单。
+- 缺少骑手信息时,不得伪造“骑手已接单”或“配送中”;平台本期不提供指派骑手能力。
+- 已取消、已退款订单收到迟到支付成功回调时,继续沿用现有异常资金记录和人工核对机制,不重新履约。
+- OMG 查询接口超时或返回未知状态时保持原支付状态,允许稍后重试。
+- OMG 退款请求超时且结果未知时保持待核对状态,不允许重复发起可能造成重复退款的请求。
+- 退款成功但订单状态同步失败时,必须记录高优先级异常日志,并允许幂等补偿,不能再次退款。
+- 相同目标状态的重复提交不得重复生成账单、退款、推送或操作日志。
+- `afterSaleStatus>0` 的订单不得通过普通状态调整绕过售后流程。
+
+## Functional Requirements
+
+- **FR-001**:平台订单修改界面 MUST 展示订单状态、配送状态、当前支付状态及人工调整原因。
+- **FR-002**:状态调整和支付操作 MUST 仅允许具备订单编辑权限的平台管理员执行。
+- **FR-003**:状态调整请求 MUST 使用状态专用输入模型,禁止接收或更新完整订单业务字段。
+- **FR-004**:订单状态 MUST 仅接受 0~4,配送状态仅接受 0~3,支付状态仅接受 0~2。
+- **FR-005**:配送状态修改 MUST 仅适用于外送订单;自取和堂食订单的配送状态保持 `NULL`。
+- **FR-006**:普通配送调整 MUST 仅允许向前推进,禁止从已送达或已完成状态回退。
+- **FR-007**:配送状态变为已送达时 MUST 原子同步订单完成状态和送达时间。
+- **FR-008**:完成订单所需的账单及关联副作用 MUST 幂等,重复请求不得重复结算。
+- **FR-009**:线下未支付订单 MUST 通过“确认收款”操作才能变为已支付。
+- **FR-010**:OMG 未支付订单 MUST 通过回调或支付查询核验后才能变为已支付,禁止人工直接写入。
+- **FR-011**:OMG 已支付订单 MUST 在真实退款成功后才能变为已退款。
+- **FR-012**:不支持自动退款的 OMG 支付方式 MUST 保持待人工处理状态,人工确认前不得标记退款完成。
+- **FR-013**:允许的全额退款成功后 MUST 同步 `payStatus=2`、`afterSaleStatus=3`、`state=4`。
+- **FR-014**:已完成并产生结算的订单 MUST 拒绝本期退款操作。
+- **FR-015**:`afterSaleStatus>0` 时,系统 MUST 拒绝可能绕过售后流程的普通状态修改。
+- **FR-016**:每次人工调整 MUST 要求非空原因,并限制合理长度。
+- **FR-017**:每次成功调整 MUST 同步记录订单号、管理员、状态前后值、调整原因和时间。
+- **FR-018**:状态更新 MUST 使用旧状态条件或等效并发控制;条件不匹配时要求管理员刷新重试。
+- **FR-019**:支付补单和退款 MUST 复用现有 OMG 幂等流水,不得另建一套不一致的支付事实。
+- **FR-020**:现有通用订单编辑入口 MUST 不再允许更新订单、配送、支付和售后状态,防止绕过专用规则。
+- **FR-021**:骑手确认送达时 MUST 同步 `deliveryStatus=3`、`state=3` 和送达时间,保持与平台调整相同的状态不变量。
+- **FR-022**:新增平台文案 MUST 同步提供简体中文、繁体中文、英文和越南文。
+- **FR-023**:状态调整成功后页面 MUST 刷新订单详情,展示服务端最终状态而不是本地推测值。
+- **FR-024**:OMG 人工退款完成 MUST 仅允许在已有未决人工退款记录且管理员二次确认后写入,确认前订单支付状态保持已支付。
+
+## Key Entities
+
+- **PosOrder(已有)**:订单业务主体,使用 `state`、`deliveryStatus`、`payStatus`、`afterSaleStatus`、`type`、`payType` 和 `sdTime` 表达订单状态。
+- **PosOrderLog(已有)**:记录人工调整的管理员、状态变化、原因及时间。
+- **PosOrderOmgPayment(已有)**:OMG 支付事实和幂等核销依据,在线支付状态不得与其冲突。
+- **PosOrderOmgRefund(已有)**:OMG 退款请求、处理结果及人工退款待办记录。
+
+## Success Criteria
+
+- **SC-001**:平台管理员可以在一个订单管理入口完成受控的订单状态、配送状态和支付业务操作。
+- **SC-002**:外送订单标记已送达后,订单状态、配送状态和送达时间三项一致率达到 100%。
+- **SC-003**:任何在线订单都不能在缺少有效支付回调或查询核验的情况下被标记为已支付。
+- **SC-004**:任何 OMG 订单都不能在真实退款成功或人工退款确认前被标记为已退款。
+- **SC-005**:非法状态、状态回退、订单类型不匹配和售后冲突请求的数据变更数为 0。
+- **SC-006**:并发回调、补单和管理员操作场景下,支付核销、退款及订单完成副作用均最多执行一次。
+- **SC-007**:人工状态调整日志覆盖率为 100%,每条日志均包含操作者、前后状态和原因。
+- **SC-008**:状态专用接口无法修改金额、用户、门店、商品、优惠等非状态字段。
+- **SC-009**:平台新增文案在简中、繁中、英文和越南文环境下均无原始 key 或空白文本。
+
+## Assumptions
+
+- `type=0/1/2` 分别表示外送、自取、堂食。
+- `payType=7` 表示 OMG 在线支付;其他历史支付方式不重新启用。
+- 继续复用现有 `system:order:edit` 权限,不新增角色体系。
+- 继续复用现有订单日志、OMG 支付流水和 OMG 退款记录,不新增数据库字段。
+- 订单完成相关账单逻辑具备按订单幂等检查,本功能只在状态成功进入完成态后触发。
+
+## Out of Scope
+
+- 已完成订单的商家/骑手结算冲正及完整售后退款流程。
+- 人工将已完成、已取消或已退款订单恢复为进行中。
+- 平台直接指派或更换骑手。
+- 新增支付渠道或重新启用 VNPay、ZaloPay、NewebPay。
+- 修改订单金额、商品、优惠、用户、门店或收货信息。
+- 新增数据库表或字段。
+- 为本功能创建 Git 分支。
+
+## Spec Kit 执行顺序
+
+1. 以本文件作为 `/speckit.specify` 阶段产物。
+2. 下一步执行技术调研与 `/speckit.plan`,明确接口、事务、并发和文件改动。
+3. 计划审核后执行 `/speckit.tasks`,拆分可独立验证的测试与实现任务。
+4. 按测试先行顺序执行任务并逐项验证。
+5. 全程沿用当前工作分支,不执行创建或切换分支操作。

+ 48 - 0
specs/018-order-status-admin/tasks.md

@@ -0,0 +1,48 @@
+# Tasks: 平台订单状态受控调整
+
+**Feature**: `018-order-status-admin`
+**Implementation status**: 代码与自动化验证完成;真实数据库、真实 OMG 网关和预发布 UI 手工验收未在本地执行。
+
+## 1. Backend contract and lifecycle
+
+- [x] T001 新增仅包含四状态快照和原因的管理端请求 DTO,并校验范围、空白原因和 200 字上限。
+- [x] T002 新增服务端状态上下文,统一返回允许的订单/配送目标及支付操作能力。
+- [x] T003 实现订单状态 `0→1→2→3`、未支付取消和配送 `NULL→0→1→2→3` 白名单。
+- [x] T004 使用订单、配送、支付、售后四字段旧快照执行 CAS,冲突时不产生日志、账单或积分副作用。
+- [x] T005 将管理员送达与骑手送达收敛到统一完成逻辑,同步 `state=3`、`deliveryStatus=3`、`sdTime` 和幂等账单。
+- [x] T006 实现线下收款及线下退款专用操作;退款同步订单三状态并复用幂等积分返还。
+- [x] T007 加固旧 `PUT /system/order`,禁止写订单、配送、支付、售后和送达时间字段。
+- [x] T008 为 7 个管理接口统一添加 `system:order:edit` 权限、请求频控和专用 DTO。
+
+## 2. OMG payment and refund
+
+- [x] T009 管理端补单复用 OMG 查询结果,仅在真实支付校验成功后同步订单支付状态。
+- [x] T010 自动退款先 CAS 支付流水,成功后再同步 `state=4/payStatus=2/afterSaleStatus=3`。
+- [x] T011 网关结果未知时保持退款中并禁止重复调用;明确失败恢复已支付。
+- [x] T012 不支持退款 API 的支付方式只创建一个人工待办,确认前订单保持已支付。
+- [x] T013 人工退款二次确认通过支付流水 `1→4→3` 收口,并支持流水已完成后的本地状态补偿重试。
+- [x] T014 网关成功但本地同步失败时写高优先级审计;异常日志只记录异常类型,不输出凭证或网关异常详情。
+
+## 3. Admin frontend
+
+- [x] T015 新增状态上下文、受控状态、线下收退款、OMG 补单/退款/人工确认 API 封装。
+- [x] T016 将订单修改弹窗改为服务端白名单驱动,支付和售后状态保持只读。
+- [x] T017 所有支付业务动作要求原因;退款及人工退款确认提供不可逆二次确认和加载态。
+- [x] T018 列表、搜索、详情和弹窗接入 `zh_CN`、`zh_TW`、`en_US`、`vi` 四语言文本。
+
+## 4. Automated verification
+
+- [x] T019 `OrderLifecycleServiceTest`、`PosOrderAdminStatusControllerTest`、`OmgPayControllerTest`:22 个用例通过。
+- [x] T020 原有 `PosOrderOmgPaymentServiceImplTest`、`PosOrderOmgRefundServiceImplTest`:5 个回归用例通过。
+- [x] T021 Maven 聚焦测试过程完成全部依赖模块及 `ruoyi-admin` 编译,构建成功。
+- [x] T022 管理后台 `npm run build:prod` 成功;仅保留两个既有 `*.vue~` 文件及包体积警告。
+- [x] T023 ESLint 全库脚本因既有 `.eslintignore` 把 `src` 全部排除而不可运行;`--no-ignore` 对修改的两个业务文件核对为 317 项,基线为 339 项,未增加历史问题总数。
+- [x] T024 服务端和管理端 `git diff --check` 通过;敏感字段日志模式扫描无命中。
+
+## 5. Staging acceptance (requires environment and test data)
+
+- [ ] T025 在预发布环境用可恢复测试订单执行 `quickstart.md` 的 10 项手工场景。
+- [ ] T026 逐一切换四种语言,核对列表、详情、弹窗、确认框及错误提示无原始 key。
+- [ ] T027 使用只读 SQL 核对订单、操作日志、OMG 支付流水和退款流水;不得调用真实生产退款。
+
+未完成的 T025~T027 属于环境验收,不阻塞代码实现完成;发布前仍必须执行。