Просмотр исходного кода

Merge branch 'redesign‌-260706' into dev

shizhongqi 1 день назад
Родитель
Сommit
de40e15b33

+ 126 - 0
app-hd/controllers/RefundController.php

@@ -13,7 +13,9 @@ use bizHd\order\services\OrderService;
 use bizHd\refund\classes\HdRefundClass;
 use bizHd\refund\classes\HdRefundGoodsClass;
 use bizHd\refund\classes\HdRefundItemClass;
+use bizHd\refund\classes\MallRefundApplyClass;
 use bizHd\refund\services\HdRefundService;
+use bizHd\refund\services\MallRefundApplyService;
 use Yii;
 use common\components\util;
 
@@ -325,4 +327,128 @@ class RefundController extends BaseController
         util::fail('功能开发中');
     }
 
+    /**
+     * 商城售后申请列表(商家端)
+     */
+    public function actionMallApplyList()
+    {
+        $get = Yii::$app->request->get();
+        $where = ['mainId' => $this->mainId, 'shopId' => $this->shopId];
+        $status = $get['status'] ?? '';
+        if ($status !== '' && $status !== null && $status !== 'all') {
+            $where['status'] = (int)$status;
+        }
+        $orderSn = trim((string)($get['orderSn'] ?? ''));
+        if ($orderSn !== '') {
+            $where['orderSn'] = $orderSn;
+        }
+        $list = MallRefundApplyClass::getApplyList($where);
+        if (!empty($list['list'])) {
+            foreach ($list['list'] as &$row) {
+                $row['statusText'] = MallRefundApplyClass::statusText($row['status'] ?? 0);
+            }
+            unset($row);
+        }
+        util::success($list);
+    }
+
+    /**
+     * 商城售后申请详情(商家端)
+     */
+    public function actionMallApplyDetail()
+    {
+        $id = (int)Yii::$app->request->get('id', 0);
+        $apply = MallRefundApplyClass::getById($id, true);
+        MallRefundApplyClass::valid($apply, $this->mainId);
+
+        $data = $apply->toArray();
+        $data['statusText'] = MallRefundApplyClass::statusText($apply->status);
+        $data['productList'] = [];
+        if (!empty($data['product'])) {
+            $data['productList'] = json_decode($data['product'], true) ?: [];
+        }
+
+        $order = OrderClass::getById((int)$apply->orderId);
+        $data['orderInfo'] = $order ?: [];
+
+        util::success($data);
+    }
+
+    /**
+     * 商城售后申请审核通过:执行真实退款
+     */
+    public function actionMallApplyPass()
+    {
+        $shopAdmin = $this->shopAdmin;
+        if (!isset($shopAdmin['super']) || $shopAdmin['super'] != 1) {
+            util::fail('超管才能审核售后');
+        }
+
+        $post = Yii::$app->request->post();
+        $id = (int)($post['id'] ?? 0);
+        util::checkRepeatCommit('mall_apply_pass_' . $id, 10);
+
+        $apply = MallRefundApplyClass::getById($id, true);
+        MallRefundApplyClass::valid($apply, $this->mainId);
+
+        $connection = Yii::$app->db;
+        $transaction = $connection->beginTransaction();
+        try {
+            $order = OrderClass::getById((int)$apply->orderId, true);
+            OrderClass::valid($order, $this->mainId);
+
+            $admin = [
+                'id' => (int)$this->shopAdminId,
+                'name' => $this->shopAdminName ?? ($shopAdmin['name'] ?? ''),
+                'shopId' => (int)$this->shopId,
+                'sjId' => (int)$this->sjId,
+                'mainId' => (int)$this->mainId,
+            ];
+            MallRefundApplyService::approve($apply, $order, $admin);
+            $orderId = (int)$apply->orderId;
+            $transaction->commit();
+            // TODO 事务提交后再重算分佣(与即时售后 create-order 一致,避免嵌套事务)
+            DistributionCommissionClass::tryCalcDistributionAfterPay($orderId);
+            util::complete('审核通过,退款已处理');
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            Yii::info('商城售后审核通过失败:' . $e->getMessage());
+            util::fail($e->getMessage() ?: '审核失败');
+        }
+    }
+
+    /**
+     * 商城售后申请驳回
+     */
+    public function actionMallApplyReject()
+    {
+        $shopAdmin = $this->shopAdmin;
+        if (!isset($shopAdmin['super']) || $shopAdmin['super'] != 1) {
+            util::fail('超管才能审核售后');
+        }
+
+        $post = Yii::$app->request->post();
+        $id = (int)($post['id'] ?? 0);
+        $reason = $post['rejectReason'] ?? ($post['reason'] ?? '');
+
+        $apply = MallRefundApplyClass::getById($id, true);
+        MallRefundApplyClass::valid($apply, $this->mainId);
+
+        $connection = Yii::$app->db;
+        $transaction = $connection->beginTransaction();
+        try {
+            $admin = [
+                'id' => (int)$this->shopAdminId,
+                'name' => $this->shopAdminName ?? ($shopAdmin['name'] ?? ''),
+            ];
+            MallRefundApplyService::reject($apply, $reason, $admin);
+            $transaction->commit();
+            util::complete('已驳回');
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            Yii::info('商城售后驳回失败:' . $e->getMessage());
+            util::fail($e->getMessage() ?: '驳回失败');
+        }
+    }
+
 }

+ 246 - 0
app-mall/controllers/RefundController.php

@@ -0,0 +1,246 @@
+<?php
+/**
+ * 用途:商城顾客售后申请接口
+ * 谁用:mallApp 订单详情「申请售后」页
+ * 解决:顾客提交/查看/撤销售后申请;真正退款由商家在 hdApp 审核通过后执行
+ */
+
+namespace mall\controllers;
+
+use bizHd\distribution\classes\DistributionOrderClass;
+use bizHd\order\classes\OrderClass as HdOrderClass;
+use bizHd\refund\classes\MallRefundApplyClass;
+use bizHd\refund\services\MallRefundApplyService;
+use bizMall\order\classes\OrderClass;
+use common\components\util;
+use Yii;
+
+class RefundController extends BaseController
+{
+    /**
+     * 申请页初始化数据:订单商品 + 可退金额 + 已有申请进度
+     */
+    public function actionGetRefundData()
+    {
+        $id = (int)Yii::$app->request->get('id', 0);
+        $order = OrderClass::getOrderById($id);
+        if (empty($order)) {
+            util::fail('没有找到订单');
+        }
+        $this->assertOrderOwner($order);
+
+        $orderPrice = (float)($order['orderPrice'] ?? 0);
+        $tkPrice = (float)($order['tkPrice'] ?? 0);
+        $couldRefundPrice = round(max(0, $orderPrice - $tkPrice), 2);
+
+        $apply = MallRefundApplyClass::getLatestByOrderId($id);
+        if (!empty($apply) && is_array($apply)) {
+            $apply['statusText'] = MallRefundApplyClass::statusText($apply['status'] ?? 0);
+            if (!empty($apply['product']) && is_string($apply['product'])) {
+                $apply['productList'] = json_decode($apply['product'], true) ?: [];
+            }
+        }
+
+        // 分销售后截止提示(仅提示,真正拦截在提交时)
+        $refundDeadlineTip = '';
+        $distOrder = DistributionOrderClass::getByCondition(['orderId' => $id]);
+        if (!empty($distOrder)) {
+            $finishTime = $distOrder['finishTime'] ?? '';
+            if (!empty($finishTime) && $finishTime !== '0000-00-00 00:00:00') {
+                $rule = \bizHd\distribution\classes\DistributionRuleClass::getRule(
+                    (int)($order['shopId'] ?? 0),
+                    (int)($order['mainId'] ?? $this->mainId)
+                );
+                $settleDays = max(0, (int)($rule['settleDays'] ?? 0));
+                $deadline = date('Y-m-d H:i', strtotime($finishTime) + $settleDays * 86400);
+                $refundDeadlineTip = "分销订单完成后{$settleDays}天内可申请售后,截止:{$deadline}";
+            }
+        }
+
+        util::success([
+            'orderInfo' => $order,
+            'goodsInfoList' => $order['goodsInfoList'] ?? [],
+            'itemInfoList' => $order['itemList'] ?? [],
+            'couldRefundPrice' => $couldRefundPrice,
+            'apply' => $apply,
+            'refundDeadlineTip' => $refundDeadlineTip,
+            'canApply' => $this->canApplyRefund($order, $apply),
+        ]);
+    }
+
+    /**
+     * 顾客提交售后申请(仅落待审核记录)
+     */
+    public function actionCreateOrder()
+    {
+        $post = Yii::$app->request->post();
+        $id = (int)($post['id'] ?? 0);
+
+        util::checkRepeatCommit('mall_refund_' . $id, 5);
+
+        $order = HdOrderClass::getById($id, true);
+        if (empty($order)) {
+            util::fail('没有找到订单');
+        }
+        $this->assertOrderOwner($order);
+
+        if ((int)$order->payStatus !== 1) {
+            util::fail('订单未付款,无法申请售后');
+        }
+        if ((int)$order->status === HdOrderClass::ORDER_STATUS_UN_PAY) {
+            util::fail('待付款订单无法申请售后');
+        }
+        if ((int)$order->status === HdOrderClass::ORDER_STATUS_CANCEL) {
+            util::fail('已取消订单无法申请售后');
+        }
+        // 前端 ORDER_STATUS 含已退款=6,全额退完后可能落此状态
+        if ((int)$order->status === 6) {
+            util::fail('已退款订单无法申请售后');
+        }
+        if ((int)($order->forward ?? 0) === 1) {
+            util::fail('售后收入单无法申请售后');
+        }
+        if (MallRefundApplyClass::hasPendingByOrderId($id)) {
+            util::fail('已有待审核的售后申请,请勿重复提交');
+        }
+
+        // 分销关联订单:完成后超过 settleDays 不可再申请
+        DistributionOrderClass::assertRefundNotExpired(
+            $id,
+            (int)$order->shopId,
+            (int)($order->mainId ?? $this->mainId)
+        );
+
+        $connection = Yii::$app->db;
+        $transaction = $connection->beginTransaction();
+        try {
+            $extra = [
+                'userId' => (int)$this->userId,
+                'hdId' => (int)$this->hdId,
+                'customId' => (int)$this->customId,
+                'shopId' => (int)$order->shopId,
+                'mainId' => (int)($order->mainId ?? $this->mainId),
+            ];
+            $apply = MallRefundApplyService::createApply($post, $order, $extra);
+            $transaction->commit();
+            util::success([
+                'id' => (int)$apply->id,
+                'status' => (int)$apply->status,
+                'statusText' => MallRefundApplyClass::statusText($apply->status),
+            ], '提交成功,请等待商家审核');
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            Yii::info('商城售后申请失败:' . $e->getMessage());
+            // util::fail 已输出时不会走到这里;其它异常统一提示
+            if (strpos($e->getMessage(), '操作失败') === false) {
+                util::fail($e->getMessage() ?: '申请失败');
+            }
+            util::fail('申请失败');
+        }
+    }
+
+    /**
+     * 顾客售后申请列表
+     */
+    public function actionList()
+    {
+        $where = ['customId' => (int)$this->customId];
+        $status = Yii::$app->request->get('status', '');
+        if ($status !== '' && $status !== null) {
+            $where['status'] = (int)$status;
+        }
+        $list = MallRefundApplyClass::getCustomApplyList($where);
+        if (!empty($list['list'])) {
+            foreach ($list['list'] as &$row) {
+                $row['statusText'] = MallRefundApplyClass::statusText($row['status'] ?? 0);
+            }
+            unset($row);
+        }
+        util::success($list);
+    }
+
+    /**
+     * 顾客售后申请详情
+     */
+    public function actionDetail()
+    {
+        $id = (int)Yii::$app->request->get('id', 0);
+        $apply = MallRefundApplyClass::getById($id, true);
+        MallRefundApplyClass::validByCustom($apply, (int)$this->customId);
+        $data = $apply->toArray();
+        $data['statusText'] = MallRefundApplyClass::statusText($apply->status);
+        if (!empty($data['product'])) {
+            $data['productList'] = json_decode($data['product'], true) ?: [];
+        }
+        util::success($data);
+    }
+
+    /**
+     * 顾客撤销待审核申请
+     */
+    public function actionCancelOrder()
+    {
+        $post = Yii::$app->request->post();
+        $id = (int)($post['id'] ?? 0);
+        $apply = MallRefundApplyClass::getById($id, true);
+        MallRefundApplyClass::validByCustom($apply, (int)$this->customId);
+
+        $connection = Yii::$app->db;
+        $transaction = $connection->beginTransaction();
+        try {
+            MallRefundApplyService::cancel($apply);
+            $transaction->commit();
+            util::complete('已撤销申请');
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            Yii::info('撤销售后申请失败:' . $e->getMessage());
+            util::fail($e->getMessage() ?: '撤销失败');
+        }
+    }
+
+    /**
+     * 校验订单归属当前登录顾客
+     * @param array|object $order
+     */
+    protected function assertOrderOwner($order)
+    {
+        $customId = is_object($order) ? (int)($order->customId ?? 0) : (int)($order['customId'] ?? 0);
+        $userId = is_object($order) ? (int)($order->userId ?? 0) : (int)($order['userId'] ?? 0);
+        if ($customId > 0 && (int)$this->customId > 0 && $customId === (int)$this->customId) {
+            return;
+        }
+        // 兼容早期仅绑 userId 的订单
+        if ($userId > 0 && $userId === (int)$this->userId) {
+            return;
+        }
+        util::fail('不是你的订单,无法操作');
+    }
+
+    /**
+     * 是否允许新开售后申请
+     * @param array $order
+     * @param array|null $apply
+     * @return int 1可申请 0不可
+     */
+    protected function canApplyRefund($order, $apply)
+    {
+        if ((int)($order['payStatus'] ?? 0) !== 1) {
+            return 0;
+        }
+        $status = (int)($order['status'] ?? 0);
+        if (in_array($status, [HdOrderClass::ORDER_STATUS_UN_PAY, HdOrderClass::ORDER_STATUS_CANCEL, 6], true)) {
+            return 0;
+        }
+        if ((int)($order['forward'] ?? 0) === 1) {
+            return 0;
+        }
+        if (!empty($apply) && (int)($apply['status'] ?? -1) === MallRefundApplyClass::STATUS_PENDING) {
+            return 0;
+        }
+        $could = bcsub((string)($order['orderPrice'] ?? 0), (string)($order['tkPrice'] ?? 0), 2);
+        if (bccomp($could, '0', 2) <= 0) {
+            return 0;
+        }
+        return 1;
+    }
+}

+ 31 - 0
biz-hd/distribution/classes/DistributionOrderClass.php

@@ -8,6 +8,7 @@ namespace bizHd\distribution\classes;
 
 use bizHd\base\classes\BaseClass;
 use common\components\dateUtil;
+use common\components\util;
 use Yii;
 use yii\db\Query;
 
@@ -172,6 +173,36 @@ class DistributionOrderClass extends BaseClass
         return 'invalid';
     }
 
+    /**
+     * 分销关联订单的售后期限校验:订单完成后超过 settleDays 天不可再发起退款
+     * 无记录(与分销无关)或不存在 finishTime(尚未完成计佣)则放行
+     * @param int $orderId xhOrder.id
+     * @param int $shopId
+     * @param int $mainId
+     */
+    public static function assertRefundNotExpired($orderId, $shopId, $mainId)
+    {
+        $orderId = (int)$orderId;
+        if ($orderId <= 0) {
+            return;
+        }
+        $distOrder = self::getByCondition(['orderId' => $orderId]);
+        if (empty($distOrder)) {
+            return;
+        }
+        $finishTime = $distOrder['finishTime'] ?? '';
+        if (empty($finishTime) || $finishTime === '0000-00-00 00:00:00') {
+            // 订单尚未写入完成时间,暂不按 settleDays 拦截
+            return;
+        }
+        $rule = DistributionRuleClass::getRule((int)$shopId, (int)$mainId);
+        $settleDays = max(0, (int)($rule['settleDays'] ?? 0));
+        $deadline = strtotime($finishTime) + $settleDays * 86400;
+        if (time() > $deadline) {
+            util::fail("该订单已超过售后期限(完成后{$settleDays}天内可申请),无法再发起退款");
+        }
+    }
+
     /**
      * 订单完成后回写 finishTime(取值与 xhOrder.successTime 一致,不改 settleStatus)
      * @param int $orderId xhOrder.id

+ 31 - 1
biz-hd/hb/classes/HbClass.php

@@ -38,7 +38,7 @@ class HbClass extends BaseClass
     }
 
     /**
-     * 红包退回
+     * 红包退回(不校验过期,供 hd 人工售后等既有流程使用)
      */
     public static function hbBack($order)
     {
@@ -53,4 +53,34 @@ class HbClass extends BaseClass
         }
     }
 
+    /**
+     * 商城售后审核通过时退回红包:未过期才恢复为可用,已过期仅解除订单占用标记
+     * @param object $order xhOrder(需 hbId>0)
+     * @return int 1已退回可用 0已过期未退回 -1无红包
+     */
+    public static function hbBackIfNotExpired($order)
+    {
+        $hbId = (int)($order->hbId ?? 0);
+        if ($hbId <= 0) {
+            return -1;
+        }
+        $hb = self::getById($hbId, true);
+        if (empty($hb)) {
+            // 红包记录缺失时仍解除订单占用,避免反复判断
+            $order->hbId = -$hbId;
+            $order->save();
+            return -1;
+        }
+        $endTime = (int)($hb->endTime ?? 0);
+        // 未过期:正常退回为可用
+        if ($endTime <= 0 || $endTime >= time()) {
+            self::hbBack($order);
+            return 1;
+        }
+        // 已过期:不恢复可用,仅把订单 hbId 置负表示已处理过
+        $order->hbId = -$hbId;
+        $order->save();
+        return 0;
+    }
+
 }

+ 134 - 0
biz-hd/refund/classes/MallRefundApplyClass.php

@@ -0,0 +1,134 @@
+<?php
+/**
+ * 用途:商城顾客售后申请业务类
+ * 谁用:app-mall RefundController、app-hd RefundController 审核入口
+ * 解决:申请记录的增删改查、归属校验、列表查询
+ */
+
+namespace bizHd\refund\classes;
+
+use bizHd\base\classes\BaseClass;
+use common\components\util;
+
+class MallRefundApplyClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\refund\models\MallRefundApply';
+
+    /** 退货并退款 */
+    const REFUND_TYPE_MONEY_GOOD = 1;
+    /** 仅退款 */
+    const REFUND_TYPE_MONEY = 2;
+
+    /** 待审核 */
+    const STATUS_PENDING = 0;
+    /** 已通过 */
+    const STATUS_PASS = 1;
+    /** 已驳回 */
+    const STATUS_REJECT = 2;
+    /** 已取消 */
+    const STATUS_CANCEL = 3;
+
+    /**
+     * 校验申请归属门店
+     * @param object|null $apply
+     * @param int $mainId
+     * @return bool
+     */
+    public static function valid($apply, $mainId)
+    {
+        if (empty($apply)) {
+            util::fail('没有找到售后申请');
+        }
+        if (!isset($apply->mainId) || (int)$apply->mainId !== (int)$mainId) {
+            util::fail('无法操作该售后申请');
+        }
+        return true;
+    }
+
+    /**
+     * 校验申请归属顾客(商城端)
+     * @param object|null $apply
+     * @param int $customId
+     * @return bool
+     */
+    public static function validByCustom($apply, $customId)
+    {
+        if (empty($apply)) {
+            util::fail('没有找到售后申请');
+        }
+        if ((int)($apply->customId ?? 0) !== (int)$customId) {
+            util::fail('不是你的售后申请');
+        }
+        return true;
+    }
+
+    /**
+     * 按订单查最新一条申请(优先待审核)
+     * @param int $orderId
+     * @return array|null
+     */
+    public static function getLatestByOrderId($orderId)
+    {
+        $orderId = (int)$orderId;
+        if ($orderId <= 0) {
+            return null;
+        }
+        // 先查待审核,便于前端直接展示进度
+        $pending = self::getByCondition(['orderId' => $orderId, 'status' => self::STATUS_PENDING]);
+        if (!empty($pending)) {
+            return $pending;
+        }
+        $list = self::getLimitList('*', ['orderId' => $orderId], 1, 'id DESC');
+        if (empty($list)) {
+            return null;
+        }
+        return $list[0] ?? null;
+    }
+
+    /**
+     * 订单是否存在待审核申请
+     * @param int $orderId
+     * @return bool
+     */
+    public static function hasPendingByOrderId($orderId)
+    {
+        $row = self::getByCondition(['orderId' => (int)$orderId, 'status' => self::STATUS_PENDING]);
+        return !empty($row);
+    }
+
+    /**
+     * 商家端申请列表
+     * @param array $where
+     * @return array
+     */
+    public static function getApplyList($where)
+    {
+        return self::getList('*', $where, 'addTime DESC');
+    }
+
+    /**
+     * 顾客端申请列表
+     * @param array $where
+     * @return array
+     */
+    public static function getCustomApplyList($where)
+    {
+        return self::getList('*', $where, 'addTime DESC');
+    }
+
+    /**
+     * 状态文案
+     * @param int $status
+     * @return string
+     */
+    public static function statusText($status)
+    {
+        $map = [
+            self::STATUS_PENDING => '待审核',
+            self::STATUS_PASS => '已通过',
+            self::STATUS_REJECT => '已驳回',
+            self::STATUS_CANCEL => '已取消',
+        ];
+        return $map[(int)$status] ?? '';
+    }
+}

+ 18 - 0
biz-hd/refund/models/MallRefundApply.php

@@ -0,0 +1,18 @@
+<?php
+/**
+ * 用途:商城顾客售后申请模型 xhMallRefundApply
+ * 谁用:mallApp 申请售后、hdApp 商家审核
+ * 解决:顾客自助提交售后申请后由商家人工审核,与即时退款表 xhHdRefund 解耦
+ */
+
+namespace bizHd\refund\models;
+
+use bizHd\base\models\Base;
+
+class MallRefundApply extends Base
+{
+    public static function tableName()
+    {
+        return 'xhMallRefundApply';
+    }
+}

+ 260 - 0
biz-hd/refund/services/MallRefundApplyService.php

@@ -0,0 +1,260 @@
+<?php
+/**
+ * 用途:商城顾客售后申请服务
+ * 谁用:app-mall 提交/撤销申请、app-hd 审核通过/驳回
+ * 解决:申请只落待审核记录;审核通过时复用 HdRefundService 执行真实退款,并处理红包与分销重算
+ */
+
+namespace bizHd\refund\services;
+
+use bizHd\base\services\BaseService;
+use bizHd\custom\classes\CustomClass;
+use bizHd\custom\classes\HdClass;
+use bizHd\hb\classes\HbClass;
+use bizHd\order\classes\OrderClass;
+use bizHd\refund\classes\MallRefundApplyClass;
+use common\components\util;
+
+class MallRefundApplyService extends BaseService
+{
+    public static $baseFile = '\bizHd\refund\classes\MallRefundApplyClass';
+
+    /**
+     * 创建售后申请(仅落库待审核,不触达资金)
+     * @param array $post 含 refundType/product/price/remark 等
+     * @param object $order xhOrder 对象
+     * @param array $extra 含 userId/hdId/customId/shopId/mainId/sjId 等上下文
+     * @return object
+     */
+    public static function createApply($post, $order, $extra = [])
+    {
+        $refundType = (int)($post['refundType'] ?? MallRefundApplyClass::REFUND_TYPE_MONEY_GOOD);
+        if (!in_array($refundType, [MallRefundApplyClass::REFUND_TYPE_MONEY_GOOD, MallRefundApplyClass::REFUND_TYPE_MONEY], true)) {
+            util::fail('退款方式不正确');
+        }
+
+        $refundPrice = $post['price'] ?? 0;
+        if (!is_numeric($refundPrice) || bccomp((string)$refundPrice, '0', 2) <= 0) {
+            util::fail('退款金额必须大于0');
+        }
+
+        // 可退上限:订单金额 - 已退累计
+        $couldRefund = bcsub((string)($order->orderPrice ?? 0), (string)($order->tkPrice ?? 0), 2);
+        if (bccomp((string)$refundPrice, $couldRefund, 2) === 1) {
+            util::fail('退款金额超过可退金额');
+        }
+
+        $productList = [];
+        if ($refundType === MallRefundApplyClass::REFUND_TYPE_MONEY_GOOD) {
+            $productJson = $post['product'] ?? '';
+            if (is_string($productJson)) {
+                $productList = json_decode($productJson, true);
+            } elseif (is_array($productJson)) {
+                $productList = $productJson;
+            }
+            if (empty($productList) || !is_array($productList)) {
+                util::fail('请选择要退的商品');
+            }
+            $productList = self::validateAndEnrichProduct($productList, $order);
+        }
+
+        $data = [
+            'mainId' => (int)($extra['mainId'] ?? $order->mainId ?? 0),
+            'shopId' => (int)($extra['shopId'] ?? $order->shopId ?? 0),
+            'hdId' => (int)($extra['hdId'] ?? $order->hdId ?? 0),
+            'customId' => (int)($extra['customId'] ?? $order->customId ?? 0),
+            'userId' => (int)($extra['userId'] ?? 0),
+            'orderId' => (int)($order->id ?? 0),
+            'orderSn' => $order->orderSn ?? '',
+            'refundType' => $refundType,
+            'product' => !empty($productList) ? json_encode($productList, JSON_UNESCAPED_UNICODE) : '',
+            'refundPrice' => round((float)$refundPrice, 2),
+            'remark' => trim((string)($post['remark'] ?? '')),
+            'hbId' => (int)($order->hbId ?? 0) > 0 ? (int)$order->hbId : 0,
+            'status' => MallRefundApplyClass::STATUS_PENDING,
+        ];
+
+        return MallRefundApplyClass::add($data, true);
+    }
+
+    /**
+     * 校验退货数量不超过可退数量,并补齐商品名快照
+     * @param array $productList [{productId,num,unitPrice,property,name?}] property 0商品 1花材
+     * @param object $order
+     * @return array 补齐 name 后的列表
+     */
+    protected static function validateAndEnrichProduct($productList, $order)
+    {
+        $orderSn = $order->orderSn ?? '';
+        $goodsMap = \bizHd\order\classes\OrderGoodsClass::getAllByCondition(
+            ['orderSn' => $orderSn],
+            null,
+            '*',
+            'goodsId',
+            true
+        );
+        $itemMap = \bizHd\order\classes\OrderItemClass::getAllByCondition(
+            ['orderSn' => $orderSn],
+            null,
+            '*',
+            'itemId',
+            true
+        );
+
+        $enriched = [];
+        foreach ($productList as $row) {
+            $num = (float)($row['num'] ?? 0);
+            if ($num <= 0) {
+                util::fail('退货数量必须大于0');
+            }
+            $productId = (int)($row['productId'] ?? 0);
+            $property = (int)($row['property'] ?? 0);
+            if ($property === 0) {
+                $line = $goodsMap[$productId] ?? null;
+                if (empty($line)) {
+                    util::fail('没有找到退款商品');
+                }
+                $remain = bcsub((string)($line->num ?? 0), (string)($line->refundNum ?? 0), 2);
+                if (bccomp((string)$num, $remain, 2) === 1) {
+                    $name = $line->name ?? '商品';
+                    util::fail("{$name}超过可退数量");
+                }
+                $row['name'] = $row['name'] ?? ($line->name ?? '');
+            } else {
+                $line = $itemMap[$productId] ?? null;
+                if (empty($line)) {
+                    util::fail('没有找到退款花材');
+                }
+                $remain = bcsub((string)($line->num ?? 0), (string)($line->refundNum ?? 0), 2);
+                if (bccomp((string)$num, $remain, 2) === 1) {
+                    $name = $line->name ?? '花材';
+                    util::fail("{$name}超过可退数量");
+                }
+                $row['name'] = $row['name'] ?? ($line->name ?? '');
+                if (empty($row['unitName'])) {
+                    $row['unitName'] = $line->unitName ?? '';
+                }
+            }
+            $enriched[] = $row;
+        }
+        return $enriched;
+    }
+
+    /**
+     * 商家审核通过:执行真实退款 + 红包按过期退回 + 消费回退 + 分销重算
+     * @param object $apply MallRefundApply
+     * @param object $order xhOrder(需可写)
+     * @param array $shopAdmin 审核人信息 [id, name, shopId, sjId, mainId]
+     * @return object 更新后的申请
+     */
+    public static function approve($apply, $order, $shopAdmin = [])
+    {
+        if ((int)$apply->status !== MallRefundApplyClass::STATUS_PENDING) {
+            util::fail('仅待审核申请可通过');
+        }
+
+        $productList = [];
+        if (!empty($apply->product)) {
+            $decoded = json_decode($apply->product, true);
+            if (is_array($decoded)) {
+                $productList = $decoded;
+            }
+        }
+
+        $post = [
+            'price' => $apply->refundPrice,
+            'refundType' => (int)$apply->refundType,
+            'product' => $productList,
+            'remark' => $apply->remark ?? '',
+            'shopId' => (int)($shopAdmin['shopId'] ?? $apply->shopId),
+            'sjId' => (int)($shopAdmin['sjId'] ?? $order->sjId ?? 0),
+            'mainId' => (int)($shopAdmin['mainId'] ?? $apply->mainId),
+            'shopAdminId' => (int)($shopAdmin['id'] ?? 0),
+            'shopAdminName' => $shopAdmin['name'] ?? '',
+        ];
+
+        // 复用现有即时退款资金处理
+        $refund = HdRefundService::addRefund($post, $order);
+        $refundId = (int)($refund->id ?? 0);
+
+        // 累计消费、花店支出回退(与 RefundController::actionCreateOrder 保持一致)
+        $custom = CustomClass::getLockById($order->customId);
+        if (!empty($custom)) {
+            $custom->buyAmount = bcsub((string)$custom->buyAmount, (string)$apply->refundPrice, 2);
+            $customName = $custom->name ?? $order->customName ?? '';
+            $orderSnText = $order->orderSn ?? '';
+            $eventPrefix = $customName !== '' ? $customName : '客户';
+            $custom->eventRemark = $eventPrefix . '商城售后' . $orderSnText;
+            $custom->relateId = $order->id;
+            $custom->relateType = 1;
+            $custom->staffId = (int)($shopAdmin['id'] ?? 0);
+            $custom->staffName = $shopAdmin['name'] ?? '';
+            $custom->mainId = (int)($shopAdmin['mainId'] ?? $apply->mainId);
+            $custom->save();
+
+            $hd = HdClass::getLockById($custom->hdId);
+            if (!empty($hd)) {
+                $hd->expendAmount = bcsub((string)$hd->expendAmount, (string)$apply->refundPrice, 2);
+                $hd->save();
+            }
+        }
+
+        // 全额退清且订单仍占用红包时,按是否过期决定是否退回
+        $updatedOrder = OrderClass::getById($order->id, true, 'id, actPrice, hbId, hbAmount');
+        if (!empty($updatedOrder) && (int)$updatedOrder->hbId > 0 && bccomp((string)$updatedOrder->actPrice, '0', 2) <= 0) {
+            HbClass::hbBackIfNotExpired($updatedOrder);
+        }
+
+        $apply->status = MallRefundApplyClass::STATUS_PASS;
+        $apply->hdRefundId = $refundId;
+        $apply->auditAdminId = (int)($shopAdmin['id'] ?? 0);
+        $apply->auditAdminName = $shopAdmin['name'] ?? '';
+        $apply->auditTime = date('Y-m-d H:i:s');
+        $apply->save();
+
+        // TODO 分销重算由 Controller 在事务 commit 后调用 tryCalcDistributionAfterPay,避免嵌套事务
+
+        return $apply;
+    }
+
+    /**
+     * 商家驳回申请
+     * @param object $apply
+     * @param string $reason
+     * @param array $shopAdmin
+     * @return object
+     */
+    public static function reject($apply, $reason, $shopAdmin = [])
+    {
+        if ((int)$apply->status !== MallRefundApplyClass::STATUS_PENDING) {
+            util::fail('仅待审核申请可驳回');
+        }
+        $reason = trim((string)$reason);
+        if ($reason === '') {
+            util::fail('请填写驳回原因');
+        }
+
+        $apply->status = MallRefundApplyClass::STATUS_REJECT;
+        $apply->rejectReason = mb_substr($reason, 0, 250);
+        $apply->auditAdminId = (int)($shopAdmin['id'] ?? 0);
+        $apply->auditAdminName = $shopAdmin['name'] ?? '';
+        $apply->auditTime = date('Y-m-d H:i:s');
+        $apply->save();
+        return $apply;
+    }
+
+    /**
+     * 顾客撤销待审核申请
+     * @param object $apply
+     * @return object
+     */
+    public static function cancel($apply)
+    {
+        if ((int)$apply->status !== MallRefundApplyClass::STATUS_PENDING) {
+            util::fail('仅待审核申请可撤销');
+        }
+        $apply->status = MallRefundApplyClass::STATUS_CANCEL;
+        $apply->save();
+        return $apply;
+    }
+}

+ 32 - 0
sql/20260730_mall_refund_apply.sql

@@ -0,0 +1,32 @@
+-- 商城顾客自助售后申请表
+-- 用途:mallApp 顾客提交售后申请(待审核),商家在 hdApp 人工通过/驳回后再执行资金退款
+-- 执行前请确认线上库无同名表
+
+CREATE TABLE IF NOT EXISTS `xhMallRefundApply` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `mainId` int(11) NOT NULL DEFAULT 0 COMMENT '中央id',
+  `shopId` int(11) NOT NULL DEFAULT 0 COMMENT '门店id',
+  `hdId` int(11) NOT NULL DEFAULT 0 COMMENT '花店客户id(xhHd)',
+  `customId` int(11) NOT NULL DEFAULT 0 COMMENT '客户id(xhCustom)',
+  `userId` int(11) NOT NULL DEFAULT 0 COMMENT '商城用户id',
+  `orderId` int(11) NOT NULL DEFAULT 0 COMMENT '零售订单id(xhOrder)',
+  `orderSn` varchar(64) NOT NULL DEFAULT '' COMMENT '零售订单号',
+  `refundType` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1退货并退款 2仅退款',
+  `product` text COMMENT '勾选商品/花材JSON快照',
+  `refundPrice` decimal(15,2) NOT NULL DEFAULT 0.00 COMMENT '申请退款金额',
+  `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '顾客备注',
+  `hbId` int(11) NOT NULL DEFAULT 0 COMMENT '下单红包id快照',
+  `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '0待审核 1已通过 2已驳回 3已取消',
+  `rejectReason` varchar(255) NOT NULL DEFAULT '' COMMENT '驳回原因',
+  `auditAdminId` int(11) NOT NULL DEFAULT 0 COMMENT '审核人id',
+  `auditAdminName` varchar(64) NOT NULL DEFAULT '' COMMENT '审核人姓名',
+  `auditTime` datetime DEFAULT NULL COMMENT '审核时间',
+  `hdRefundId` int(11) NOT NULL DEFAULT 0 COMMENT '通过后关联xhHdRefund.id',
+  `addTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '申请时间',
+  `updateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_orderId` (`orderId`),
+  KEY `idx_shop_status` (`shopId`,`status`),
+  KEY `idx_customId` (`customId`),
+  KEY `idx_userId` (`userId`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商城顾客售后申请';