Bladeren bron

充值、扫码付款订单确认收货回调

ouyang 1 dag geleden
bovenliggende
commit
0d2e54dd9b

+ 38 - 0
app-pt/controllers/WxController.php

@@ -92,6 +92,13 @@ class WxController extends PublicController
             Yii::$app->end();
         }
         $postObj = simplexml_load_string($msg, 'SimpleXMLElement', LIBXML_NOCDATA);
+        // 解密后完整报文(事件/消息排查,尤其确认收货结算)
+        Yii::debug([
+            'scene' => 'wx-open-event-decrypt',
+            'style' => $style,
+            'appId' => $appId,
+            'parsed' => json_decode(json_encode($postObj), true),
+        ], 'wx-event');
         //巨坑,要转string才行
         $this->fromUsername = (string)$postObj->FromUserName; // 发送方账号 openId
         $this->toUsername = $postObj->ToUserName; // 开发者微信账号
@@ -113,6 +120,22 @@ class WxController extends PublicController
         $MediaId = $postObj->MediaId;//图片媒体id
         $Content = trim($postObj->Content); // 消息内容
 
+        // 关键事件字段再打一条,方便过滤结算类推送
+        if ((string)$this->msgType === 'event') {
+            Yii::debug([
+                'scene' => 'wx-open-event-fields',
+                'style' => $style,
+                'Event' => (string)$Event,
+                'MsgType' => (string)$this->msgType,
+                'FromUserName' => (string)$this->fromUsername,
+                'merchant_trade_no' => trim((string)($postObj->merchant_trade_no ?? '')),
+                'transaction_id' => trim((string)($postObj->transaction_id ?? '')),
+                'estimated_settlement_time' => trim((string)($postObj->estimated_settlement_time ?? '')),
+                'settlement_time' => trim((string)($postObj->settlement_time ?? '')),
+                'confirm_receive_method' => trim((string)($postObj->confirm_receive_method ?? '')),
+                'confirm_receive_time' => trim((string)($postObj->confirm_receive_time ?? '')),
+            ], 'wx-event');
+        }
 
         if ($style == dict::getDict('ptStyle', 'hd')) {
             //零售
@@ -235,6 +258,21 @@ class WxController extends PublicController
             if ($this->msgType == 'event') {
 
                 switch ($Event) {
+                    case 'trade_manage_remind_access_api':
+                        noticeUtil::push('trade_manage_remind_access_api', '15280215347');
+                        break;
+                    case 'trade_manage_remind_shipping':
+                        noticeUtil::push('trade_manage_remind_shipping', '15280215347');
+                        break;
+                    case 'trade_manage_order_settlement':
+                        // api.pt 落在本仓库 app-pt:确认收货/超时后有 settlement_time 才解冻入账
+                        try {
+                            \bizMall\order\classes\WxFreezeBalanceClass::handleMallSettlementEvent($postObj);
+                        } catch (\Throwable $e) {
+                            noticeUtil::push('mall结算处理异常:' . $e->getMessage(), '15280215347');
+                            Yii::error('mall trade_manage_order_settlement: ' . $e->getMessage(), 'wx-settle');
+                        }
+                        break;
                     //关注
                     case 'subscribe':
                         $text = $this->replyTextContent("等您好久,终于等到您 /::)");

+ 46 - 4
biz-hd/order/classes/ScanPayClass.php

@@ -86,7 +86,7 @@ class ScanPayClass extends BaseClass
         return self::add($data, true);
     }
 
-    public static function thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId)
+    public static function thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId, $accTradeNo = '')
     {
         $order = self::getByCondition(['orderSn' => $orderSn], true);
         if (empty($order)) {
@@ -107,9 +107,46 @@ class ScanPayClass extends BaseClass
         $order->payStatus = 1;
         $order->status = 2;
         $order->payTime = $date;
-        $order->returnCode = $transactionId;
-        $order->save();
+        $order->payWay = $payWay;
+        // returnCode 仍存拉卡拉 trade_no(退款用);thirdPayNo 存微信 transaction_id(发货用)
+        $order->returnCode = mb_substr(trim((string)$transactionId), 0, 50);
+        $accTradeNo = trim((string)$accTradeNo);
+        if ($accTradeNo !== '') {
+            $order->thirdPayNo = mb_substr($accTradeNo, 0, 50);
+        }
+        $order->save(false);
         self::payAfter($order);
+        self::tryWxVirtualShipping($order, $accTradeNo);
+    }
+
+    /**
+     * mallApp 微信支付扫码付款成功后向微信录入虚拟发货。
+     * 失败只记日志,不回滚冻结/入账。
+     */
+    public static function tryWxVirtualShipping($order, $accTradeNo = '')
+    {
+        try {
+            \bizMall\order\classes\WxOrderShippingClass::uploadVirtualByScanPay($order, $accTradeNo);
+        } catch (\Throwable $e) {
+            Yii::error('扫码付款微信虚拟发货异常: ' . $e->getMessage(), 'wx-order-shipping');
+        }
+    }
+
+    /**
+     * 支付回调 10 秒去重命中时补录发货。
+     */
+    public static function tryWxVirtualShippingByOrderSn($orderSn, $accTradeNo = '')
+    {
+        $order = self::getByCondition(['orderSn' => $orderSn], true);
+        if (empty($order)) {
+            return;
+        }
+        $accTradeNo = trim((string)$accTradeNo);
+        if ($accTradeNo !== '' && trim((string)($order->thirdPayNo ?? '')) === '') {
+            $order->thirdPayNo = mb_substr($accTradeNo, 0, 50);
+            $order->save(false);
+        }
+        self::tryWxVirtualShipping($order, $accTradeNo);
     }
 
     public static function payAfter($order)
@@ -118,8 +155,13 @@ class ScanPayClass extends BaseClass
         $mainId = $order->mainId;
         $shopId = $order->shopId;
         $amount = $order->actPrice ?? 0;
-        $main = MainClass::getById($mainId, true);
+        $main = MainClass::getLockById($mainId);
         $shop = ShopClass::getLockById($shopId);
+        // mallApp 微信扫码:先进冻结池,等确认收货/结算后再入可提现余额
+        if (\bizMall\order\classes\WxFreezeBalanceClass::shouldFreezeMallWxScanPay($order)) {
+            \bizMall\order\classes\WxFreezeBalanceClass::freezeOnScanPay($main, $shop, $order);
+            return;
+        }
         ShopClass::customScanPayAddBalance($main, $shop, $amount, $order, $capitalType);
     }
 

+ 13 - 1
biz-hd/order/classes/ScanPayRefundClass.php

@@ -132,7 +132,19 @@ class ScanPayRefundClass extends BaseClass
         $order->save();
 
         $capitalType = dict::getDict('capitalType', 'scanPayRefund', 'id');
-        ShopClass::customScanPayRefundReduceBalance($main, $shop, $refundPrice, $order, $refund, $capitalType);
+        // 确认收货前:扣冻结池;已结算入账:仍扣可提现余额
+        $needReduce = \bizMall\order\classes\WxFreezeBalanceClass::unfreezeOnScanPayRefund(
+            $main,
+            $shop,
+            $order,
+            $refund,
+            $refundPrice
+        );
+        if (bccomp((string)$needReduce, '0', 2) > 0) {
+            $main = MainClass::getLockById($mainId);
+            $shop = ShopClass::getLockById($shopId);
+            ShopClass::customScanPayRefundReduceBalance($main, $shop, $needReduce, $order, $refund, $capitalType);
+        }
 
         return $refund;
     }

+ 84 - 0
biz-mall/order/classes/OrderWxExtClass.php

@@ -0,0 +1,84 @@
+<?php
+
+/**
+ * 零售单微信扩展表读写。
+ * 发货监听器更新录入状态;支付冻结/售后退款改 freezeAmount,结算推送也走此表防重入账。
+ */
+
+namespace bizMall\order\classes;
+
+use bizMall\base\classes\BaseClass;
+
+class OrderWxExtClass extends BaseClass
+{
+    public static $baseFile = '\bizMall\order\models\OrderWxExt';
+
+    const FREEZE_NONE = 0;
+    const FREEZE_ING = 1;
+    const FREEZE_DONE = 2;
+
+    // 按零售单主键取扩展行,没有则返回空数组
+    public static function getByOrderId($orderId)
+    {
+        $orderId = (int)$orderId;
+        if ($orderId <= 0) {
+            return [];
+        }
+        $row = self::getByCondition(['orderId' => $orderId]);
+        return empty($row) ? [] : $row;
+    }
+
+    /**
+     * 支付成功写入本单冻结金额。已有扩展行则只改冻结字段,保留发货录入状态。
+     * 金额用 freezeAmount,与现网 xhOrderWxExt 字段一致。
+     */
+    public static function applyFreeze($orderId, $orderSn, $amount)
+    {
+        $orderId = (int)$orderId;
+        if ($orderId <= 0) {
+            return;
+        }
+        $amount = bcadd((string)$amount, '0', 2);
+        $now = date('Y-m-d H:i:s');
+        $data = [
+            'orderId' => $orderId,
+            'orderSn' => (string)$orderSn,
+            'freezeStatus' => self::FREEZE_ING,
+            'freezeAmount' => $amount,
+        ];
+        $ext = self::getByOrderId($orderId);
+        if (empty($ext)) {
+            $data['addTime'] = $now;
+            self::add($data);
+            return;
+        }
+        self::updateById($ext['id'], $data);
+    }
+
+    /**
+     * 售后解冻:本单 freezeAmount 减少,扣到 0 则标记已解冻。
+     * @return string 解冻后剩余冻结金额
+     */
+    public static function reduceFreeze($orderId, $unfreezeAmount)
+    {
+        $ext = self::getByOrderId($orderId);
+        if (empty($ext)) {
+            return '0.00';
+        }
+        $current = bcadd((string)($ext['freezeAmount'] ?? '0'), '0', 2);
+        $unfreeze = bcadd((string)$unfreezeAmount, '0', 2);
+        if (bccomp($unfreeze, $current, 2) === 1) {
+            $unfreeze = $current;
+        }
+        $remain = bcsub($current, $unfreeze, 2);
+        if (bccomp($remain, '0', 2) < 0) {
+            $remain = '0.00';
+        }
+        $freezeStatus = bccomp($remain, '0', 2) <= 0 ? self::FREEZE_DONE : self::FREEZE_ING;
+        self::updateById($ext['id'], [
+            'freezeAmount' => $remain,
+            'freezeStatus' => $freezeStatus,
+        ]);
+        return $remain;
+    }
+}

+ 226 - 0
biz-mall/order/classes/RechargeWxExtClass.php

@@ -0,0 +1,226 @@
+<?php
+
+/**
+ * 充值单微信扩展表读写。
+ * 支付冻结、虚拟发货、结算入账、退款解冻都改此表当前状态;xhWxFreezeChange / shippingLog 只记流水。
+ * 发货失败补录靠 RabbitMQ 延迟消息;nextRetryTime/shipRetryCount 仅作状态与运维查看。
+ */
+
+namespace bizMall\order\classes;
+
+use bizMall\base\classes\BaseClass;
+
+class RechargeWxExtClass extends BaseClass
+{
+    public static $baseFile = '\bizMall\order\models\RechargeWxExt';
+
+    const SHIP_NONE = 0;
+    const SHIP_OK = 1;
+    const SHIP_FAIL = 2;
+
+    const SETTLE_UNKNOWN = 0;
+    const SETTLE_WAIT = 1;
+    const SETTLE_DONE = 2;
+
+    const FREEZE_NONE = 0;
+    const FREEZE_ING = 1;
+    const FREEZE_DONE = 2;
+
+    /** 补录最大次数,防止永久 10060001 刷接口 */
+    const MAX_SHIP_RETRY = 30;
+
+    /** 首次/每次失败后默认延迟秒数(等微信支付单入库) */
+    const RETRY_DELAY_SECONDS = 60;
+
+    // 按充值单主键取扩展行
+    public static function getByRechargeId($rechargeId)
+    {
+        $rechargeId = (int)$rechargeId;
+        if ($rechargeId <= 0) {
+            return [];
+        }
+        $row = self::getByCondition(['rechargeId' => $rechargeId]);
+        return empty($row) ? [] : $row;
+    }
+
+    // 按商户单号定位(结算推送 merchant_trade_no)
+    public static function getByOrderSn($orderSn)
+    {
+        $orderSn = trim((string)$orderSn);
+        if ($orderSn === '') {
+            return [];
+        }
+        $row = self::getByCondition(['orderSn' => $orderSn]);
+        return empty($row) ? [] : $row;
+    }
+
+    /**
+     * 支付成功写入本单冻结金额。已有行只改冻结字段,保留发货状态。
+     * 虚拟商品 logisticsType 固定为 3。
+     * nextRetryTime 仅在发货失败可重试时由 markShipResult 写入,供运维查看;实际补录靠 RabbitMQ。
+     */
+    public static function applyFreeze($rechargeId, $orderSn, $amount)
+    {
+        $rechargeId = (int)$rechargeId;
+        if ($rechargeId <= 0) {
+            return;
+        }
+        $amount = bcadd((string)$amount, '0', 2);
+        $now = date('Y-m-d H:i:s');
+        $data = [
+            'rechargeId' => $rechargeId,
+            'orderSn' => (string)$orderSn,
+            'freezeStatus' => self::FREEZE_ING,
+            'freezeAmount' => $amount,
+            'logisticsType' => 3,
+            'balancePosted' => 0,
+        ];
+        $ext = self::getByRechargeId($rechargeId);
+        if (empty($ext)) {
+            $data['addTime'] = $now;
+            $data['shipRetryCount'] = 0;
+            self::add($data);
+            return;
+        }
+        self::updateById($ext['id'], $data);
+    }
+
+    /**
+     * 虚拟发货录入结果回写;成功则标待结算并移出补录队列。
+     * 可重试失败:累加 shipRetryCount 并推后 nextRetryTime;永久缺参:nextRetryTime 置空。
+     */
+    public static function markShipResult($rechargeId, $orderSn, $ok, $errMsg = '', $logisticsType = 3)
+    {
+        $rechargeId = (int)$rechargeId;
+        if ($rechargeId <= 0) {
+            return;
+        }
+        $now = date('Y-m-d H:i:s');
+        $errMsg = mb_substr((string)$errMsg, 0, 500);
+        $ext = self::getByRechargeId($rechargeId);
+
+        $data = [
+            'rechargeId' => $rechargeId,
+            'orderSn' => (string)$orderSn,
+            'shipStatus' => $ok ? self::SHIP_OK : self::SHIP_FAIL,
+            'logisticsType' => (int)$logisticsType,
+            'errMsg' => $errMsg,
+        ];
+        if ($ok) {
+            $data['settleStatus'] = self::SETTLE_WAIT;
+            $data['nextRetryTime'] = null;
+        } else {
+            $retryCount = (int)($ext['shipRetryCount'] ?? 0) + 1;
+            $data['shipRetryCount'] = $retryCount;
+            if (self::isPermanentShipError($errMsg) || $retryCount >= self::MAX_SHIP_RETRY) {
+                $data['nextRetryTime'] = null;
+            } else {
+                $data['nextRetryTime'] = self::calcNextRetryTime($retryCount);
+            }
+        }
+
+        if (empty($ext)) {
+            $data['addTime'] = $now;
+            if (!isset($data['shipRetryCount'])) {
+                $data['shipRetryCount'] = 0;
+            }
+            self::add($data);
+            return;
+        }
+        self::updateById($ext['id'], $data);
+    }
+
+    // 缺参类错误不会因等待好转,不要进补录队列
+    public static function isPermanentShipError($errMsg)
+    {
+        $errMsg = (string)$errMsg;
+        if ($errMsg === '') {
+            return false;
+        }
+        $hints = [
+            '缺少商城用户userId',
+            '缺少商城小程序openid',
+            '缺少微信transaction_id',
+            '没有找到商城小程序配置',
+        ];
+        foreach ($hints as $hint) {
+            if (mb_strpos($errMsg, $hint) !== false) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // 失败后下次补录时间:60s 起步,随次数封顶 10 分钟
+    public static function calcNextRetryTime($retryCount)
+    {
+        $retryCount = max(0, (int)$retryCount);
+        $delay = min(600, self::RETRY_DELAY_SECONDS * (1 + $retryCount));
+        return date('Y-m-d H:i:s', time() + $delay);
+    }
+
+    /**
+     * 「将要结算」推送:只记预计时间,仍为待结算,不加余额。
+     */
+    public static function markEstimatedSettlement($rechargeId, $estimatedTime)
+    {
+        $ext = self::getByRechargeId($rechargeId);
+        if (empty($ext)) {
+            return;
+        }
+        $data = [
+            'settleStatus' => self::SETTLE_WAIT,
+        ];
+        if ($estimatedTime !== '') {
+            $data['estimatedSettlementTime'] = $estimatedTime;
+        }
+        self::updateById($ext['id'], $data);
+    }
+
+    /**
+     * 已经结算:标已结算、已解冻、已入账,并写实际结算时间。
+     */
+    public static function markSettled($rechargeId, $settlementTime)
+    {
+        $ext = self::getByRechargeId($rechargeId);
+        if (empty($ext)) {
+            return;
+        }
+        self::updateById($ext['id'], [
+            'settleStatus' => self::SETTLE_DONE,
+            'freezeStatus' => self::FREEZE_DONE,
+            'freezeAmount' => '0.00',
+            'balancePosted' => 1,
+            'settlementTime' => $settlementTime !== '' ? $settlementTime : date('Y-m-d H:i:s'),
+            'nextRetryTime' => null,
+        ]);
+    }
+
+    /**
+     * 未入账退款解冻:本单 freezeAmount 清零并标已解冻,不改 balancePosted。
+     */
+    public static function markUnfreezeOnRefund($rechargeId, $unfreezeAmount)
+    {
+        $ext = self::getByRechargeId($rechargeId);
+        if (empty($ext)) {
+            return '0.00';
+        }
+        $current = bcadd((string)($ext['freezeAmount'] ?? '0'), '0', 2);
+        $unfreeze = bcadd((string)$unfreezeAmount, '0', 2);
+        if (bccomp($unfreeze, $current, 2) === 1) {
+            $unfreeze = $current;
+        }
+        $remain = bcsub($current, $unfreeze, 2);
+        if (bccomp($remain, '0', 2) < 0) {
+            $remain = '0.00';
+        }
+        $freezeStatus = bccomp($remain, '0', 2) <= 0 ? self::FREEZE_DONE : self::FREEZE_ING;
+        self::updateById($ext['id'], [
+            'freezeAmount' => $remain,
+            'freezeStatus' => $freezeStatus,
+            // 退款后不再补录发货
+            'nextRetryTime' => null,
+        ]);
+        return $remain;
+    }
+}

+ 223 - 0
biz-mall/order/classes/ScanPayWxExtClass.php

@@ -0,0 +1,223 @@
+<?php
+
+/**
+ * 扫码付款微信扩展表读写。
+ * 支付冻结、虚拟发货、结算入账、退款解冻改此表当前状态;流水记 xhWxFreezeChange / shippingLog。
+ * 发货失败补录靠 RabbitMQ;nextRetryTime/shipRetryCount 供运维与延迟秒数对齐。
+ */
+
+namespace bizMall\order\classes;
+
+use bizMall\base\classes\BaseClass;
+
+class ScanPayWxExtClass extends BaseClass
+{
+    public static $baseFile = '\bizMall\order\models\ScanPayWxExt';
+
+    const SHIP_NONE = 0;
+    const SHIP_OK = 1;
+    const SHIP_FAIL = 2;
+
+    const SETTLE_UNKNOWN = 0;
+    const SETTLE_WAIT = 1;
+    const SETTLE_DONE = 2;
+
+    const FREEZE_NONE = 0;
+    const FREEZE_ING = 1;
+    const FREEZE_DONE = 2;
+
+    /** 补录最大次数,防止永久 10060001 刷接口 */
+    const MAX_SHIP_RETRY = 30;
+
+    /** 首次/每次失败后默认延迟秒数 */
+    const RETRY_DELAY_SECONDS = 60;
+
+    // 按扫码付款主键取扩展行
+    public static function getByScanPayId($scanPayId)
+    {
+        $scanPayId = (int)$scanPayId;
+        if ($scanPayId <= 0) {
+            return [];
+        }
+        $row = self::getByCondition(['scanPayId' => $scanPayId]);
+        return empty($row) ? [] : $row;
+    }
+
+    // 按商户单号定位(结算推送 merchant_trade_no)
+    public static function getByOrderSn($orderSn)
+    {
+        $orderSn = trim((string)$orderSn);
+        if ($orderSn === '') {
+            return [];
+        }
+        $row = self::getByCondition(['orderSn' => $orderSn]);
+        return empty($row) ? [] : $row;
+    }
+
+    /**
+     * 支付成功写入本单冻结金额。已有行只改冻结字段,保留发货状态。
+     * 虚拟商品 logisticsType 固定为 3。
+     */
+    public static function applyFreeze($scanPayId, $orderSn, $amount)
+    {
+        $scanPayId = (int)$scanPayId;
+        if ($scanPayId <= 0) {
+            return;
+        }
+        $amount = bcadd((string)$amount, '0', 2);
+        $now = date('Y-m-d H:i:s');
+        $data = [
+            'scanPayId' => $scanPayId,
+            'orderSn' => (string)$orderSn,
+            'freezeStatus' => self::FREEZE_ING,
+            'freezeAmount' => $amount,
+            'logisticsType' => 3,
+            'balancePosted' => 0,
+        ];
+        $ext = self::getByScanPayId($scanPayId);
+        if (empty($ext)) {
+            $data['addTime'] = $now;
+            $data['shipRetryCount'] = 0;
+            self::add($data);
+            return;
+        }
+        self::updateById($ext['id'], $data);
+    }
+
+    /**
+     * 虚拟发货录入结果回写;成功则标待结算。
+     * 可重试失败:累加 shipRetryCount 并推后 nextRetryTime。
+     */
+    public static function markShipResult($scanPayId, $orderSn, $ok, $errMsg = '', $logisticsType = 3)
+    {
+        $scanPayId = (int)$scanPayId;
+        if ($scanPayId <= 0) {
+            return;
+        }
+        $errMsg = mb_substr((string)$errMsg, 0, 500);
+        $ext = self::getByScanPayId($scanPayId);
+
+        $data = [
+            'scanPayId' => $scanPayId,
+            'orderSn' => (string)$orderSn,
+            'shipStatus' => $ok ? self::SHIP_OK : self::SHIP_FAIL,
+            'logisticsType' => (int)$logisticsType,
+            'errMsg' => $errMsg,
+        ];
+        if ($ok) {
+            $data['settleStatus'] = self::SETTLE_WAIT;
+            $data['nextRetryTime'] = null;
+        } else {
+            $retryCount = (int)($ext['shipRetryCount'] ?? 0) + 1;
+            $data['shipRetryCount'] = $retryCount;
+            if (self::isPermanentShipError($errMsg) || $retryCount >= self::MAX_SHIP_RETRY) {
+                $data['nextRetryTime'] = null;
+            } else {
+                $data['nextRetryTime'] = self::calcNextRetryTime($retryCount);
+            }
+        }
+
+        if (empty($ext)) {
+            $data['addTime'] = date('Y-m-d H:i:s');
+            if (!isset($data['shipRetryCount'])) {
+                $data['shipRetryCount'] = 0;
+            }
+            self::add($data);
+            return;
+        }
+        self::updateById($ext['id'], $data);
+    }
+
+    // 缺参类错误不会因等待好转,不要进补录队列
+    public static function isPermanentShipError($errMsg)
+    {
+        $errMsg = (string)$errMsg;
+        if ($errMsg === '') {
+            return false;
+        }
+        $hints = [
+            '缺少商城用户userId',
+            '缺少商城小程序openid',
+            '缺少微信transaction_id',
+            '没有找到商城小程序配置',
+        ];
+        foreach ($hints as $hint) {
+            if (mb_strpos($errMsg, $hint) !== false) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // 失败后下次补录时间:60s 起步,随次数封顶 10 分钟
+    public static function calcNextRetryTime($retryCount)
+    {
+        $retryCount = max(0, (int)$retryCount);
+        $delay = min(600, self::RETRY_DELAY_SECONDS * (1 + $retryCount));
+        return date('Y-m-d H:i:s', time() + $delay);
+    }
+
+    /**
+     * 「将要结算」推送:只记预计时间,仍为待结算,不加余额。
+     */
+    public static function markEstimatedSettlement($scanPayId, $estimatedTime)
+    {
+        $ext = self::getByScanPayId($scanPayId);
+        if (empty($ext)) {
+            return;
+        }
+        $data = [
+            'settleStatus' => self::SETTLE_WAIT,
+        ];
+        if ($estimatedTime !== '') {
+            $data['estimatedSettlementTime'] = $estimatedTime;
+        }
+        self::updateById($ext['id'], $data);
+    }
+
+    /**
+     * 已经结算:标已结算、已解冻、已入账。
+     */
+    public static function markSettled($scanPayId, $settlementTime)
+    {
+        $ext = self::getByScanPayId($scanPayId);
+        if (empty($ext)) {
+            return;
+        }
+        self::updateById($ext['id'], [
+            'settleStatus' => self::SETTLE_DONE,
+            'freezeStatus' => self::FREEZE_DONE,
+            'freezeAmount' => '0.00',
+            'balancePosted' => 1,
+            'settlementTime' => $settlementTime !== '' ? $settlementTime : date('Y-m-d H:i:s'),
+            'nextRetryTime' => null,
+        ]);
+    }
+
+    /**
+     * 未入账退款解冻:本单 freezeAmount 减少,不改 balancePosted。
+     */
+    public static function markUnfreezeOnRefund($scanPayId, $unfreezeAmount)
+    {
+        $ext = self::getByScanPayId($scanPayId);
+        if (empty($ext)) {
+            return '0.00';
+        }
+        $current = bcadd((string)($ext['freezeAmount'] ?? '0'), '0', 2);
+        $unfreeze = bcadd((string)$unfreezeAmount, '0', 2);
+        if (bccomp($unfreeze, $current, 2) === 1) {
+            $unfreeze = $current;
+        }
+        $remain = bcsub($current, $unfreeze, 2);
+        if (bccomp($remain, '0', 2) < 0) {
+            $remain = '0.00';
+        }
+        $freezeStatus = bccomp($remain, '0', 2) <= 0 ? self::FREEZE_DONE : self::FREEZE_ING;
+        self::updateById($ext['id'], [
+            'freezeAmount' => $remain,
+            'freezeStatus' => $freezeStatus,
+            'nextRetryTime' => null,
+        ]);
+        return $remain;
+    }
+}

+ 558 - 0
biz-mall/order/classes/WxFreezeBalanceClass.php

@@ -0,0 +1,558 @@
+<?php
+
+/**
+ * mallApp 微信支付待结算冻结编排。
+ * 零售:支付冻 wxFreezeBalance,售后未入账解冻;充值:支付冻、结算入账、退款解冻。
+ */
+
+namespace bizMall\order\classes;
+
+use biz\shop\classes\ShopClass;
+use bizHd\order\classes\OrderClass as HdOrderClass;
+use bizHd\recharge\classes\RechargeClass;
+use bizHd\shop\classes\MainClass;
+use common\components\dict;
+use common\components\noticeUtil;
+use Yii;
+
+class WxFreezeBalanceClass
+{
+    const FREEZE_NONE = 0;
+    const FREEZE_ING = 1;
+    const FREEZE_DONE = 2;
+
+    // mallApp 微信线上付才进小程序订单管理冻结池,其它支付仍立即入账
+    public static function shouldFreezeMallWxOrder($order)
+    {
+        if (empty($order)) {
+            return false;
+        }
+        $fromType = is_object($order) ? (int)($order->fromType ?? 0) : (int)($order['fromType'] ?? 0);
+        $payWay = is_object($order) ? (int)($order->payWay ?? -1) : (int)($order['payWay'] ?? -1);
+        $mallFromType = (int)dict::getDict('fromType', 'mall');
+        $wxPayWay = (int)dict::getDict('payWay', 'wxPay');
+        return $fromType === $mallFromType && $payWay === $wxPayWay;
+    }
+
+    /**
+     * 商城余额充值是否走微信冻结池。
+     * 条件:微信支付 + 线上付 + 绑定商城 userId(mallApp 充值单)。
+     */
+    public static function shouldFreezeMallWxRecharge($recharge)
+    {
+        if (empty($recharge)) {
+            return false;
+        }
+        $payWay = is_object($recharge) ? (int)($recharge->payWay ?? -1) : (int)($recharge['payWay'] ?? -1);
+        $onlinePay = is_object($recharge) ? (int)($recharge->onlinePay ?? 0) : (int)($recharge['onlinePay'] ?? 0);
+        $userId = is_object($recharge) ? (int)($recharge->userId ?? 0) : (int)($recharge['userId'] ?? 0);
+        $wxPayWay = (int)dict::getDict('payWay', 'wxPay');
+        return $payWay === $wxPayWay && $onlinePay === 2 && $userId > 0;
+    }
+
+    /**
+     * 充值支付成功:只冻 wxFreezeBalance,写冻结明细与 RechargeWxExt,不改 balance。
+     */
+    public static function freezeOnRechargePay($main, $shop, $recharge)
+    {
+        $amount = bcadd((string)($recharge->amount ?? '0'), '0', 2);
+        if (bccomp($amount, '0', 2) <= 0) {
+            return;
+        }
+        $rechargeId = (int)($recharge->id ?? 0);
+        $orderSn = (string)($recharge->orderSn ?? '');
+        $ext = RechargeWxExtClass::getByRechargeId($rechargeId);
+        if (!empty($ext) && (int)$ext['freezeStatus'] === self::FREEZE_ING && bccomp((string)($ext['freezeAmount'] ?? '0'), '0', 2) > 0) {
+            return;
+        }
+
+        $capitalType = (int)dict::getDict('capitalType', 'xhRecharge', 'id');
+        $custom = !empty($recharge->customName) ? $recharge->customName : '客户';
+        $event = $custom . '充值微信冻结' . floatval($amount) . '元(单号 ' . $orderSn . ')';
+        ShopClass::wxPayFreezeBalance($main, $shop, $amount, $recharge, $capitalType, $event);
+        RechargeWxExtClass::applyFreeze($rechargeId, $orderSn, $amount);
+    }
+
+    /**
+     * 充值未入账退款:只减冻结池并写解冻明细;已入账返回仍需扣 balance 的金额。
+     * @return string 仍需扣 xhMain.balance 的两位小数
+     */
+    public static function unfreezeOnRechargeRefund($main, $shop, $recharge)
+    {
+        $amount = bcadd((string)($recharge->amount ?? '0'), '0', 2);
+        if (bccomp($amount, '0', 2) <= 0) {
+            return '0.00';
+        }
+        if (!self::shouldFreezeMallWxRecharge($recharge)) {
+            return $amount;
+        }
+
+        $rechargeId = (int)($recharge->id ?? 0);
+        $orderSn = (string)($recharge->orderSn ?? '');
+        $capitalType = (int)dict::getDict('capitalType', 'xhRecharge', 'id');
+
+        // 同一充值单已有退款解冻流水则不再扣冻结
+        $exist = \biz\shop\classes\WxFreezeChangeClass::getByCondition([
+            'capitalType' => $capitalType,
+            'relateId' => $rechargeId,
+            'orderSn' => $orderSn,
+            'io' => 0,
+        ]);
+        if (!empty($exist) && strpos((string)($exist['event'] ?? ''), '退款') !== false) {
+            return '0.00';
+        }
+
+        $ext = RechargeWxExtClass::getByRechargeId($rechargeId);
+        if (empty($ext)) {
+            return $amount;
+        }
+        if ((int)($ext['balancePosted'] ?? 0) === 1) {
+            return $amount;
+        }
+
+        $frozen = bcadd((string)($ext['freezeAmount'] ?? '0'), '0', 2);
+        if (bccomp($frozen, '0', 2) <= 0) {
+            return $amount;
+        }
+
+        $unfreeze = $amount;
+        if (bccomp($unfreeze, $frozen, 2) === 1) {
+            $unfreeze = $frozen;
+        }
+
+        $staffHint = '';
+        $event = '充值退款解冻' . floatval($unfreeze) . '元(单号 ' . $orderSn . ')' . $staffHint;
+        ShopClass::wxPayUnfreezeBalance($main, $shop, $unfreeze, $recharge, $capitalType, $event, $orderSn);
+        RechargeWxExtClass::markUnfreezeOnRefund($rechargeId, $unfreeze);
+
+        $needReduce = bcsub($amount, $unfreeze, 2);
+        return bccomp($needReduce, '0', 2) > 0 ? $needReduce : '0.00';
+    }
+
+    /**
+     * 商城扫码付款是否走微信冻结池。
+     * 条件:微信支付 + 绑定商城 userId(mallApp 收款码)。
+     */
+    public static function shouldFreezeMallWxScanPay($scanPay)
+    {
+        if (empty($scanPay)) {
+            return false;
+        }
+        $payWay = is_object($scanPay) ? (int)($scanPay->payWay ?? -1) : (int)($scanPay['payWay'] ?? -1);
+        $userId = is_object($scanPay) ? (int)($scanPay->userId ?? 0) : (int)($scanPay['userId'] ?? 0);
+        $wxPayWay = (int)dict::getDict('payWay', 'wxPay');
+        return $payWay === $wxPayWay && $userId > 0;
+    }
+
+    /**
+     * 扫码付款支付成功:只冻 wxFreezeBalance,写冻结明细与 ScanPayWxExt,不改 balance。
+     */
+    public static function freezeOnScanPay($main, $shop, $scanPay)
+    {
+        $amount = bcadd((string)($scanPay->actPrice ?? '0'), '0', 2);
+        if (bccomp($amount, '0', 2) <= 0) {
+            return;
+        }
+        $scanPayId = (int)($scanPay->id ?? 0);
+        $orderSn = (string)($scanPay->orderSn ?? '');
+        $ext = ScanPayWxExtClass::getByScanPayId($scanPayId);
+        if (!empty($ext) && (int)$ext['freezeStatus'] === self::FREEZE_ING && bccomp((string)($ext['freezeAmount'] ?? '0'), '0', 2) > 0) {
+            return;
+        }
+
+        $capitalType = (int)dict::getDict('capitalType', 'scanPay', 'id');
+        $custom = !empty($scanPay->customName) ? $scanPay->customName : '客户';
+        $event = $custom . '扫码付款微信冻结' . floatval($amount) . '元(单号 ' . $orderSn . ')';
+        ShopClass::wxPayFreezeBalance($main, $shop, $amount, $scanPay, $capitalType, $event);
+        ScanPayWxExtClass::applyFreeze($scanPayId, $orderSn, $amount);
+    }
+
+    /**
+     * 扫码付款未入账退款:只减冻结池并写解冻明细;已入账返回仍需扣 balance 的金额。
+     * @return string 仍需扣 xhMain.balance 的两位小数
+     */
+    public static function unfreezeOnScanPayRefund($main, $shop, $scanPay, $refund, $refundPrice)
+    {
+        $refundPrice = bcadd((string)$refundPrice, '0', 2);
+        if (bccomp($refundPrice, '0', 2) <= 0) {
+            return '0.00';
+        }
+        if (!self::shouldFreezeMallWxScanPay($scanPay)) {
+            return $refundPrice;
+        }
+
+        $scanPayId = (int)($scanPay->id ?? 0);
+        $orderSn = (string)($scanPay->orderSn ?? '');
+        $refundId = (int)(is_object($refund) ? ($refund->id ?? 0) : ($refund['id'] ?? 0));
+        $capitalType = (int)dict::getDict('capitalType', 'scanPayRefund', 'id');
+
+        // 同一退款单已有解冻流水则不再扣冻结
+        if ($refundId > 0) {
+            $exist = \biz\shop\classes\WxFreezeChangeClass::getByCondition([
+                'capitalType' => $capitalType,
+                'relateId' => $refundId,
+                'orderSn' => $orderSn,
+                'io' => 0,
+            ]);
+            if (!empty($exist)) {
+                return '0.00';
+            }
+        }
+
+        $ext = ScanPayWxExtClass::getByScanPayId($scanPayId);
+        if (empty($ext)) {
+            return $refundPrice;
+        }
+        if ((int)($ext['balancePosted'] ?? 0) === 1) {
+            return $refundPrice;
+        }
+
+        $frozen = bcadd((string)($ext['freezeAmount'] ?? '0'), '0', 2);
+        if (bccomp($frozen, '0', 2) <= 0) {
+            return $refundPrice;
+        }
+
+        $unfreeze = $refundPrice;
+        if (bccomp($unfreeze, $frozen, 2) === 1) {
+            $unfreeze = $frozen;
+        }
+
+        $event = '扫码付款退款解冻' . floatval($unfreeze) . '元(单号 ' . $orderSn . ')';
+        ShopClass::wxPayUnfreezeBalance($main, $shop, $unfreeze, $refund, $capitalType, $event, $orderSn);
+        ScanPayWxExtClass::markUnfreezeOnRefund($scanPayId, $unfreeze);
+
+        $needReduce = bcsub($refundPrice, $unfreeze, 2);
+        return bccomp($needReduce, '0', 2) > 0 ? $needReduce : '0.00';
+    }
+
+    /**
+     * 处理 mall 微信 trade_manage_order_settlement。
+     * 先匹配充值扩展,再匹配扫码付款扩展;无 settlement_time 只记待结算。
+     */
+    public static function handleMallSettlementEvent($postObj)
+    {
+        $merchantTradeNo = trim((string)($postObj->merchant_trade_no ?? ''));
+        $settlementTimeRaw = trim((string)($postObj->settlement_time ?? ''));
+        $estimatedRaw = trim((string)($postObj->estimated_settlement_time ?? ''));
+        $transactionId = trim((string)($postObj->transaction_id ?? ''));
+
+        // 结算事件解析后的业务字段(确认收货/自动确认都会进这里)
+        Yii::debug([
+            'scene' => 'mall-trade-manage-order-settlement',
+            'merchant_trade_no' => $merchantTradeNo,
+            'transaction_id' => $transactionId,
+            'settlement_time_raw' => $settlementTimeRaw,
+            'estimated_settlement_time_raw' => $estimatedRaw,
+            'confirm_receive_method' => trim((string)($postObj->confirm_receive_method ?? '')),
+            'confirm_receive_time' => trim((string)($postObj->confirm_receive_time ?? '')),
+            'raw' => json_decode(json_encode($postObj), true),
+        ], 'wx-event');
+
+        if ($merchantTradeNo === '') {
+            noticeUtil::push('mall结算事件缺少 merchant_trade_no', '15280215347');
+            return;
+        }
+
+        $estimatedTime = self::formatWxEventTime($estimatedRaw);
+        $settlementTime = self::formatWxEventTime($settlementTimeRaw);
+
+        Yii::debug([
+            'scene' => 'mall-settlement-normalized',
+            'merchant_trade_no' => $merchantTradeNo,
+            'settlementTime' => $settlementTime,
+            'estimatedTime' => $estimatedTime,
+            'willPostBalance' => $settlementTime !== '',
+        ], 'wx-event');
+
+        // 1) 充值
+        $ext = RechargeWxExtClass::getByOrderSn($merchantTradeNo);
+        if (!empty($ext) || !empty(RechargeClass::getByCondition(['orderSn' => $merchantTradeNo], true))) {
+            if (empty($ext)) {
+                $recharge = RechargeClass::getByCondition(['orderSn' => $merchantTradeNo], true);
+                $rechargeId = (int)$recharge->id;
+            } else {
+                $rechargeId = (int)$ext['rechargeId'];
+                $recharge = RechargeClass::getById($rechargeId, true);
+                if (empty($recharge)) {
+                    noticeUtil::push('mall结算有扩展表但充值单不存在 id=' . $rechargeId, '15280215347');
+                    return;
+                }
+            }
+            if ($settlementTime === '') {
+                RechargeWxExtClass::markEstimatedSettlement($rechargeId, $estimatedTime);
+                return;
+            }
+            self::settleRecharge($recharge, $settlementTime);
+            return;
+        }
+
+        // 2) 扫码付款
+        $scanExt = ScanPayWxExtClass::getByOrderSn($merchantTradeNo);
+        $scanPay = null;
+        if (!empty($scanExt)) {
+            $scanPay = \bizHd\order\classes\ScanPayClass::getById((int)$scanExt['scanPayId'], true);
+        }
+        if (empty($scanPay)) {
+            $scanPay = \bizHd\order\classes\ScanPayClass::getByCondition(['orderSn' => $merchantTradeNo], true);
+        }
+        if (empty($scanPay)) {
+            Yii::info('mall结算未匹配充值/扫码单 orderSn=' . $merchantTradeNo, 'wx-settle');
+            return;
+        }
+        $scanPayId = (int)$scanPay->id;
+        if ($settlementTime === '') {
+            ScanPayWxExtClass::markEstimatedSettlement($scanPayId, $estimatedTime);
+            return;
+        }
+        self::settleScanPay($scanPay, $settlementTime);
+    }
+
+    /**
+     * 扫码付款已结算:解冻 + customScanPayAddBalance 入账;balancePosted 防重。
+     */
+    public static function settleScanPay($scanPay, $settlementTime = '')
+    {
+        $scanPayId = (int)($scanPay->id ?? 0);
+        $orderSn = (string)($scanPay->orderSn ?? '');
+        $tkPrice = bcadd((string)($scanPay->tkPrice ?? '0'), '0', 2);
+        // 已全额退款:冻结应已在退款时解开,不能再入账
+        $actPrice = bcadd((string)($scanPay->actPrice ?? '0'), '0', 2);
+        if (bccomp($tkPrice, $actPrice, 2) >= 0 && bccomp($actPrice, '0', 2) > 0) {
+            return;
+        }
+
+        $ext = ScanPayWxExtClass::getByScanPayId($scanPayId);
+        if (!empty($ext) && (int)($ext['balancePosted'] ?? 0) === 1) {
+            return;
+        }
+
+        $shopId = (int)($scanPay->shopId ?? 0);
+        $mainId = (int)($scanPay->mainId ?? 0);
+        $shop = ShopClass::getLockById($shopId);
+        $main = MainClass::getLockById($mainId);
+        if (empty($shop) || empty($main)) {
+            noticeUtil::push('扫码付款结算入账失败,无门店/资产 orderSn=' . $orderSn, '15280215347');
+            return;
+        }
+
+        // 入账金额 = 实付 - 已退(部分退后结算只入剩余)
+        $amount = bcsub($actPrice, $tkPrice, 2);
+        if (bccomp($amount, '0', 2) <= 0) {
+            ScanPayWxExtClass::markSettled($scanPayId, $settlementTime !== '' ? $settlementTime : date('Y-m-d H:i:s'));
+            return;
+        }
+
+        $frozen = !empty($ext) ? bcadd((string)($ext['freezeAmount'] ?? '0'), '0', 2) : '0.00';
+        if (bccomp($frozen, '0', 2) <= 0) {
+            $frozen = $amount;
+        }
+
+        $capitalType = (int)dict::getDict('capitalType', 'scanPay', 'id');
+        $settledUnfreeze = \biz\shop\classes\WxFreezeChangeClass::getByCondition([
+            'capitalType' => $capitalType,
+            'relateId' => $scanPayId,
+            'orderSn' => $orderSn,
+            'io' => 0,
+        ]);
+        $alreadySettleUnfreeze = !empty($settledUnfreeze) && strpos((string)($settledUnfreeze['event'] ?? ''), '结算') !== false;
+
+        if (!$alreadySettleUnfreeze && bccomp($frozen, '0', 2) > 0) {
+            $event = '微信结算解冻入账' . floatval($frozen) . '元(扫码单 ' . $orderSn . ')';
+            ShopClass::wxPayUnfreezeBalance($main, $shop, $frozen, $scanPay, $capitalType, $event, $orderSn);
+        }
+
+        $main = MainClass::getLockById($mainId);
+        $shop = ShopClass::getLockById($shopId);
+        ShopClass::customScanPayAddBalance($main, $shop, $amount, $scanPay, $capitalType);
+        ScanPayWxExtClass::markSettled($scanPayId, $settlementTime !== '' ? $settlementTime : date('Y-m-d H:i:s'));
+    }
+
+    /**
+     * 充值已结算:解冻 + 调用现有 skRechargeAddBalance 入账;balancePosted 防重。
+     */
+    public static function settleRecharge($recharge, $settlementTime = '')
+    {
+        $rechargeId = (int)($recharge->id ?? 0);
+        $orderSn = (string)($recharge->orderSn ?? '');
+        // 花店已退款:冻结已在退款时解开,不能再 AddBalance
+        if (intval($recharge->isRefund ?? 0) === 1) {
+            return;
+        }
+        $ext = RechargeWxExtClass::getByRechargeId($rechargeId);
+        if (!empty($ext) && (int)($ext['balancePosted'] ?? 0) === 1) {
+            return;
+        }
+
+        $shopId = (int)($recharge->shopId ?? 0);
+        $mainId = (int)($recharge->mainId ?? 0);
+        $shop = ShopClass::getLockById($shopId);
+        $main = MainClass::getLockById($mainId);
+        if (empty($shop) || empty($main)) {
+            noticeUtil::push('充值结算入账失败,无门店/资产 orderSn=' . $orderSn, '15280215347');
+            return;
+        }
+
+        $amount = bcadd((string)($recharge->amount ?? '0'), '0', 2);
+        $frozen = !empty($ext) ? bcadd((string)($ext['freezeAmount'] ?? '0'), '0', 2) : '0.00';
+        if (bccomp($frozen, '0', 2) <= 0) {
+            $frozen = $amount;
+        }
+
+        $capitalType = (int)dict::getDict('capitalType', 'xhRecharge', 'id');
+        // 已有「结算解冻」流水则不再减冻结,只补入账标记(防半成功重推)
+        $settledUnfreeze = \biz\shop\classes\WxFreezeChangeClass::getByCondition([
+            'capitalType' => $capitalType,
+            'relateId' => $rechargeId,
+            'orderSn' => $orderSn,
+            'io' => 0,
+        ]);
+        $alreadySettleUnfreeze = !empty($settledUnfreeze) && strpos((string)($settledUnfreeze['event'] ?? ''), '结算') !== false;
+
+        if (!$alreadySettleUnfreeze && bccomp($frozen, '0', 2) > 0) {
+            $event = '微信结算解冻入账' . floatval($frozen) . '元(单号 ' . $orderSn . ')';
+            ShopClass::wxPayUnfreezeBalance($main, $shop, $frozen, $recharge, $capitalType, $event, $orderSn);
+        }
+
+        $main = MainClass::getLockById($mainId);
+        ShopClass::skRechargeAddBalance($main, $shop, $recharge);
+        RechargeWxExtClass::markSettled($rechargeId, $settlementTime !== '' ? $settlementTime : date('Y-m-d H:i:s'));
+    }
+
+    // 微信事件时间可能是 unix 秒或已格式化字符串
+    private static function formatWxEventTime($raw)
+    {
+        $raw = trim((string)$raw);
+        if ($raw === '' || $raw === '0') {
+            return '';
+        }
+        if (ctype_digit($raw)) {
+            $ts = (int)$raw;
+            if ($ts > 0) {
+                return date('Y-m-d H:i:s', $ts);
+            }
+            return '';
+        }
+        return $raw;
+    }
+
+    /**
+     * 支付成功:增加店铺冻结总额、写冻结明细、更新本单 freezeAmount。
+     * 已冻过则跳过,避免回调重入把冻结加两次。
+     */
+    public static function freezeOnPay($main, $shop, $order, $amount, $capitalType)
+    {
+        $amount = bcadd((string)$amount, '0', 2);
+        if (bccomp($amount, '0', 2) <= 0) {
+            return;
+        }
+        $orderId = (int)($order->id ?? 0);
+        $orderSn = (string)($order->orderSn ?? '');
+        $ext = OrderWxExtClass::getByOrderId($orderId);
+        if (!empty($ext) && (int)$ext['freezeStatus'] === self::FREEZE_ING && bccomp((string)($ext['freezeAmount'] ?? '0'), '0', 2) > 0) {
+            return;
+        }
+
+        $event = '商城微信下单冻结' . floatval($amount) . '元(单号 ' . $orderSn . ')';
+        ShopClass::wxPayFreezeBalance($main, $shop, $amount, $order, $capitalType, $event);
+        OrderWxExtClass::applyFreeze($orderId, $orderSn, $amount);
+    }
+
+    /**
+     * 售后退款解冻。
+     * 未入账:从本单冻结池扣,写解冻明细,不减 balance。
+     * 已入账或从未冻结:返回仍需扣余额的金额,由调用方走 hdSaleRefundReduceBalance。
+     * @return string 仍需扣 xhMain.balance 的两位小数字符串
+     */
+    public static function unfreezeOnRefund($main, $shop, $order, $refund, $refundPrice)
+    {
+        $refundPrice = bcadd((string)$refundPrice, '0', 2);
+        if (bccomp($refundPrice, '0', 2) <= 0) {
+            return '0.00';
+        }
+
+        $order = self::resolveOrder($order, $refund);
+        if (!self::shouldFreezeMallWxOrder($order)) {
+            return $refundPrice;
+        }
+
+        $orderId = (int)($order->id ?? 0);
+        $orderSn = (string)($order->orderSn ?? '');
+        $refundId = (int)(is_object($refund) ? ($refund->id ?? 0) : ($refund['id'] ?? 0));
+        $capitalType = (int)dict::getDict('capitalType', 'hdOrderRefund', 'id');
+
+        // 同一退款单已解冻过:只补尚未覆盖的余额扣减,避免审核重入双记
+        $exist = self::findRefundUnfreezeChange($capitalType, $refundId, $orderSn);
+        if (!empty($exist)) {
+            $already = bcadd((string)($exist['amount'] ?? '0'), '0', 2);
+            $need = bcsub($refundPrice, $already, 2);
+            return bccomp($need, '0', 2) > 0 ? $need : '0.00';
+        }
+
+        $ext = OrderWxExtClass::getByOrderId($orderId);
+        if (empty($ext)) {
+            return $refundPrice;
+        }
+        // 微信已结算入账:冻结池已清,售后改扣可提现余额
+        if ((int)($ext['balancePosted'] ?? 0) === 1) {
+            return $refundPrice;
+        }
+
+        $frozen = bcadd((string)($ext['freezeAmount'] ?? '0'), '0', 2);
+        if (bccomp($frozen, '0', 2) <= 0) {
+            return $refundPrice;
+        }
+
+        $unfreeze = $refundPrice;
+        if (bccomp($unfreeze, $frozen, 2) === 1) {
+            $unfreeze = $frozen;
+        }
+
+        $refundSn = is_object($refund) ? (string)($refund->refundSn ?? '') : (string)($refund['refundSn'] ?? '');
+        $event = '商城售后退款解冻' . floatval($unfreeze) . '元(退款单 ' . $refundSn . ')';
+        ShopClass::wxPayUnfreezeBalance($main, $shop, $unfreeze, $refund, $capitalType, $event, $orderSn);
+        OrderWxExtClass::reduceFreeze($orderId, $unfreeze);
+
+        $needReduce = bcsub($refundPrice, $unfreeze, 2);
+        return bccomp($needReduce, '0', 2) > 0 ? $needReduce : '0.00';
+    }
+
+    // 售后资金路径可能没带订单对象,用退款单 orderId 补齐
+    private static function resolveOrder($order, $refund)
+    {
+        if (!empty($order) && is_object($order) && isset($order->id)) {
+            return $order;
+        }
+        $orderId = 0;
+        if (!empty($refund)) {
+            $orderId = is_object($refund) ? (int)($refund->orderId ?? 0) : (int)($refund['orderId'] ?? 0);
+        }
+        if ($orderId <= 0) {
+            return $order;
+        }
+        try {
+            $found = HdOrderClass::getById($orderId, true);
+            return empty($found) ? $order : $found;
+        } catch (\Throwable $e) {
+            Yii::error('售后解冻补查订单失败: ' . $e->getMessage(), 'wx-freeze');
+            return $order;
+        }
+    }
+
+    // 按退款单查是否已有解冻流水
+    private static function findRefundUnfreezeChange($capitalType, $refundId, $orderSn)
+    {
+        if ($refundId <= 0) {
+            return [];
+        }
+        $where = [
+            'capitalType' => (int)$capitalType,
+            'relateId' => $refundId,
+            'io' => 0,
+        ];
+        if ($orderSn !== '') {
+            $where['orderSn'] = $orderSn;
+        }
+        $row = \biz\shop\classes\WxFreezeChangeClass::getByCondition($where);
+        return empty($row) ? [] : $row;
+    }
+}

+ 658 - 0
biz-mall/order/classes/WxOrderShippingClass.php

@@ -0,0 +1,658 @@
+<?php
+
+/**
+ * mallApp 微信小程序发货信息录入。
+ * 零售同城:花店自配送事件监听器调用;充值:payUtil xhRecharge 回调调用。
+ * 内部复用 shippingUtil,失败只记日志,不打断发货/入账主流程。
+ */
+
+namespace bizMall\order\classes;
+
+use biz\shop\classes\ShopClass;
+use biz\wx\classes\WxOpenClass as PlatformWxOpenClass;
+use bizHd\wx\classes\WxBaseClass;
+use bizHd\wx\classes\WxMiniBaseClass;
+use bizMall\user\classes\UserClass;
+use common\components\dict;
+use common\components\shippingUtil;
+use PhpAmqpLib\Wire\AMQPTable;
+use Yii;
+
+class WxOrderShippingClass
+{
+    const LOGISTICS_INTRA_CITY = 2;
+    const LOGISTICS_VIRTUAL = 3;
+    const ACTION_SHIPPING = 'shipping';
+    const SHIP_STATUS_NONE = 0;
+    const SHIP_STATUS_OK = 1;
+    const SHIP_STATUS_FAIL = 2;
+    const SETTLE_WAIT = 1;
+
+    /**
+     * 同城自配送:mall 微信支付单录入 logistics_type=2。
+     * 非商城/非微信/已录入成功则跳过;缺 openid / 微信单号 记失败不阻断发货。
+     * transaction_id 取 xhOrder.thirdOrderId(拉卡拉 acc_trade_no),不要用 thirdNo。
+     */
+    public static function uploadIntraCityByOrder($order)
+    {
+        $order = self::modelToArray($order);
+        $orderId = (int)($order['id'] ?? 0);
+        if ($orderId <= 0) {
+            return;
+        }
+
+        $mallFromType = (int)dict::getDict('fromType', 'mall');
+        $wxPayWay = (int)dict::getDict('payWay', 'wxPay');
+        // 只有 mallApp 微信支付才进该小程序订单管理
+        if ((int)($order['fromType'] ?? 0) !== $mallFromType) {
+            return;
+        }
+        if ((int)($order['payWay'] ?? -1) !== $wxPayWay) {
+            return;
+        }
+
+        $ext = OrderWxExtClass::getByOrderId($orderId);
+        if (!empty($ext) && (int)($ext['shipStatus'] ?? 0) === self::SHIP_STATUS_OK) {
+            return;
+        }
+
+        $orderSn = (string)($order['orderSn'] ?? '');
+        // 拉卡拉 acc_trade_no 才是微信 transaction_id,支付回调已写入 thirdOrderId
+        $transactionId = trim((string)($order['thirdOrderId'] ?? ''));
+        $openId = self::resolveMallOpenId($order);
+        $itemDesc = self::buildOrderItemDesc($orderSn);
+        $payload = self::buildShippingPayload($transactionId, $openId, $itemDesc, self::LOGISTICS_INTRA_CITY);
+
+        $merchant = self::getMallMerchantOrEmpty();
+        if (empty($merchant)) {
+            self::persistOrderShip($orderId, $orderSn, $payload, ['errcode' => -1, 'errmsg' => '没有找到商城小程序配置'], self::LOGISTICS_INTRA_CITY);
+            return;
+        }
+        if ($openId === '') {
+            self::persistOrderShip($orderId, $orderSn, $payload, ['errcode' => -1, 'errmsg' => '缺少商城小程序openid'], self::LOGISTICS_INTRA_CITY);
+            return;
+        }
+        if ($transactionId === '') {
+            self::persistOrderShip($orderId, $orderSn, $payload, ['errcode' => -1, 'errmsg' => '缺少微信transaction_id(thirdOrderId)'], self::LOGISTICS_INTRA_CITY);
+            return;
+        }
+
+        $ptStyle = (int)dict::getDict('ptStyle', 'mall');
+        // token 查找里可能 util::fail 直接结束请求,临时改成抛异常以便本单发货仍成功
+        $oldReport = Yii::$app->params['errorReport'] ?? 0;
+        Yii::$app->params['errorReport'] = 1;
+        try {
+            $result = shippingUtil::shipping($merchant, $payload, $ptStyle);
+        } catch (\Throwable $e) {
+            $result = ['errcode' => -1, 'errmsg' => $e->getMessage()];
+        }
+        Yii::$app->params['errorReport'] = $oldReport;
+        if (!is_array($result)) {
+            $result = ['errcode' => -1, 'errmsg' => '微信接口返回异常'];
+        }
+        self::persistOrderShip($orderId, $orderSn, $payload, $result, self::LOGISTICS_INTRA_CITY);
+    }
+
+    /**
+     * mallApp 余额充值支付成功后录入虚拟发货(logistics_type=3)。
+     * 仅商城回调(ptStyle=mall)+ 微信支付;商品信息=门店展示名+余额充值。
+     * transaction_id 用 thirdPayNo(拉卡拉 acc_trade_no),不要用 returnCode(那是 trade_no,退款仍用它)。
+     */
+    public static function uploadVirtualByRecharge($recharge, $accTradeNo = '')
+    {
+        $mallPtStyle = (int)dict::getDict('ptStyle', 'mall');
+        if ((int)(Yii::$app->params['ptStyle'] ?? 0) !== $mallPtStyle) {
+            return;
+        }
+
+        $recharge = self::modelToArray($recharge);
+        $rechargeId = (int)($recharge['id'] ?? 0);
+        if ($rechargeId <= 0) {
+            return;
+        }
+
+        $wxPayWay = (int)dict::getDict('payWay', 'wxPay');
+        if ((int)($recharge['payWay'] ?? -1) !== $wxPayWay) {
+            return;
+        }
+        if ((int)($recharge['payStatus'] ?? 0) !== 1) {
+            return;
+        }
+
+        $capitalType = (int)dict::getDict('capitalType', 'xhRecharge', 'id');
+        $orderSn = (string)($recharge['orderSn'] ?? '');
+
+        try {
+            if (WxOrderShippingLogClass::exists([
+                'capitalType' => $capitalType,
+                'relateId' => $rechargeId,
+                'errcode' => 0,
+            ])) {
+                // 日志已成功但扩展表可能仍失败态,补齐状态供列表/结算使用
+                try {
+                    RechargeWxExtClass::markShipResult($rechargeId, $orderSn, true, '', self::LOGISTICS_VIRTUAL);
+                } catch (\Throwable $e) {
+                    Yii::error('补齐充值发货成功态失败: ' . $e->getMessage(), 'wx-order-shipping');
+                }
+                return;
+            }
+        } catch (\Throwable $e) {
+            Yii::error('查充值发货日志失败: ' . $e->getMessage(), 'wx-order-shipping');
+        }
+
+        $transactionId = trim((string)$accTradeNo);
+        if ($transactionId === '') {
+            $transactionId = trim((string)($recharge['thirdPayNo'] ?? ''));
+        }
+        $openId = self::resolveMallOpenId($recharge);
+        $itemDesc = self::buildRechargeItemDesc($recharge);
+        $payload = self::buildShippingPayload($transactionId, $openId, $itemDesc, self::LOGISTICS_VIRTUAL);
+
+        if ((int)($recharge['userId'] ?? 0) <= 0) {
+            self::persistShippingLog($capitalType, $rechargeId, $orderSn, $payload, ['errcode' => -1, 'errmsg' => '缺少商城用户userId'], self::LOGISTICS_VIRTUAL);
+            RechargeWxExtClass::markShipResult($rechargeId, $orderSn, false, '缺少商城用户userId', self::LOGISTICS_VIRTUAL);
+            return;
+        }
+        if ($openId === '') {
+            self::persistShippingLog($capitalType, $rechargeId, $orderSn, $payload, ['errcode' => -1, 'errmsg' => '缺少商城小程序openid'], self::LOGISTICS_VIRTUAL);
+            RechargeWxExtClass::markShipResult($rechargeId, $orderSn, false, '缺少商城小程序openid', self::LOGISTICS_VIRTUAL);
+            return;
+        }
+        if ($transactionId === '') {
+            self::persistShippingLog($capitalType, $rechargeId, $orderSn, $payload, ['errcode' => -1, 'errmsg' => '缺少微信transaction_id(acc_trade_no)'], self::LOGISTICS_VIRTUAL);
+            RechargeWxExtClass::markShipResult($rechargeId, $orderSn, false, '缺少微信transaction_id(acc_trade_no)', self::LOGISTICS_VIRTUAL);
+            return;
+        }
+
+        $merchant = self::getMallMerchantOrEmpty();
+        if (empty($merchant)) {
+            self::persistShippingLog($capitalType, $rechargeId, $orderSn, $payload, ['errcode' => -1, 'errmsg' => '没有找到商城小程序配置'], self::LOGISTICS_VIRTUAL);
+            RechargeWxExtClass::markShipResult($rechargeId, $orderSn, false, '没有找到商城小程序配置', self::LOGISTICS_VIRTUAL);
+            return;
+        }
+
+        $oldReport = Yii::$app->params['errorReport'] ?? 0;
+        Yii::$app->params['errorReport'] = 1;
+        try {
+            $result = shippingUtil::shipping($merchant, $payload, $mallPtStyle);
+        } catch (\Throwable $e) {
+            $result = ['errcode' => -1, 'errmsg' => $e->getMessage()];
+        }
+        Yii::$app->params['errorReport'] = $oldReport;
+        if (!is_array($result)) {
+            $result = ['errcode' => -1, 'errmsg' => '微信接口返回异常'];
+        }
+        self::persistShippingLog($capitalType, $rechargeId, $orderSn, $payload, $result, self::LOGISTICS_VIRTUAL);
+        // 扩展表记发货结果;失败不回滚充值,由 RabbitMQ 延迟补录
+        $errcode = isset($result['errcode']) ? (int)$result['errcode'] : -1;
+        $errmsg = (string)($result['errmsg'] ?? '');
+        try {
+            RechargeWxExtClass::markShipResult(
+                $rechargeId,
+                $orderSn,
+                $errcode === 0,
+                $errmsg,
+                self::LOGISTICS_VIRTUAL
+            );
+        } catch (\Throwable $e) {
+            Yii::error('回写充值发货扩展失败: ' . $e->getMessage(), 'wx-order-shipping');
+        }
+
+        // 可重试失败:投递 RabbitMQ 延迟消息(方案 B),等微信支付单入库后再补录
+        if ($errcode !== 0 && !RechargeWxExtClass::isPermanentShipError($errmsg)) {
+            $ext = RechargeWxExtClass::getByRechargeId($rechargeId);
+            $retryCount = (int)($ext['shipRetryCount'] ?? 0);
+            if ($retryCount < RechargeWxExtClass::MAX_SHIP_RETRY && !empty($ext['nextRetryTime'])) {
+                $delaySec = max(RechargeWxExtClass::RETRY_DELAY_SECONDS, strtotime($ext['nextRetryTime']) - time());
+                self::scheduleRechargeShipDelay($rechargeId, $delaySec);
+            }
+        }
+    }
+
+    /**
+     * mallApp 扫码付款支付成功后录入虚拟发货(logistics_type=3)。
+     * transaction_id 用 thirdPayNo(拉卡拉 acc_trade_no),不要用 returnCode。
+     */
+    public static function uploadVirtualByScanPay($scanPay, $accTradeNo = '')
+    {
+        $mallPtStyle = (int)dict::getDict('ptStyle', 'mall');
+        if ((int)(Yii::$app->params['ptStyle'] ?? 0) !== $mallPtStyle) {
+            return;
+        }
+
+        $scanPay = self::modelToArray($scanPay);
+        $scanPayId = (int)($scanPay['id'] ?? 0);
+        if ($scanPayId <= 0) {
+            return;
+        }
+
+        $wxPayWay = (int)dict::getDict('payWay', 'wxPay');
+        if ((int)($scanPay['payWay'] ?? -1) !== $wxPayWay) {
+            return;
+        }
+        if ((int)($scanPay['payStatus'] ?? 0) !== 1) {
+            return;
+        }
+
+        $capitalType = (int)dict::getDict('capitalType', 'scanPay', 'id');
+        $orderSn = (string)($scanPay['orderSn'] ?? '');
+
+        try {
+            if (WxOrderShippingLogClass::exists([
+                'capitalType' => $capitalType,
+                'relateId' => $scanPayId,
+                'errcode' => 0,
+            ])) {
+                try {
+                    ScanPayWxExtClass::markShipResult($scanPayId, $orderSn, true, '', self::LOGISTICS_VIRTUAL);
+                } catch (\Throwable $e) {
+                    Yii::error('补齐扫码发货成功态失败: ' . $e->getMessage(), 'wx-order-shipping');
+                }
+                return;
+            }
+        } catch (\Throwable $e) {
+            Yii::error('查扫码发货日志失败: ' . $e->getMessage(), 'wx-order-shipping');
+        }
+
+        $transactionId = trim((string)$accTradeNo);
+        if ($transactionId === '') {
+            $transactionId = trim((string)($scanPay['thirdPayNo'] ?? ''));
+        }
+        $openId = self::resolveMallOpenId($scanPay);
+        $itemDesc = self::buildScanPayItemDesc($scanPay);
+        $payload = self::buildShippingPayload($transactionId, $openId, $itemDesc, self::LOGISTICS_VIRTUAL);
+
+        if ((int)($scanPay['userId'] ?? 0) <= 0) {
+            self::persistShippingLog($capitalType, $scanPayId, $orderSn, $payload, ['errcode' => -1, 'errmsg' => '缺少商城用户userId'], self::LOGISTICS_VIRTUAL);
+            ScanPayWxExtClass::markShipResult($scanPayId, $orderSn, false, '缺少商城用户userId', self::LOGISTICS_VIRTUAL);
+            return;
+        }
+        if ($openId === '') {
+            self::persistShippingLog($capitalType, $scanPayId, $orderSn, $payload, ['errcode' => -1, 'errmsg' => '缺少商城小程序openid'], self::LOGISTICS_VIRTUAL);
+            ScanPayWxExtClass::markShipResult($scanPayId, $orderSn, false, '缺少商城小程序openid', self::LOGISTICS_VIRTUAL);
+            return;
+        }
+        if ($transactionId === '') {
+            self::persistShippingLog($capitalType, $scanPayId, $orderSn, $payload, ['errcode' => -1, 'errmsg' => '缺少微信transaction_id(acc_trade_no)'], self::LOGISTICS_VIRTUAL);
+            ScanPayWxExtClass::markShipResult($scanPayId, $orderSn, false, '缺少微信transaction_id(acc_trade_no)', self::LOGISTICS_VIRTUAL);
+            return;
+        }
+
+        $merchant = self::getMallMerchantOrEmpty();
+        if (empty($merchant)) {
+            self::persistShippingLog($capitalType, $scanPayId, $orderSn, $payload, ['errcode' => -1, 'errmsg' => '没有找到商城小程序配置'], self::LOGISTICS_VIRTUAL);
+            ScanPayWxExtClass::markShipResult($scanPayId, $orderSn, false, '没有找到商城小程序配置', self::LOGISTICS_VIRTUAL);
+            return;
+        }
+
+        $oldReport = Yii::$app->params['errorReport'] ?? 0;
+        Yii::$app->params['errorReport'] = 1;
+        try {
+            $result = shippingUtil::shipping($merchant, $payload, $mallPtStyle);
+        } catch (\Throwable $e) {
+            $result = ['errcode' => -1, 'errmsg' => $e->getMessage()];
+        }
+        Yii::$app->params['errorReport'] = $oldReport;
+        if (!is_array($result)) {
+            $result = ['errcode' => -1, 'errmsg' => '微信接口返回异常'];
+        }
+        self::persistShippingLog($capitalType, $scanPayId, $orderSn, $payload, $result, self::LOGISTICS_VIRTUAL);
+        $errcode = isset($result['errcode']) ? (int)$result['errcode'] : -1;
+        $errmsg = (string)($result['errmsg'] ?? '');
+        try {
+            ScanPayWxExtClass::markShipResult(
+                $scanPayId,
+                $orderSn,
+                $errcode === 0,
+                $errmsg,
+                self::LOGISTICS_VIRTUAL
+            );
+        } catch (\Throwable $e) {
+            Yii::error('回写扫码发货扩展失败: ' . $e->getMessage(), 'wx-order-shipping');
+        }
+
+        if ($errcode !== 0 && !ScanPayWxExtClass::isPermanentShipError($errmsg)) {
+            $ext = ScanPayWxExtClass::getByScanPayId($scanPayId);
+            $retryCount = (int)($ext['shipRetryCount'] ?? 0);
+            if ($retryCount < ScanPayWxExtClass::MAX_SHIP_RETRY && !empty($ext['nextRetryTime'])) {
+                $delaySec = max(ScanPayWxExtClass::RETRY_DELAY_SECONDS, strtotime($ext['nextRetryTime']) - time());
+                self::scheduleScanPayShipDelay($scanPayId, $delaySec);
+            }
+        }
+    }
+
+    /**
+     * 投递扫码付款发货延迟补录(复用 rechargeShip 队列路由)。
+     */
+    public static function scheduleScanPayShipDelay($scanPayId, $delaySeconds = 60)
+    {
+        $scanPayId = (int)$scanPayId;
+        if ($scanPayId <= 0) {
+            return false;
+        }
+        try {
+            $delayMs = max(1000, (int)$delaySeconds * 1000);
+            $message = serialize([
+                'type' => 'scan_pay_ship_retry',
+                'scanPayId' => $scanPayId,
+            ]);
+            $producer = Yii::$app->rabbitmq->getProducer('notifyProducer');
+            $producer->publish($message, 'limitBuyDelayExchange', 'rechargeShipDelayRoute', [
+                'delivery_mode' => 2,
+                'content_type' => 'application/octet-stream',
+                'application_headers' => new AMQPTable([
+                    'x-delay' => intval($delayMs),
+                ]),
+            ]);
+            Yii::info('scan pay ship delay: scanPayId=' . $scanPayId . ' delayMs=' . $delayMs, 'wx-order-shipping');
+            return true;
+        } catch (\Throwable $e) {
+            Yii::error('scheduleScanPayShipDelay fail: ' . $e->getMessage(), 'wx-order-shipping');
+            return false;
+        }
+    }
+
+    /**
+     * 延迟消息消费:补录扫码付款虚拟发货。
+     */
+    public static function handleScanPayShipDelayMessage($scanPayId)
+    {
+        $scanPayId = (int)$scanPayId;
+        if ($scanPayId <= 0) {
+            return true;
+        }
+
+        $mallPtStyle = (int)dict::getDict('ptStyle', 'mall');
+        Yii::$app->params['ptStyle'] = $mallPtStyle;
+
+        $scanPay = \bizHd\order\classes\ScanPayClass::getById($scanPayId, true);
+        if (empty($scanPay)) {
+            return true;
+        }
+        if ((int)($scanPay->payStatus ?? 0) !== 1) {
+            return true;
+        }
+        // 已全额退款则不再补录
+        $act = bcadd((string)($scanPay->actPrice ?? '0'), '0', 2);
+        $tk = bcadd((string)($scanPay->tkPrice ?? '0'), '0', 2);
+        if (bccomp($act, '0', 2) > 0 && bccomp($tk, $act, 2) >= 0) {
+            return true;
+        }
+
+        $ext = ScanPayWxExtClass::getByScanPayId($scanPayId);
+        if (!empty($ext) && (int)($ext['shipStatus'] ?? 0) === ScanPayWxExtClass::SHIP_OK) {
+            return true;
+        }
+
+        self::uploadVirtualByScanPay($scanPay, (string)($scanPay->thirdPayNo ?? ''));
+        return true;
+    }
+
+    /**
+     * 投递充值发货延迟补录消息(复用 limitBuyDelayExchange)。
+     * 失败只打日志,不阻断支付回调。
+     */
+    public static function scheduleRechargeShipDelay($rechargeId, $delaySeconds = 60)
+    {
+        $rechargeId = (int)$rechargeId;
+        if ($rechargeId <= 0) {
+            return false;
+        }
+        try {
+            $delayMs = max(1000, (int)$delaySeconds * 1000);
+            $message = serialize([
+                'type' => 'recharge_ship_retry',
+                'rechargeId' => $rechargeId,
+            ]);
+            $producer = Yii::$app->rabbitmq->getProducer('notifyProducer');
+            $producer->publish($message, 'limitBuyDelayExchange', 'rechargeShipDelayRoute', [
+                'delivery_mode' => 2,
+                'content_type' => 'application/octet-stream',
+                'application_headers' => new AMQPTable([
+                    'x-delay' => intval($delayMs),
+                ]),
+            ]);
+            Yii::info('recharge ship delay: rechargeId=' . $rechargeId . ' delayMs=' . $delayMs, 'wx-order-shipping');
+            return true;
+        } catch (\Throwable $e) {
+            Yii::error('scheduleRechargeShipDelay fail: ' . $e->getMessage(), 'wx-order-shipping');
+            return false;
+        }
+    }
+
+    /**
+     * 延迟消息消费:补录充值虚拟发货;仍失败且可重试时由 uploadVirtualByRecharge 再投递下一次。
+     * @return bool 消费是否视为处理完成(始终 true,避免死循环 requeue)
+     */
+    public static function handleRechargeShipDelayMessage($rechargeId)
+    {
+        $rechargeId = (int)$rechargeId;
+        if ($rechargeId <= 0) {
+            return true;
+        }
+
+        $mallPtStyle = (int)dict::getDict('ptStyle', 'mall');
+        Yii::$app->params['ptStyle'] = $mallPtStyle;
+
+        $recharge = \bizHd\recharge\classes\RechargeClass::getById($rechargeId, true);
+        if (empty($recharge)) {
+            return true;
+        }
+        if ((int)($recharge->payStatus ?? 0) !== 1) {
+            return true;
+        }
+        if ((int)($recharge->isRefund ?? 0) === 1) {
+            return true;
+        }
+
+        $ext = RechargeWxExtClass::getByRechargeId($rechargeId);
+        if (!empty($ext) && (int)($ext['shipStatus'] ?? 0) === RechargeWxExtClass::SHIP_OK) {
+            return true;
+        }
+
+        self::uploadVirtualByRecharge($recharge, (string)($recharge->thirdPayNo ?? ''));
+        return true;
+    }
+
+    /**
+     * 组装微信发货录入报文。
+     * order_number_type=2:微信 transaction_id(拉卡拉 acc_trade_no)。
+     */
+    private static function buildShippingPayload($transactionId, $openId, $itemDesc, $logisticsType)
+    {
+        $payload = [
+            'order_key' => [
+                'order_number_type' => 2,
+                'transaction_id' => (string)$transactionId,
+            ],
+            'delivery_mode' => 1,
+            'logistics_type' => (int)$logisticsType,
+            'shipping_list' => [
+                ['item_desc' => $itemDesc],
+            ],
+            'upload_time' => date(DATE_ATOM),
+        ];
+        if ($openId !== '') {
+            $payload['payer'] = ['openid' => $openId];
+        }
+        return $payload;
+    }
+
+    /**
+     * 零售发货:写调用日志 + 更新 xhOrderWxExt 当前发货状态。
+     * 日志字段与现网表一致(capitalType/action/errMsg)。
+     */
+    private static function persistOrderShip($orderId, $orderSn, $payload, $result, $logisticsType)
+    {
+        $capitalType = (int)dict::getDict('capitalType', 'xhOrder', 'id');
+        self::persistShippingLog($capitalType, $orderId, $orderSn, $payload, $result, $logisticsType);
+
+        $errcode = isset($result['errcode']) ? (int)$result['errcode'] : -1;
+        $errmsg = (string)($result['errmsg'] ?? '');
+        $ok = $errcode === 0;
+        $now = date('Y-m-d H:i:s');
+        $extData = [
+            'orderId' => (int)$orderId,
+            'orderSn' => (string)$orderSn,
+            'shipStatus' => $ok ? self::SHIP_STATUS_OK : self::SHIP_STATUS_FAIL,
+            'logisticsType' => (int)$logisticsType,
+            'errMsg' => mb_substr($errmsg, 0, 500),
+        ];
+        if ($ok) {
+            $extData['settleStatus'] = self::SETTLE_WAIT;
+        }
+
+        try {
+            $ext = OrderWxExtClass::getByOrderId($orderId);
+            if (empty($ext)) {
+                $extData['addTime'] = $now;
+                OrderWxExtClass::add($extData);
+            } else {
+                OrderWxExtClass::updateById($ext['id'], $extData);
+            }
+        } catch (\Throwable $e) {
+            Yii::error('写xhOrderWxExt失败: ' . $e->getMessage(), 'wx-order-shipping');
+        }
+    }
+
+    /**
+     * 现网 xhWxOrderShippingLog 字段是 capitalType/action/logisticsType/errMsg。
+     */
+    private static function persistShippingLog($capitalType, $relateId, $orderSn, $payload, $result, $logisticsType)
+    {
+        $errcode = isset($result['errcode']) ? (int)$result['errcode'] : -1;
+        $errmsg = (string)($result['errmsg'] ?? '');
+        try {
+            WxOrderShippingLogClass::add([
+                'capitalType' => (int)$capitalType,
+                'relateId' => (int)$relateId,
+                'orderSn' => (string)$orderSn,
+                'action' => self::ACTION_SHIPPING,
+                'logisticsType' => (int)$logisticsType,
+                'errcode' => $errcode,
+                'errMsg' => mb_substr($errmsg, 0, 500),
+                'request' => json_encode($payload, JSON_UNESCAPED_UNICODE),
+                'response' => json_encode($result, JSON_UNESCAPED_UNICODE),
+                'addTime' => date('Y-m-d H:i:s'),
+            ]);
+        } catch (\Throwable $e) {
+            Yii::error('写xhWxOrderShippingLog失败: ' . $e->getMessage(), 'wx-order-shipping');
+        }
+    }
+
+    // 商城用户 miniOpenId,对应支付时的 payer.openid
+    private static function resolveMallOpenId($row)
+    {
+        $userId = (int)($row['userId'] ?? 0);
+        if ($userId <= 0) {
+            return '';
+        }
+        $user = UserClass::getById($userId);
+        if (empty($user)) {
+            return '';
+        }
+        return trim((string)($user['miniOpenId'] ?? ''));
+    }
+
+    // 零售商品短描述;xhOrderItem/xhOrderGoods 按 orderSn 关联
+    private static function buildOrderItemDesc($orderSn)
+    {
+        if ($orderSn === '') {
+            return '鲜花';
+        }
+        $names = [];
+        try {
+            $goodsList = OrderGoodsClass::getAllByCondition(['orderSn' => $orderSn]);
+            $itemList = OrderItemClass::getAllByCondition(['orderSn' => $orderSn]);
+            $rows = array_merge(empty($goodsList) ? [] : $goodsList, empty($itemList) ? [] : $itemList);
+            foreach ($rows as $item) {
+                $name = trim((string)($item['name'] ?? $item['itemName'] ?? $item['title'] ?? ''));
+                if ($name !== '') {
+                    $names[] = $name;
+                }
+            }
+        } catch (\Throwable $e) {
+            Yii::error('组装微信发货item_desc失败: ' . $e->getMessage(), 'wx-order-shipping');
+        }
+        $desc = empty($names) ? '鲜花' : implode('、', array_unique($names));
+        return mb_substr($desc, 0, 120);
+    }
+
+    // 商品信息:门店展示名称 + 余额充值,对齐 mallApp 充值页门店
+    private static function buildRechargeItemDesc($recharge)
+    {
+        $shopName = '';
+        $shopId = (int)($recharge['shopId'] ?? 0);
+        if ($shopId > 0) {
+            try {
+                $shop = ShopClass::getById($shopId, true);
+                if (!empty($shop)) {
+                    $shopName = ShopClass::formatDisplayShopName($shop);
+                }
+            } catch (\Throwable $e) {
+                Yii::error('组装充值发货门店名失败: ' . $e->getMessage(), 'wx-order-shipping');
+            }
+        }
+        if ($shopName === '') {
+            $shopName = trim((string)($recharge['hdName'] ?? ''));
+        }
+        if ($shopName === '') {
+            $shopName = '门店';
+        }
+        return mb_substr($shopName . '余额充值', 0, 120);
+    }
+
+    // 商品信息:门店展示名称 + 扫码付款
+    private static function buildScanPayItemDesc($scanPay)
+    {
+        $shopName = '';
+        $shopId = (int)($scanPay['shopId'] ?? 0);
+        if ($shopId > 0) {
+            try {
+                $shop = ShopClass::getById($shopId, true);
+                if (!empty($shop)) {
+                    $shopName = ShopClass::formatDisplayShopName($shop);
+                }
+            } catch (\Throwable $e) {
+                Yii::error('组装扫码发货门店名失败: ' . $e->getMessage(), 'wx-order-shipping');
+            }
+        }
+        if ($shopName === '') {
+            $shopName = trim((string)($scanPay['hdName'] ?? ''));
+        }
+        if ($shopName === '') {
+            $shopName = '门店';
+        }
+        return mb_substr($shopName . '扫码付款', 0, 120);
+    }
+
+    // 自己查商城小程序配置,避免 getMallWxInfo 里 util::fail 把发货接口直接结束掉
+    private static function getMallMerchantOrEmpty()
+    {
+        $open = PlatformWxOpenClass::getMallOpen();
+        if (empty($open) || empty($open['wxMiniBaseId'])) {
+            return [];
+        }
+        $mini = WxMiniBaseClass::getById($open['wxMiniBaseId']);
+        if (empty($mini)) {
+            return [];
+        }
+        $wx = [];
+        if (!empty($open['wxBaseId'])) {
+            $wxBase = WxBaseClass::getById($open['wxBaseId']);
+            $wx = empty($wxBase) ? [] : $wxBase;
+        }
+        return array_merge($wx, $mini);
+    }
+
+    private static function modelToArray($model)
+    {
+        if (is_array($model)) {
+            return $model;
+        }
+        if (is_object($model) && method_exists($model, 'getAttributes')) {
+            return $model->getAttributes();
+        }
+        return [];
+    }
+}

+ 18 - 0
biz-mall/order/models/OrderWxExt.php

@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * 零售订单微信发货/结算扩展表模型。
+ * 与 xhOrder 1:1,专记发货录入、冻结与结算当前状态,避免改主表字段。
+ */
+
+namespace bizMall\order\models;
+
+use bizMall\base\models\Base;
+
+class OrderWxExt extends Base
+{
+    public static function tableName()
+    {
+        return 'xhOrderWxExt';
+    }
+}

+ 18 - 0
biz-mall/order/models/RechargeWxExt.php

@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * 充值单微信发货/结算扩展表模型。
+ * 与 xhRecharge 1:1,专记虚拟发货录入、冻结与结算状态,避免改主表字段。
+ */
+
+namespace bizMall\order\models;
+
+use bizMall\base\models\Base;
+
+class RechargeWxExt extends Base
+{
+    public static function tableName()
+    {
+        return 'xhRechargeWxExt';
+    }
+}

+ 18 - 0
biz-mall/order/models/ScanPayWxExt.php

@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * 扫码付款微信发货/结算扩展表模型。
+ * 与 xhScanPay 1:1,专记虚拟发货、冻结与结算状态。
+ */
+
+namespace bizMall\order\models;
+
+use bizMall\base\models\Base;
+
+class ScanPayWxExt extends Base
+{
+    public static function tableName()
+    {
+        return 'xhScanPayWxExt';
+    }
+}

+ 21 - 0
biz/shop/classes/WxFreezeChangeClass.php

@@ -0,0 +1,21 @@
+<?php
+
+/**
+ * 微信待结算冻结明细读写。
+ * ShopClass 冻结/解冻时落一行,售后按 relateId=退款单id 防重复解冻。
+ */
+
+namespace biz\shop\classes;
+
+use biz\base\classes\BaseClass;
+
+class WxFreezeChangeClass extends BaseClass
+{
+    public static $baseFile = '\biz\shop\models\WxFreezeChange';
+
+    // 写入一条冻结或解冻流水,返回刚插入的行
+    public static function addChange($change, $returnObj = false)
+    {
+        return self::add($change, $returnObj);
+    }
+}

+ 18 - 0
biz/shop/models/WxFreezeChange.php

@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * 微信待结算冻结明细表模型。
+ * 对齐 xhShopYeChange,只记 xhMain.wxFreezeBalance 进出,不改可提现余额。
+ */
+
+namespace biz\shop\models;
+
+use biz\base\models\Base;
+
+class WxFreezeChange extends Base
+{
+    public static function tableName()
+    {
+        return 'xhWxFreezeChange';
+    }
+}

+ 14 - 7
common/components/payUtil.php

@@ -18,13 +18,20 @@ use Yii;
 class payUtil
 {
 
-    public static function thirdPay($payWay, $capitalType, $orderSn, $totalFee, $attach, $transactionId = '')
+    public static function thirdPay($payWay, $capitalType, $orderSn, $totalFee, $attach, $transactionId = '', $accTradeNo = '')
     {
         // 同一 orderSn 短时重复通知直接跳过(NoticeController 仍会回 SUCCESS,避免渠道反复重试)
         $cacheKey = 'third_pay_' . $orderSn;
         $has = Yii::$app->redis->executeCommand('GET', [$cacheKey]);
         if (!empty($has)) {
             Yii::info("支付回调短时间内出现重复通知,流水类型:{$capitalType} orderSn:{$orderSn}");
+            // 充值入账可能已完成,短时重复通知仍补录微信虚拟发货
+            if ($capitalType == dict::getDict('capitalType', 'xhRecharge', 'id')) {
+                RechargeClass::tryWxVirtualShippingByOrderSn($orderSn, $accTradeNo);
+            }
+            if ($capitalType == dict::getDict('capitalType', 'scanPay', 'id')) {
+                ScanPayClass::tryWxVirtualShippingByOrderSn($orderSn, $accTradeNo);
+            }
             return false;
         }
         Yii::$app->redis->executeCommand('SETEX', [$cacheKey, 10, 'has']);
@@ -32,8 +39,8 @@ class payUtil
         //流水类型列表
         switch ($capitalType) {
             case dict::getDict('capitalType', 'xhRecharge', 'id'):
-                //散客向花店充值
-                RechargeClass::thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId);
+                // 散客向花店充值;acc_trade_no 写入 thirdPayNo,returnCode 仍是拉卡拉 trade_no
+                RechargeClass::thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId, $accTradeNo);
                 break;
             case dict::getDict('capitalType', 'xhPurchase', 'id'):
                 //零售采购
@@ -44,12 +51,12 @@ class payUtil
                 PurchaseClearClass::thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId);
                 break;
             case dict::getDict('capitalType', 'xhOrder', 'id'):
-                //散客下单
-                OrderClass::thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId);
+                //散客下单;acc_trade_no 写入 thirdOrderId,供微信发货录入
+                OrderClass::thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId, $accTradeNo);
                 break;
             case dict::getDict('capitalType', 'scanPay', 'id'):
-                //扫码付款
-                ScanPayClass::thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId);
+                //扫码付款;acc_trade_no 写入 thirdPayNo,供微信虚拟发货
+                ScanPayClass::thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId, $accTradeNo);
                 break;
             case dict::getDict('capitalType', 'xhRenew', 'id'):
                 //商家续期、购买套餐和设备

+ 59 - 0
common/components/rabbitmq/rechargeShipConsumer.php

@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * mall 充值/扫码付款虚拟发货延迟补录消费者。
+ * 支付回调立刻录入常遇 10060001;由 limitBuyDelayExchange 延迟投递后再调微信。
+ */
+
+namespace common\components\rabbitmq;
+
+use bizMall\order\classes\WxOrderShippingClass;
+use common\components\noticeUtil;
+use mikemadisonweb\rabbitmq\components\ConsumerInterface;
+use PhpAmqpLib\Message\AMQPMessage;
+use Yii;
+
+class rechargeShipConsumer extends baseConsumer
+{
+    /**
+     * @param AMQPMessage $msg
+     * @return string
+     */
+    public function execute(AMQPMessage $msg)
+    {
+        try {
+            $this->ensureDbConnection();
+            $data = unserialize($msg->body);
+            if (!is_array($data)) {
+                noticeUtil::push('发货补录消费者:消息格式错误 ' . $msg->body, '15280215347');
+                return ConsumerInterface::MSG_REJECT;
+            }
+
+            $type = $data['type'] ?? '';
+            if ($type === 'recharge_ship_retry') {
+                $rechargeId = (int)($data['rechargeId'] ?? 0);
+                echo 'recharge_ship_retry --- rechargeId=' . $rechargeId . PHP_EOL;
+                $this->runWithDbReconnect(function () use ($rechargeId) {
+                    return WxOrderShippingClass::handleRechargeShipDelayMessage($rechargeId);
+                });
+                return ConsumerInterface::MSG_ACK;
+            }
+
+            if ($type === 'scan_pay_ship_retry') {
+                $scanPayId = (int)($data['scanPayId'] ?? 0);
+                echo 'scan_pay_ship_retry --- scanPayId=' . $scanPayId . PHP_EOL;
+                $this->runWithDbReconnect(function () use ($scanPayId) {
+                    return WxOrderShippingClass::handleScanPayShipDelayMessage($scanPayId);
+                });
+                return ConsumerInterface::MSG_ACK;
+            }
+
+            noticeUtil::push('发货补录消费者:未知 type=' . $type, '15280215347');
+            return ConsumerInterface::MSG_ACK;
+        } catch (\Exception $e) {
+            noticeUtil::push('发货补录消费者异常:' . $e->getMessage(), '15280215347');
+            Yii::error('rechargeShipConsumer: ' . $e->getMessage(), 'wx-order-shipping');
+            return ConsumerInterface::MSG_ACK;
+        }
+    }
+}