ouyang 1 долоо хоног өмнө
parent
commit
a23c9e95a3

+ 3 - 1
app-hd/controllers/IntraCityController.php

@@ -4,6 +4,7 @@ namespace hd\controllers;
 
 use bizHd\express\classes\ExpressOrderClass;
 use bizHd\order\classes\OrderClass;
+use bizHd\distribution\classes\DistributionCommissionClass;
 use bizHd\order\classes\OrderGoodsClass;
 use bizHd\order\classes\OrderItemClass;
 use bizHd\shop\classes\MainClass;
@@ -723,6 +724,7 @@ class IntraCityController extends BaseController
                         $order->status = 4;//配送完成
                         $order->sendStatus = 2;//配送完成
                         $order->save();
+                        DistributionCommissionClass::onRetailOrderCompleted((int)$order->id);
                     }
                     break;
                 // 商家取消订单
@@ -795,4 +797,4 @@ class IntraCityController extends BaseController
         util::complete();
     }
 
-}
+}

+ 4 - 1
app-hd/controllers/MtController.php

@@ -5,6 +5,7 @@ namespace hd\controllers;
 use biz\shop\classes\ShopExtClass;
 use biz\stock\classes\GoodsStockRecordClass;
 use bizHd\custom\classes\CustomClass;
+use bizHd\distribution\classes\DistributionCommissionClass;
 use bizHd\goods\classes\CategoryClass;
 use bizHd\goods\classes\GoodsCategoryClass;
 use bizHd\goods\classes\GoodsClass;
@@ -665,6 +666,8 @@ class MtController extends PublicController
 
             $transaction->commit();
 
+            DistributionCommissionClass::tryCalcDistributionAfterPay((int)($order->id ?? 0));
+
             //制作单取消通知
             $shopExt = ShopExtClass::getByCondition(['shopId' => $shopId], true);
             ShopExtClass::orderCancelRemind($shopExt, $order);
@@ -698,4 +701,4 @@ class MtController extends PublicController
         return Json::encode(['data' => 'ok']);
     }
 
-}
+}

+ 7 - 0
app-hd/controllers/OrderController.php

@@ -9,6 +9,7 @@ use biz\sj\services\MerchantExtendService;
 use biz\sj\services\MerchantService;
 use bizHd\custom\classes\CustomClass;
 use bizHd\custom\classes\HdClass;
+use bizHd\distribution\classes\DistributionCommissionClass;
 use bizHd\goods\classes\GoodsClass;
 use bizHd\merchant\services\ShopService;
 use bizHd\order\classes\OrderClass;
@@ -76,6 +77,7 @@ class OrderController extends BaseController
             if ($info->getGoods == 1 && $info->sendType == 1 && $info->status == 2) {
                 $info->status = 4;
                 $info->save();
+                DistributionCommissionClass::onRetailOrderCompleted((int)$info->id);
             }
 
             $transaction->commit();
@@ -290,6 +292,7 @@ class OrderController extends BaseController
         }
         $order->status = 4;
         $order->save();
+        DistributionCommissionClass::onRetailOrderCompleted((int)$order->id);
         util::complete('操作成功');
     }
 
@@ -320,7 +323,10 @@ class OrderController extends BaseController
             util::fail('由跑腿送货,无需确认');
         }
         $order->status = 4;
+        $successTime = date('Y-m-d H:i:s');
+        $order->successTime = $successTime;
         $order->save();
+        DistributionCommissionClass::onRetailOrderCompleted((int)$order->id, $successTime);
         util::complete('操作成功');
     }
 
@@ -1058,6 +1064,7 @@ class OrderController extends BaseController
             if ($order->getGoods == 1 && $order->sendType == 1 && $order->status == 2) {
                 $order->status = 4;
                 $order->save();
+                DistributionCommissionClass::onRetailOrderCompleted((int)$order->id);
             }
 
             $transaction->commit();

+ 7 - 1
app-hd/controllers/RefundController.php

@@ -5,6 +5,7 @@ namespace hd\controllers;
 use biz\shop\classes\ShopExtClass;
 use bizHd\custom\classes\CustomClass;
 use bizHd\custom\classes\HdClass;
+use bizHd\distribution\classes\DistributionCommissionClass;
 use bizHd\hb\classes\HbClass;
 use bizHd\member\classes\MemberLevelClass;
 use bizHd\order\classes\OrderClass;
@@ -255,6 +256,8 @@ class RefundController extends BaseController
                 //售后开付款单,库存要变化,关系要建立,多处需要同步修改 sh_pay_change ssh
                 OrderClass::shPayChange($returnOrder, $shop, $staff, $order);
                 $transaction->commit();
+                // 负数售后单:原单金额未走 addRefund,仍尝试按原单重算分佣(无变化则内部忽略)
+                DistributionCommissionClass::tryCalcDistributionAfterPay((int)$id);
                 $orderId = $returnOrder->id;
                 util::success(['forward' => 1, 'orderId' => $orderId]);
             } else {
@@ -299,6 +302,9 @@ class RefundController extends BaseController
             }
             $transaction->commit();
 
+            // 售后落库、红包回退等均已提交后再重算分佣(与 tkPrice/actPrice 一致)
+            DistributionCommissionClass::tryCalcDistributionAfterPay((int)$id);
+
             //制作单取消通知
             $shopId = $this->shopId ?? 0;
             $shopExt = ShopExtClass::getByCondition(['shopId' => $shopId], true);
@@ -319,4 +325,4 @@ class RefundController extends BaseController
         util::fail('功能开发中');
     }
 
-}
+}

+ 26 - 0
app-mall/controllers/DistributionController.php

@@ -6,6 +6,7 @@
 
 namespace mall\controllers;
 
+use bizHd\distribution\classes\DistributionDepositClass;
 use bizHd\distribution\classes\DistributionFlowClass;
 use bizHd\distribution\classes\DistributionOrderClass;
 use bizHd\distribution\classes\DistributionUserClass;
@@ -78,4 +79,29 @@ class DistributionController extends BaseController
             util::fail($e->getMessage());
         }
     }
+
+    /**
+     * 可存分红全额存入花店余额
+     */
+    public function actionDepositAvail()
+    {
+        if (empty($this->customId)) {
+            util::fail('请先选择花店');
+        }
+        if (empty($this->hdId)) {
+            util::fail('花店信息无效');
+        }
+        try {
+            $result = DistributionDepositClass::depositAvailFull(
+                (int)$this->shopId,
+                (int)$this->mainId,
+                (int)$this->hdId,
+                (int)$this->customId
+            );
+            $stat = DistributionUserClass::getMallMyStat($this->shopId, $this->customId);
+            util::success(array_merge($result, ['stat' => $stat]));
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
 }

+ 7 - 5
app-mall/controllers/PsMethodController.php

@@ -41,19 +41,21 @@ class PsMethodController extends BaseController
                 unset($config);
             }
 
-            // 今日已收过附加费时,前端不再展示门槛提示(每日每客户每配送方式只收一次
+            // 今日已对附加费做过决策时,前端不再展示门槛加收(每日每客户每购买方式只收一次;首单未收则当日不再收
             $customId = (int)($this->customId ?? 0);
             if ($customId > 0) {
                 foreach ($configs as &$config) {
                     $style = (int)($config['style'] ?? 0);
                     $unMeet = (int)($config['unMeet'] ?? 0);
-                    $hasPaidSendCost = PsMethodClass::isSendCostPaidToday($customId, $style);
-                    $hasPaidPackCost = PsMethodClass::isPackPaidToday($customId, $style);
-                    if ($unMeet === 0 && $hasPaidSendCost) {
+                    $hasSendDecision = PsMethodClass::hasSendFeeDecisionToday($customId, $style);
+                    $hasPackDecision = PsMethodClass::hasPackFeeDecisionToday($customId, $style);
+                    $config['todaySendFeeLocked'] = $hasSendDecision ? 1 : 0;
+                    $config['todayPackFeeLocked'] = $hasPackDecision ? 1 : 0;
+                    if ($unMeet === 0 && $hasSendDecision) {
                         $config['minAmount'] = 0;
                         $config['minNum'] = 0;
                         $config['unMeetFee'] = 0;
-                    } elseif ($unMeet === 2 && $hasPaidPackCost) {
+                    } elseif ($unMeet === 2 && $hasPackDecision) {
                         $config['minAmount'] = 0;
                         $config['minNum'] = 0;
                         $config['unMeetFee'] = 0;

+ 5 - 2
app-mall/controllers/ShopController.php

@@ -37,11 +37,14 @@ class ShopController extends BaseController
         $user = $this->user;
         $hd = [];
         $custom = [];
+        $inviteBound = 0;
         if (!empty($user)) {
             if (empty($hdId)) {
-                $respond = CustomClass::buildRelation($shop, $user);
+                $inviterCustomId = isset($get['inviterCustomId']) ? (int)$get['inviterCustomId'] : 0;
+                $respond = CustomClass::buildRelation($shop, $user, $inviterCustomId);
                 $hd = $respond['hd'];
                 $custom = $respond['custom'];
+                $inviteBound = !empty($respond['inviteBound']) ? (int)$respond['inviteBound'] : 0;
             } else {
                 $hd = HdClass::getById($hdId, true);
                 $customId = $hd->customId;
@@ -54,7 +57,7 @@ class ShopController extends BaseController
             StatVisitClass::addHdCustomVisit($shop->id, $user->id);
         }
 
-        util::success(['info' => $info, 'hd' => $hd, 'custom' => $custom]);
+        util::success(['info' => $info, 'hd' => $hd, 'custom' => $custom, 'inviteBound' => $inviteBound]);
     }
 
     //获取商家的默认门店 ssh 2019.12.6

+ 0 - 4
app-mall/controllers/UserController.php

@@ -204,10 +204,6 @@ class UserController extends BaseController
     {
         $post = Yii::$app->request->post();
         $user = UserClass::replaceUser($post);
-        if (!empty($this->shop)) {
-            //花店增加客户
-            CustomClass::buildRelation($this->shop, $user);
-        }
         $userId = $user->id ?? 0;
 
         if ($userId > 0) {

+ 32 - 3
biz-hd/custom/classes/CustomClass.php

@@ -7,6 +7,7 @@ use bizHd\balance\classes\BalanceChangeClass;
 use bizHd\balance\classes\BalanceGiveChangeClass;
 use bizHd\balance\classes\BalancePayChangeClass;
 use bizHd\birthday\classes\BirthdayGiftClass;
+use bizHd\distribution\classes\DistributionUserClass;
 use bizHd\member\classes\MemberChangeClass;
 use bizHd\member\classes\MemberLevelClass;
 use bizHd\order\classes\OrderClass;
@@ -792,7 +793,14 @@ class CustomClass extends BaseClass
     }
 
     //创建或获取客户信息 ssh 2021.3.19
-    public static function buildRelation($shop, $user)
+    /**
+     * 建立用户与花店的 xhCustom / hd 关系(花店增加客户)
+     * @param object $shop 门店
+     * @param object $user 商城用户
+     * @param int $inviterCustomId 分享邀请人 xhCustom.id,仅新建客户时尝试拉新绑定
+     * @return array|false custom、hd、isNewCustom、inviteBound
+     */
+    public static function buildRelation($shop, $user, $inviterCustomId = 0)
     {
         if (empty($user)) {
             return false;
@@ -805,6 +813,8 @@ class CustomClass extends BaseClass
         }
         $salt = stringUtil::charsShuffleLowerCase(8);
         $isNewCustom = 0;
+        /** 仅 xhCustom 新建时为 1,供分享拉新绑定(与 hd 新建触发的 $isNewCustom 区分) */
+        $customCreated = 0;
         $userId = $user->id;
         $name = $user->name ?? '';
         $mobile = $user->mobile ?? '';
@@ -876,6 +886,7 @@ class CustomClass extends BaseClass
             try {
                 $custom = self::addCustom($data);
                 $isNewCustom = 1;
+                $customCreated = 1;
             } catch (\yii\db\IntegrityException $e) {
                 // 唯一键冲突,说明其他并发请求已经插入,重新查询
                 if (strpos($e->getMessage(), 'Duplicate entry') !== false) {
@@ -885,6 +896,7 @@ class CustomClass extends BaseClass
                         throw $e;
                     }
                     $isNewCustom = 0;
+                    $customCreated = 0;
                 } else {
                     throw $e;
                 }
@@ -934,11 +946,28 @@ class CustomClass extends BaseClass
         $custom->hdId = $hdId;
         $custom->save();
 
+        // 新建 xhCustom 时:customId 无分销记录则创建,有邀请人则绑定
+        $inviteBound = 0;
+        $inviterCustomId = (int)$inviterCustomId;
+        try {
+            $inviteBound = DistributionUserClass::bindInviteOnRegister(
+                (int)$shopId,
+                (int)$customId,
+                $inviterCustomId
+            );
+        } catch (\Throwable $e) {
+            // 分销初始化失败不影响建客主流程
+        }
+
         if ($isNewCustom == 1) {
             WxMessageClass::newHdCustomInform($shop, $custom);
         }
-
-        return ['custom' => $custom, 'hd' => $hd];
+        return [
+            'custom' => $custom,
+            'hd' => $hd,
+            'isNewCustom' => $customCreated,
+            'inviteBound' => $inviteBound,
+        ];
     }
 
     //获取客户信息

+ 821 - 0
biz-hd/distribution/classes/DistributionCommissionClass.php

@@ -0,0 +1,821 @@
+<?php
+/**
+ * 用途:按订单计算分销提成并落库(无记录新建,待结算/失效自动重算)
+ * 谁用:OrderClass::payAfter、RefundController/MtController、定时 settle-due、console calc-order
+ */
+
+namespace bizHd\distribution\classes;
+
+use bizHd\custom\classes\CustomClass;
+use bizHd\goods\classes\GoodsCategoryClass;
+use bizHd\order\classes\OrderClass;
+use bizHd\order\classes\OrderGoodsClass;
+use bizHd\order\classes\OrderItemClass;
+use bizHd\shop\classes\ShopClass;
+use Yii;
+
+class DistributionCommissionClass
+{
+    /**
+     * 按订单 ID 计算并落库分销提成
+     * @param int $orderId xhOrder.id
+     * @return array
+     * @throws \Throwable
+     */
+    public static function calcAndPersist($orderId)
+    {
+        $context = self::buildOrderContext($orderId);
+        $snapshot = self::computeCommissionSnapshot($context);
+
+        $existing = DistributionOrderClass::getByCondition(['orderId' => $context['orderId'], 'distId' => $context['distId']]);
+        $isRecalc = false;
+        if (!empty($existing)) {
+            $allowRemoveSettled = !empty($snapshot['shouldRemoveDistOrder']);
+            if ((int)($existing['settleStatus'] ?? 0) === 1 && !$allowRemoveSettled) {
+                throw new \Exception('已结算订单不可重算,记录ID:' . (int)$existing['id']);
+            }
+            $isRecalc = true;
+        }
+
+        $transaction = Yii::$app->db->beginTransaction();
+        try {
+            // 全部退款导致分佣为 0:删除分销订单记录并回滚统计
+            if (!empty($snapshot['shouldRemoveDistOrder'])) {
+                $distributionOrderId = 0;
+                if (!empty($existing)) {
+                    self::rollbackAllDistOrderEffects($existing);
+                    $distOrderId = (int)$existing['id'];
+                    DistributionOrderClass::deleteByCondition(['id' => $distOrderId]);
+                    // 兜底:按 orderId + distId 再删一次,防止条件未命中
+                    DistributionOrderClass::deleteByCondition([
+                        'orderId' => (int)$context['orderId'],
+                        'distId' => (int)$context['distId'],
+                    ]);
+                }
+                $transaction->commit();
+                return array_merge($snapshot['result'], [
+                    'ok' => 1,
+                    'recalc' => $isRecalc ? 1 : 0,
+                    'distributionOrderId' => 0,
+                    'deleted' => !empty($existing) ? 1 : 0,
+                ]);
+            }
+
+            if (!empty($existing)) {
+                self::rollbackPendingEffects($existing, $context);
+                DistributionOrderClass::updateByCondition(['id' => (int)$existing['id']], $snapshot['distOrderData']);
+                $distributionOrderId = (int)$existing['id'];
+            } else {
+                $distOrder = DistributionOrderClass::add($snapshot['distOrderData'], true);
+                $distributionOrderId = (int)$distOrder->id;
+            }
+
+            if ($snapshot['shouldApplyStats']) {
+                // 待结算与已结算(finishTime+settleDays 到期)走不同累加字段
+                $newSettleStatus = (int)($snapshot['distOrderData']['settleStatus'] ?? 0);
+                if ($newSettleStatus === 1) {
+                    self::applySettledEffects($context, $snapshot, $distributionOrderId);
+                } else {
+                    self::applyPendingEffects($context, $snapshot, $distributionOrderId);
+                }
+            }
+
+            $transaction->commit();
+        } catch (\Throwable $e) {
+            $transaction->rollBack();
+            throw $e;
+        }
+
+        return array_merge($snapshot['result'], [
+            'ok' => 1,
+            'recalc' => $isRecalc ? 1 : 0,
+            'distributionOrderId' => $distributionOrderId,
+        ]);
+    }
+
+    /**
+     * 仅计算不落库(与落库同一套规则)
+     * @param int $orderId
+     * @return array
+     * @throws \Throwable
+     */
+    public static function calculatePreview($orderId)
+    {
+        $context = self::buildOrderContext($orderId);
+        $snapshot = self::computeCommissionSnapshot($context);
+        return array_merge($snapshot['result'], ['ok' => 1, 'preview' => 1]);
+    }
+
+    /**
+     * 支付/售后后尝试落库分销提成(失败不阻断主流程)
+     * @param int $orderId xhOrder.id
+     */
+    public static function tryCalcDistributionAfterPay($orderId)
+    {
+        $orderId = (int)$orderId;
+        if ($orderId <= 0) {
+            return;
+        }
+        try {
+            self::calcAndPersist($orderId);
+        } catch (\Throwable $e) {
+            Yii::warning('分销计佣失败 orderId=' . $orderId . ' ' . $e->getMessage(), __METHOD__);
+        }
+    }
+
+    /**
+     * 分销结算用完成时间:优先 xhOrder.successTime
+     * @param \bizHd\order\models\Order|array $order
+     * @return string|null
+     */
+    public static function getSuccessTimeForDistribution($order)
+    {
+        $successTime = is_object($order) ? ($order->successTime ?? '') : ($order['successTime'] ?? '');
+        if (!empty($successTime) && $successTime !== '0000-00-00 00:00:00') {
+            return $successTime;
+        }
+        return null;
+    }
+
+    /**
+     * 零售订单完成后:以 successTime 同步 xhDistributionOrder.finishTime(不触发结佣)
+     * @param int $orderId xhOrder.id
+     * @param string|null $successTime 与 xhOrder.successTime 一致;空则读库或补写
+     */
+    public static function onRetailOrderCompleted($orderId, $successTime = null)
+    {
+        $orderId = (int)$orderId;
+        if ($orderId <= 0) {
+            return;
+        }
+        try {
+            $order = OrderClass::getById($orderId, true);
+            if (empty($order) || (int)($order->status ?? 0) !== OrderClass::ORDER_STATUS_COMPLETE) {
+                return;
+            }
+            $time = $successTime;
+            if ($time === null || $time === '') {
+                $time = self::getSuccessTimeForDistribution($order);
+            }
+            if ($time === null) {
+                $time = date('Y-m-d H:i:s');
+                OrderClass::updateById($orderId, ['successTime' => $time]);
+            } elseif ($time !== self::getSuccessTimeForDistribution($order)) {
+                OrderClass::updateById($orderId, ['successTime' => $time]);
+            }
+            DistributionOrderClass::touchFinishTimeByOrderId($orderId, $time);
+        } catch (\Throwable $e) {
+            Yii::warning('分销完成时间回写失败 orderId=' . $orderId . ' ' . $e->getMessage(), __METHOD__);
+        }
+    }
+
+    /**
+     * 校验订单并组装计佣上下文
+     */
+    protected static function buildOrderContext($orderId)
+    {
+        $orderId = (int)$orderId;
+        if ($orderId <= 0) {
+            throw new \Exception('订单ID无效');
+        }
+
+        $order = OrderClass::getById($orderId, true);
+        if (empty($order)) {
+            throw new \Exception('订单不存在');
+        }
+
+        $shopId = (int)($order->shopId ?? 0);
+        if ($shopId <= 0) {
+            throw new \Exception('订单门店无效');
+        }
+        if ((int)($order->payStatus ?? 0) !== 1) {
+            throw new \Exception('订单未付款,无法计佣');
+        }
+        if ((int)($order->status ?? 0) === OrderClass::ORDER_STATUS_CANCEL) {
+            throw new \Exception('订单已取消,无法计佣');
+        }
+
+        $shop = ShopClass::getById($shopId, true);
+        $mainId = (int)($shop->mainId ?? 0);
+        $rule = DistributionRuleClass::getRule($shopId, $mainId);
+        if ((int)($rule['status'] ?? 0) !== 1) {
+            throw new \Exception('门店分销功能未开启');
+        }
+
+        $buyerId = (int)($order->customId ?? 0);
+        if ($buyerId <= 0) {
+            throw new \Exception('订单无客户信息');
+        }
+
+        $buyerDu = DistributionUserClass::getByCondition(['customId' => $buyerId]);
+        $distId = (int)($buyerDu['inviterId'] ?? 0);
+        if ($distId <= 0) {
+            throw new \Exception('买家无绑定分销员');
+        }
+
+        $bindTime = $buyerDu['inviteTime'] ?? '';
+        $orderTime = !empty($order->payTime) && $order->payTime !== '0000-00-00 00:00:00'
+            ? $order->payTime
+            : ($order->addTime ?? date('Y-m-d H:i:s'));
+
+        return [
+            'orderId' => $orderId,
+            'order' => $order,
+            'shopId' => $shopId,
+            'mainId' => $mainId,
+            'rule' => $rule,
+            'buyerId' => $buyerId,
+            'distId' => $distId,
+            'bindTime' => $bindTime,
+            'orderTime' => $orderTime,
+        ];
+    }
+
+    /**
+     * 统一计佣:商品实付、范围、比例、落库快照
+     */
+    protected static function computeCommissionSnapshot(array $context)
+    {
+        $order = $context['order'];
+        $rule = $context['rule'];
+        $shopId = $context['shopId'];
+        $mainId = $context['mainId'];
+        $buyerId = $context['buyerId'];
+        $distId = $context['distId'];
+        $bindTime = $context['bindTime'];
+        $orderTime = $context['orderTime'];
+
+        $bindDays = self::calcBindDaysAt($bindTime, $orderTime);
+        $payAmount = round((float)($order->actPrice ?? 0), 2);
+        $sendCost = round((float)($order->sendCost ?? 0), 2);
+        $goodsPayAmount = self::resolveGoodsPayAmount($order);
+        $orderAmount = $goodsPayAmount;
+
+        $commissionBase = self::adjustCommissionBaseByScope(
+            $rule,
+            $order->orderSn ?? '',
+            $goodsPayAmount,
+            $shopId,
+            $mainId
+        );
+        // 订单有累计退款时,计佣基数扣减 tkPrice(与 xhOrder.tkPrice 一致)
+        $commissionBase = self::applyRefundToCommissionBase($commissionBase, $order);
+
+        $calc = self::resolveCommissionRate($rule, $bindDays);
+        $commissionRate = $calc['rate'];
+        $invalidReason = $calc['invalidReason'];
+        $settleStatus = 0;
+        $commissionAmount = 0;
+        $orderComplete = (int)($order->status ?? 0) === OrderClass::ORDER_STATUS_COMPLETE;
+        $settleDays = max(0, (int)($rule['settleDays'] ?? 0));
+
+        $existingRow = DistributionOrderClass::getByCondition([
+            'orderId' => (int)$context['orderId'],
+            'distId' => $distId,
+        ]);
+        $finishTime = self::resolveDistributionFinishTime($order, $existingRow);
+
+        if ($invalidReason !== '') {
+            $settleStatus = 2;
+        } elseif ($commissionBase > 0 && $commissionRate > 0) {
+            $commissionAmount = round($commissionBase * $commissionRate / 100, 2);
+        }
+        // 完成不等于已结算:须 finishTime + settleDays 到期后才 settleStatus=1
+        if ($settleStatus !== 2 && $commissionAmount > 0) {
+            $canSettle = false;
+            if ($orderComplete && !empty($finishTime)) {
+                $canSettle = time() >= (strtotime($finishTime) + $settleDays * 86400);
+            }
+            $settleStatus = $canSettle ? 1 : 0;
+        }
+
+        $buyer = CustomClass::getById($buyerId, true);
+        $dist = CustomClass::getById($distId, true);
+        $buyerName = $buyer->name ?? ($order->customName ?? '');
+        $distName = $dist->name ?? '';
+
+        $distOrderData = [
+            'shopId' => $shopId,
+            'hdId' => (int)($order->hdId ?? 0),
+            'orderId' => (int)$context['orderId'],
+            'orderSn' => $order->orderSn ?? '',
+            'buyerId' => $buyerId,
+            'buyerName' => $buyerName,
+            'distId' => $distId,
+            'distName' => $distName,
+            'orderAmount' => $orderAmount,
+            'payAmount' => $payAmount,
+            'sendCost' => $sendCost,
+            'commissionBase' => $commissionBase,
+            'commissionRate' => $commissionRate,
+            'commissionAmount' => $commissionAmount,
+            'bonusType' => (int)($rule['bonusType'] ?? 1),
+            'bindTime' => $bindTime ?: null,
+            'bindDays' => $bindDays,
+            'settleStatus' => $settleStatus,
+            'settleTime' => $settleStatus === 1 ? date('Y-m-d H:i:s') : null,
+            'orderTime' => $order->addTime ?? null,
+            'payTime' => $order->payTime ?? null,
+            'finishTime' => $finishTime,
+            'invalidReason' => $settleStatus === 2 ? $invalidReason : '',
+            'invalidTime' => $settleStatus === 2 ? date('Y-m-d H:i:s') : null,
+        ];
+
+        $shouldRemoveDistOrder = self::shouldRemoveDistributionOrder($order, $commissionAmount, $commissionBase);
+
+        return [
+            'distOrderData' => $distOrderData,
+            // 有效计佣(非失效)均需同步 xhDistributionUser / 流水;待结算与已结算走不同累加字段
+            'shouldApplyStats' => ($settleStatus !== 2 && $commissionAmount > 0),
+            'shouldRemoveDistOrder' => $shouldRemoveDistOrder,
+            'commissionAmount' => $commissionAmount,
+            'commissionBase' => $commissionBase,
+            'buyerName' => $buyerName,
+            'result' => [
+                'orderId' => (int)$context['orderId'],
+                'orderSn' => $order->orderSn ?? '',
+                'buyerId' => $buyerId,
+                'distId' => $distId,
+                'bindDays' => $bindDays,
+                'commissionRate' => $commissionRate,
+                'commissionBase' => $commissionBase,
+                'goodsPayAmount' => $goodsPayAmount,
+                'tkPrice' => round((float)($order->tkPrice ?? 0), 2),
+                'commissionAmount' => $commissionAmount,
+                'settleStatus' => $settleStatus,
+                'invalidReason' => $invalidReason,
+                'shouldRemoveDistOrder' => $shouldRemoveDistOrder ? 1 : 0,
+                'deleted' => 0,
+            ],
+        ];
+    }
+
+    /**
+     * 是否应删除 xhDistributionOrder(有退款且分佣为 0)
+     */
+    protected static function shouldRemoveDistributionOrder($order, $commissionAmount, $commissionBase)
+    {
+        if (round((float)$commissionAmount, 2) > 0) {
+            return false;
+        }
+        $tkPrice = round((float)($order->tkPrice ?? 0), 2);
+        if ($tkPrice <= 0) {
+            return false;
+        }
+        $actPrice = round((float)($order->actPrice ?? 0), 2);
+        // 退款后实付已退尽(HdRefundService 会扣减 actPrice)
+        if (bccomp((string)$actPrice, '0', 2) <= 0) {
+            return true;
+        }
+        $mainPay = round((float)($order->mainPay ?? 0), 2);
+        if ($mainPay > 0 && bccomp((string)$tkPrice, (string)$mainPay, 2) >= 0) {
+            return true;
+        }
+        // 累计退款扣减后计佣基数已为 0
+        if (bccomp((string)round((float)$commissionBase, 2), '0', 2) <= 0) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * 累计退款已覆盖原实付 mainPay(兼容 actPrice 未归零的数据)
+     */
+    protected static function isOrderFullyRefunded($order)
+    {
+        $tkPrice = round((float)($order->tkPrice ?? 0), 2);
+        if ($tkPrice <= 0) {
+            return false;
+        }
+        $actPrice = round((float)($order->actPrice ?? 0), 2);
+        if (bccomp((string)$actPrice, '0', 2) <= 0) {
+            return true;
+        }
+        $mainPay = round((float)($order->mainPay ?? 0), 2);
+        if ($mainPay > 0 && bccomp((string)$tkPrice, (string)$mainPay, 2) >= 0) {
+            return true;
+        }
+        return bccomp((string)$tkPrice, (string)$actPrice, 2) >= 0;
+    }
+
+    /**
+     * 删除分销订单前回滚待结算/已结算统计与流水
+     */
+    protected static function rollbackAllDistOrderEffects(array $existing)
+    {
+        $settleStatus = (int)($existing['settleStatus'] ?? 0);
+        if ($settleStatus === 1) {
+            self::rollbackSettledEffects($existing);
+            return;
+        }
+        self::rollbackPendingEffects($existing, []);
+    }
+
+    /**
+     * 回滚已结算计佣对 xhDistributionUser / 流水的影响
+     */
+    protected static function rollbackSettledEffects(array $existing)
+    {
+        $oldAmount = round((float)($existing['commissionAmount'] ?? 0), 2);
+        $oldBase = round((float)($existing['commissionBase'] ?? 0), 2);
+        $distOrderId = (int)$existing['id'];
+
+        DistributionFlowClass::deleteByCondition([
+            'refType' => 1,
+            'refId' => $distOrderId,
+        ]);
+
+        if ($oldAmount <= 0) {
+            return;
+        }
+
+        $distId = (int)($existing['distId'] ?? 0);
+        $buyerId = (int)($existing['buyerId'] ?? 0);
+
+        DistributionUserClass::$baseFile::updateAllCounters(
+            [
+                'settledCommission' => -$oldAmount,
+                'availDeposit' => -$oldAmount,
+                'totalCommission' => -$oldAmount,
+                'orderCount' => -1,
+                'orderAmount' => -$oldBase,
+            ],
+            ['customId' => $distId]
+        );
+
+        if ($buyerId > 0) {
+            DistributionUserClass::$baseFile::updateAllCounters(
+                [
+                    'upOrderCount' => -1,
+                    'upOrderAmount' => -$oldBase,
+                    'upCommissionAmount' => -$oldAmount,
+                ],
+                ['customId' => $buyerId]
+            );
+        }
+    }
+
+    /**
+     * 重算前回滚原待结算计佣对统计/流水的影响
+     */
+    protected static function rollbackPendingEffects(array $existing, array $context)
+    {
+        $settleStatus = (int)($existing['settleStatus'] ?? 0);
+        $oldAmount = round((float)($existing['commissionAmount'] ?? 0), 2);
+        $oldBase = round((float)($existing['commissionBase'] ?? 0), 2);
+        if ($settleStatus !== 0 || $oldAmount <= 0) {
+            DistributionFlowClass::deleteByCondition([
+                'refType' => 1,
+                'refId' => (int)$existing['id'],
+            ]);
+            return;
+        }
+
+        $distId = (int)($existing['distId'] ?? 0);
+        $buyerId = (int)($existing['buyerId'] ?? 0);
+        $distOrderId = (int)$existing['id'];
+
+        DistributionUserClass::$baseFile::updateAllCounters(
+            [
+                'pendingCommission' => -$oldAmount,
+                'totalCommission' => -$oldAmount,
+                'orderCount' => -1,
+                'orderAmount' => -$oldBase,
+            ],
+            ['customId' => $distId]
+        );
+
+        if ($buyerId > 0) {
+            DistributionUserClass::$baseFile::updateAllCounters(
+                [
+                    'upOrderCount' => -1,
+                    'upOrderAmount' => -$oldBase,
+                    'upCommissionAmount' => -$oldAmount,
+                ],
+                ['customId' => $buyerId]
+            );
+        }
+
+        DistributionFlowClass::deleteByCondition([
+            'refType' => 1,
+            'refId' => $distOrderId,
+        ]);
+    }
+
+    /**
+     * 写入待结算流水并累加分销员/买家统计
+     */
+    protected static function applyPendingEffects(array $context, array $snapshot, $distributionOrderId)
+    {
+        $order = $context['order'];
+        $shopId = $context['shopId'];
+        $distId = $context['distId'];
+        $buyerId = $context['buyerId'];
+        $orderTime = $context['orderTime'];
+        $commissionAmount = $snapshot['commissionAmount'];
+        $commissionBase = $snapshot['commissionBase'];
+        $buyerName = $snapshot['buyerName'];
+
+        self::ensureDistributionUser($distId, $shopId);
+        self::ensureDistributionUser($buyerId, $shopId);
+
+        $distRow = DistributionUserClass::getByCondition(['customId' => $distId]);
+        $availBefore = round((float)($distRow['availDeposit'] ?? 0), 2);
+
+        DistributionFlowClass::add([
+            'shopId' => $shopId,
+            'hdId' => (int)($order->hdId ?? 0),
+            'xhCustomId' => $distId,
+            'flowType' => 1,
+            'amount' => $commissionAmount,
+            'availAfter' => $availBefore,
+            'refType' => 1,
+            'refId' => (int)$distributionOrderId,
+            'refSn' => $order->orderSn ?? '',
+            'buyerName' => $buyerName,
+            'flowStatus' => 1,
+            'flowTime' => $orderTime,
+            'remark' => '订单分红计佣',
+        ], true);
+
+        DistributionUserClass::$baseFile::updateAllCounters(
+            [
+                'pendingCommission' => $commissionAmount,
+                'totalCommission' => $commissionAmount,
+                'orderCount' => 1,
+                'orderAmount' => $commissionBase,
+            ],
+            ['customId' => $distId]
+        );
+
+        DistributionUserClass::$baseFile::updateAllCounters(
+            [
+                'upOrderCount' => 1,
+                'upOrderAmount' => $commissionBase,
+                'upCommissionAmount' => $commissionAmount,
+            ],
+            ['customId' => $buyerId]
+        );
+    }
+
+    /**
+     * 订单已完成:写入已结算流水,累加 settledCommission / availDeposit(可存入余额)
+     */
+    protected static function applySettledEffects(array $context, array $snapshot, $distributionOrderId)
+    {
+        $order = $context['order'];
+        $shopId = $context['shopId'];
+        $distId = $context['distId'];
+        $buyerId = $context['buyerId'];
+        $orderTime = $context['orderTime'];
+        $commissionAmount = $snapshot['commissionAmount'];
+        $commissionBase = $snapshot['commissionBase'];
+        $buyerName = $snapshot['buyerName'];
+
+        self::ensureDistributionUser($distId, $shopId);
+        self::ensureDistributionUser($buyerId, $shopId);
+
+        $distRow = DistributionUserClass::getByCondition(['customId' => $distId]);
+        $availBefore = round((float)($distRow['availDeposit'] ?? 0), 2);
+        $availAfter = round($availBefore + $commissionAmount, 2);
+
+        DistributionFlowClass::add([
+            'shopId' => $shopId,
+            'hdId' => (int)($order->hdId ?? 0),
+            'xhCustomId' => $distId,
+            'flowType' => 1,
+            'amount' => $commissionAmount,
+            'availAfter' => $availAfter,
+            'refType' => 1,
+            'refId' => (int)$distributionOrderId,
+            'refSn' => $order->orderSn ?? '',
+            'buyerName' => $buyerName,
+            'flowStatus' => 2,
+            'flowTime' => $orderTime,
+            'remark' => '订单分红计佣',
+        ], true);
+
+        DistributionUserClass::$baseFile::updateAllCounters(
+            [
+                'settledCommission' => $commissionAmount,
+                'availDeposit' => $commissionAmount,
+                'totalCommission' => $commissionAmount,
+                'orderCount' => 1,
+                'orderAmount' => $commissionBase,
+            ],
+            ['customId' => $distId]
+        );
+
+        DistributionUserClass::$baseFile::updateAllCounters(
+            [
+                'upOrderCount' => 1,
+                'upOrderAmount' => $commissionBase,
+                'upCommissionAmount' => $commissionAmount,
+            ],
+            ['customId' => $buyerId]
+        );
+    }
+
+    /**
+     * 解析订单「商品实付」金额(计佣基数来源,不含杂费)
+     */
+    protected static function resolveGoodsPayAmount($order)
+    {
+        $actPrice = (string)($order->actPrice ?? '0');
+        $goodsPrice = (string)($order->goodsPrice ?? '0');
+        $prePrice = (string)($order->prePrice ?? '0');
+
+        if (bccomp($goodsPrice, '0', 2) > 0) {
+            if (bccomp($prePrice, '0', 2) > 0 && bccomp($prePrice, $actPrice, 2) > 0 && bccomp($actPrice, '0', 2) > 0) {
+                return round((float)bcmul($goodsPrice, bcdiv($actPrice, $prePrice, 6), 2), 2);
+            }
+            if (bccomp($actPrice, '0', 2) > 0 && bccomp($goodsPrice, $actPrice, 2) > 0) {
+                return round((float)$actPrice, 2);
+            }
+            return round((float)$goodsPrice, 2);
+        }
+
+        $fees = '0';
+        foreach (['sendCost', 'labourCost', 'packingFee', 'packCost', 'serviceFee'] as $field) {
+            $fees = bcadd($fees, (string)($order->$field ?? '0'), 2);
+        }
+        $goodsPay = bcsub($actPrice, $fees, 2);
+
+        if (bccomp($goodsPay, '0', 2) <= 0) {
+            $lineSum = (string)self::sumOrderLinesAmount($order->orderSn ?? '');
+            if (bccomp($lineSum, '0', 2) > 0) {
+                $goodsPay = $lineSum;
+                if (bccomp($prePrice, '0', 2) > 0 && bccomp($prePrice, $actPrice, 2) > 0 && bccomp($actPrice, '0', 2) > 0) {
+                    $goodsPay = bcmul($lineSum, bcdiv($actPrice, $prePrice, 6), 2);
+                }
+            }
+        }
+
+        return round(max(0, (float)$goodsPay), 2);
+    }
+
+    protected static function sumOrderLinesAmount($orderSn)
+    {
+        if ($orderSn === '') {
+            return 0;
+        }
+        $sum = '0';
+        foreach (OrderGoodsClass::getListBySn($orderSn) as $row) {
+            $sum = bcadd($sum, (string)($row['price'] ?? '0'), 2);
+        }
+        foreach (OrderItemClass::getListBySn($orderSn) as $row) {
+            $sum = bcadd($sum, (string)($row['price'] ?? '0'), 2);
+        }
+        return round((float)$sum, 2);
+    }
+
+    /**
+     * 分销 finishTime:与 xhOrder.successTime 对齐,无则保留库中 finishTime
+     */
+    protected static function resolveDistributionFinishTime($order, array $existingRow)
+    {
+        $fromOrder = self::getSuccessTimeForDistribution($order);
+        if ($fromOrder !== null) {
+            return $fromOrder;
+        }
+        $storedFinish = $existingRow['finishTime'] ?? '';
+        if (!empty($storedFinish) && $storedFinish !== '0000-00-00 00:00:00') {
+            return $storedFinish;
+        }
+        return null;
+    }
+
+    protected static function calcBindDaysAt($bindTime, $orderTime)
+    {
+        if (empty($bindTime) || $bindTime === '0000-00-00 00:00:00') {
+            return 0;
+        }
+        $bindTs = strtotime($bindTime);
+        $orderTs = strtotime($orderTime);
+        if ($bindTs <= 0 || $orderTs <= 0) {
+            return 0;
+        }
+        return max(0, (int)floor(($orderTs - $bindTs) / 86400));
+    }
+
+    /**
+     * 计佣基数扣减订单累计退款金额
+     * @param float $commissionBase 范围调整后的基数
+     * @param object $order xhOrder
+     * @return float
+     */
+    protected static function applyRefundToCommissionBase($commissionBase, $order)
+    {
+        $commissionBase = round((float)$commissionBase, 2);
+        $tkPrice = round((float)($order->tkPrice ?? 0), 2);
+        if ($tkPrice <= 0 || $commissionBase <= 0) {
+            return $commissionBase;
+        }
+        $after = bcsub((string)$commissionBase, (string)$tkPrice, 2);
+        return round(max(0, (float)$after), 2);
+    }
+
+    protected static function resolveCommissionRate($rule, $bindDays)
+    {
+        $bonusType = (int)($rule['bonusType'] ?? 1);
+        $durationType = (int)($rule['durationType'] ?? 1);
+        $durationDays = (int)($rule['durationDays'] ?? 0);
+
+        if ($bonusType === 1 && $durationType === 2 && $durationDays > 0 && $bindDays > $durationDays) {
+            return ['rate' => 0, 'invalidReason' => '超出分红限制天数'];
+        }
+
+        if ($bonusType === 1) {
+            return ['rate' => round((float)($rule['bonusRate'] ?? 0), 2), 'invalidReason' => ''];
+        }
+
+        $tiers = isset($rule['tiers']) && is_array($rule['tiers']) ? $rule['tiers'] : [];
+        foreach ($tiers as $tier) {
+            $minDays = (int)($tier['minDays'] ?? 0);
+            $maxDays = (int)($tier['maxDays'] ?? 0);
+            $rate = round((float)($tier['bonusRate'] ?? 0), 2);
+            if ($bindDays >= $minDays && ($maxDays === 0 || $bindDays <= $maxDays)) {
+                return ['rate' => $rate, 'invalidReason' => ''];
+            }
+        }
+
+        return ['rate' => 0, 'invalidReason' => '未匹配到时间阶梯'];
+    }
+
+    protected static function adjustCommissionBaseByScope($rule, $orderSn, $goodsPayAmount, $shopId, $mainId)
+    {
+        $productScope = (int)($rule['productScope'] ?? 1);
+        $goodsPayAmount = round((float)$goodsPayAmount, 2);
+        if ($productScope === 1 || $goodsPayAmount <= 0 || $orderSn === '') {
+            return $goodsPayAmount;
+        }
+
+        $goodsList = OrderGoodsClass::getListBySn($orderSn);
+        $itemList = OrderItemClass::getListBySn($orderSn);
+        $totalLineAmount = 0;
+        $eligibleAmount = 0;
+
+        if ($productScope === 2) {
+            $goodsIds = array_map('intval', $rule['goodsIds'] ?? []);
+            $goodsIds = array_filter($goodsIds);
+            foreach ($goodsList as $row) {
+                $lineAmount = round((float)($row['price'] ?? 0), 2);
+                $totalLineAmount += $lineAmount;
+                if (in_array((int)($row['goodsId'] ?? 0), $goodsIds, true)) {
+                    $eligibleAmount += $lineAmount;
+                }
+            }
+            foreach ($itemList as $row) {
+                $totalLineAmount += round((float)($row['price'] ?? 0), 2);
+            }
+        } elseif ($productScope === 3) {
+            $categoryIds = array_map('intval', $rule['categoryIds'] ?? []);
+            $categoryIds = array_filter($categoryIds);
+            $goodsIdsInOrder = array_filter(array_map('intval', array_column($goodsList, 'goodsId')));
+            $eligibleGoodsIds = [];
+            if (!empty($goodsIdsInOrder) && !empty($categoryIds)) {
+                $catRows = GoodsCategoryClass::getAllByCondition([
+                    'gId' => ['in', $goodsIdsInOrder],
+                    'cId' => ['in', $categoryIds],
+                ]);
+                foreach ($catRows as $catRow) {
+                    $eligibleGoodsIds[(int)$catRow['gId']] = 1;
+                }
+            }
+            foreach ($goodsList as $row) {
+                $lineAmount = round((float)($row['price'] ?? 0), 2);
+                $totalLineAmount += $lineAmount;
+                if (!empty($eligibleGoodsIds[(int)($row['goodsId'] ?? 0)])) {
+                    $eligibleAmount += $lineAmount;
+                }
+            }
+            foreach ($itemList as $row) {
+                $lineAmount = round((float)($row['price'] ?? 0), 2);
+                $totalLineAmount += $lineAmount;
+                if (in_array((int)($row['classId'] ?? 0), $categoryIds, true)) {
+                    $eligibleAmount += $lineAmount;
+                }
+            }
+        }
+
+        if ($totalLineAmount <= 0 || $eligibleAmount <= 0) {
+            return 0;
+        }
+
+        return round($goodsPayAmount * ($eligibleAmount / $totalLineAmount), 2);
+    }
+
+    protected static function ensureDistributionUser($customId, $shopId)
+    {
+        $customId = (int)$customId;
+        $row = DistributionUserClass::getByCondition(['customId' => $customId]);
+        if (!empty($row)) {
+            return;
+        }
+        DistributionUserClass::add([
+            'id' => $customId,
+            'customId' => $customId,
+            'shopId' => (int)$shopId,
+        ], true);
+    }
+}

+ 185 - 0
biz-hd/distribution/classes/DistributionDepositClass.php

@@ -0,0 +1,185 @@
+<?php
+/**
+ * 用途:可存分红一次性转入花店余额(全额 availDeposit)
+ * 谁用:app-mall DistributionController::actionDepositAvail
+ */
+
+namespace bizHd\distribution\classes;
+
+use bizHd\balance\classes\BalanceChangeClass;
+use bizHd\balance\classes\BalancePayChangeClass;
+use bizHd\base\classes\BaseClass;
+use bizHd\custom\classes\CustomClass;
+use bizHd\custom\classes\HdClass;
+use common\components\dict;
+use common\components\stringUtil;
+use common\components\util;
+use Yii;
+
+class DistributionDepositClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\distribution\models\DistributionDepositLog';
+
+    /**
+     * 将当前全部可存分红转入 xhCustom 余额
+     * @param int $shopId 门店
+     * @param int $mainId mainId
+     * @param int $hdId 花店 xhCustomHd.id
+     * @param int $customId 客户 xhCustom.id
+     * @return array depositSn、depositAmount、availDeposit 等
+     * @throws \Throwable
+     */
+    public static function depositAvailFull($shopId, $mainId, $hdId, $customId)
+    {
+        $shopId = (int)$shopId;
+        $mainId = (int)$mainId;
+        $hdId = (int)$hdId;
+        $customId = (int)$customId;
+        if ($shopId <= 0 || $hdId <= 0 || $customId <= 0) {
+            throw new \Exception('参数无效');
+        }
+
+        $lockKey = 'distribution_deposit_' . $shopId . '_' . $customId;
+        $locked = Yii::$app->redis->executeCommand('SET', [$lockKey, '1', 'NX', 'EX', 15]);
+        if ($locked === false || $locked === null) {
+            throw new \Exception('操作进行中,请稍后再试');
+        }
+
+        try {
+            return self::depositAvailFullInTransaction($shopId, $mainId, $hdId, $customId);
+        } finally {
+            Yii::$app->redis->executeCommand('DEL', [$lockKey]);
+        }
+    }
+
+    /**
+     * 事务内落库:分销用户、存入日志、余额变动、分红流水
+     */
+    protected static function depositAvailFullInTransaction($shopId, $mainId, $hdId, $customId)
+    {
+        $transaction = Yii::$app->db->beginTransaction();
+        try {
+            $distUser = DistributionUserClass::getLockById($customId);
+            if (empty($distUser)) {
+                throw new \Exception('暂无分红账户');
+            }
+            if ((int)($distUser->shopId ?? 0) !== $shopId) {
+                throw new \Exception('门店与分红账户不匹配');
+            }
+
+            $beforeAvail = round((float)($distUser->availDeposit ?? 0), 2);
+            if ($beforeAvail <= 0) {
+                throw new \Exception('暂无可存入分红');
+            }
+            $amount = $beforeAvail;
+            $afterAvail = 0;
+
+            $hd = HdClass::getLockById($hdId);
+            if (empty($hd) || (int)($hd->customId ?? 0) !== $customId || (int)($hd->shopId ?? 0) !== $shopId) {
+                throw new \Exception('花店账户无效');
+            }
+            $custom = CustomClass::getLockById($customId);
+            if (empty($custom) || (int)($custom->shopId ?? 0) !== $shopId) {
+                throw new \Exception('客户信息无效');
+            }
+
+            $hdName = $hd->name ?? '';
+            $customName = $custom->name ?? '';
+            $depositSn = stringUtil::generateDistributionDepositSn();
+            $flowTime = date('Y-m-d H:i:s');
+
+            // 可存分红扣减、累计存入增加
+            $distUser->availDeposit = bcsub((string)$distUser->availDeposit, (string)$amount, 2);
+            $distUser->totalDeposit = bcadd((string)($distUser->totalDeposit ?? 0), (string)$amount, 2);
+            $distUser->save(false);
+
+            // 余额增加(全部计入充值余额 balancePay,不参与赠送/积分)
+            $custom->balance = bcadd((string)($custom->balance ?? 0), (string)$amount, 2);
+            $custom->balancePay = bcadd((string)($custom->balancePay ?? 0), (string)$amount, 2);
+            $hd->balance = bcadd((string)($hd->balance ?? 0), (string)$amount, 2);
+            $hd->balancePay = bcadd((string)($hd->balancePay ?? 0), (string)$amount, 2);
+            $custom->save(false);
+            $hd->save(false);
+
+            if (bccomp((string)$hd->balance, (string)$custom->balance, 2) !== 0
+                || bccomp((string)$hd->balancePay, (string)$custom->balancePay, 2) !== 0) {
+                throw new \Exception('余额同步异常');
+            }
+
+            $depositLog = self::add([
+                'shopId' => $shopId,
+                'hdId' => $hdId,
+                'xhCustomId' => $customId,
+                'depositSn' => $depositSn,
+                'depositAmount' => $amount,
+                'beforeAvail' => $beforeAvail,
+                'afterAvail' => $afterAvail,
+                'balanceChangeId' => 0,
+                'remark' => '分红存入余额',
+            ], true);
+            $depositLogId = (int)$depositLog->id;
+
+            $capitalType = dict::getDict('capitalType', 'distributionDeposit', 'id');
+            $payWay = dict::getDict('payWay', 'unknown');
+            $event = '分红存入' . $depositSn;
+            $changeBase = [
+                'customId' => $customId,
+                'customName' => $customName,
+                'hdId' => $hdId,
+                'hdName' => $hdName,
+                'relateId' => $depositLogId,
+                'onlinePay' => 0,
+                'capitalType' => $capitalType,
+                'amount' => $amount,
+                'io' => 1,
+                'side' => 1,
+                'payWay' => $payWay,
+                'event' => $event,
+                'staffId' => 0,
+                'staffName' => '',
+                'shopId' => $shopId,
+                'mainId' => $mainId,
+                'remark' => '分红存入余额',
+            ];
+
+            $balanceChange = BalanceChangeClass::add(array_merge($changeBase, [
+                'balance' => $custom->balance,
+            ]), true);
+            $balanceChangeId = (int)$balanceChange->id;
+
+            BalancePayChangeClass::add(array_merge($changeBase, [
+                'balance' => $custom->balancePay,
+            ]), true);
+
+            self::updateById($depositLogId, ['balanceChangeId' => $balanceChangeId]);
+
+            DistributionFlowClass::add([
+                'shopId' => $shopId,
+                'hdId' => $hdId,
+                'xhCustomId' => $customId,
+                'flowType' => 2,
+                'amount' => $amount,
+                'availAfter' => $afterAvail,
+                'refType' => 2,
+                'refId' => $depositLogId,
+                'refSn' => $depositSn,
+                'buyerName' => '',
+                'flowStatus' => 3,
+                'flowTime' => $flowTime,
+                'remark' => '分红存入余额',
+            ], true);
+
+            $transaction->commit();
+
+            return [
+                'depositSn' => $depositSn,
+                'depositAmount' => $amount,
+                'availDeposit' => $afterAvail,
+                'balance' => round((float)$custom->balance, 2),
+            ];
+        } catch (\Throwable $e) {
+            $transaction->rollBack();
+            throw $e;
+        }
+    }
+}

+ 28 - 1
biz-hd/distribution/classes/DistributionOrderClass.php

@@ -41,7 +41,7 @@ class DistributionOrderClass extends BaseClass
 
         $period = dateUtil::formatTime($searchTime, $startTime, $endTime);
 
-        // 本月分红合计(同筛选月份,不受 settleStatus Tab 影响
+        // 分红合计:与列表相同的 searchTime 时段、settleStatus(不含 keyword 分页条件
         $monthQuery = (new Query())
             ->from('xhDistributionOrder')
             ->where(['shopId' => $shopId]);
@@ -51,6 +51,9 @@ class DistributionOrderClass extends BaseClass
         if ($buyerId > 0) {
             $monthQuery->andWhere(['buyerId' => $buyerId]);
         }
+        if ($settleStatus !== '' && $settleStatus !== 'all') {
+            $monthQuery->andWhere(['settleStatus' => (int)$settleStatus]);
+        }
         if (!empty($period['startTime']) && !empty($period['endTime'])) {
             $monthQuery->andWhere(['between', 'orderTime', $period['startTime'], $period['endTime']]);
         }
@@ -168,4 +171,28 @@ class DistributionOrderClass extends BaseClass
         }
         return 'invalid';
     }
+
+    /**
+     * 订单完成后回写 finishTime(取值与 xhOrder.successTime 一致,不改 settleStatus)
+     * @param int $orderId xhOrder.id
+     * @param string|null $finishTime 默认当前时间
+     * @return int 更新行数
+     */
+    public static function touchFinishTimeByOrderId($orderId, $finishTime = null)
+    {
+        $orderId = (int)$orderId;
+        if ($orderId <= 0) {
+            return 0;
+        }
+        $finishTime = $finishTime ?: date('Y-m-d H:i:s');
+        return (int)self::$baseFile::updateAll(
+            ['finishTime' => $finishTime],
+            [
+                'and',
+                ['orderId' => $orderId],
+                ['settleStatus' => 0],
+                ['>', 'commissionAmount', 0],
+            ]
+        );
+    }
 }

+ 85 - 0
biz-hd/distribution/classes/DistributionSettleClass.php

@@ -0,0 +1,85 @@
+<?php
+/**
+ * 用途:分销延迟结算(finishTime + xhDistributionRule.settleDays 到期后结佣)
+ * 谁用:console distribution/settle-due 定时任务
+ */
+
+namespace bizHd\distribution\classes;
+
+use bizHd\order\classes\OrderClass;
+use Yii;
+use yii\db\Query;
+
+class DistributionSettleClass
+{
+    /**
+     * 扫描到期待结算记录并调用 calcAndPersist
+     * @param int $limit 单次最多处理条数
+     * @param int $shopId 0 表示全部门店
+     * @return array scanned settled failed errors
+     */
+    public static function runDueSettle($limit = 200, $shopId = 0)
+    {
+        $limit = max(1, min(2000, (int)$limit));
+        $shopId = (int)$shopId;
+
+        $query = (new Query())
+            ->select(['d.id', 'd.orderId', 'd.shopId'])
+            ->from(['d' => 'xhDistributionOrder'])
+            ->innerJoin(
+                ['r' => 'xhDistributionRule'],
+                'r.shopId = d.shopId AND r.status = 1'
+            )
+            ->innerJoin(['o' => 'xhOrder'], 'o.id = d.orderId')
+            ->where([
+                'd.settleStatus' => 0,
+                'o.payStatus' => 1,
+                'o.status' => OrderClass::ORDER_STATUS_COMPLETE,
+            ])
+            ->andWhere(['>', 'd.commissionAmount', 0])
+            ->andWhere(['not', ['d.finishTime' => null]])
+            ->andWhere(['<>', 'd.finishTime', ''])
+            ->andWhere(['<>', 'd.finishTime', '0000-00-00 00:00:00'])
+            ->andWhere('d.finishTime <= DATE_SUB(NOW(), INTERVAL r.settleDays DAY)');
+
+        if ($shopId > 0) {
+            $query->andWhere(['d.shopId' => $shopId]);
+        }
+
+        $rows = $query
+            ->orderBy(['d.id' => SORT_ASC])
+            ->limit($limit)
+            ->all(Yii::$app->db);
+
+        $settled = 0;
+        $failed = 0;
+        $errors = [];
+
+        foreach ($rows as $row) {
+            $orderId = (int)($row['orderId'] ?? 0);
+            if ($orderId <= 0) {
+                continue;
+            }
+            try {
+                $result = DistributionCommissionClass::calcAndPersist($orderId);
+                if ((int)($result['settleStatus'] ?? 0) === 1) {
+                    $settled++;
+                }
+            } catch (\Throwable $e) {
+                $failed++;
+                $errors[] = [
+                    'orderId' => $orderId,
+                    'msg' => $e->getMessage(),
+                ];
+                Yii::warning('分销到期结算失败 orderId=' . $orderId . ' ' . $e->getMessage(), __METHOD__);
+            }
+        }
+
+        return [
+            'scanned' => count($rows),
+            'settled' => $settled,
+            'failed' => $failed,
+            'errors' => $errors,
+        ];
+    }
+}

+ 83 - 0
biz-hd/distribution/classes/DistributionUserClass.php

@@ -75,6 +75,89 @@ class DistributionUserClass extends BaseClass
         return $stat;
     }
 
+    /**
+     * 新建 xhCustom 时初始化 xhDistributionUser
+     * 规则:customId 无记录则创建;有邀请人则写 inviterId、inviteTime,并给邀请人 inviteCount +1;已有记录则不操作
+     * @param int $shopId 门店 ID
+     * @param int $inviteeCustomId 被邀请人 xhCustom.id
+     * @param int $inviterCustomId 邀请人 xhCustom.id,0 表示无邀请人
+     * @return int 0未操作 1已创建且完成邀请绑定
+     * @throws \Throwable
+     */
+    public static function bindInviteOnRegister($shopId, $inviteeCustomId, $inviterCustomId = 0)
+    {
+        $shopId = (int)$shopId;
+        $inviteeCustomId = (int)$inviteeCustomId;
+        $inviterCustomId = (int)$inviterCustomId;
+
+        if ($shopId <= 0 || $inviteeCustomId <= 0) {
+            return 0;
+        }
+
+        // 按 customId 查是否已有分销记录,有则不做任何操作
+        $inviteeRow = self::getByCondition(['customId' => $inviteeCustomId]);
+        if (!empty($inviteeRow)) {
+            return 0;
+        }
+
+        // 校验邀请人(无效则仍创建被邀请人记录,但不绑定)
+        $canBindInviter = false;
+        if ($inviterCustomId > 0 && $inviterCustomId !== $inviteeCustomId) {
+            $inviter = CustomClass::getById($inviterCustomId, true);
+            if (!empty($inviter) && (int)$inviter->shopId === $shopId) {
+                $canBindInviter = true;
+            }
+        }
+
+        $createData = [
+            'id' => $inviteeCustomId,
+            'customId' => $inviteeCustomId,
+            'shopId' => $shopId,
+        ];
+        if ($canBindInviter) {
+            $createData['inviterId'] = $inviterCustomId;
+            $createData['inviteTime'] = date('Y-m-d H:i:s');
+        }
+
+        $transaction = Yii::$app->db->beginTransaction();
+        try {
+            try {
+                self::add($createData, true);
+            } catch (\yii\db\IntegrityException $e) {
+                // 并发下记录已被创建,不做任何操作
+                if (strpos($e->getMessage(), 'Duplicate entry') !== false) {
+                    $transaction->rollBack();
+                    return 0;
+                }
+                throw $e;
+            }
+
+            // 绑定邀请人成功后,邀请人拉新数量 +1
+            if ($canBindInviter) {
+                $inviterRow = self::getByCondition(['customId' => $inviterCustomId]);
+                if (empty($inviterRow)) {
+                    self::add([
+                        'id' => $inviterCustomId,
+                        'customId' => $inviterCustomId,
+                        'shopId' => $shopId,
+                        'inviteCount' => 1,
+                    ], true);
+                } else {
+                    static::$baseFile::updateAllCounters(
+                        ['inviteCount' => 1],
+                        ['customId' => $inviterCustomId]
+                    );
+                }
+            }
+
+            $transaction->commit();
+            return $canBindInviter ? 1 : 0;
+        } catch (\Throwable $e) {
+            $transaction->rollBack();
+            throw $e;
+        }
+    }
+
     /**
      * 无分销记录时的默认统计
      */

+ 17 - 0
biz-hd/distribution/models/DistributionDepositLog.php

@@ -0,0 +1,17 @@
+<?php
+/**
+ * 用途:分红存入日志表模型 xhDistributionDepositLog
+ * 谁用:DistributionDepositClass、mallApp 分红存入
+ */
+
+namespace bizHd\distribution\models;
+
+use bizHd\base\models\Base;
+
+class DistributionDepositLog extends Base
+{
+    public static function tableName()
+    {
+        return 'xhDistributionDepositLog';
+    }
+}

+ 5 - 0
biz-hd/order/classes/OrderClass.php

@@ -14,6 +14,7 @@ use bizGhs\product\classes\ProductClass;
 use bizGhs\stock\classes\StockRecordClass;
 use bizHd\custom\classes\CustomClass;
 use bizHd\custom\classes\HdClass;
+use bizHd\distribution\classes\DistributionCommissionClass;
 use bizHd\hb\classes\HbClass;
 use bizHd\item\classes\ItemClass;
 use bizHd\merchant\classes\SjClass;
@@ -671,6 +672,10 @@ class OrderClass extends BaseClass
         if (intval($order->groupBuyId ?? 0) > 0) {
             GroupBuyClass::onOrderPaid($order);
         }
+        // 常规零售单支付成功后计分销提成(无分销关系等场景内部抛错,不阻断付款)
+        if ((int)($order->forward ?? 0) === 0) {
+            DistributionCommissionClass::tryCalcDistributionAfterPay((int)$orderId);
+        }
     }
 
     //转化出送到时间 ssh 2020.3.15

+ 4 - 1
biz-hd/order/classes/OrderSendClass.php

@@ -6,6 +6,7 @@ use biz\shop\models\Shop;
 use biz\sj\classes\MerchantClass;
 use bizHd\message\classes\InformAdminClass;
 use bizHd\message\classes\InformUserClass;
+use bizHd\distribution\classes\DistributionCommissionClass;
 use bizHd\user\classes\UserClass;
 use common\components\util;
 use bizHd\base\classes\BaseClass;
@@ -77,6 +78,8 @@ class OrderSendClass extends BaseClass
         $shop->unSendOrder -= 1;
         $shop->save();
 
+        DistributionCommissionClass::onRetailOrderCompleted((int)$id);
+
         return OrderClass::ORDER_TOTAL_FLOW;
     }
 
@@ -102,4 +105,4 @@ class OrderSendClass extends BaseClass
         //InformAdminClass::sendComplete($order);
     }
 
-}
+}

+ 77 - 12
biz-hd/order/classes/PsMethodClass.php

@@ -349,6 +349,28 @@ class PsMethodClass extends BaseClass
         return (!empty($has) && $has == 1);
     }
 
+    /**
+     * 当天首单未收包装费时标记(后续单不再收)
+     */
+    public static function isPackFeeSkippedToday($customId, $sendType = 0)
+    {
+        if (empty($customId)) {
+            return false;
+        }
+        $current = date('Y_m_d');
+        $key = 'hd_ps_custom_today_skip_pack_cost_' . (int)$sendType . '_' . $current . '_' . $customId;
+        $has = Yii::$app->redis->executeCommand('GET', [$key]);
+        return (!empty($has) && $has == 1);
+    }
+
+    /**
+     * 当天是否已对包装附加费做过决策(已收或首单免收)
+     */
+    public static function hasPackFeeDecisionToday($customId, $sendType = 0)
+    {
+        return self::isPackPaidToday($customId, $sendType) || self::isPackFeeSkippedToday($customId, $sendType);
+    }
+
     /**
      * 判断当天是否已收过运费类附加费(xhPsMethod unMeet=0)
      */
@@ -363,23 +385,66 @@ class PsMethodClass extends BaseClass
         return (!empty($has) && $has == 1);
     }
 
+    /**
+     * 当天首单未收附加运费时标记(后续单不再收)
+     */
+    public static function isSendFeeSkippedToday($customId, $sendType = 0)
+    {
+        if (empty($customId)) {
+            return false;
+        }
+        $current = date('Y_m_d');
+        $key = 'hd_ps_custom_today_skip_send_cost_' . (int)$sendType . '_' . $current . '_' . $customId;
+        $has = Yii::$app->redis->executeCommand('GET', [$key]);
+        return (!empty($has) && $has == 1);
+    }
+
+    /**
+     * 当天是否已对附加运费做过决策(已收或首单免收)
+     */
+    public static function hasSendFeeDecisionToday($customId, $sendType = 0)
+    {
+        return self::isSendCostPaidToday($customId, $sendType) || self::isSendFeeSkippedToday($customId, $sendType);
+    }
+
     /**
      * 支付成功后标记当天已收附加费,保证同一客户同配送方式每日只收一次
      */
     public static function markUnMeetFeePaid($customId, $sendType, $packingFee = 0, $unMeetSendCost = 0)
+    {
+        self::recordDailyUnMeetFeeState($customId, $sendType, $packingFee, $unMeetSendCost);
+    }
+
+    /**
+     * 首单支付后记录当日附加费决策:收过则不再收;首单未收则当日后续也不收
+     */
+    protected static function recordDailyUnMeetFeeState($customId, $sendType, $packingFee = 0, $unMeetSendCost = 0)
     {
         if (empty($customId)) {
             return;
         }
         $current = date('Y_m_d');
         $style = (int)$sendType;
-        if ((float)$packingFee > 0) {
-            $packKey = 'hd_ps_custom_today_has_get_pack_cost_' . $style . '_' . $current . '_' . $customId;
-            Yii::$app->redis->executeCommand('SETEX', [$packKey, 86400, '1']);
+        $ttl = 86400;
+
+        if (!self::hasSendFeeDecisionToday($customId, $style)) {
+            if ((float)$unMeetSendCost > 0) {
+                $sendKey = 'hd_ps_custom_today_has_get_send_cost_' . $style . '_' . $current . '_' . $customId;
+                Yii::$app->redis->executeCommand('SETEX', [$sendKey, $ttl, '1']);
+            } else {
+                $skipKey = 'hd_ps_custom_today_skip_send_cost_' . $style . '_' . $current . '_' . $customId;
+                Yii::$app->redis->executeCommand('SETEX', [$skipKey, $ttl, '1']);
+            }
         }
-        if ((float)$unMeetSendCost > 0) {
-            $sendKey = 'hd_ps_custom_today_has_get_send_cost_' . $style . '_' . $current . '_' . $customId;
-            Yii::$app->redis->executeCommand('SETEX', [$sendKey, 86400, '1']);
+
+        if (!self::hasPackFeeDecisionToday($customId, $style)) {
+            if ((float)$packingFee > 0) {
+                $packKey = 'hd_ps_custom_today_has_get_pack_cost_' . $style . '_' . $current . '_' . $customId;
+                Yii::$app->redis->executeCommand('SETEX', [$packKey, $ttl, '1']);
+            } else {
+                $skipKey = 'hd_ps_custom_today_skip_pack_cost_' . $style . '_' . $current . '_' . $customId;
+                Yii::$app->redis->executeCommand('SETEX', [$skipKey, $ttl, '1']);
+            }
         }
     }
 
@@ -403,7 +468,7 @@ class PsMethodClass extends BaseClass
                 $unMeetSendCost = (float)$cached;
             }
         }
-        self::markUnMeetFeePaid($customId, $sendType, $packingFee, $unMeetSendCost);
+        self::recordDailyUnMeetFeeState($customId, $sendType, $packingFee, $unMeetSendCost);
     }
 
     /**
@@ -434,10 +499,10 @@ class PsMethodClass extends BaseClass
             return $costs;
         }
 
-        // unMeet: 0 加收运费,2 加收包装费
-        if ($unMeet === 0 && !self::isSendCostPaidToday($customId, $style)) {
+        // unMeet: 0 加收运费,2 加收包装费(每客户每购买方式每日至多收一次;首单未收则当日不再收)
+        if ($unMeet === 0 && !self::hasSendFeeDecisionToday($customId, $style)) {
             $costs['sendCost'] = $unMeetFee;
-        } elseif ($unMeet === 2 && !self::isPackPaidToday($customId, $style)) {
+        } elseif ($unMeet === 2 && !self::hasPackFeeDecisionToday($customId, $style)) {
             $costs['packCost'] = $unMeetFee;
         }
 
@@ -487,10 +552,10 @@ class PsMethodClass extends BaseClass
 
         if (($unMeet === 0 || $unMeet === 2) && $unMeetFee > 0) {
             $feeToCheck = $unMeet === 2 ? $packCost : $unMeetSendCost;
-            if ($unMeet === 2 && self::isPackPaidToday($customId, $style)) {
+            if ($unMeet === 2 && self::hasPackFeeDecisionToday($customId, $style)) {
                 return '';
             }
-            if ($unMeet === 0 && self::isSendCostPaidToday($customId, $style)) {
+            if ($unMeet === 0 && self::hasSendFeeDecisionToday($customId, $style)) {
                 return '';
             }
             if (bccomp((string)$feeToCheck, (string)$unMeetFee, 2) < 0) {

+ 3 - 1
biz-hd/order/services/OrderSendService.php

@@ -8,6 +8,7 @@ use bizHd\goods\classes\CategoryClass;
 use bizHd\goods\classes\UsageClass;
 use biz\sj\classes\MerchantAssetClass;
 use bizHd\order\classes\OrderClass;
+use bizHd\distribution\classes\DistributionCommissionClass;
 use bizHd\order\classes\OrderExpressClass;
 use bizHd\order\classes\OrderSendClass;
 use common\components\util;
@@ -134,6 +135,7 @@ class OrderSendService extends BaseService
 
         $id = $order['id'] ?? 0;
         OrderClass::updateById($id, ['status' => OrderClass::ORDER_STATUS_COMPLETE, 'currentFlow' => OrderClass::ORDER_TOTAL_FLOW]);
+        DistributionCommissionClass::onRetailOrderCompleted((int)$id);
 
         //配送中订单-1,完成订单+1
         $shopId = $order['shopId'] ?? 0;
@@ -191,4 +193,4 @@ class OrderSendService extends BaseService
         return $list;
     }
 
-}
+}

+ 2 - 1
biz-hd/refund/services/HdRefundService.php

@@ -317,8 +317,9 @@ class HdRefundService extends BaseService
                 CustomClass::skRefundDebtAmountReduce($custom, $refundPrice, $refund, $payWay);
             }
         }
+
         return $refund;
     }
 
 
-}
+}

+ 4 - 0
common/components/dict.php

@@ -504,6 +504,8 @@ class dict
             'balanceMerge' => ['id' => 81, 'name' => 'balanceMerge'],
             // 客户充值退款(供货商后台操作)
             'customRechargeRefund' => ['id' => 82, 'name' => 'customRechargeRefund'],
+            // 商城分红存入花店余额(xhDistributionDepositLog)
+            'distributionDeposit' => ['id' => 83, 'name' => 'distributionDeposit'],
         ],
         "capitalTypeList" => [//流水类型的对应链接,后台收支明细查看时跳转的链接
             0 => ['link' => '/capital/order-detail', 'name' => '网店', 'orderLink' => '/order/detail', 'id' => 0,],
@@ -557,6 +559,8 @@ class dict
             79 => ['link' => '', 'name' => '扫码付款', 'id' => 79,],
             80 => ['link' => '', 'name' => '扫码退款', 'id' => 80,],
             81 => ['link' => '', 'name' => '账户合并', 'id' => 81,],
+            82 => ['link' => '', 'name' => '充值退款', 'id' => 82,],
+            83 => ['link' => '', 'name' => '分红存入', 'id' => 83,],
         ],
 
         //用户来源

+ 19 - 1
common/components/stringUtil.php

@@ -340,6 +340,24 @@ class stringUtil
         return $no;
     }
 
+    /**
+     * 分红存入编号:D + 年月日时分秒(14) + 毫秒(3) + 4位随机数
+     */
+    public static function generateDistributionDepositSn()
+    {
+        $micro = microtime(true);
+        $sec = (int)$micro;
+        $ms = (int)round(($micro - $sec) * 1000);
+        if ($ms > 999) {
+            $ms = 999;
+        }
+        $no = 'D' . date('ymdHi', $sec) . sprintf('%03d', $ms) . mt_rand(1, 99);
+        if (strlen($no) > 32) {
+            $no = substr($no, 0, 32);
+        }
+        return $no;
+    }
+
     //微信32位的场景ID ssh 2019.9.4
     public static function generateSceneId($sjId)
     {
@@ -415,4 +433,4 @@ class stringUtil
         return $str;
     }
 
-}
+}

+ 75 - 0
console/controllers/DistributionController.php

@@ -0,0 +1,75 @@
+<?php
+/**
+ * 用途:分销相关 Console 命令
+ * 谁用:运维/开发按订单 ID 触发分销计佣
+ */
+
+namespace console\controllers;
+
+use bizHd\distribution\classes\DistributionCommissionClass;
+use bizHd\distribution\classes\DistributionSettleClass;
+use common\components\stringUtil;
+use yii\console\Controller;
+
+class DistributionController extends Controller
+{
+    /**
+     * 按订单 ID 计算并落库分销提成
+     * 无记录则新建;已有待结算/失效记录则自动重算;已结算不可重算
+     * 用法:php yii distribution/calc-order 12345
+     * @param int $orderId xhOrder.id
+     * @return int
+     */
+    public function actionCalcOrder($orderId = 0)
+    {
+        $orderId = (int)$orderId;
+        if ($orderId <= 0) {
+            echo json_encode(['ok' => 0, 'msg' => '请传入 orderId,例如:php yii distribution/calc-order 12345'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL;
+            return 1;
+        }
+
+        try {
+            $result = DistributionCommissionClass::calcAndPersist($orderId);
+            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL;
+            return 0;
+        } catch (\Throwable $e) {
+            echo json_encode(['ok' => 0, 'msg' => $e->getMessage()], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL;
+            return 1;
+        }
+    }
+
+    /**
+     * 仅预览计佣结果(与 calc-order 同一套规则,不写库)
+     */
+    public function actionPreviewOrder($orderId = 0)
+    {
+        $orderId = (int)$orderId;
+        if ($orderId <= 0) {
+            echo json_encode(['ok' => 0, 'msg' => '请传入 orderId'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL;
+            return 1;
+        }
+
+        try {
+            $result = DistributionCommissionClass::calculatePreview($orderId);
+            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL;
+            return 0;
+        } catch (\Throwable $e) {
+            echo json_encode(['ok' => 0, 'msg' => $e->getMessage()], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL;
+            return 1;
+        }
+    }
+
+    /**
+     * 到期分销结佣(finishTime + settleDays)
+     * 用法:php yii distribution/settle-due 200
+     * @param int $limit 单次处理条数
+     * @param int $shopId 0 全部门店
+     * @return int
+     */
+    public function actionSettleDue($limit = 200, $shopId = 0)
+    {
+        $result = DistributionSettleClass::runDueSettle((int)$limit, (int)$shopId);
+        echo json_encode(array_merge(['ok' => 1], $result), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL;
+        return !empty($result['failed']) ? 1 : 0;
+    }
+}

+ 3 - 1
console/shell/crontab.txt

@@ -11,4 +11,6 @@ expire_logs_day=7
 #花卉宝 每周执行脚本
 #0 1 * * 0 /opt/shell/week.sh 1>> /opt/shell/log/week_info_$(date +\%Y\%m\%d).txt 2>> /opt/shell/log/week_err_$(date +\%Y\%m\%d).txt
 #花卉宝 每天执行脚本
-#0 1 * * * /opt/shell/day.sh 1>> /opt/shell/log/day_info_$(date +\%Y\%m\%d).txt 2>> /opt/shell/log/day_err_$(date +\%Y\%m\%d).txt
+#0 1 * * * /opt/shell/day.sh 1>> /opt/shell/log/day_info_$(date +\%Y\%m\%d).txt 2>> /opt/shell/log/day_err_$(date +\%Y\%m\%d).txt
+# 分销延迟结佣(finishTime + settleDays),建议每小时;路径按实际 huahuibao 目录调整
+#0 * * * * cd /path/to/huahuibao && php yii distribution/settle-due 500

+ 212 - 0
sql/20260706_redesign.sql

@@ -76,3 +76,215 @@ CREATE TABLE `xhPsReduceRule` (
   PRIMARY KEY (`id`),
   KEY `idx_method_sort` (`methodId`,`sort`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='花店送货方式,跑腿,花材,满减规则';
+
+
+-- ouyang 2026-7-22 17:13:06
+-- =============================================================================
+-- 分销功能 — 数据库建表脚本(hdApp)
+-- =============================================================================
+
+SET NAMES utf8mb4;
+SET SESSION lock_wait_timeout = 10;
+
+
+-- -----------------------------------------------------------------------------
+-- 1. 分销用户表(id = xhCustom.id,非自增)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `xhDistributionUser` (
+    `id` bigint(20) unsigned NOT NULL COMMENT '主键,与 xhCustom.id 一致',
+    `customId` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '客户ID, xhCustom id',
+    `shopId` int(11) NOT NULL DEFAULT '0' COMMENT '门店ID',
+    `inviterId` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '邀请人 xhCustom.id,0无',
+    `inviteTime` datetime DEFAULT NULL COMMENT '邀请时间',
+    `inviteCount` int(11) NOT NULL DEFAULT '0' COMMENT '拉新数量',
+    `orderCount` int(11) NOT NULL DEFAULT '0' COMMENT '本人获佣订单数',
+    `orderAmount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '本人获佣订单金额(计佣基数合计)',
+    `pendingCommission` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '待结算提成(已付款未分佣)',
+    `settledCommission` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '已结算提成(已分佣)',
+    `totalCommission` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '累计提成(待结算+已结算)',
+    `totalDeposit` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '累计存入金额',
+    `availDeposit` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '可存入分红余额(已结算未存入)',
+    `upOrderCount` int(11) NOT NULL DEFAULT '0' COMMENT '为上级贡献订单数(本人下单产生)',
+    `upOrderAmount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '为上级贡献订单金额(计佣基数合计)',
+    `upCommissionAmount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '为上级贡献提成金额';
+    `status` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '1正常 0禁用',
+    `addTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `updateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_shop` (`shopId`),
+    KEY `idx_inviter` (`shopId`, `inviterId`),
+    KEY `idx_customId` (`customId`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分销用户统计表';
+
+-- -----------------------------------------------------------------------------
+-- 1.1 客户与分销员绑定(按时间递减计佣必填;inviteTime 是邀请成为分销员,bindTime 是客户绑定关系)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `xhDistributionCustomerBind` (
+    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `shopId` int(11) NOT NULL DEFAULT '0' COMMENT '门店ID',
+    `hdId` int(11) NOT NULL DEFAULT '0' COMMENT '花店ID',
+    `buyerId` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '下单客户 xhCustom.id',
+    `distId` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '绑定分销员 xhCustom.id',
+    `bindTime` datetime NOT NULL COMMENT '绑定时间(递减计佣起点)',
+    `bindSource` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '1邀请 2扫码 3首单 4后台',
+    `status` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '1有效 0失效',
+    `addTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `updateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_shop_buyer` (`shopId`, `buyerId`),
+    KEY `idx_dist` (`shopId`, `distId`, `bindTime`),
+    KEY `idx_buyer` (`buyerId`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户与分销员绑定';
+
+
+-- -----------------------------------------------------------------------------
+-- 2. 分销规则(图2 固定比例 / 图3 按时间递减)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `xhDistributionRule` (
+    `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `shopId` int(11) NOT NULL DEFAULT '0' COMMENT '门店ID',
+    `mainId` int(11) NOT NULL DEFAULT '0' COMMENT 'mainId',
+    `status` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '分销功能 0关 1开',
+    `settleDays` smallint(5) unsigned NOT NULL DEFAULT '0' COMMENT '订单完成后 N 天结算',
+    `bonusType` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '1固定比例 2按时间递减',
+    `bonusRate` decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '固定比例%(bonusType=1)',
+    `durationType` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '分红时长 1不限 2限制',
+    `durationDays` int(11) NOT NULL DEFAULT '0' COMMENT '限制天数(durationType=2)',
+    `timeStartType` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '计时起点 1绑定时间',
+    `productScope` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '1全部 2指定商品 3指定分类',
+    `addTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `updateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_shop` (`shopId`),
+    KEY `idx_mainId` (`mainId`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分销规则';
+
+
+-- -----------------------------------------------------------------------------
+-- 2.1 时间递减阶梯
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `xhDistributionRuleTier` (
+    `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `ruleId` int(11) NOT NULL DEFAULT '0' COMMENT 'xhDistributionRule.id',
+    `minDays` int(11) NOT NULL DEFAULT '0' COMMENT '起始天数(含)',
+    `maxDays` int(11) NOT NULL DEFAULT '0' COMMENT '结束天数(含);0=及以上',
+    `bonusRate` decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '佣金比例%',
+    `sort` smallint(5) unsigned NOT NULL DEFAULT '0' COMMENT '排序',
+    `addTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_rule_sort` (`ruleId`, `sort`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分销规则阶梯';
+
+
+-- -----------------------------------------------------------------------------
+-- 2.2 指定商品/分类(productScope=2/3 时使用,若仅做「全部商品」可暂不建)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `xhDistributionRuleScope` (
+    `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `ruleId` int(11) NOT NULL DEFAULT '0' COMMENT 'xhDistributionRule.id',
+    `scopeType` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '1商品 2分类',
+    `targetId` int(11) NOT NULL DEFAULT '0' COMMENT 'goodsId 或 categoryId',
+    `addTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_rule_target` (`ruleId`, `scopeType`, `targetId`),
+    KEY `idx_rule` (`ruleId`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分销规则商品范围';
+
+
+-- -----------------------------------------------------------------------------
+-- 3. 订单分销记录(图4/5)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `xhDistributionOrder` (
+    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `shopId` int(11) NOT NULL DEFAULT '0' COMMENT '门店ID',
+    `hdId` int(11) NOT NULL DEFAULT '0' COMMENT '花店ID',
+    `orderId` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '订单ID',
+    `orderSn` varchar(64) NOT NULL DEFAULT '' COMMENT '订单号',
+    `buyerId` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '下单客户 xhCustom.id',
+    `buyerName` varchar(80) NOT NULL DEFAULT '' COMMENT '下单客户名(快照)',
+    `distId` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '获佣分销员 xhCustom.id',
+    `distName` varchar(80) NOT NULL DEFAULT '' COMMENT '获佣人名(快照)',
+    `orderAmount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '订单商品金额',
+    `payAmount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '实付(含运费)',
+    `sendCost` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '运费',
+    `commissionBase` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '计佣基数=实付-运费',
+    `commissionRate` decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '佣金比例%(快照)',
+    `commissionAmount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '佣金金额',
+    `bonusType` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '1固定 2递减(快照)',
+    `bindTime` datetime DEFAULT NULL COMMENT '绑定时间(快照)',
+    `bindDays` int(11) NOT NULL DEFAULT '0' COMMENT '下单距绑定天数(快照)',
+    `settleStatus` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '0待结算 1已结算 2已失效',
+    `orderTime` datetime DEFAULT NULL COMMENT '下单时间',
+    `payTime` datetime DEFAULT NULL COMMENT '付款时间',
+    `finishTime` datetime DEFAULT NULL COMMENT '订单完成时间',
+    `settleTime` datetime DEFAULT NULL COMMENT '分佣时间',
+    `invalidTime` datetime DEFAULT NULL COMMENT '失效时间',
+    `invalidReason` varchar(255) NOT NULL DEFAULT '' COMMENT '失效原因',
+    `addTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `updateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_order_dist` (`orderId`, `distId`),
+    KEY `idx_shop_settle` (`shopId`, `settleStatus`, `orderTime`),
+    KEY `idx_dist_settle` (`distId`, `settleStatus`, `settleTime`),
+    KEY `idx_orderSn` (`orderSn`),
+    KEY `idx_buyer` (`buyerId`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单分销记录';
+
+
+-- -----------------------------------------------------------------------------
+-- 4. 分销流水(图6)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `xhDistributionFlow` (
+    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `shopId` int(11) NOT NULL DEFAULT '0' COMMENT '门店ID',
+    `hdId` int(11) NOT NULL DEFAULT '0' COMMENT '花店ID',
+    `xhCustomId` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '分销员 xhCustom.id',
+    `flowType` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '1订单分红 2分红存入 3退款扣回',
+    `amount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '变动金额;分红+ 存入/扣回-',
+    `availAfter` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '变动后可存分红余额',
+    `refType` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '1分销订单 2存入日志',
+    `refId` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '关联业务ID',
+    `refSn` varchar(64) NOT NULL DEFAULT '' COMMENT '订单号/存入编号',
+    `buyerName` varchar(80) NOT NULL DEFAULT '' COMMENT '下单客户(flowType=1)',
+    `flowStatus` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '1待结算 2已结算 3存入成功 4已扣回',
+    `flowTime` datetime NOT NULL COMMENT '业务时间',
+    `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
+    `addTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_custom_time` (`xhCustomId`, `flowTime`),
+    KEY `idx_shop_type_time` (`shopId`, `flowType`, `flowTime`),
+    KEY `idx_ref` (`refType`, `refId`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分销流水';
+
+
+-- -----------------------------------------------------------------------------
+-- 5. 分红存入日志(图7)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `xhDistributionDepositLog` (
+    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `shopId` int(11) NOT NULL DEFAULT '0' COMMENT '门店ID',
+    `hdId` int(11) NOT NULL DEFAULT '0' COMMENT '花店ID',
+    `xhCustomId` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '客户 xhCustom.id',
+    `depositSn` varchar(32) NOT NULL DEFAULT '' COMMENT '存入编号',
+    `depositAmount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '存入金额',
+    `beforeAvail` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '存入前可存分红',
+    `afterAvail` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '存入后可存分红',
+    `balanceChangeId` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT 'xhCustomBalanceChange.id',
+    `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
+    `addTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '存入时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_depositSn` (`depositSn`),
+    KEY `idx_custom_time` (`xhCustomId`, `addTime`),
+    KEY `idx_shop_time` (`shopId`, `addTime`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分红存入日志';
+
+-- =============================================================================
+-- 分销功能脚本结束
+-- =============================================================================
+
+-- 新增订单完成时间 ouyang 2026-7-24 18:18:01
+ALTER TABLE `xhOrder`
+    ADD COLUMN `successTime` datetime NULL DEFAULT '0000-00-00 00:00:00' COMMENT '订单完成时间' AFTER `settleTime`;
+
+-- # 分销延迟结算定时任务 ouyang 2026-7-24 18:27:52
+-- 0 * * * * cd /path/to/huahuibao && php yii distribution/settle-due --limit=100