瀏覽代碼

feat(hd-recharge): 支持充值退款

- 新增 HD 充值记录退款接口,限制财务权限并防止短时间重复提交
- 线上充值保存支付流水号并支持拉卡拉原路退款,线下现金退款同步扣减现金账户
- 充值列表和统计按退款状态统计支出,并区分线上/线下支付渠道
- API: 新增 /recharge/refund,并扩展 /recharge/list 与 /recharge/stat 的退款和渠道语义
- Database: 新增 xhRecharge.isRefund 字段及 hd 充值列表/统计索引 SQL
shizhongqi 3 周之前
父節點
當前提交
1183cc109d

+ 36 - 2
app-hd/controllers/RechargeController.php

@@ -231,7 +231,7 @@ class RechargeController extends BaseController
     {
         $get = Yii::$app->request->get();
         $customId = $get['customId'] ?? 0;
-        $where = ['mainId' => $this->mainId, 'shopId' => $this->shopId, 'status' => 1];
+        $where = ['shopId' => $this->shopId, 'status' => 1];
         if (!empty($customId)) {
             $custom = \bizHd\custom\classes\CustomClass::getById($customId, true);
             if (empty($custom)) {
@@ -282,10 +282,44 @@ class RechargeController extends BaseController
 //        }
 
         $get = Yii::$app->request->get();
-        $data = \bizHd\recharge\classes\RechargeClass::getStatProfile($this->mainId, $this->shopId, $get);
+        $data = \bizHd\recharge\classes\RechargeClass::getStatProfile($this->shopId, $get);
         util::success($data);
     }
 
+    //充值记录退款:财务权限;线上原路退,线下扣减客户余额。
+    public function actionRefund()
+    {
+        $staff = $this->shopAdmin;
+        if (!isset($staff->finance) || intval($staff->finance) === 0) {
+            util::fail('请有财务权限的人操作');
+        }
+
+        $post = Yii::$app->request->post();
+        $id = intval($post['id'] ?? 0);
+        if ($id <= 0) {
+            util::fail('参数错误');
+        }
+
+        $cacheKey = 'hd_recharge_refund_' . $id;
+        $has = Yii::$app->redis->executeCommand('GET', [$cacheKey]);
+        if (!empty($has)) {
+            util::fail('请5秒之后再提交');
+        }
+        Yii::$app->redis->executeCommand('SETEX', [$cacheKey, 5, 'has']);
+
+        $connection = Yii::$app->db;
+        $transaction = $connection->beginTransaction();
+        try {
+            \bizHd\recharge\classes\RechargeClass::refundPaidRecharge($id, $this->shop, $staff);
+            $transaction->commit();
+            util::complete('退款成功');
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            Yii::info('充值退款失败:' . $e->getMessage());
+            util::fail($e->getMessage() ?: '退款失败');
+        }
+    }
+
     //免费续期活动 ssh 2021.2.21
     public function actionRenew()
     {

+ 264 - 27
biz-hd/recharge/classes/RechargeClass.php

@@ -21,6 +21,7 @@ use common\components\dateUtil;
 use common\components\dict;
 use common\components\lakala\Lakala;
 use common\components\noticeUtil;
+use common\components\orderSn;
 use common\components\util;
 use Yii;
 use bizHd\base\classes\BaseClass;
@@ -39,6 +40,14 @@ class RechargeClass extends BaseClass
             case 'system':
                 $where['onlinePay'] = 2;
                 break;
+            case 'online_0':
+                $where['onlinePay'] = 2;
+                $where['payWay'] = 0;
+                break;
+            case 'online_1':
+                $where['onlinePay'] = 2;
+                $where['payWay'] = 1;
+                break;
             case 'pay_0':
                 $where['onlinePay!='] = 2;
                 $where['payWay'] = 0;
@@ -72,11 +81,7 @@ class RechargeClass extends BaseClass
         if ((int)$io === 1) {
             $query->andWhere('IFNULL(amount, 0) + IFNULL(giveAmount, 0) > 0');
         } else {
-            $query->andWhere([
-                'or',
-                ['>', 'settleAmount', 0],
-                ['>', 'clearAmount', 0],
-            ]);
+            $query->andWhere(['isRefund' => 1]);
         }
 
         return self::getRechargePageList($query, 'addTime DESC,id DESC');
@@ -166,7 +171,7 @@ class RechargeClass extends BaseClass
     }
 
     //第三支付后流程 ssh 2021.4.27
-    public static function thirdPay($payWay, $orderSn, $totalFee, $attach)
+    public static function thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId = '')
     {
         $recharge = RechargeClass::getByCondition(['orderSn' => $orderSn], true);
         if (empty($recharge)) {
@@ -187,6 +192,8 @@ class RechargeClass extends BaseClass
             'onlinePay' => 2,
             'side' => 1,
         ];
+        $recharge->returnCode = $transactionId;
+        $recharge->save(false);
         self::complete($recharge, $payWay, $params);
         return $recharge;
     }
@@ -601,6 +608,236 @@ class RechargeClass extends BaseClass
         return $returnChange;
     }
 
+    /**
+     * 花店后台:对已付款客户充值单退款。线上原路退,线下扣减客户余额;xhRecharge.isRefund 标记退款状态。
+     */
+    public static function refundPaidRecharge($rechargeId, $shop, $staff)
+    {
+        $rechargeId = intval($rechargeId);
+        if ($rechargeId <= 0) {
+            util::fail('参数错误');
+        }
+        $recharge = self::getLockById($rechargeId);
+        if (empty($recharge)) {
+            util::fail('没有找到充值记录');
+        }
+        if (intval($recharge->mainId ?? 0) !== intval($shop->mainId ?? 0) || intval($recharge->shopId ?? 0) !== intval($shop->id ?? 0)) {
+            util::fail('没有权限操作该充值记录');
+        }
+        if (intval($recharge->payStatus ?? 0) !== 1 || intval($recharge->status ?? 0) !== 1) {
+            util::fail('仅已付款的充值可退款');
+        }
+        if (self::isRechargeRefunded($recharge)) {
+            util::fail('该充值已退款');
+        }
+
+        $amount = bcadd((string)($recharge->amount ?? '0'), '0', 2);
+        if (bccomp($amount, '0', 2) <= 0) {
+            util::fail('充值金额有误');
+        }
+
+        $customId = intval($recharge->customId ?? 0);
+        $hdId = intval($recharge->hdId ?? 0);
+        $custom = CustomClass::getLockById($customId);
+        $hd = HdClass::getLockById($hdId);
+        if (empty($custom) || empty($hd)) {
+            util::fail('没有找到客户余额账户');
+        }
+
+        $isOnline = intval($recharge->onlinePay ?? 0) === 2;
+        $payWay = intval($recharge->payWay ?? -1);
+        $wxPay = intval(dict::getDict('payWay', 'wxPay'));
+        $aliPay = intval(dict::getDict('payWay', 'alipay'));
+        $cashPay = intval(dict::getDict('payWay', 'cash'));
+        $onlineMain = null;
+
+        if ($isOnline) {
+            if ($payWay !== $wxPay && $payWay !== $aliPay) {
+                util::fail('暂不支持该支付方式原路退款');
+            }
+            $returnCode = trim((string)($recharge->returnCode ?? ''));
+            if ($returnCode === '') {
+                util::fail('缺少支付流水号,无法原路退款');
+            }
+            $onlineMain = MainClass::getLockById($shop->mainId ?? 0);
+            if (empty($onlineMain)) {
+                util::fail('没有找到资产信息');
+            }
+            if (bccomp((string)($onlineMain->balance ?? '0'), $amount, 2) < 0) {
+                util::fail('门店余额不足,无法退款');
+            }
+            self::refundOnlineByLakala($recharge, $shop, $amount, $returnCode);
+        }
+
+        $giveAmount = bcadd((string)($recharge->giveAmount ?? '0'), '0', 2);
+        if (bccomp($giveAmount, '0', 2) < 0) {
+            $giveAmount = '0.00';
+        }
+        $currentGive = bcadd((string)($hd->balanceGive ?? '0'), '0', 2);
+        $deductGive = '0.00';
+        if (bccomp($giveAmount, '0', 2) === 1 && bccomp($currentGive, '0', 2) === 1) {
+            $deductGive = bccomp($currentGive, $giveAmount, 2) >= 0 ? $giveAmount : $currentGive;
+        }
+        $deductPay = bcadd($amount, bcsub($giveAmount, $deductGive, 2), 2);
+        $totalDeduct = bcadd($deductPay, $deductGive, 2);
+
+        $custom->balancePay = bcsub((string)($custom->balancePay ?? '0'), $deductPay, 2);
+        $hd->balancePay = bcsub((string)($hd->balancePay ?? '0'), $deductPay, 2);
+        if (bccomp($deductGive, '0', 2) === 1) {
+            $custom->balanceGive = bcsub((string)($custom->balanceGive ?? '0'), $deductGive, 2);
+            $hd->balanceGive = bcsub((string)($hd->balanceGive ?? '0'), $deductGive, 2);
+        }
+        $custom->balance = bcsub((string)($custom->balance ?? '0'), $totalDeduct, 2);
+        $hd->balance = bcsub((string)($hd->balance ?? '0'), $totalDeduct, 2);
+        $custom->save(false);
+        $hd->save(false);
+
+        if (floatval($custom->balance) != floatval($hd->balance) || floatval($custom->balancePay) != floatval($hd->balancePay) || floatval($custom->balanceGive) != floatval($hd->balanceGive)) {
+            util::fail('账户余额有问题,请联系管理员');
+        }
+
+        $staffId = intval($staff->id ?? 0);
+        $staffName = (string)($staff->name ?? '');
+        $capitalType = dict::getDict('capitalType', 'customRechargeRefund', 'id');
+        $event = '充值退款(操作人:' . $staffName . ')单号:' . ($recharge->orderSn ?? '');
+        $changeBase = [
+            'hdId' => $hdId,
+            'hdName' => $hd->name ?? ($recharge->hdName ?? ''),
+            'customId' => $customId,
+            'customName' => $custom->name ?? ($recharge->customName ?? ''),
+            'relateId' => $rechargeId,
+            'onlinePay' => $recharge->onlinePay ?? 1,
+            'capitalType' => $capitalType,
+            'side' => 0,
+            'payWay' => $payWay,
+            'event' => $event,
+            'staffId' => $staffId,
+            'staffName' => $staffName,
+            'shopId' => $recharge->shopId ?? 0,
+            'mainId' => $recharge->mainId ?? 0,
+            'remark' => '',
+        ];
+
+        BalanceChangeClass::add(array_merge($changeBase, [
+            'amount' => $totalDeduct,
+            'balance' => $hd->balance,
+            'io' => 0,
+        ]), true);
+
+        BalancePayChangeClass::add(array_merge($changeBase, [
+            'amount' => $deductPay,
+            'balance' => $hd->balancePay,
+            'io' => 0,
+        ]), true);
+
+        if (bccomp($deductGive, '0', 2) === 1) {
+            BalanceGiveChangeClass::add(array_merge($changeBase, [
+                'amount' => $deductGive,
+                'balance' => $hd->balanceGive,
+                'io' => 0,
+            ]), true);
+        }
+
+        if ($isOnline) {
+            $main = $onlineMain ?: MainClass::getLockById($shop->mainId ?? 0);
+            if (empty($main)) {
+                util::fail('没有找到资产信息');
+            }
+            ShopClass::customRechargeRefundReduceBalance($main, $shop, $recharge, dict::getDict('capitalType', 'xhRecharge', 'id'), $staffName);
+        } elseif ($payWay === $cashPay) {
+            self::cashRechargeRefundReduceMoney($recharge, $shop, $staffName, $amount);
+        }
+
+        self::markRechargeRefunded($recharge);
+        $recharge->balance = $hd->balance;
+        $recharge->save(false);
+
+        return $recharge;
+    }
+
+    protected static function cashRechargeRefundReduceMoney($recharge, $shop, $staffName, $amount)
+    {
+        $main = MainClass::getLockById($shop->mainId ?? 0);
+        if (empty($main)) {
+            util::fail('没有找到现金账户');
+        }
+        if (bccomp((string)($main->money ?? '0'), $amount, 2) < 0) {
+            util::fail('现金不足' . floatval($amount) . '元');
+        }
+        $main->money = bcsub((string)$main->money, $amount, 2);
+        $main->save(false);
+        ShopMoneyClass::addData([
+            'mainId' => $shop->mainId ?? 0,
+            'sjId' => 0,
+            'amount' => $amount,
+            'balance' => $main->money,
+            'io' => 0,
+            'staffId' => $recharge->staffId ?? 0,
+            'staffName' => $staffName,
+            'customName' => $recharge->customName ?? '',
+            'ptStyle' => dict::getDict('ptStyle', 'hd'),
+            'capitalType' => dict::getDict('capitalType', 'xhRecharge', 'id'),
+            'event' => ($recharge->customName ?? '客户') . '现金充值退款' . floatval($amount) . '元',
+            'remark' => '操作人:' . $staffName,
+        ]);
+    }
+
+    protected static function refundOnlineByLakala($recharge, $shop, $amount, $returnCode)
+    {
+        $termNo = $shop->lklScanTermNo ?? '';
+        if (intval($recharge->payWay ?? 0) === intval(dict::getDict('payWay', 'alipay'))) {
+            $termNo = $shop->lklB2BTermNo ?? '';
+        }
+        if (empty($shop->lklSjNo) || empty($termNo)) {
+            util::fail('门店未配置拉卡拉商户信息');
+        }
+        $merchantPrivateKeyPath = Yii::getAlias('@vendor/lakala') . '/production/api_private_key.pem';
+        $lklCertificatePath = Yii::getAlias('@vendor/lakala') . '/production/lkl-apigw-v1.cer';
+        $laResource = new Lakala([
+            'appid' => 'OP00002119',
+            'serial_no' => '018b08cfddbd',
+            'merchant_no' => $shop->lklSjNo,
+            'term_no' => $termNo,
+            'merchantPrivateKeyPath' => $merchantPrivateKeyPath,
+            'lklCertificatePath' => $lklCertificatePath,
+        ]);
+        $response = $laResource->refund([
+            'refundSn' => orderSn::getRechargeSn(),
+            'orderSn' => $recharge->orderSn ?? '',
+            'refundAmount' => bcmul($amount, 100),
+            'refundReason' => '充值退款',
+            'thirdNo' => $returnCode,
+        ]);
+        if (!isset($response['code']) || $response['code'] != 'BBS00000') {
+            util::fail('原路退款失败:' . ($response['msg'] ?? '退款失败'));
+        }
+    }
+
+    protected static function isRechargeRefunded($recharge)
+    {
+        if (empty($recharge)) {
+            return true;
+        }
+        if (self::modelHasAttr($recharge, 'isRefund')) {
+            return intval($recharge->isRefund ?? 0) === 1;
+        }
+        return false;
+    }
+
+    protected static function markRechargeRefunded($recharge)
+    {
+        if (empty($recharge) || !self::modelHasAttr($recharge, 'isRefund')) {
+            return;
+        }
+        $recharge->isRefund = 1;
+        $recharge->save(false, ['isRefund']);
+    }
+
+    protected static function modelHasAttr($model, $attr)
+    {
+        return is_object($model) && method_exists($model, 'hasAttribute') && $model->hasAttribute($attr);
+    }
+
     /**
      * 售后付款单是否已经充值过了 ssh 20250801
      */
@@ -613,7 +850,7 @@ class RechargeClass extends BaseClass
         }
     }
 
-    public static function getStatProfile($mainId, $shopId, $params = [])
+    public static function getStatProfile($shopId, $params = [])
     {
         $searchTime = $params['searchTime'] ?? 'today';
         $startTime = $params['startTime'] ?? '';
@@ -630,9 +867,9 @@ class RechargeClass extends BaseClass
             }
         }
 
-        $summary = self::getRechargeStatSummary($mainId, $shopId, $start, $end);
-        $channelList = self::getRechargeStatChannelList($mainId, $shopId, $start, $end);
-        $customerList = self::getRechargeStatCustomerList($mainId, $shopId, $start, $end);
+        $summary = self::getRechargeStatSummary($shopId, $start, $end);
+        $channelList = self::getRechargeStatChannelList($shopId, $start, $end);
+        $customerList = self::getRechargeStatCustomerList($shopId, $start, $end);
 
         $data = [
             'summary' => $summary,
@@ -645,12 +882,11 @@ class RechargeClass extends BaseClass
         return $data;
     }
 
-    private static function getPaidQuery($mainId, $shopId, $start, $end)
+    private static function getPaidQuery($shopId, $start, $end)
     {
         $model = self::getActiveRecord();
         return $model::find()
             ->where([
-                'mainId' => $mainId,
                 'shopId' => $shopId,
                 'payStatus' => 1,
                 'status' => 1,
@@ -658,15 +894,15 @@ class RechargeClass extends BaseClass
             ->andWhere(['between', 'addTime', $start, $end]);
     }
 
-    private static function getRechargeStatSummary($mainId, $shopId, $start, $end)
+    private static function getRechargeStatSummary($shopId, $start, $end)
     {
-        $row = self::getPaidQuery($mainId, $shopId, $start, $end)
+        $row = self::getPaidQuery($shopId, $start, $end)
             ->select([
                 'rechargeCount' => new Expression('SUM(CASE WHEN IFNULL(amount, 0) + IFNULL(giveAmount, 0) > 0 THEN 1 ELSE 0 END)'),
-                'reduceCount' => new Expression('SUM(CASE WHEN IFNULL(settleAmount, 0) > 0 THEN 1 ELSE 0 END)'),
+                'reduceCount' => new Expression('SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN 1 ELSE 0 END)'),
                 'realAmount' => new Expression('IFNULL(SUM(amount), 0)'),
                 'giveAmount' => new Expression('IFNULL(SUM(giveAmount), 0)'),
-                'reduceAmount' => new Expression('IFNULL(SUM(settleAmount), 0)'),
+                'reduceAmount' => new Expression('IFNULL(SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN amount ELSE 0 END), 0)'),
             ])
             ->asArray()
             ->one();
@@ -683,24 +919,25 @@ class RechargeClass extends BaseClass
         ];
     }
 
-    private static function getRechargeStatChannelList($mainId, $shopId, $start, $end)
+    private static function getRechargeStatChannelList($shopId, $start, $end)
     {
-        $rows = self::getPaidQuery($mainId, $shopId, $start, $end)
+        $rows = self::getPaidQuery($shopId, $start, $end)
             ->select([
-                'channelKey' => new Expression("IF(onlinePay = 2, 'system', CONCAT('pay_', payWay))"),
+                'channelKey' => new Expression("IF(onlinePay = 2, CONCAT('online_', payWay), CONCAT('pay_', payWay))"),
                 'rechargeCount' => new Expression('SUM(CASE WHEN IFNULL(amount, 0) + IFNULL(giveAmount, 0) > 0 THEN 1 ELSE 0 END)'),
-                'reduceCount' => new Expression('SUM(CASE WHEN IFNULL(settleAmount, 0) > 0 THEN 1 ELSE 0 END)'),
+                'reduceCount' => new Expression('SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN 1 ELSE 0 END)'),
                 'realAmount' => new Expression('IFNULL(SUM(amount), 0)'),
                 'giveAmount' => new Expression('IFNULL(SUM(giveAmount), 0)'),
-                'reduceAmount' => new Expression('IFNULL(SUM(settleAmount), 0)'),
+                'reduceAmount' => new Expression('IFNULL(SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN amount ELSE 0 END), 0)'),
             ])
-            ->groupBy(new Expression("IF(onlinePay = 2, 'system', CONCAT('pay_', payWay))"))
+            ->groupBy(new Expression("IF(onlinePay = 2, CONCAT('online_', payWay), CONCAT('pay_', payWay))"))
             ->asArray()
             ->all();
 
         $payWayName = dict::getDict('payWayName');
         $defaultList = [
-            'system' => '系统',
+            'online_0' => '线上微信',
+            'online_1' => '线上支付宝',
             'pay_0' => '线下微信',
             'pay_1' => '线下支付宝',
             'pay_4' => '现金',
@@ -727,17 +964,17 @@ class RechargeClass extends BaseClass
         return $list;
     }
 
-    private static function getRechargeStatCustomerList($mainId, $shopId, $start, $end)
+    private static function getRechargeStatCustomerList($shopId, $start, $end)
     {
-        $rows = self::getPaidQuery($mainId, $shopId, $start, $end)
+        $rows = self::getPaidQuery($shopId, $start, $end)
             ->select([
                 'customId',
                 'customName',
                 'rechargeCount' => new Expression('SUM(CASE WHEN IFNULL(amount, 0) + IFNULL(giveAmount, 0) > 0 THEN 1 ELSE 0 END)'),
-                'reduceCount' => new Expression('SUM(CASE WHEN IFNULL(settleAmount, 0) > 0 THEN 1 ELSE 0 END)'),
+                'reduceCount' => new Expression('SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN 1 ELSE 0 END)'),
                 'realAmount' => new Expression('IFNULL(SUM(amount), 0)'),
                 'giveAmount' => new Expression('IFNULL(SUM(giveAmount), 0)'),
-                'reduceAmount' => new Expression('IFNULL(SUM(settleAmount), 0)'),
+                'reduceAmount' => new Expression('IFNULL(SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN amount ELSE 0 END), 0)'),
             ])
             ->groupBy(['customId', 'customName'])
             ->orderBy(new Expression('IFNULL(SUM(amount), 0) + IFNULL(SUM(giveAmount), 0) DESC'))

+ 1 - 1
common/components/payUtil.php

@@ -32,7 +32,7 @@ class payUtil
         switch ($capitalType) {
             case dict::getDict('capitalType', 'xhRecharge', 'id'):
                 //散客向花店充值
-                RechargeClass::thirdPay($payWay, $orderSn, $totalFee, $attach);
+                RechargeClass::thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId);
                 break;
             case dict::getDict('capitalType', 'xhPurchase', 'id'):
                 //零售采购

+ 38 - 0
scripts/sql/optimize_xh_recharge_hd_stat.sql

@@ -0,0 +1,38 @@
+-- =============================================================================
+-- hdApp 充值记录/统计查询索引建议 - xhRecharge
+-- =============================================================================
+-- 当前查询形态:
+-- 1. /recharge/list:
+--    shopId + status,按 addTime DESC,id DESC 分页;
+--    可选 customId / onlinePay + payWay / isRefund。
+-- 2. /recharge/stat:
+--    shopId + payStatus + status + addTime 时间范围;
+--    按渠道或客户聚合,并用 isRefund 统计减少金额。
+--
+-- 当前表已有索引:
+-- PRIMARY(id), orderSn(orderSn), custom_status(customId,status),
+-- main_custom_status(mainId,customId,status)。
+-- 这些索引缺少 shopId、payStatus、addTime、isRefund,对当前 hdApp 统计和分页查询帮助有限。
+--
+-- 执行前建议先在目标环境 EXPLAIN /recharge/list 与 /recharge/stat 的 SQL;
+-- 大表请在低峰期执行,并按 MySQL 版本确认 ALGORITHM/LOCK 支持情况。
+-- =============================================================================
+
+ALTER TABLE `xhRecharge`
+    ADD COLUMN `isRefund` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否退款:0不是 1是' AFTER status;
+
+ALTER TABLE `xhRecharge`
+    ADD INDEX `idx_hd_recharge_list` (`shopId`, `status`, `addTime`),
+    ALGORITHM = INPLACE,
+    LOCK = NONE;
+
+ALTER TABLE `xhRecharge`
+    ADD INDEX `idx_hd_recharge_stat` (`shopId`, `payStatus`, `status`, `addTime`),
+    ALGORITHM = INPLACE,
+    LOCK = NONE;
+
+-- 如果客户维度明细页仍然慢,再评估增加这个索引;不要在未验证前盲目增加写入开销。
+-- ALTER TABLE `xhRecharge`
+--     ADD INDEX `idx_hd_recharge_custom_list` (`shopId`, `customId`, `status`, `addTime`),
+--     ALGORITHM = INPLACE,
+--     LOCK = NONE;