Pārlūkot izejas kodu

Merge branch 'dev' of http://git.huaml.com/zhh/huahuibao into dev

shish 1 nedēļu atpakaļ
vecāks
revīzija
dbf1ed05d0
37 mainītis faili ar 3534 papildinājumiem un 46 dzēšanām
  1. 153 0
      app-hd/controllers/DistributionController.php
  2. 3 0
      app-hd/models/homePageConfig/SaveGroupBuyForm.php
  3. 81 0
      app-mall/controllers/DistributionController.php
  4. 365 0
      app-mall/controllers/GroupBuyController.php
  5. 23 20
      app-mall/controllers/OrderController.php
  6. 49 0
      app-mall/models/groupBuy/CreateGroupBuyForm.php
  7. 49 0
      app-mall/models/groupBuy/JoinGroupBuyForm.php
  8. 235 0
      biz-hd/distribution/classes/DistributionFlowClass.php
  9. 171 0
      biz-hd/distribution/classes/DistributionOrderClass.php
  10. 91 0
      biz-hd/distribution/classes/DistributionReportClass.php
  11. 268 0
      biz-hd/distribution/classes/DistributionRuleClass.php
  12. 13 0
      biz-hd/distribution/classes/DistributionRuleScopeClass.php
  13. 13 0
      biz-hd/distribution/classes/DistributionRuleTierClass.php
  14. 407 0
      biz-hd/distribution/classes/DistributionUserClass.php
  15. 17 0
      biz-hd/distribution/models/DistributionFlow.php
  16. 17 0
      biz-hd/distribution/models/DistributionOrder.php
  17. 17 0
      biz-hd/distribution/models/DistributionRule.php
  18. 16 0
      biz-hd/distribution/models/DistributionRuleScope.php
  19. 16 0
      biz-hd/distribution/models/DistributionRuleTier.php
  20. 17 0
      biz-hd/distribution/models/DistributionUser.php
  21. 372 0
      biz-hd/groupBuy/classes/GroupBuyActivityClass.php
  22. 593 0
      biz-hd/groupBuy/classes/GroupBuyClass.php
  23. 203 0
      biz-hd/groupBuy/classes/GroupBuyGoodsClass.php
  24. 46 0
      biz-hd/groupBuy/classes/GroupBuyMemberClass.php
  25. 17 0
      biz-hd/groupBuy/models/GroupBuy.php
  26. 17 0
      biz-hd/groupBuy/models/GroupBuyActivity.php
  27. 17 0
      biz-hd/groupBuy/models/GroupBuyGoods.php
  28. 17 0
      biz-hd/groupBuy/models/GroupBuyMember.php
  29. 22 20
      biz-hd/homePageConfig/classes/HomePageModuleClass.php
  30. 18 0
      biz-hd/order/classes/OrderClass.php
  31. 8 0
      biz-hd/order/services/OrderService.php
  32. 19 4
      biz-mall/order/services/OrderService.php
  33. 1 1
      common/base/classes/BaseClass.php
  34. 8 0
      common/components/rabbitmq/stockConsumer.php
  35. 12 1
      common/config/rabbitMQ.php
  36. 46 0
      console/controllers/GroupBuyController.php
  37. 97 0
      sql/20260723_group_buy.sql

+ 153 - 0
app-hd/controllers/DistributionController.php

@@ -0,0 +1,153 @@
+<?php
+
+namespace hd\controllers;
+
+use bizHd\distribution\classes\DistributionRuleClass;
+use bizHd\distribution\classes\DistributionUserClass;
+use bizHd\distribution\classes\DistributionFlowClass;
+use bizHd\distribution\classes\DistributionOrderClass;
+use bizHd\distribution\classes\DistributionReportClass;
+use Yii;
+use common\components\util;
+
+/**
+ * 分销(hdApp 门店设置-分销规则 / 客户详情-分销统计)
+ */
+class DistributionController extends BaseController
+{
+    /**
+     * 获取当前门店分销规则
+     */
+    public function actionGetRule()
+    {
+        try {
+            $data = DistributionRuleClass::getRule($this->shopId, $this->mainId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+
+    /**
+     * 保存分销规则
+     */
+    public function actionSaveRule()
+    {
+        $post = Yii::$app->request->post();
+        try {
+            DistributionRuleClass::saveRule($this->shopId, $this->mainId, $post);
+            util::complete('保存成功');
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+
+    /**
+     * 客户分销统计(客户详情页)
+     */
+    public function actionGetUserStat()
+    {
+        $customId = (int)Yii::$app->request->get('customId', 0);
+        try {
+            $data = DistributionUserClass::getUserStat($this->shopId, $customId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+
+    /**
+     * 客户分红变动明细(xhDistributionFlow)
+     * customId=0 且 scope=shop 时查门店全部流水
+     */
+    public function actionGetFlowList()
+    {
+        $customId = (int)Yii::$app->request->get('customId', 0);
+        $scope = trim(Yii::$app->request->get('scope', ''));
+        if ($scope === 'shop') {
+            $customId = 0;
+        }
+        try {
+            $data = DistributionFlowClass::getFlowList($this->shopId, $customId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+
+    /**
+     * 拉新客户列表(xhDistributionUser)
+     */
+    public function actionGetInviteList()
+    {
+        $customId = (int)Yii::$app->request->get('customId', 0);
+        try {
+            $data = DistributionUserClass::getInviteList($this->shopId, $customId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+
+    /**
+     * 下线贡献分红明细(xhDistributionOrder)
+     * distId/customId 可选;scope=shop 时查门店全部订单
+     */
+    public function actionGetContribOrderList()
+    {
+        $scope = trim(Yii::$app->request->get('scope', ''));
+        $distId = (int)Yii::$app->request->get('distId', 0);
+        if ($distId <= 0) {
+            $distId = (int)Yii::$app->request->get('customId', 0);
+        }
+        if ($scope === 'shop') {
+            $distId = 0;
+        }
+        $buyerId = (int)Yii::$app->request->get('buyerId', 0);
+        try {
+            $data = DistributionOrderClass::getContribOrderList($this->shopId, $distId, $buyerId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+
+    /**
+     * 门店分销报表汇总
+     */
+    public function actionGetReportStat()
+    {
+        try {
+            $data = DistributionReportClass::getReportStat($this->shopId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+
+    /**
+     * 门店获佣人数明细
+     */
+    public function actionGetShopDistList()
+    {
+        try {
+            $data = DistributionUserClass::getShopDistList($this->shopId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+
+    /**
+     * 门店拉新明细
+     */
+    public function actionGetShopInviteList()
+    {
+        try {
+            $data = DistributionUserClass::getShopInviteList($this->shopId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+}

+ 3 - 0
app-hd/models/homePageConfig/SaveGroupBuyForm.php

@@ -97,6 +97,8 @@ class SaveGroupBuyForm extends BaseForm
                 return;
             }
             $list[] = [
+                // 透传活动商品版本 id,便于后端判断更新现有版本还是新建版本
+                'id' => intval($item['id'] ?? 0),
                 'goodsId' => $goodsId,
                 'price' => $price,
                 'stock' => $stock,
@@ -109,6 +111,7 @@ class SaveGroupBuyForm extends BaseForm
                 'name' => strval($item['name'] ?? ''),
                 'cover' => strval($item['cover'] ?? ''),
                 'originPrice' => floatval($item['originPrice'] ?? 0),
+                'specName' => strval($item['specName'] ?? ''),
             ];
         }
         $this->$attribute = $list;

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

@@ -0,0 +1,81 @@
+<?php
+/**
+ * 商城 C 端分销接口
+ * 用途:mallApp 我的分红页
+ */
+
+namespace mall\controllers;
+
+use bizHd\distribution\classes\DistributionFlowClass;
+use bizHd\distribution\classes\DistributionOrderClass;
+use bizHd\distribution\classes\DistributionUserClass;
+use common\components\util;
+use Yii;
+
+class DistributionController extends BaseController
+{
+    /**
+     * 我的分红汇总
+     */
+    public function actionGetMyStat()
+    {
+        if (empty($this->customId)) {
+            util::fail('请先选择花店');
+        }
+        try {
+            $data = DistributionUserClass::getMallMyStat($this->shopId, $this->customId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+
+    /**
+     * 分红变动明细
+     */
+    public function actionGetFlowList()
+    {
+        if (empty($this->customId)) {
+            util::fail('请先选择花店');
+        }
+        try {
+            $data = DistributionFlowClass::getFlowList($this->shopId, $this->customId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+
+    /**
+     * 拉新客户列表(当前登录客户为邀请人)
+     */
+    public function actionGetInviteList()
+    {
+        if (empty($this->customId)) {
+            util::fail('请先选择花店');
+        }
+        try {
+            $data = DistributionUserClass::getInviteList($this->shopId, $this->customId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+
+    /**
+     * 下线贡献分红明细(xhDistributionOrder)
+     */
+    public function actionGetContribOrderList()
+    {
+        if (empty($this->customId)) {
+            util::fail('请先选择花店');
+        }
+        $buyerId = (int)Yii::$app->request->get('buyerId', 0);
+        try {
+            $data = DistributionOrderClass::getContribOrderList($this->shopId, $this->customId, $buyerId);
+            util::success($data);
+        } catch (\Exception $e) {
+            util::fail($e->getMessage());
+        }
+    }
+}

+ 365 - 0
app-mall/controllers/GroupBuyController.php

@@ -0,0 +1,365 @@
+<?php
+
+namespace mall\controllers;
+
+use bizHd\goods\classes\GoodsClass;
+use bizHd\groupBuy\classes\GroupBuyActivityClass;
+use bizHd\groupBuy\classes\GroupBuyClass;
+use bizHd\groupBuy\classes\GroupBuyMemberClass;
+use bizHd\order\services\OrderService;
+use bizHd\shop\classes\ShopClass;
+use common\components\business;
+use common\components\dict;
+use common\components\orderSn;
+use common\components\stringUtil;
+use common\components\util;
+use mall\models\groupBuy\CreateGroupBuyForm;
+use mall\models\groupBuy\JoinGroupBuyForm;
+use Yii;
+
+/**
+ * 商城端拼团接口
+ * 落地页 / 团详情 / 开团 / 参团 / 我的拼团
+ */
+class GroupBuyController extends BaseController
+{
+    public $guestAccess = [
+        'goods-landing',
+        'detail',
+    ];
+
+    /**
+     * 团购商品落地页:活动商品信息 + 当前最快成团开放团
+     * GET goodsId
+     */
+    public function actionGoodsLanding()
+    {
+        $mainId = intval($this->mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        $goodsId = intval(Yii::$app->request->get('goodsId', 0));
+        if ($goodsId <= 0) {
+            util::fail('请选择商品');
+        }
+        $row = GroupBuyActivityClass::getActiveGoodsRowByGoodsId($mainId, $goodsId);
+        if (empty($row)) {
+            util::fail('团购活动已结束或商品已下架');
+        }
+        $goods = GoodsClass::getById($goodsId, true);
+        $cover = strval($row['cover'] ?? '');
+        $coverUrl = $cover;
+        if ($cover !== '' && strpos($cover, 'http') !== 0) {
+            $formatted = business::formatUploadImg($cover);
+            $coverUrl = $formatted['url'] ?? $cover;
+        }
+        $detailImages = [];
+        if (!empty($goods)) {
+            $pics = $goods->detail ?? ($goods->pic ?? '');
+            if (is_string($pics) && $pics !== '') {
+                $decoded = json_decode($pics, true);
+                if (is_array($decoded)) {
+                    foreach ($decoded as $p) {
+                        if (is_string($p) && $p !== '') {
+                            if (strpos($p, 'http') === 0) {
+                                $detailImages[] = $p;
+                            } else {
+                                $fmt = business::formatUploadImg($p);
+                                $detailImages[] = $fmt['url'] ?? $p;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        $openGroup = GroupBuyClass::getHottestOpenGroup(
+            $mainId,
+            $goodsId,
+            intval($row['activityGoodsId'] ?? 0)
+        );
+        util::success([
+            'activityGoodsId' => intval($row['activityGoodsId']),
+            'activityId' => intval($row['activityId']),
+            'goodsId' => $goodsId,
+            'name' => strval($row['name']),
+            'cover' => $cover,
+            'coverUrl' => $coverUrl,
+            'price' => floatval($row['price']),
+            'originPrice' => floatval($row['originPrice']),
+            'groupSize' => intval($row['groupSize']),
+            'stock' => intval($row['stock']),
+            'limit' => intval($row['limit']),
+            'virtualGroup' => intval($row['virtualGroup']),
+            'virtualMinutes' => intval($row['virtualMinutes']),
+            'autoRefund' => 1,
+            'startTime' => intval($row['startTime']),
+            'endTime' => intval($row['endTime']),
+            'title' => strval($row['title']),
+            'subtitle' => strval($row['subtitle']),
+            'desc' => strval($row['desc']),
+            'showCountdown' => intval($row['showCountdown']),
+            'detailImages' => $detailImages,
+            'openGroup' => $openGroup,
+        ]);
+    }
+
+    /**
+     * 拼团详情
+     * GET id
+     */
+    public function actionDetail()
+    {
+        $mainId = intval($this->mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        $id = intval(Yii::$app->request->get('id', 0));
+        if ($id <= 0) {
+            util::fail('请选择拼团');
+        }
+        $detail = GroupBuyClass::getDetail($mainId, $id);
+        // 附带活动商品落地信息,便于分享进入后完整展示
+        $landing = GroupBuyActivityClass::getActiveGoodsRowById($mainId, intval($detail['activityGoodsId']));
+        if (empty($landing)) {
+            $landing = GroupBuyActivityClass::getActiveGoodsRowByGoodsId($mainId, intval($detail['goodsId']));
+        }
+        $detail['activityGoods'] = $landing;
+        util::success($detail);
+    }
+
+    /**
+     * 我要开团:创建团 + 创建待支付订单 + 挂接团长成员
+     */
+    public function actionCreate()
+    {
+        $form = new CreateGroupBuyForm();
+        $form->loadAndValidate();
+        $mainId = intval($this->mainId);
+        $custom = $this->custom;
+        if (empty($custom)) {
+            util::fail('请先登录');
+        }
+        $customId = intval($custom->id);
+        $activityGoodsId = intval($form->activityGoodsId);
+        $goodsNum = max(1, intval($form->goodsNum));
+
+        $row = GroupBuyActivityClass::getActiveGoodsRowById($mainId, $activityGoodsId);
+        if (empty($row)) {
+            util::fail('团购活动已结束或商品已下架');
+        }
+        if ($goodsNum > intval($row['limit'])) {
+            util::fail('超出单人限购数量');
+        }
+
+        $connection = Yii::$app->db;
+        $transaction = $connection->beginTransaction();
+        try {
+            $group = GroupBuyClass::createGroup($mainId, intval($this->shopId), $customId, $row);
+            $groupBuyId = intval($group['id']);
+            $order = $this->createGroupBuyOrder($form, $row, $groupBuyId, $goodsNum);
+            GroupBuyMemberClass::bindOrderMember(
+                $groupBuyId,
+                $mainId,
+                $customId,
+                intval($order->id),
+                strval($order->orderSn),
+                GroupBuyMemberClass::ROLE_LEADER
+            );
+            $transaction->commit();
+            util::success([
+                'groupBuyId' => $groupBuyId,
+                'orderSn' => $order->orderSn,
+                'totalPrice' => $order->actPrice,
+                'id' => $order->id,
+            ]);
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            Yii::error('开团失败:' . $e->getMessage(), __METHOD__);
+            $msg = $e->getMessage();
+            if ($msg !== '' && mb_strlen($msg) < 80 && strpos($msg, 'SQLSTATE') === false) {
+                util::fail($msg);
+            }
+            util::fail('开团失败');
+        }
+    }
+
+    /**
+     * 我要参团:加入已有团 + 创建待支付订单
+     */
+    public function actionJoin()
+    {
+        $form = new JoinGroupBuyForm();
+        $form->loadAndValidate();
+        $mainId = intval($this->mainId);
+        $custom = $this->custom;
+        if (empty($custom)) {
+            util::fail('请先登录');
+        }
+        $customId = intval($custom->id);
+        $groupBuyId = intval($form->groupBuyId);
+        $goodsNum = max(1, intval($form->goodsNum));
+
+        $connection = Yii::$app->db;
+        $transaction = $connection->beginTransaction();
+        try {
+            $group = GroupBuyClass::assertCanJoin($mainId, $groupBuyId, $customId);
+            $row = GroupBuyActivityClass::getActiveGoodsRowById($mainId, intval($group->activityGoodsId));
+            if (empty($row)) {
+                // 活动结束后仍允许在截止前参团:用团上快照价
+                $row = [
+                    'activityId' => intval($group->activityId),
+                    'activityGoodsId' => intval($group->activityGoodsId),
+                    'goodsId' => intval($group->goodsId),
+                    'price' => floatval($group->price),
+                    'limit' => 1,
+                    'endTime' => intval($group->deadline),
+                    'groupSize' => intval($group->needNum),
+                    'virtualGroup' => intval($group->virtualGroup),
+                    'virtualMinutes' => 0,
+                ];
+            }
+            if ($goodsNum > intval($row['limit'] ?? 1)) {
+                util::fail('超出单人限购数量');
+            }
+            $order = $this->createGroupBuyOrder($form, $row, $groupBuyId, $goodsNum);
+            GroupBuyMemberClass::bindOrderMember(
+                $groupBuyId,
+                $mainId,
+                $customId,
+                intval($order->id),
+                strval($order->orderSn),
+                GroupBuyMemberClass::ROLE_MEMBER
+            );
+            $transaction->commit();
+            util::success([
+                'groupBuyId' => $groupBuyId,
+                'orderSn' => $order->orderSn,
+                'totalPrice' => $order->actPrice,
+                'id' => $order->id,
+            ]);
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            Yii::error('参团失败:' . $e->getMessage(), __METHOD__);
+            $msg = $e->getMessage();
+            if ($msg !== '' && mb_strlen($msg) < 80 && strpos($msg, 'SQLSTATE') === false) {
+                util::fail($msg);
+            }
+            util::fail('参团失败');
+        }
+    }
+
+    /**
+     * 我的拼团列表
+     */
+    public function actionMyList()
+    {
+        $mainId = intval($this->mainId);
+        $custom = $this->custom;
+        if (empty($custom)) {
+            util::fail('请先登录');
+        }
+        util::success([
+            'list' => GroupBuyClass::getMyList($mainId, intval($custom->id)),
+        ]);
+    }
+
+    /**
+     * 组装并创建拼团订单(核价以后端活动商品为准)
+     *
+     * @param object $form
+     * @param array $row
+     * @param int $groupBuyId
+     * @param int $goodsNum
+     * @return object
+     */
+    private function createGroupBuyOrder($form, $row, $groupBuyId, $goodsNum)
+    {
+        $shop = $this->shop;
+        $openShop = ShopClass::getOpenShop($shop);
+        if ($openShop == 0) {
+            util::fail('已休店');
+        }
+        $mainId = intval($this->mainId);
+        $custom = $this->custom;
+        $hd = $this->hd;
+        $user = $this->user;
+        $saleGoodsId = intval($row['goodsId']);
+        $goodsInfo = GoodsClass::getById($saleGoodsId, true);
+        if (empty($goodsInfo) || intval($goodsInfo->mainId) !== $mainId) {
+            util::fail('没有找到商品');
+        }
+        $stock = intval($goodsInfo->stock ?? 0);
+        if ($stock < $goodsNum) {
+            util::fail('库存不足');
+        }
+        $unitPrice = floatval($row['price'] ?? 0);
+        if ($unitPrice <= 0) {
+            util::fail('团购价格异常');
+        }
+        $goodsPrice = bcmul($unitPrice, $goodsNum, 2);
+        $sendType = intval($form->sendType ?? dict::getDict('sendType', 'shopGet'));
+        $sendCost = 0;
+        // 跑腿配送暂不在拼团链路单独报价,统一引导自取或送货上门(运费0,与花店协商)
+        if ($sendType == dict::getDict('sendType', 'thirdSend')) {
+            util::fail('拼团暂不支持跑腿,请选择自取或送货上门');
+        }
+        if ($sendType == dict::getDict('sendType', 'carGet')) {
+            if (empty($form->receiveUserName)) {
+                util::fail('请填写收花人名字');
+            }
+            if (empty($form->receiveMobile) || !stringUtil::isMobile($form->receiveMobile)) {
+                util::fail('请填写正确的收花人手机号');
+            }
+        }
+
+        $post = [
+            'sendType' => $sendType,
+            'receiveUserName' => strval($form->receiveUserName ?? ''),
+            'receiveMobile' => strval($form->receiveMobile ?? ''),
+            'address' => strval($form->address ?? ''),
+            'floor' => strval($form->floor ?? ''),
+            'city' => strval($form->city ?? ''),
+            'long' => strval($form->long ?? ''),
+            'lat' => strval($form->lat ?? ''),
+            'showAddress' => strval($form->showAddress ?? ''),
+            'remark' => strval($form->remark ?? ''),
+            'reachDate' => !empty($form->reachDate) ? $form->reachDate : date('Y-m-d'),
+            'reachPeriod' => strval($form->reachPeriod ?? ''),
+            'sendCost' => $sendCost,
+            'sendDistance' => 0,
+            'sjId' => intval($this->sjId ?: ($shop->sjId ?? 0)),
+            'shopId' => $this->shopId,
+            'mainId' => $mainId,
+            'userId' => $this->userId,
+            'customId' => intval($custom->id),
+            'customName' => strval($custom->name ?? ''),
+            'customNamePy' => stringUtil::py(strval($custom->name ?? '')),
+            'hdId' => intval($hd->id ?? 0),
+            'hdName' => strval($hd->name ?? ''),
+            'bookName' => strval($user->name ?? ''),
+            'bookMobile' => !empty($form->bookMobile) && stringUtil::isMobile($form->bookMobile)
+                ? $form->bookMobile
+                : strval($user->mobile ?? ''),
+            'payWay' => 0,
+            'store' => 0,
+            'modifyPrice' => stringUtil::calcAdd($sendCost, $goodsPrice),
+            'deadline' => time() + 1800,
+            'orderSn' => orderSn::getOrderSn(),
+            'fromType' => dict::getDict('fromType', 'mall'),
+            'cash' => 0,
+            'needPrint' => dict::getDict('needPrint', 'need'),
+            'groupBuyId' => intval($groupBuyId),
+            'product' => [[
+                'productId' => $saleGoodsId,
+                'unitType' => 0,
+                'property' => 0,
+                'unitPrice' => $unitPrice,
+                'num' => $goodsNum,
+            ]],
+        ];
+        if ($sendType != 1 && empty($post['reachPeriod'])) {
+            util::fail('请选择配送时间');
+        }
+        return OrderService::createHdOrder($post, $custom, 0);
+    }
+}

+ 23 - 20
app-mall/controllers/OrderController.php

@@ -967,19 +967,13 @@ class OrderController extends BaseController
         $userId = $this->userId;
         util::checkRepeatCommit($userId, 3);
 
+        $mainId = $this->mainId;
         $shop = $this->shop;
         $openShop = \bizHd\shop\classes\ShopClass::getOpenShop($shop);
         if ($openShop == 0) {
             util::fail('已休店');
         }
 
-        $mainId = $this->mainId;
-        if (getenv('YII_ENV') == 'production') {
-            if (in_array($mainId, [40057, 7779, 42940, 26374, 10536, 65381])) {
-                util::fail('暂时无法访问');
-            }
-        }
-
         $hdId = $post['hdId'] ?? 0;
         $hd = HdClass::getById($hdId, true);
         if (empty($hd)) {
@@ -995,12 +989,12 @@ class OrderController extends BaseController
         if (empty($custom)) {
             util::fail('没有找到客户');
         }
-        $customName = $custom->name ?? '';
+        $customName = $custom->name;
         $post['customId'] = $hdCustomId;
         $post['customName'] = $customName;
         $post['customNamePy'] = stringUtil::py($customName);
 
-        $productJson = $post['product'] ?? '';
+        $productJson = $post['product'];
         if (empty($productJson)) {
             util::fail('请选择商品');
         }
@@ -1086,7 +1080,7 @@ class OrderController extends BaseController
         });
 
         try {
-            $orderValidTime = !getenv('ORDER_VALID_TIME') ? 600 : getenv('ORDER_VALID_TIME');
+            $orderValidTime = !getenv('ORDER_VALID_TIME') ? 600 : getenv('ORDER_VALID_TIME'); // TODO 找少华确认
             $post['deadline'] = time() + $orderValidTime;
             $post['reachDate'] = !empty($post['reachDate']) ? $post['reachDate'] : date("Y-m-d");
             $post['needPrint'] = dict::getDict('needPrint', 'need');
@@ -1107,8 +1101,9 @@ class OrderController extends BaseController
             $priceMap = \bizGhs\custom\classes\CustomClass::$levelPriceKeyMap;
             $addPriceMap = \bizGhs\custom\classes\CustomClass::$levelAddPriceKeyMap;
 
+            $post['orderType'] = 0;
             foreach ($productList as $eleKey => $element) {
-                $property = intval($element['property'] ?? -1);
+                $property = $element['property']; // 1: 花材, 0: 花束
                 if ($property === 1) {
                     $level = 0;
                     $productId = $element['productId'];
@@ -1147,14 +1142,14 @@ class OrderController extends BaseController
                         'unitPrice' => $price,
                     ];
                 } else {
-                    $goodsId = intval($element['goodsId'] ?? 0);
-                    $specGoodsId = intval($element['specGoodsId'] ?? 0);
-                    $saleGoodsId = intval($element['productId'] ?? 0);
+                    $goodsId = $element['goodsId'];
+                    $specGoodsId = $element['specGoodsId'];
+                    $saleGoodsId = $element['productId'];
                     $goodsNum = isset($element['num']) && $element['num'] > 0 ? $element['num'] : 1;
                     if ($goodsId <= 0 && $saleGoodsId > 0) {
                         $probe = GoodsClass::getById($saleGoodsId, true);
                         if (!empty($probe)) {
-                            $masterId = intval($probe->masterId ?? 0);
+                            $masterId = intval($probe->masterId);
                             if ($masterId > 0) {
                                 $goodsId = $masterId;
                                 $specGoodsId = $saleGoodsId;
@@ -1189,8 +1184,8 @@ class OrderController extends BaseController
                         }
                         $saleGoodsId = $goodsId;
                     }
-                    $stock = $goodsInfo->stock ?? 0;
-                    $stockSet = $goodsInfo->stockSet ?? 0;
+                    $stock = $goodsInfo->stock;
+                    $stockSet = $goodsInfo->stockSet;
                     if ($stockSet == 1 && $stock <= 0) {
                         util::fail('花束库存不足');
                     }
@@ -1202,14 +1197,14 @@ class OrderController extends BaseController
                         if (empty($seckillRow)) {
                             util::fail('秒杀活动已结束或商品已下架,请重新选择');
                         }
-                        $seckillStock = floatval($seckillRow['stock'] ?? 0);
+                        $seckillStock = floatval($seckillRow['stock']);
                         $soldCount = HomePageModuleClass::getSeckillSoldCount($mainId, $saleGoodsId);
                         if (bcadd($soldCount, $goodsNum, 2) > $seckillStock) {
                             util::fail('秒杀库存不足,请重新选择');
                         }
                         $seckillLimit = floatval($seckillRow['limit'] ?? 0);
                         if ($seckillLimit > 0) {
-                            $boughtCount = HomePageModuleClass::getSeckillCustomBoughtCount($mainId, $saleGoodsId, $hdCustomId);
+                            $boughtCount = HomePageModuleClass::getSeckillCustomBoughtCount($mainId, $saleGoodsId, $hdCustomId); // TODO 同一个商品,如果缓存没有合理清除,就会一直限购
                             if (bcadd($boughtCount, $goodsNum, 2) > $seckillLimit) {
                                 util::fail("秒杀商品每人限购{$seckillLimit}件,已超出可购买数量");
                             }
@@ -1255,6 +1250,13 @@ class OrderController extends BaseController
                     ];
                 }
             }
+            // 根据 $resolvedProduct 设置订单类型:1纯花束 2纯花材 3混合
+            $properties = array_unique(array_column($resolvedProduct, 'property'));
+            if (count($properties) === 1) {
+                $post['orderType'] = intval($properties[0]) === 0 ? 1 : 2;
+            } elseif (count($properties) > 1) {
+                $post['orderType'] = 3;
+            }
 
             $post['product'] = $resolvedProduct;
             $post['reachDiscountPrice'] = $totalReachDiscount;
@@ -1842,7 +1844,7 @@ class OrderController extends BaseController
     {
         $get = Yii::$app->request->get();
         $status = $get['status'] ?? 0;
-        $hdId = $get['hdId'] ?? ($get['id'] ?? 0);
+        $hdId = $get['hdId'] ?? 0;
         $userId = $this->userId;
         $where = ['userId' => $userId];
         if (!empty($status)) {
@@ -1859,6 +1861,7 @@ class OrderController extends BaseController
             $where['hdId'] = $hdId;
         }
         $list = OrderService::getOrderList($where);
+
         util::success($list);
     }
 

+ 49 - 0
app-mall/models/groupBuy/CreateGroupBuyForm.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace mall\models\groupBuy;
+
+use mall\models\BaseForm;
+
+/**
+ * 开团下单表单校验
+ */
+class CreateGroupBuyForm extends BaseForm
+{
+    public $activityGoodsId;
+    public $goodsNum;
+    public $sendType;
+    public $receiveUserName;
+    public $receiveMobile;
+    public $address;
+    public $floor;
+    public $city;
+    public $long;
+    public $lat;
+    public $showAddress;
+    public $remark;
+    public $reachDate;
+    public $reachPeriod;
+    public $bookMobile;
+
+    public function rules()
+    {
+        return [
+            ['activityGoodsId', 'required', 'message' => '请选择团购商品'],
+            ['activityGoodsId', 'integer', 'min' => 1, 'message' => '团购商品无效'],
+            ['goodsNum', 'default', 'value' => 1],
+            ['goodsNum', 'integer', 'min' => 1, 'max' => 99, 'message' => '购买数量无效'],
+            ['sendType', 'default', 'value' => 1],
+            ['sendType', 'integer'],
+            [['receiveUserName', 'receiveMobile', 'address', 'floor', 'city', 'long', 'lat', 'showAddress', 'remark', 'reachDate', 'reachPeriod', 'bookMobile'], 'safe'],
+        ];
+    }
+
+    public function attributeLabels()
+    {
+        return [
+            'activityGoodsId' => '团购商品',
+            'goodsNum' => '数量',
+            'sendType' => '配送方式',
+        ];
+    }
+}

+ 49 - 0
app-mall/models/groupBuy/JoinGroupBuyForm.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace mall\models\groupBuy;
+
+use mall\models\BaseForm;
+
+/**
+ * 参团下单表单校验
+ */
+class JoinGroupBuyForm extends BaseForm
+{
+    public $groupBuyId;
+    public $goodsNum;
+    public $sendType;
+    public $receiveUserName;
+    public $receiveMobile;
+    public $address;
+    public $floor;
+    public $city;
+    public $long;
+    public $lat;
+    public $showAddress;
+    public $remark;
+    public $reachDate;
+    public $reachPeriod;
+    public $bookMobile;
+
+    public function rules()
+    {
+        return [
+            ['groupBuyId', 'required', 'message' => '请选择拼团'],
+            ['groupBuyId', 'integer', 'min' => 1, 'message' => '拼团无效'],
+            ['goodsNum', 'default', 'value' => 1],
+            ['goodsNum', 'integer', 'min' => 1, 'max' => 99, 'message' => '购买数量无效'],
+            ['sendType', 'default', 'value' => 1],
+            ['sendType', 'integer'],
+            [['receiveUserName', 'receiveMobile', 'address', 'floor', 'city', 'long', 'lat', 'showAddress', 'remark', 'reachDate', 'reachPeriod', 'bookMobile'], 'safe'],
+        ];
+    }
+
+    public function attributeLabels()
+    {
+        return [
+            'groupBuyId' => '拼团',
+            'goodsNum' => '数量',
+            'sendType' => '配送方式',
+        ];
+    }
+}

+ 235 - 0
biz-hd/distribution/classes/DistributionFlowClass.php

@@ -0,0 +1,235 @@
+<?php
+/**
+ * 用途:分销流水 xhDistributionFlow 查询与展示格式化
+ * 谁用:hdApp 客户详情-分红变动明细
+ */
+
+namespace bizHd\distribution\classes;
+
+use bizHd\base\classes\BaseClass;
+use bizHd\custom\classes\CustomClass;
+use common\components\dateUtil;
+use Yii;
+use yii\db\Query;
+
+class DistributionFlowClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\distribution\models\DistributionFlow';
+
+    /**
+     * 客户分红流水列表(分页,支持按月份筛选)
+     * @param int $shopId
+     * @param int $customId xhCustom.id
+     * @return array
+     * @throws \Exception
+     */
+    public static function getFlowList($shopId, $customId)
+    {
+        $customId = (int)$customId;
+        if ($customId < 0) {
+            throw new \Exception('客户ID无效');
+        }
+
+        $where = self::buildFlowWhere($shopId, $customId);
+        $data = self::getList('*', $where, 'flowTime DESC, id DESC');
+
+        $customIds = [];
+        foreach ($data['list'] as $row) {
+            $cid = (int)($row['xhCustomId'] ?? 0);
+            if ($cid > 0) {
+                $customIds[] = $cid;
+            }
+        }
+        $customMap = [];
+        if (!empty($customIds)) {
+            foreach (CustomClass::getCustomByIds(array_values(array_unique($customIds))) as $customItem) {
+                $customMap[(int)$customItem['id']] = $customItem;
+            }
+        }
+
+        $list = [];
+        foreach ($data['list'] as $row) {
+            $item = self::formatFlowRow($row);
+            $cid = (int)($row['xhCustomId'] ?? 0);
+            $custom = $customMap[$cid] ?? [];
+            $item['distName'] = $custom['name'] ?? '';
+            $item['distMobile'] = $custom['mobile'] ?? '';
+            $item['distDisplay'] = self::formatDistDisplay($custom);
+            $list[] = $item;
+        }
+        $data['list'] = $list;
+        $data['monthTotal'] = (int)self::getCount($where);
+        return $data;
+    }
+
+    /** 获佣人展示:优先姓名,无则手机尾号 */
+    protected static function formatDistDisplay($custom)
+    {
+        if (!empty($custom['name'])) {
+            return $custom['name'];
+        }
+        $mobile = isset($custom['mobile']) ? trim($custom['mobile']) : '';
+        if (strlen($mobile) >= 4) {
+            return '尾号' . substr($mobile, -4);
+        }
+        return '-';
+    }
+
+    /**
+     * 组装查询条件(门店 + 客户 + 可选月份)
+     */
+    protected static function buildFlowWhere($shopId, $customId)
+    {
+        $where = [
+            'shopId' => (int)$shopId,
+        ];
+        if ($customId > 0) {
+            $where['xhCustomId'] = (int)$customId;
+        }
+
+        $get = Yii::$app->request->get();
+        $flowType = isset($get['flowType']) ? trim($get['flowType']) : '';
+        if ($flowType !== '' && $flowType !== 'all') {
+            $where['flowType'] = (int)$flowType;
+        }
+        $searchTime = isset($get['searchTime']) ? trim($get['searchTime']) : '';
+        $startTime = isset($get['startTime']) ? trim($get['startTime']) : '';
+        $endTime = isset($get['endTime']) ? trim($get['endTime']) : '';
+
+        if ($searchTime === '') {
+            // 默认当前月
+            $searchTime = 'thisMonth';
+        }
+
+        if ($searchTime !== 'all') {
+            $period = dateUtil::formatTime($searchTime, $startTime, $endTime);
+            if (!empty($period['startTime']) && !empty($period['endTime'])) {
+                $where['flowTime'] = ['between', [$period['startTime'], $period['endTime']]];
+            }
+        }
+
+        // 查看某下线客户的贡献分红流水(仅指定分销员时)
+        $buyerId = isset($get['buyerId']) ? (int)$get['buyerId'] : 0;
+        if ($customId > 0 && $buyerId > 0) {
+            $orderIds = (new Query())
+                ->select('id')
+                ->from('xhDistributionOrder')
+                ->where([
+                    'shopId' => (int)$shopId,
+                    'distId' => (int)$customId,
+                    'buyerId' => $buyerId,
+                ])
+                ->column();
+            $where['flowType'] = 1;
+            $where['refType'] = 1;
+            // conditionQuery 的 IN 须写成 ['in', [...]];无订单时用不可能命中的 id
+            $where['refId'] = !empty($orderIds) ? ['in', $orderIds] : -1;
+        }
+
+        return $where;
+    }
+
+    /**
+     * 格式化单条流水供前端展示
+     */
+    protected static function formatFlowRow($row)
+    {
+        $flowType = (int)$row['flowType'];
+        $flowStatus = (int)$row['flowStatus'];
+        $amount = round((float)$row['amount'], 2);
+        $goodsName = isset($row['remark']) ? trim($row['remark']) : '';
+
+        $item = [
+            'id' => (int)$row['id'],
+            'flowType' => $flowType,
+            'flowTypeName' => self::flowTypeName($flowType),
+            'amount' => $amount,
+            'amountText' => self::formatAmountText($flowType, $amount),
+            'refSn' => $row['refSn'],
+            'buyerName' => $row['buyerName'],
+            'goodsName' => $goodsName,
+            'subTitle' => self::buildSubTitle($flowType, $row['buyerName'], $goodsName),
+            'flowTime' => $row['flowTime'],
+            'flowStatus' => $flowStatus,
+            'statusText' => self::statusText($flowType, $flowStatus),
+            'statusClass' => self::statusClass($flowType, $flowStatus),
+        ];
+        return $item;
+    }
+
+    protected static function flowTypeName($flowType)
+    {
+        $map = [
+            1 => '订单分红',
+            2 => '分红存入',
+            3 => '退款扣回',
+        ];
+        return isset($map[$flowType]) ? $map[$flowType] : '分销流水';
+    }
+
+    protected static function formatAmountText($flowType, $amount)
+    {
+        $abs = number_format(abs($amount), 2, '.', '');
+        if ($flowType === 1) {
+            return '+¥' . $abs;
+        }
+        return '-¥' . $abs;
+    }
+
+    protected static function buildSubTitle($flowType, $buyerName, $goodsName)
+    {
+        if ($flowType !== 1) {
+            return '';
+        }
+        $buyerName = trim($buyerName);
+        $goodsName = trim($goodsName);
+        if ($buyerName !== '' && $goodsName !== '') {
+            return $buyerName . ' · ' . $goodsName;
+        }
+        return $buyerName !== '' ? $buyerName : $goodsName;
+    }
+
+    /**
+     * 状态文案:按 flowType 区分,避免分红存入误用订单分红的「待结算」
+     * flowType=1 订单分红:1待结算 2已结算
+     * flowType=2 分红存入:3存入成功(测试/默认写 1 时展示待存入)
+     * flowType=3 退款扣回:4已扣回
+     */
+    protected static function statusText($flowType, $flowStatus)
+    {
+        if ($flowType === 1) {
+            $map = [
+                1 => '待结算',
+                2 => '已结算',
+                4 => '已扣回',
+            ];
+        } elseif ($flowType === 2) {
+            $map = [
+                1 => '待存入',
+                3 => '存入成功',
+            ];
+        } elseif ($flowType === 3) {
+            $map = [
+                4 => '已扣回',
+            ];
+        } else {
+            $map = [];
+        }
+        return isset($map[$flowStatus]) ? $map[$flowStatus] : '';
+    }
+
+    protected static function statusClass($flowType, $flowStatus)
+    {
+        if ($flowType === 1 && $flowStatus === 1) {
+            return 'pending';
+        }
+        if ($flowType === 2 && $flowStatus === 1) {
+            return 'pending';
+        }
+        if (($flowType === 1 && $flowStatus === 2)
+            || ($flowType === 2 && $flowStatus === 3)) {
+            return 'success';
+        }
+        return 'default';
+    }
+}

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

@@ -0,0 +1,171 @@
+<?php
+/**
+ * 用途:订单分销记录业务(xhDistributionOrder)
+ * 谁用:拉新客户-查看贡献明细(分红明细页)
+ */
+
+namespace bizHd\distribution\classes;
+
+use bizHd\base\classes\BaseClass;
+use common\components\dateUtil;
+use Yii;
+use yii\db\Query;
+
+class DistributionOrderClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\distribution\models\DistributionOrder';
+
+    /**
+     * 分销员分红订单列表(xhDistributionOrder)
+     * @param int $shopId
+     * @param int $distId 获佣分销员 xhCustom.id
+     * @param int $buyerId 下单客户 xhCustom.id,0 表示不限下线
+     * @return array
+     * @throws \Exception
+     */
+    public static function getContribOrderList($shopId, $distId, $buyerId = 0)
+    {
+        $shopId = (int)$shopId;
+        $distId = (int)$distId;
+        $buyerId = (int)$buyerId;
+        if ($distId < 0) {
+            throw new \Exception('参数无效');
+        }
+
+        $get = Yii::$app->request->get();
+        $settleStatus = isset($get['settleStatus']) ? trim($get['settleStatus']) : '';
+        $keyword = isset($get['keyword']) ? trim($get['keyword']) : '';
+        $searchTime = isset($get['searchTime']) ? trim($get['searchTime']) : 'thisMonth';
+        $startTime = isset($get['startTime']) ? trim($get['startTime']) : '';
+        $endTime = isset($get['endTime']) ? trim($get['endTime']) : '';
+
+        $period = dateUtil::formatTime($searchTime, $startTime, $endTime);
+
+        // 本月分红合计(同筛选月份,不受 settleStatus Tab 影响)
+        $monthQuery = (new Query())
+            ->from('xhDistributionOrder')
+            ->where(['shopId' => $shopId]);
+        if ($distId > 0) {
+            $monthQuery->andWhere(['distId' => $distId]);
+        }
+        if ($buyerId > 0) {
+            $monthQuery->andWhere(['buyerId' => $buyerId]);
+        }
+        if (!empty($period['startTime']) && !empty($period['endTime'])) {
+            $monthQuery->andWhere(['between', 'orderTime', $period['startTime'], $period['endTime']]);
+        }
+        $monthCommission = (float)$monthQuery->sum('commissionAmount');
+
+        // Base::conditionQuery 不支持 or 组合,关键词搜索改用 Query 分页
+        $page = isset($get['page']) ? max(1, (int)$get['page']) : 1;
+        $pageSize = isset($get['pageSize']) && (int)$get['pageSize'] > 0
+            ? (int)$get['pageSize']
+            : (int)Yii::$app->params['pageSize'];
+
+        $query = (new Query())
+            ->from('xhDistributionOrder')
+            ->where(['shopId' => $shopId]);
+        if ($distId > 0) {
+            $query->andWhere(['distId' => $distId]);
+        }
+        if ($buyerId > 0) {
+            $query->andWhere(['buyerId' => $buyerId]);
+        }
+        if ($settleStatus !== '' && $settleStatus !== 'all') {
+            $query->andWhere(['settleStatus' => (int)$settleStatus]);
+        }
+        if (!empty($period['startTime']) && !empty($period['endTime'])) {
+            $query->andWhere(['between', 'orderTime', $period['startTime'], $period['endTime']]);
+        }
+        if ($keyword !== '') {
+            $query->andWhere([
+                'or',
+                ['like', 'orderSn', $keyword],
+                ['like', 'buyerName', $keyword],
+                ['like', 'distName', $keyword],
+            ]);
+        }
+
+        $total = (int)(clone $query)->count('*', Yii::$app->db);
+        $totalPage = $pageSize > 0 ? (int)ceil($total / $pageSize) : 0;
+        $rows = $query
+            ->orderBy(['orderTime' => SORT_DESC, 'id' => SORT_DESC])
+            ->offset(($page - 1) * $pageSize)
+            ->limit($pageSize)
+            ->all(Yii::$app->db);
+
+        $list = [];
+        foreach ($rows as $row) {
+            $list[] = self::formatContribOrderRow($row);
+        }
+
+        return [
+            'list' => $list,
+            'totalNum' => $total,
+            'totalPage' => $totalPage,
+            'moreData' => $page < $totalPage ? 1 : 0,
+            'monthCommission' => round($monthCommission, 2),
+        ];
+    }
+
+    /**
+     * 格式化贡献订单行(金额字段与库表一致,另附展示用文案)
+     */
+    protected static function formatContribOrderRow($row)
+    {
+        $settleStatus = (int)$row['settleStatus'];
+        $commissionAmount = round((float)$row['commissionAmount'], 2);
+        $isSettled = $settleStatus === 1;
+
+        return [
+            'id' => (int)$row['id'],
+            'orderId' => (int)$row['orderId'],
+            'orderSn' => $row['orderSn'],
+            'buyerId' => (int)$row['buyerId'],
+            'buyerName' => $row['buyerName'],
+            'distId' => (int)$row['distId'],
+            'distName' => $row['distName'],
+            'orderAmount' => round((float)$row['orderAmount'], 2),
+            'payAmount' => round((float)$row['payAmount'], 2),
+            'sendCost' => round((float)$row['sendCost'], 2),
+            'commissionBase' => round((float)$row['commissionBase'], 2),
+            'commissionRate' => round((float)$row['commissionRate'], 2),
+            'commissionAmount' => $commissionAmount,
+            'bonusType' => (int)$row['bonusType'],
+            'bindDays' => (int)$row['bindDays'],
+            'settleStatus' => $settleStatus,
+            'orderTime' => $row['orderTime'],
+            'payTime' => $row['payTime'],
+            'finishTime' => $row['finishTime'],
+            'settleTime' => $row['settleTime'],
+            'invalidTime' => $row['invalidTime'],
+            'invalidReason' => $row['invalidReason'],
+            'amountText' => '+¥' . number_format($commissionAmount, 2, '.', ''),
+            'statusText' => self::settleStatusText($settleStatus),
+            'statusClass' => self::settleStatusClass($settleStatus),
+            'timeLabel' => $isSettled ? '结算时间' : '下单时间',
+            'timeValue' => $isSettled ? ($row['settleTime'] ?: $row['orderTime']) : $row['orderTime'],
+        ];
+    }
+
+    protected static function settleStatusText($settleStatus)
+    {
+        $map = [
+            0 => '待结算',
+            1 => '已结算',
+            2 => '已失效',
+        ];
+        return isset($map[$settleStatus]) ? $map[$settleStatus] : '';
+    }
+
+    protected static function settleStatusClass($settleStatus)
+    {
+        if ($settleStatus === 0) {
+            return 'pending';
+        }
+        if ($settleStatus === 1) {
+            return 'success';
+        }
+        return 'invalid';
+    }
+}

+ 91 - 0
biz-hd/distribution/classes/DistributionReportClass.php

@@ -0,0 +1,91 @@
+<?php
+/**
+ * 用途:门店分销报表统计(xhDistributionOrder / xhDistributionFlow / xhDistributionUser)
+ * 谁用:hdApp 应用-门店设置-分销报表
+ */
+
+namespace bizHd\distribution\classes;
+
+use common\components\dateUtil;
+use Yii;
+use yii\db\Query;
+
+class DistributionReportClass
+{
+    /**
+     * 门店分销报表汇总
+     * @param int $shopId
+     * @return array
+     */
+    public static function getReportStat($shopId)
+    {
+        $shopId = (int)$shopId;
+        $get = Yii::$app->request->get();
+        $searchTime = isset($get['searchTime']) ? trim($get['searchTime']) : 'today';
+        $startTime = isset($get['startTime']) ? trim($get['startTime']) : '';
+        $endTime = isset($get['endTime']) ? trim($get['endTime']) : '';
+
+        $period = dateUtil::formatTime($searchTime, $startTime, $endTime);
+        $timeBetween = null;
+        if (!empty($period['startTime']) && !empty($period['endTime'])) {
+            $timeBetween = [$period['startTime'], $period['endTime']];
+        }
+
+        $orderQuery = (new Query())
+            ->from('xhDistributionOrder')
+            ->where(['shopId' => $shopId]);
+        if ($timeBetween) {
+            $orderQuery->andWhere(['between', 'orderTime', $timeBetween[0], $timeBetween[1]]);
+        }
+
+        $orderCount = (int)(clone $orderQuery)->count('*', Yii::$app->db);
+        $commissionAmount = (float)(clone $orderQuery)->sum('commissionAmount');
+        $distUserCount = (int)(clone $orderQuery)
+            ->select('distId')
+            ->distinct()
+            ->count('distId', Yii::$app->db);
+
+        $inviteQuery = (new Query())
+            ->from('xhDistributionUser')
+            ->where(['shopId' => $shopId])
+            ->andWhere(['>', 'inviterId', 0]);
+        if ($timeBetween) {
+            $inviteQuery->andWhere(['between', 'inviteTime', $timeBetween[0], $timeBetween[1]]);
+        }
+        $inviteCount = (int)$inviteQuery->count('*', Yii::$app->db);
+
+        $pendingQuery = (new Query())
+            ->from('xhDistributionOrder')
+            ->where(['shopId' => $shopId, 'settleStatus' => 0]);
+        if ($timeBetween) {
+            $pendingQuery->andWhere(['between', 'orderTime', $timeBetween[0], $timeBetween[1]]);
+        }
+        $pendingAmount = (float)$pendingQuery->sum('commissionAmount');
+
+        $settledQuery = (new Query())
+            ->from('xhDistributionOrder')
+            ->where(['shopId' => $shopId, 'settleStatus' => 1]);
+        if ($timeBetween) {
+            $settledQuery->andWhere(['between', 'settleTime', $timeBetween[0], $timeBetween[1]]);
+        }
+        $settledAmount = (float)$settledQuery->sum('commissionAmount');
+
+        $depositQuery = (new Query())
+            ->from('xhDistributionFlow')
+            ->where(['shopId' => $shopId, 'flowType' => 2, 'flowStatus' => 3]);
+        if ($timeBetween) {
+            $depositQuery->andWhere(['between', 'flowTime', $timeBetween[0], $timeBetween[1]]);
+        }
+        $depositAmount = (float)$depositQuery->sum('amount');
+
+        return [
+            'orderCount' => $orderCount,
+            'commissionAmount' => round($commissionAmount, 2),
+            'distUserCount' => $distUserCount,
+            'inviteCount' => $inviteCount,
+            'pendingAmount' => round($pendingAmount, 2),
+            'settledAmount' => round($settledAmount, 2),
+            'depositAmount' => round($depositAmount, 2),
+        ];
+    }
+}

+ 268 - 0
biz-hd/distribution/classes/DistributionRuleClass.php

@@ -0,0 +1,268 @@
+<?php
+/**
+ * 用途:分销规则业务(xhDistributionRule / Tier / Scope)
+ * 谁用:hdApp 应用-门店设置-分销规则
+ */
+
+namespace bizHd\distribution\classes;
+
+use bizHd\base\classes\BaseClass;
+use Yii;
+
+class DistributionRuleClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\distribution\models\DistributionRule';
+
+    /**
+     * 获取门店分销规则(无则返回默认结构)
+     * @param int $shopId
+     * @param int $mainId
+     * @return array
+     */
+    public static function getRule($shopId, $mainId)
+    {
+        $rule = self::getByCondition(['shopId' => $shopId]);
+        if (empty($rule)) {
+            return self::defaultRule($shopId, $mainId);
+        }
+
+        $ruleId = (int)$rule['id'];
+        $tiers = DistributionRuleTierClass::getAllByCondition(['ruleId' => $ruleId], 'sort ASC, id ASC');
+        $scopes = DistributionRuleScopeClass::getAllByCondition(['ruleId' => $ruleId], 'id ASC');
+
+        $goodsIds = [];
+        $categoryIds = [];
+        foreach ($scopes as $row) {
+            if ((int)$row['scopeType'] === 1) {
+                $goodsIds[] = (int)$row['targetId'];
+            } elseif ((int)$row['scopeType'] === 2) {
+                $categoryIds[] = (int)$row['targetId'];
+            }
+        }
+
+        $rule['tiers'] = $tiers ?: self::defaultTiers(false);
+        $rule['goodsIds'] = $goodsIds;
+        $rule['categoryIds'] = $categoryIds;
+        return $rule;
+    }
+
+    /**
+     * 保存分销规则
+     * @param int $shopId
+     * @param int $mainId
+     * @param array $data
+     * @throws \Exception
+     */
+    public static function saveRule($shopId, $mainId, $data)
+    {
+        self::validateRulePayload($data);
+
+        $existing = self::getByCondition(['shopId' => $shopId], true);
+        $saveData = [
+            'shopId' => $shopId,
+            'mainId' => $mainId,
+            'status' => isset($data['status']) ? (int)$data['status'] : 0,
+            'settleDays' => isset($data['settleDays']) ? (int)$data['settleDays'] : 0,
+            'bonusType' => isset($data['bonusType']) ? (int)$data['bonusType'] : 1,
+            'bonusRate' => isset($data['bonusRate']) ? round((float)$data['bonusRate'], 2) : 0,
+            'durationType' => isset($data['durationType']) ? (int)$data['durationType'] : 1,
+            'durationDays' => isset($data['durationDays']) ? (int)$data['durationDays'] : 0,
+            'timeStartType' => isset($data['timeStartType']) ? (int)$data['timeStartType'] : 1,
+            'productScope' => isset($data['productScope']) ? (int)$data['productScope'] : 1,
+        ];
+
+        $transaction = Yii::$app->db->beginTransaction();
+        try {
+            if (empty($existing)) {
+                $model = self::add($saveData, true);
+                $ruleId = (int)$model->id;
+            } else {
+                self::updateByCondition(['id' => $existing->id], $saveData);
+                $ruleId = (int)$existing->id;
+            }
+
+            DistributionRuleTierClass::deleteByCondition(['ruleId' => $ruleId]);
+            if ((int)$saveData['bonusType'] === 2) {
+                $tierRows = [];
+                foreach ($data['tiers'] as $index => $tier) {
+                    $tierRows[] = [
+                        'ruleId' => $ruleId,
+                        'minDays' => (int)$tier['minDays'],
+                        'maxDays' => (int)$tier['maxDays'],
+                        'bonusRate' => round((float)$tier['bonusRate'], 2),
+                        'sort' => isset($tier['sort']) ? (int)$tier['sort'] : $index,
+                    ];
+                }
+                if (!empty($tierRows)) {
+                    DistributionRuleTierClass::batchAdd($tierRows);
+                }
+            }
+
+            DistributionRuleScopeClass::deleteByCondition(['ruleId' => $ruleId]);
+            $scopeRows = [];
+            $productScope = (int)$saveData['productScope'];
+            if ($productScope === 2 && !empty($data['goodsIds']) && is_array($data['goodsIds'])) {
+                foreach ($data['goodsIds'] as $gid) {
+                    $gid = (int)$gid;
+                    if ($gid > 0) {
+                        $scopeRows[] = ['ruleId' => $ruleId, 'scopeType' => 1, 'targetId' => $gid];
+                    }
+                }
+            }
+            if ($productScope === 3 && !empty($data['categoryIds']) && is_array($data['categoryIds'])) {
+                foreach ($data['categoryIds'] as $cid) {
+                    $cid = (int)$cid;
+                    if ($cid > 0) {
+                        $scopeRows[] = ['ruleId' => $ruleId, 'scopeType' => 2, 'targetId' => $cid];
+                    }
+                }
+            }
+            if (!empty($scopeRows)) {
+                DistributionRuleScopeClass::batchAdd($scopeRows);
+            }
+
+            $transaction->commit();
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            throw $e;
+        }
+    }
+
+    /**
+     * 校验提交参数
+     * @param array $data
+     * @throws \Exception
+     */
+    protected static function validateRulePayload($data)
+    {
+        $settleDays = isset($data['settleDays']) ? (int)$data['settleDays'] : 0;
+        if ($settleDays < 0) {
+            throw new \Exception('佣金发放天数不能为负');
+        }
+
+        $bonusType = isset($data['bonusType']) ? (int)$data['bonusType'] : 1;
+        if ($bonusType === 1) {
+            $rate = isset($data['bonusRate']) ? (float)$data['bonusRate'] : 0;
+            if ($rate <= 0 || $rate > 100) {
+                throw new \Exception('请填写正确的固定佣金比例(0-100)');
+            }
+            $durationType = isset($data['durationType']) ? (int)$data['durationType'] : 1;
+            if ($durationType === 2) {
+                $durationDays = isset($data['durationDays']) ? (int)$data['durationDays'] : 0;
+                if ($durationDays <= 0) {
+                    throw new \Exception('请填写分红限制天数');
+                }
+            }
+        } else {
+            $tiers = isset($data['tiers']) && is_array($data['tiers']) ? $data['tiers'] : [];
+            if (empty($tiers)) {
+                throw new \Exception('请至少添加一个时间阶梯');
+            }
+            self::validateTiers($tiers);
+        }
+
+        $productScope = isset($data['productScope']) ? (int)$data['productScope'] : 1;
+        if ($productScope === 2) {
+            $goodsIds = isset($data['goodsIds']) && is_array($data['goodsIds']) ? $data['goodsIds'] : [];
+            if (empty($goodsIds)) {
+                throw new \Exception('请选择参与分红的商品');
+            }
+        }
+        if ($productScope === 3) {
+            $categoryIds = isset($data['categoryIds']) && is_array($data['categoryIds']) ? $data['categoryIds'] : [];
+            if (empty($categoryIds)) {
+                throw new \Exception('请选择参与分红的分类');
+            }
+        }
+    }
+
+    /**
+     * 校验时间阶梯:不可重叠、不可留空
+     * @param array $tiers
+     * @throws \Exception
+     */
+    protected static function validateTiers($tiers)
+    {
+        $normalized = [];
+        foreach ($tiers as $tier) {
+            $minDays = isset($tier['minDays']) ? (int)$tier['minDays'] : -1;
+            $maxDays = isset($tier['maxDays']) ? (int)$tier['maxDays'] : -1;
+            $rate = isset($tier['bonusRate']) ? (float)$tier['bonusRate'] : 0;
+            if ($minDays < 0) {
+                throw new \Exception('时间阶梯起始天数不能为空');
+            }
+            if ($maxDays > 0 && $maxDays < $minDays) {
+                throw new \Exception('时间阶梯结束天数不能小于起始天数');
+            }
+            if ($rate <= 0 || $rate > 100) {
+                throw new \Exception('请填写正确的阶梯佣金比例');
+            }
+            $normalized[] = ['min' => $minDays, 'max' => $maxDays, 'rate' => $rate];
+        }
+
+        usort($normalized, function ($a, $b) {
+            return $a['min'] - $b['min'];
+        });
+
+        $unlimitedCount = 0;
+        for ($i = 0; $i < count($normalized); $i++) {
+            $cur = $normalized[$i];
+            if ($cur['max'] === 0) {
+                $unlimitedCount++;
+                if ($i !== count($normalized) - 1) {
+                    throw new \Exception('「及以上」阶梯只能放在最后一行');
+                }
+            }
+            if ($i > 0) {
+                $prev = $normalized[$i - 1];
+                if ($prev['max'] <= 0) {
+                    throw new \Exception('时间阶梯区间不可重叠');
+                }
+                if ($cur['min'] <= $prev['max']) {
+                    throw new \Exception('时间阶梯区间不可重叠');
+                }
+            }
+        }
+    }
+
+    /**
+     * 默认规则(未保存过)
+     */
+    protected static function defaultRule($shopId, $mainId)
+    {
+        return [
+            'id' => 0,
+            'shopId' => $shopId,
+            'mainId' => $mainId,
+            'status' => 0,
+            'settleDays' => 5,
+            'bonusType' => 1,
+            'bonusRate' => '0.00',
+            'durationType' => 1,
+            'durationDays' => 0,
+            'timeStartType' => 1,
+            'productScope' => 1,
+            'tiers' => self::defaultTiers(true),
+            'goodsIds' => [],
+            'categoryIds' => [],
+        ];
+    }
+
+    /**
+     * 默认递减阶梯示例
+     */
+    protected static function defaultTiers($asNew = true)
+    {
+        $rows = [
+            ['minDays' => 0, 'maxDays' => 30, 'bonusRate' => '10.00', 'sort' => 0],
+            ['minDays' => 31, 'maxDays' => 60, 'bonusRate' => '5.00', 'sort' => 1],
+            ['minDays' => 61, 'maxDays' => 0, 'bonusRate' => '2.00', 'sort' => 2],
+        ];
+        if (!$asNew) {
+            return $rows;
+        }
+        return array_map(function ($row) {
+            return array_merge($row, ['id' => 0]);
+        }, $rows);
+    }
+}

+ 13 - 0
biz-hd/distribution/classes/DistributionRuleScopeClass.php

@@ -0,0 +1,13 @@
+<?php
+/**
+ * 用途:分销规则商品范围 xhDistributionRuleScope 增删改查
+ */
+
+namespace bizHd\distribution\classes;
+
+use bizHd\base\classes\BaseClass;
+
+class DistributionRuleScopeClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\distribution\models\DistributionRuleScope';
+}

+ 13 - 0
biz-hd/distribution/classes/DistributionRuleTierClass.php

@@ -0,0 +1,13 @@
+<?php
+/**
+ * 用途:分销规则阶梯 xhDistributionRuleTier 增删改查
+ */
+
+namespace bizHd\distribution\classes;
+
+use bizHd\base\classes\BaseClass;
+
+class DistributionRuleTierClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\distribution\models\DistributionRuleTier';
+}

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

@@ -0,0 +1,407 @@
+<?php
+/**
+ * 用途:分销用户统计(xhDistributionUser)
+ * 谁用:hdApp 客户详情-分销板块
+ */
+
+namespace bizHd\distribution\classes;
+
+use bizHd\base\classes\BaseClass;
+use bizHd\custom\classes\CustomClass;
+use common\components\dateUtil;
+use Yii;
+use yii\db\Query;
+
+class DistributionUserClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\distribution\models\DistributionUser';
+
+    /**
+     * 获取客户分销统计(无记录返回默认 0)
+     * @param int $shopId 门店ID
+     * @param int $customId 客户 xhCustom.id
+     * @return array
+     * @throws \Exception
+     */
+    public static function getUserStat($shopId, $customId)
+    {
+        $customId = (int)$customId;
+        if ($customId <= 0) {
+            throw new \Exception('客户ID无效');
+        }
+
+        $row = self::getByCondition(['shopId' => (int)$shopId, 'id' => $customId]);
+        if (empty($row)) {
+            return self::defaultStat($customId);
+        }
+
+        return [
+            'customId' => $customId,
+            'totalCommission' => $row['totalCommission'],
+            'inviteCount' => (int)$row['inviteCount'],
+            'orderCount' => (int)$row['orderCount'],
+            'pendingCommission' => $row['pendingCommission'],
+            'settledCommission' => $row['settledCommission'],
+        ];
+    }
+
+    /**
+     * 商城 C 端我的分红汇总
+     * @param int $shopId
+     * @param int $customId
+     * @return array
+     * @throws \Exception
+     */
+    public static function getMallMyStat($shopId, $customId)
+    {
+        $stat = self::getUserStat($shopId, $customId);
+        $row = self::getByCondition(['shopId' => (int)$shopId, 'id' => (int)$customId]);
+        $stat['availDeposit'] = round((float)(!empty($row) ? ($row['availDeposit'] ?? 0) : 0), 2);
+
+        $period = dateUtil::formatTime('thisMonth', '', '');
+        $monthCommission = 0;
+        if (!empty($period['startTime']) && !empty($period['endTime'])) {
+            $monthCommission = (float)(new Query())
+                ->from('xhDistributionFlow')
+                ->where([
+                    'shopId' => (int)$shopId,
+                    'xhCustomId' => (int)$customId,
+                    'flowType' => 1,
+                ])
+                ->andWhere(['between', 'flowTime', $period['startTime'], $period['endTime']])
+                ->sum('amount');
+        }
+        $stat['monthCommission'] = round($monthCommission, 2);
+        return $stat;
+    }
+
+    /**
+     * 无分销记录时的默认统计
+     */
+    protected static function defaultStat($customId)
+    {
+        return [
+            'customId' => (int)$customId,
+            'totalCommission' => '0.00',
+            'inviteCount' => 0,
+            'orderCount' => 0,
+            'pendingCommission' => '0.00',
+            'settledCommission' => '0.00',
+            'availDeposit' => '0.00',
+            'monthCommission' => '0.00',
+        ];
+    }
+
+    /**
+     * 拉新客户列表(xhDistributionUser + xhCustom)
+     * 条件:du.inviterId = 当前分销员;绑定时间取 inviteTime
+     * @param int $shopId
+     * @param int $inviterId 邀请人 xhCustom.id
+     * @return array
+     * @throws \Exception
+     */
+    public static function getInviteList($shopId, $inviterId)
+    {
+        $shopId = (int)$shopId;
+        $inviterId = (int)$inviterId;
+        if ($inviterId <= 0) {
+            throw new \Exception('客户ID无效');
+        }
+
+        $get = Yii::$app->request->get();
+        $keyword = isset($get['keyword']) ? trim($get['keyword']) : '';
+        $sortField = isset($get['sortField']) ? trim($get['sortField']) : 'inviteTime';
+        $sortOrder = isset($get['sortOrder']) && strtolower($get['sortOrder']) === 'asc' ? 'ASC' : 'DESC';
+        $page = isset($get['page']) ? max(1, (int)$get['page']) : 1;
+        $pageSize = isset($get['pageSize']) && (int)$get['pageSize'] > 0
+            ? (int)$get['pageSize']
+            : (int)Yii::$app->params['pageSize'];
+
+        $query = (new Query())
+            ->from(['du' => 'xhDistributionUser'])
+            ->leftJoin(['c' => 'xhCustom'], 'c.id = du.id')
+            ->where(['du.shopId' => $shopId, 'du.inviterId' => $inviterId])
+            ->select([
+                'du.id',
+                'du.inviteTime',
+                'du.upOrderCount',
+                'du.upCommissionAmount',
+                'c.name',
+                'c.mobile',
+                'c.avatar',
+            ]);
+
+        if ($keyword !== '') {
+            $query->andWhere([
+                'or',
+                ['like', 'c.name', $keyword],
+                ['like', 'c.mobile', $keyword],
+            ]);
+        }
+
+        // 排序:贡献字段取 up*(本人下单给上级的贡献)
+        $orderMap = [
+            'inviteTime' => 'du.inviteTime',
+            'commission' => 'du.upCommissionAmount',
+            'orderCount' => 'du.upOrderCount',
+        ];
+        $orderColumn = isset($orderMap[$sortField]) ? $orderMap[$sortField] : 'du.inviteTime';
+        $query->orderBy([$orderColumn => $sortOrder === 'ASC' ? SORT_ASC : SORT_DESC, 'du.id' => SORT_DESC]);
+
+        $total = (int)(clone $query)->count('*', Yii::$app->db);
+        $totalPage = $pageSize > 0 ? (int)ceil($total / $pageSize) : 0;
+        $rows = $query->offset(($page - 1) * $pageSize)->limit($pageSize)->all(Yii::$app->db);
+
+        $customRows = [];
+        foreach ($rows as $row) {
+            $customRows[] = [
+                'id' => (int)$row['id'],
+                'name' => $row['name'] ?: '',
+                'mobile' => $row['mobile'] ?: '',
+                'avatar' => $row['avatar'] ?: '',
+            ];
+        }
+        $avatarMap = [];
+        if (!empty($customRows)) {
+            foreach (CustomClass::groupBaseInfo($customRows) as $customItem) {
+                $avatarMap[(int)$customItem['id']] = $customItem;
+            }
+        }
+
+        $list = [];
+        foreach ($rows as $row) {
+            $custom = $avatarMap[(int)$row['id']] ?? [];
+            $bindTime = $row['inviteTime'];
+            $list[] = [
+                'customId' => (int)$row['id'],
+                'name' => $custom['name'] ?? ($row['name'] ?: ''),
+                'mobile' => $custom['mobile'] ?? ($row['mobile'] ?: ''),
+                'smallAvatar' => $custom['smallAvatar'] ?? '',
+                'bindTime' => $bindTime,
+                'bindDays' => self::calcBindDays($bindTime),
+                'bindDaysText' => self::buildBindDaysText($bindTime),
+                'contribCommission' => round((float)$row['upCommissionAmount'], 2),
+                'contribOrderCount' => (int)$row['upOrderCount'],
+            ];
+        }
+
+        return [
+            'list' => $list,
+            'totalPage' => $totalPage,
+            'moreData' => $page < $totalPage ? 1 : 0,
+        ];
+    }
+
+    /**
+     * 门店获佣人数明细(报表跳转)
+     * @param int $shopId
+     * @return array
+     */
+    public static function getShopDistList($shopId)
+    {
+        $shopId = (int)$shopId;
+        $get = Yii::$app->request->get();
+        $keyword = isset($get['keyword']) ? trim($get['keyword']) : '';
+        $page = isset($get['page']) ? max(1, (int)$get['page']) : 1;
+        $pageSize = isset($get['pageSize']) && (int)$get['pageSize'] > 0
+            ? (int)$get['pageSize']
+            : (int)Yii::$app->params['pageSize'];
+        $period = self::buildReportPeriod($get);
+
+        $orderQuery = (new Query())
+            ->from('xhDistributionOrder')
+            ->where(['shopId' => $shopId])
+            ->andWhere(['>', 'distId', 0]);
+        if ($period) {
+            $orderQuery->andWhere(['between', 'orderTime', $period[0], $period[1]]);
+        }
+        if ($keyword !== '') {
+            $orderQuery->innerJoin(['c' => 'xhCustom'], 'c.id = xhDistributionOrder.distId')
+                ->andWhere([
+                    'or',
+                    ['like', 'c.name', $keyword],
+                    ['like', 'c.mobile', $keyword],
+                ]);
+        }
+
+        $subQuery = (clone $orderQuery)
+            ->select([
+                'distId',
+                'orderCount' => 'COUNT(*)',
+                'commissionAmount' => 'SUM(commissionAmount)',
+                'distName' => 'MAX(distName)',
+            ])
+            ->groupBy('distId');
+
+        $total = (int)(new Query())->from(['t' => $subQuery])->count('*', Yii::$app->db);
+        $totalPage = $pageSize > 0 ? (int)ceil($total / $pageSize) : 0;
+        $rows = (new Query())
+            ->from(['t' => $subQuery])
+            ->orderBy(['commissionAmount' => SORT_DESC, 'distId' => SORT_DESC])
+            ->offset(($page - 1) * $pageSize)
+            ->limit($pageSize)
+            ->all(Yii::$app->db);
+
+        $distIds = [];
+        foreach ($rows as $row) {
+            $distIds[] = (int)$row['distId'];
+        }
+        $avatarMap = [];
+        if (!empty($distIds)) {
+            // groupBaseInfo 仅处理头像,需先 getCustomByIds 拉取 name/mobile
+            foreach (CustomClass::getCustomByIds($distIds) as $customItem) {
+                $avatarMap[(int)$customItem['id']] = $customItem;
+            }
+        }
+
+        $list = [];
+        foreach ($rows as $row) {
+            $distId = (int)$row['distId'];
+            $custom = $avatarMap[$distId] ?? [];
+            $list[] = [
+                'customId' => $distId,
+                'name' => $custom['name'] ?? ($row['distName'] ?? ''),
+                'mobile' => $custom['mobile'] ?? '',
+                'smallAvatar' => $custom['smallAvatar'] ?? '',
+                'orderCount' => (int)$row['orderCount'],
+                'commissionAmount' => round((float)$row['commissionAmount'], 2),
+            ];
+        }
+
+        return [
+            'list' => $list,
+            'totalPage' => $totalPage,
+            'moreData' => $page < $totalPage ? 1 : 0,
+        ];
+    }
+
+    /**
+     * 门店拉新明细(报表跳转)
+     * @param int $shopId
+     * @return array
+     */
+    public static function getShopInviteList($shopId)
+    {
+        $shopId = (int)$shopId;
+        $get = Yii::$app->request->get();
+        $keyword = isset($get['keyword']) ? trim($get['keyword']) : '';
+        $page = isset($get['page']) ? max(1, (int)$get['page']) : 1;
+        $pageSize = isset($get['pageSize']) && (int)$get['pageSize'] > 0
+            ? (int)$get['pageSize']
+            : (int)Yii::$app->params['pageSize'];
+        $period = self::buildReportPeriod($get);
+
+        $query = (new Query())
+            ->from(['du' => 'xhDistributionUser'])
+            ->leftJoin(['c' => 'xhCustom'], 'c.id = du.id')
+            ->leftJoin(['inv' => 'xhCustom'], 'inv.id = du.inviterId')
+            ->where(['du.shopId' => $shopId])
+            ->andWhere(['>', 'du.inviterId', 0])
+            ->select([
+                'du.id',
+                'du.inviterId',
+                'du.inviteTime',
+                'du.upOrderCount',
+                'du.upCommissionAmount',
+                'c.name',
+                'c.mobile',
+                'c.avatar',
+                'inv.name AS inviterName',
+                'inv.mobile AS inviterMobile',
+            ]);
+
+        if ($period) {
+            $query->andWhere(['between', 'du.inviteTime', $period[0], $period[1]]);
+        }
+        if ($keyword !== '') {
+            $query->andWhere([
+                'or',
+                ['like', 'c.name', $keyword],
+                ['like', 'c.mobile', $keyword],
+                ['like', 'inv.name', $keyword],
+            ]);
+        }
+
+        $total = (int)(clone $query)->count('*', Yii::$app->db);
+        $totalPage = $pageSize > 0 ? (int)ceil($total / $pageSize) : 0;
+        $rows = $query
+            ->orderBy(['du.inviteTime' => SORT_DESC, 'du.id' => SORT_DESC])
+            ->offset(($page - 1) * $pageSize)
+            ->limit($pageSize)
+            ->all(Yii::$app->db);
+
+        $customRows = [];
+        foreach ($rows as $row) {
+            $customRows[] = [
+                'id' => (int)$row['id'],
+                'name' => $row['name'] ?: '',
+                'mobile' => $row['mobile'] ?: '',
+                'avatar' => $row['avatar'] ?: '',
+            ];
+        }
+        $avatarMap = [];
+        if (!empty($customRows)) {
+            foreach (CustomClass::groupBaseInfo($customRows) as $customItem) {
+                $avatarMap[(int)$customItem['id']] = $customItem;
+            }
+        }
+
+        $list = [];
+        foreach ($rows as $row) {
+            $custom = $avatarMap[(int)$row['id']] ?? [];
+            $bindTime = $row['inviteTime'];
+            $list[] = [
+                'customId' => (int)$row['id'],
+                'inviterId' => (int)$row['inviterId'],
+                'inviterName' => $row['inviterName'] ?: '',
+                'inviterMobile' => $row['inviterMobile'] ?: '',
+                'name' => $custom['name'] ?? ($row['name'] ?: ''),
+                'mobile' => $custom['mobile'] ?? ($row['mobile'] ?: ''),
+                'smallAvatar' => $custom['smallAvatar'] ?? '',
+                'bindTime' => $bindTime,
+                'bindDaysText' => self::buildBindDaysText($bindTime),
+                'contribCommission' => round((float)$row['upCommissionAmount'], 2),
+                'contribOrderCount' => (int)$row['upOrderCount'],
+            ];
+        }
+
+        return [
+            'list' => $list,
+            'totalPage' => $totalPage,
+            'moreData' => $page < $totalPage ? 1 : 0,
+        ];
+    }
+
+    /** 报表页时间范围 */
+    protected static function buildReportPeriod($get)
+    {
+        $searchTime = isset($get['searchTime']) ? trim($get['searchTime']) : 'today';
+        $startTime = isset($get['startTime']) ? trim($get['startTime']) : '';
+        $endTime = isset($get['endTime']) ? trim($get['endTime']) : '';
+        $period = dateUtil::formatTime($searchTime, $startTime, $endTime);
+        if (empty($period['startTime']) || empty($period['endTime'])) {
+            return null;
+        }
+        return [$period['startTime'], $period['endTime']];
+    }
+
+    /** 计算绑定天数 */
+    protected static function calcBindDays($bindTime)
+    {
+        if (empty($bindTime) || $bindTime === '0000-00-00 00:00:00') {
+            return 0;
+        }
+        $time = strtotime($bindTime);
+        if ($time <= 0) {
+            return 0;
+        }
+        return max(0, (int)floor((time() - $time) / 86400));
+    }
+
+    /** 绑定天数文案 */
+    protected static function buildBindDaysText($bindTime)
+    {
+        $days = self::calcBindDays($bindTime);
+        return '已绑定' . $days . '天';
+    }
+}

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

@@ -0,0 +1,17 @@
+<?php
+/**
+ * 用途:分销流水表模型 xhDistributionFlow
+ * 谁用:hdApp 客户详情-分红变动明细
+ */
+
+namespace bizHd\distribution\models;
+
+use bizHd\base\models\Base;
+
+class DistributionFlow extends Base
+{
+    public static function tableName()
+    {
+        return 'xhDistributionFlow';
+    }
+}

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

@@ -0,0 +1,17 @@
+<?php
+/**
+ * 用途:订单分销记录模型 xhDistributionOrder
+ * 谁用:拉新客户列表-贡献分红/订单数统计
+ */
+
+namespace bizHd\distribution\models;
+
+use bizHd\base\models\Base;
+
+class DistributionOrder extends Base
+{
+    public static function tableName()
+    {
+        return 'xhDistributionOrder';
+    }
+}

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

@@ -0,0 +1,17 @@
+<?php
+/**
+ * 用途:分销规则主表模型 xhDistributionRule
+ * 谁用:hdApp 门店设置-分销规则
+ */
+
+namespace bizHd\distribution\models;
+
+use bizHd\base\models\Base;
+
+class DistributionRule extends Base
+{
+    public static function tableName()
+    {
+        return 'xhDistributionRule';
+    }
+}

+ 16 - 0
biz-hd/distribution/models/DistributionRuleScope.php

@@ -0,0 +1,16 @@
+<?php
+/**
+ * 用途:分销规则参与商品/分类范围 xhDistributionRuleScope
+ */
+
+namespace bizHd\distribution\models;
+
+use bizHd\base\models\Base;
+
+class DistributionRuleScope extends Base
+{
+    public static function tableName()
+    {
+        return 'xhDistributionRuleScope';
+    }
+}

+ 16 - 0
biz-hd/distribution/models/DistributionRuleTier.php

@@ -0,0 +1,16 @@
+<?php
+/**
+ * 用途:分销规则时间递减阶梯 xhDistributionRuleTier
+ */
+
+namespace bizHd\distribution\models;
+
+use bizHd\base\models\Base;
+
+class DistributionRuleTier extends Base
+{
+    public static function tableName()
+    {
+        return 'xhDistributionRuleTier';
+    }
+}

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

@@ -0,0 +1,17 @@
+<?php
+/**
+ * 用途:分销用户统计表模型 xhDistributionUser
+ * 谁用:hdApp 客户详情-分销板块
+ */
+
+namespace bizHd\distribution\models;
+
+use bizHd\base\models\Base;
+
+class DistributionUser extends Base
+{
+    public static function tableName()
+    {
+        return 'xhDistributionUser';
+    }
+}

+ 372 - 0
biz-hd/groupBuy/classes/GroupBuyActivityClass.php

@@ -0,0 +1,372 @@
+<?php
+
+namespace bizHd\groupBuy\classes;
+
+use bizHd\base\classes\BaseClass;
+use bizHd\goods\classes\GoodsClass;
+use bizHd\homePageConfig\classes\HomePageConfigClass;
+use bizHd\homePageConfig\classes\HomePageModuleClass;
+use common\components\util;
+
+/**
+ * 团购活动批次与商品版本业务类
+ * 供 hd 后台保存/读取、mall 首页展示与落地页核价使用
+ * 替代原先仅写 Redis 的单例配置,按起止时间批次化持久化到 MySQL
+ */
+class GroupBuyActivityClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\groupBuy\models\GroupBuyActivity';
+
+    /**
+     * 读取当前门店「生效中」的团购配置,返回结构与历史 Redis getGroupBuy 兼容
+     *
+     * @param int $mainId
+     * @param bool $refreshStock 是否回查真实库存
+     * @return array
+     */
+    public static function getGroupBuy($mainId, $refreshStock = true)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+
+        $enabled = HomePageConfigClass::getModuleEnabled($mainId, 'groupBuy');
+        $activity = self::getCurrentActivity($mainId);
+        if (empty($activity)) {
+            // 无 MySQL 批次时回退 Redis,兼容尚未迁移的旧数据
+            return HomePageModuleClass::getGroupBuyFromRedis($mainId, $refreshStock);
+        }
+
+        $goodsRows = GroupBuyGoodsClass::getActiveGoodsByActivityId(intval($activity['id']), $mainId, $refreshStock);
+        $base = [
+            'activityId' => intval($activity['id']),
+            'title' => strval($activity['title'] ?? ''),
+            'subtitle' => strval($activity['subtitle'] ?? ''),
+            'showCountdown' => !empty($activity['showCountdown']) ? 1 : 0,
+            'expand' => !empty($activity['expand']) ? 1 : 0,
+            'startTime' => intval($activity['startTime'] ?? 0),
+            'endTime' => intval($activity['endTime'] ?? 0),
+            'desc' => strval($activity['desc'] ?? ''),
+        ];
+        $layout = HomePageModuleClass::resolveActivityLayout(count($goodsRows), $base['expand']);
+        $base['enabled'] = $enabled;
+        $base['goods'] = $goodsRows;
+        $base['layoutCols'] = $layout['layoutCols'];
+        $base['displayCount'] = $layout['displayCount'];
+        $base['status'] = HomePageModuleClass::calcActivityStatus($base['startTime'], $base['endTime'], $enabled);
+        return $base;
+    }
+
+    /**
+     * 保存团购配置:按时间重叠复用/新建活动批次,商品按关键字段变化新建版本
+     *
+     * @param int $mainId
+     * @param array $base
+     * @param array $goods
+     * @param int $enabled
+     * @return bool
+     */
+    public static function saveGroupBuy($mainId, $base, $goods = [], $enabled = 0)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        if (!is_array($base)) {
+            util::fail('参数错误');
+        }
+
+        $validatedGoods = self::validateGroupBuyGoods($mainId, is_array($goods) ? $goods : []);
+        $activity = self::resolveOrCreateActivity($mainId, $base);
+        $activityId = intval($activity['id'] ?? ($activity->id ?? 0));
+        if ($activityId <= 0) {
+            util::fail('活动保存失败');
+        }
+
+        // 更新活动基础信息(同一批次内修改标题/说明等)
+        self::updateById($activityId, [
+            'title' => strval($base['title'] ?? ''),
+            'subtitle' => strval($base['subtitle'] ?? ''),
+            'showCountdown' => !empty($base['showCountdown']) ? 1 : 0,
+            'expand' => !empty($base['expand']) ? 1 : 0,
+            'desc' => strval($base['desc'] ?? ''),
+            'status' => 1,
+            'delStatus' => 0,
+        ]);
+
+        GroupBuyGoodsClass::syncActivityGoods($mainId, $activityId, $validatedGoods);
+        HomePageConfigClass::updateModuleEnabled($mainId, 'groupBuy', $enabled);
+
+        // 同步写 Redis,保证旧读路径与预览链路仍可用
+        $syncGoods = GroupBuyGoodsClass::getActiveGoodsByActivityId($activityId, $mainId, false);
+        $persistGoods = [];
+        foreach ($syncGoods as $row) {
+            $copy = $row;
+            unset($copy['realStock'], $copy['activityGoodsId'], $copy['activityId'], $copy['id']);
+            // Redis 历史结构不带版本 id;id 仅给后台编辑用,mall 列表靠 goodsId
+            $persistGoods[] = [
+                'goodsId' => intval($row['goodsId']),
+                'price' => floatval($row['price']),
+                'stock' => intval($row['stock']),
+                'limit' => intval($row['limit']),
+                'groupSize' => intval($row['groupSize']),
+                'virtualGroup' => intval($row['virtualGroup']),
+                'virtualMinutes' => intval($row['virtualMinutes']),
+                'autoRefund' => 1,
+                'status' => intval($row['status']),
+                'name' => strval($row['name']),
+                'cover' => strval($row['cover']),
+                'originPrice' => floatval($row['originPrice']),
+                'specName' => strval($row['specName'] ?? ''),
+            ];
+        }
+        HomePageModuleClass::setJson($mainId, HomePageModuleClass::REDIS_GROUP_BUY, array_merge([
+            'title' => strval($base['title'] ?? ''),
+            'subtitle' => strval($base['subtitle'] ?? ''),
+            'showCountdown' => !empty($base['showCountdown']) ? 1 : 0,
+            'expand' => !empty($base['expand']) ? 1 : 0,
+            'startTime' => intval($base['startTime'] ?? 0),
+            'endTime' => intval($base['endTime'] ?? 0),
+            'desc' => strval($base['desc'] ?? ''),
+        ], ['goods' => $persistGoods]));
+
+        return true;
+    }
+
+    /**
+     * 取当前应展示的活动:优先进行中,其次未开始最近一场,再取最近结束的一场
+     *
+     * @param int $mainId
+     * @return array|null
+     */
+    public static function getCurrentActivity($mainId)
+    {
+        $mainId = intval($mainId);
+        $now = time();
+        $list = self::getAllByCondition([
+            'mainId' => $mainId,
+            'delStatus' => 0,
+            'status' => 1,
+        ], 'startTime DESC', '*', null);
+        if (empty($list)) {
+            return null;
+        }
+
+        $running = null;
+        $upcoming = null;
+        $latestEnded = null;
+        foreach ($list as $row) {
+            $start = intval($row['startTime'] ?? 0);
+            $end = intval($row['endTime'] ?? 0);
+            if ($start <= $now && $now <= $end) {
+                $running = $row;
+                break;
+            }
+            if ($start > $now) {
+                if ($upcoming === null || $start < intval($upcoming['startTime'])) {
+                    $upcoming = $row;
+                }
+            }
+            if ($end < $now) {
+                if ($latestEnded === null || $end > intval($latestEnded['endTime'])) {
+                    $latestEnded = $row;
+                }
+            }
+        }
+        return $running ?: ($upcoming ?: $latestEnded);
+    }
+
+    /**
+     * 按起止时间重叠复用历史活动,否则新建批次
+     *
+     * @param int $mainId
+     * @param array $base
+     * @return array|\yii\db\ActiveRecord
+     */
+    public static function resolveOrCreateActivity($mainId, $base)
+    {
+        $mainId = intval($mainId);
+        $startTime = intval($base['startTime'] ?? 0);
+        $endTime = intval($base['endTime'] ?? 0);
+        if ($startTime <= 0 || $endTime <= $startTime) {
+            util::fail('活动时间无效');
+        }
+
+        // 时间区间有重叠即复用:startA < endB AND endA > startB
+        $existed = self::getAllByCondition([
+            'mainId' => $mainId,
+            'delStatus' => 0,
+            'status' => 1,
+            'startTime<' => $endTime,
+            'endTime>' => $startTime,
+        ], 'id DESC', '*', null);
+        if (!empty($existed[0])) {
+            $row = $existed[0];
+            // 重叠时沿用历史起止时间,不因小改动拆分批次
+            return $row;
+        }
+
+        return self::add([
+            'mainId' => $mainId,
+            'title' => strval($base['title'] ?? ''),
+            'subtitle' => strval($base['subtitle'] ?? ''),
+            'showCountdown' => !empty($base['showCountdown']) ? 1 : 0,
+            'expand' => !empty($base['expand']) ? 1 : 0,
+            'startTime' => $startTime,
+            'endTime' => $endTime,
+            'desc' => strval($base['desc'] ?? ''),
+            'status' => 1,
+            'delStatus' => 0,
+        ]);
+    }
+
+    /**
+     * 业务校验团购商品(归属/库存/成团人数),与历史 validateGroupBuyGoods 对齐并保留 id/specName
+     *
+     * @param int $mainId
+     * @param array $rawList
+     * @return array
+     */
+    public static function validateGroupBuyGoods($mainId, $rawList)
+    {
+        $list = [];
+        foreach ($rawList as $index => $item) {
+            if (!is_array($item)) {
+                continue;
+            }
+            $no = $index + 1;
+            $goodsId = intval($item['goodsId'] ?? 0);
+            $price = floatval($item['price'] ?? 0);
+            $stock = intval($item['stock'] ?? 0);
+            $limit = intval($item['limit'] ?? 0);
+            $groupSize = intval($item['groupSize'] ?? 3);
+            $goods = GoodsClass::getById($goodsId, true);
+            if (empty($goods) || intval($goods->mainId ?? 0) !== intval($mainId)) {
+                util::fail("第{$no}个团购商品无效");
+            }
+            $realStock = intval($goods->stock ?? 0);
+            if ($stock > $realStock) {
+                util::fail("第{$no}个团购库存不能超过商品实际库存({$realStock})");
+            }
+            $virtualGroup = !empty($item['virtualGroup']) ? 1 : 0;
+            $virtualMinutes = intval($item['virtualMinutes'] ?? 0);
+            $name = $goods->masterId > 0 ? $goods->name . '(' . $goods->specName . ')' : $goods->name;
+            $list[] = [
+                'id' => intval($item['id'] ?? 0),
+                'goodsId' => $goodsId,
+                'price' => round($price, 2),
+                'stock' => $stock,
+                'limit' => $limit,
+                'groupSize' => $groupSize,
+                'virtualGroup' => $virtualGroup,
+                'virtualMinutes' => $virtualMinutes,
+                'autoRefund' => 1,
+                'status' => ($realStock < $stock) ? 0 : (!empty($item['status']) ? 1 : 0),
+                'name' => $name,
+                'cover' => strval($goods->shortCover ?? ($goods->cover ?? ($item['cover'] ?? ''))),
+                'originPrice' => floatval($goods->price ?? ($item['originPrice'] ?? 0)),
+                'specName' => strval($goods->specName ?? ($item['specName'] ?? '')),
+                'realStock' => $realStock,
+            ];
+        }
+        return $list;
+    }
+
+    /**
+     * 按 activityGoodsId 取进行中活动的有效商品行(下单核价用)
+     *
+     * @param int $mainId
+     * @param int $activityGoodsId
+     * @return array|null
+     */
+    public static function getActiveGoodsRowById($mainId, $activityGoodsId)
+    {
+        $mainId = intval($mainId);
+        $activityGoodsId = intval($activityGoodsId);
+        if ($mainId <= 0 || $activityGoodsId <= 0) {
+            return null;
+        }
+        $row = GroupBuyGoodsClass::getById($activityGoodsId, true);
+        if (empty($row) || intval($row->mainId) !== $mainId || intval($row->delStatus) !== 0 || intval($row->status) !== 1) {
+            return null;
+        }
+        $activity = self::getById(intval($row->activityId), true);
+        if (empty($activity) || intval($activity->mainId) !== $mainId || intval($activity->delStatus) !== 0) {
+            return null;
+        }
+        $enabled = HomePageConfigClass::getModuleEnabled($mainId, 'groupBuy');
+        $status = HomePageModuleClass::calcActivityStatus(
+            intval($activity->startTime),
+            intval($activity->endTime),
+            $enabled
+        );
+        if ($status !== 1) {
+            return null;
+        }
+        return [
+            'id' => intval($row->id),
+            'activityId' => intval($row->activityId),
+            'activityGoodsId' => intval($row->id),
+            'goodsId' => intval($row->goodsId),
+            'price' => floatval($row->price),
+            'stock' => intval($row->stock),
+            'limit' => intval($row->limit),
+            'groupSize' => intval($row->groupSize),
+            'virtualGroup' => intval($row->virtualGroup),
+            'virtualMinutes' => intval($row->virtualMinutes),
+            'autoRefund' => 1,
+            'status' => 1,
+            'name' => strval($row->name),
+            'cover' => strval($row->cover),
+            'originPrice' => floatval($row->originPrice),
+            'specName' => strval($row->specName),
+            'startTime' => intval($activity->startTime),
+            'endTime' => intval($activity->endTime),
+            'title' => strval($activity->title),
+            'subtitle' => strval($activity->subtitle),
+            'desc' => strval($activity->desc),
+            'showCountdown' => intval($activity->showCountdown),
+        ];
+    }
+
+    /**
+     * 按商品 id 取当前进行中活动的有效商品行
+     *
+     * @param int $mainId
+     * @param int $goodsId
+     * @return array|null
+     */
+    public static function getActiveGoodsRowByGoodsId($mainId, $goodsId)
+    {
+        $mainId = intval($mainId);
+        $goodsId = intval($goodsId);
+        if ($mainId <= 0 || $goodsId <= 0) {
+            return null;
+        }
+        $activity = self::getCurrentActivity($mainId);
+        if (empty($activity)) {
+            return null;
+        }
+        $enabled = HomePageConfigClass::getModuleEnabled($mainId, 'groupBuy');
+        $status = HomePageModuleClass::calcActivityStatus(
+            intval($activity['startTime']),
+            intval($activity['endTime']),
+            $enabled
+        );
+        if ($status !== 1) {
+            return null;
+        }
+        $row = GroupBuyGoodsClass::getByCondition([
+            'activityId' => intval($activity['id']),
+            'mainId' => $mainId,
+            'goodsId' => $goodsId,
+            'status' => 1,
+            'delStatus' => 0,
+        ], true);
+        if (empty($row)) {
+            return null;
+        }
+        return self::getActiveGoodsRowById($mainId, intval($row->id));
+    }
+}

+ 593 - 0
biz-hd/groupBuy/classes/GroupBuyClass.php

@@ -0,0 +1,593 @@
+<?php
+
+namespace bizHd\groupBuy\classes;
+
+use bizHd\base\classes\BaseClass;
+use bizHd\custom\classes\CustomClass;
+use bizHd\order\classes\OrderClass;
+use bizHd\order\classes\OrderGoodsClass;
+use bizHd\refund\classes\HdRefundClass;
+use bizHd\refund\services\HdRefundService;
+use bizHd\shop\classes\ShopClass;
+use common\components\noticeUtil;
+use common\components\util;
+use PhpAmqpLib\Wire\AMQPTable;
+use Yii;
+
+/**
+ * 拼团单核心业务类
+ * 职责:开团/参团校验、支付成功入团计数、到期虚拟成团或失败自动退款
+ * 订单与拼团解耦:订单只记 groupBuyId,人数由 xhGroupBuyMember 维护
+ */
+class GroupBuyClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\groupBuy\models\GroupBuy';
+
+    /** 拼团中 */
+    const STATUS_ONGOING = 1;
+    /** 拼团成功 */
+    const STATUS_SUCCESS = 2;
+    /** 拼团失败 */
+    const STATUS_FAIL = 3;
+    /** 已取消(从未成团,如团长未支付超时) */
+    const STATUS_CANCEL = 4;
+
+    /** 非虚拟成团默认有效期:24 小时 */
+    const DEFAULT_DEADLINE_SECONDS = 86400;
+
+    /**
+     * 开团:创建拼团单(currentNum=0),成员与订单由 Controller 在下单后挂接
+     *
+     * @param int $mainId
+     * @param int $shopId
+     * @param int $customId 团长
+     * @param array $activityGoodsRow getActiveGoodsRowById 结果
+     * @return \yii\db\ActiveRecord
+     */
+    public static function createGroup($mainId, $shopId, $customId, $activityGoodsRow)
+    {
+        $mainId = intval($mainId);
+        $shopId = intval($shopId);
+        $customId = intval($customId);
+        if ($mainId <= 0 || $customId <= 0 || empty($activityGoodsRow)) {
+            util::fail('开团参数无效');
+        }
+        $needNum = intval($activityGoodsRow['groupSize'] ?? 0);
+        if (!in_array($needNum, [2, 3, 5], true)) {
+            util::fail('成团人数无效');
+        }
+        $virtualGroup = !empty($activityGoodsRow['virtualGroup']) ? 1 : 0;
+        $virtualMinutes = intval($activityGoodsRow['virtualMinutes'] ?? 0);
+        $now = time();
+        if ($virtualGroup && $virtualMinutes > 0) {
+            $deadline = $now + $virtualMinutes * 60;
+        } else {
+            $deadline = $now + self::DEFAULT_DEADLINE_SECONDS;
+        }
+        // 团截止不得超过活动结束时间
+        $activityEnd = intval($activityGoodsRow['endTime'] ?? 0);
+        if ($activityEnd > 0 && $deadline > $activityEnd) {
+            $deadline = $activityEnd;
+        }
+        if ($deadline <= $now) {
+            util::fail('活动即将结束,无法开团');
+        }
+
+        $group = self::add([
+            'mainId' => $mainId,
+            'shopId' => $shopId,
+            'activityId' => intval($activityGoodsRow['activityId'] ?? 0),
+            'activityGoodsId' => intval($activityGoodsRow['activityGoodsId'] ?? $activityGoodsRow['id'] ?? 0),
+            'goodsId' => intval($activityGoodsRow['goodsId'] ?? 0),
+            'leaderCustomId' => $customId,
+            'needNum' => $needNum,
+            'currentNum' => 0,
+            'price' => floatval($activityGoodsRow['price'] ?? 0),
+            'status' => self::STATUS_ONGOING,
+            'virtualGroup' => $virtualGroup,
+            'deadline' => $deadline,
+        ]);
+        $groupBuyId = intval($group['id']);
+        if ($groupBuyId > 0) {
+            self::scheduleExpireDelay($groupBuyId, $deadline);
+        }
+        return $group;
+    }
+
+    /**
+     * 参团前校验:团存在、拼团中、未过期、未满、客户未重复加入
+     *
+     * @param int $mainId
+     * @param int $groupBuyId
+     * @param int $customId
+     * @return object 拼团单 AR
+     */
+    public static function assertCanJoin($mainId, $groupBuyId, $customId)
+    {
+        $mainId = intval($mainId);
+        $groupBuyId = intval($groupBuyId);
+        $customId = intval($customId);
+        $group = self::getLockById($groupBuyId);
+        if (empty($group) || intval($group->mainId) !== $mainId) {
+            util::fail('拼团不存在');
+        }
+        if (intval($group->status) !== self::STATUS_ONGOING) {
+            util::fail('该团已结束,无法参团');
+        }
+        if (intval($group->deadline) > 0 && intval($group->deadline) <= time()) {
+            util::fail('该团已到期,无法参团');
+        }
+        if (intval($group->currentNum) >= intval($group->needNum)) {
+            util::fail('该团已满员');
+        }
+        $existMember = GroupBuyMemberClass::getByCondition([
+            'groupBuyId' => $groupBuyId,
+            'customId' => $customId,
+            'status' => ['in', [
+                GroupBuyMemberClass::STATUS_WAIT_PAY,
+                GroupBuyMemberClass::STATUS_PAID,
+            ]],
+        ], true);
+        if (!empty($existMember)) {
+            util::fail('您已在该团中,请勿重复参团');
+        }
+        return $group;
+    }
+
+    /**
+     * 支付成功钩子:成员置已支付、currentNum+1、判定满团
+     * 由 OrderClass::payAfter 在 groupBuyId>0 时调用
+     *
+     * @param object $order
+     * @return bool
+     */
+    public static function onOrderPaid($order)
+    {
+        if (empty($order)) {
+            return false;
+        }
+        $groupBuyId = intval($order->groupBuyId ?? 0);
+        if ($groupBuyId <= 0) {
+            return false;
+        }
+        $orderId = intval($order->id ?? 0);
+        $customId = intval($order->customId ?? 0);
+        $orderSn = strval($order->orderSn ?? '');
+
+        $group = self::getLockById($groupBuyId);
+        if (empty($group)) {
+            return false;
+        }
+
+        $member = GroupBuyMemberClass::getByCondition([
+            'groupBuyId' => $groupBuyId,
+            'customId' => $customId,
+        ], true);
+        if (empty($member)) {
+            // 兜底:支付成功但成员记录缺失时补建
+            GroupBuyMemberClass::add([
+                'groupBuyId' => $groupBuyId,
+                'mainId' => intval($group->mainId),
+                'orderId' => $orderId,
+                'orderSn' => $orderSn,
+                'customId' => $customId,
+                'role' => intval($group->leaderCustomId) === $customId
+                    ? GroupBuyMemberClass::ROLE_LEADER
+                    : GroupBuyMemberClass::ROLE_MEMBER,
+                'status' => GroupBuyMemberClass::STATUS_PAID,
+                'joinTime' => date('Y-m-d H:i:s'),
+            ]);
+        } else {
+            if (intval($member->status) === GroupBuyMemberClass::STATUS_PAID) {
+                return true;
+            }
+            GroupBuyMemberClass::updateById(intval($member->id), [
+                'status' => GroupBuyMemberClass::STATUS_PAID,
+                'orderId' => $orderId,
+                'orderSn' => $orderSn,
+                'joinTime' => date('Y-m-d H:i:s'),
+            ]);
+        }
+
+        // 仅拼团中才累加人数;已成功/失败的团不再变更
+        if (intval($group->status) !== self::STATUS_ONGOING) {
+            return true;
+        }
+        $currentNum = intval($group->currentNum) + 1;
+        $needNum = intval($group->needNum);
+        $update = ['currentNum' => $currentNum];
+        if ($currentNum >= $needNum) {
+            $update['status'] = self::STATUS_SUCCESS;
+            $update['successTime'] = date('Y-m-d H:i:s');
+            $update['currentNum'] = $needNum;
+        }
+        self::updateById($groupBuyId, $update);
+        return true;
+    }
+
+    /**
+     * 订单超时取消时:成员置失效;若团无人支付则取消团
+     *
+     * @param object $order
+     * @return bool
+     */
+    public static function onOrderExpired($order)
+    {
+        if (empty($order)) {
+            return false;
+        }
+        $groupBuyId = intval($order->groupBuyId ?? 0);
+        $customId = intval($order->customId ?? 0);
+        if ($groupBuyId <= 0 || $customId <= 0) {
+            return false;
+        }
+        $member = GroupBuyMemberClass::getByCondition([
+            'groupBuyId' => $groupBuyId,
+            'customId' => $customId,
+        ], true);
+        if (!empty($member) && intval($member->status) === GroupBuyMemberClass::STATUS_WAIT_PAY) {
+            GroupBuyMemberClass::updateById(intval($member->id), [
+                'status' => GroupBuyMemberClass::STATUS_INVALID,
+            ]);
+        }
+        $group = self::getLockById($groupBuyId);
+        if (empty($group) || intval($group->status) !== self::STATUS_ONGOING) {
+            return true;
+        }
+        if (intval($group->currentNum) <= 0) {
+            self::updateById($groupBuyId, [
+                'status' => self::STATUS_CANCEL,
+                'failTime' => date('Y-m-d H:i:s'),
+            ]);
+        }
+        return true;
+    }
+
+    /**
+     * 幂等到期判定:虚拟成团 → 成功;非虚拟 → 失败并批量退款
+     *
+     * @param int $groupBuyId
+     * @return bool
+     */
+    public static function expireById($groupBuyId)
+    {
+        $groupBuyId = intval($groupBuyId);
+        if ($groupBuyId <= 0) {
+            return true;
+        }
+        $connection = Yii::$app->db;
+        $transaction = $connection->beginTransaction();
+        try {
+            $group = self::getLockById($groupBuyId);
+            if (empty($group)) {
+                $transaction->commit();
+                return true;
+            }
+            if (intval($group->status) !== self::STATUS_ONGOING) {
+                $transaction->commit();
+                return true;
+            }
+            $deadline = intval($group->deadline);
+            if ($deadline > time()) {
+                $transaction->commit();
+                return true;
+            }
+
+            $currentNum = intval($group->currentNum);
+            $needNum = intval($group->needNum);
+            if ($currentNum >= $needNum) {
+                self::updateById($groupBuyId, [
+                    'status' => self::STATUS_SUCCESS,
+                    'successTime' => date('Y-m-d H:i:s'),
+                    'currentNum' => $needNum,
+                ]);
+                $transaction->commit();
+                return true;
+            }
+
+            // 无人支付:直接取消,无需退款
+            if ($currentNum <= 0) {
+                self::updateById($groupBuyId, [
+                    'status' => self::STATUS_CANCEL,
+                    'failTime' => date('Y-m-d H:i:s'),
+                ]);
+                GroupBuyMemberClass::updateByCondition(
+                    [
+                        'groupBuyId' => $groupBuyId,
+                        'status' => GroupBuyMemberClass::STATUS_WAIT_PAY,
+                    ],
+                    ['status' => GroupBuyMemberClass::STATUS_INVALID]
+                );
+                $transaction->commit();
+                return true;
+            }
+
+            if (!empty($group->virtualGroup)) {
+                // 虚拟成团:不伪造成员,直接判定成功
+                self::updateById($groupBuyId, [
+                    'status' => self::STATUS_SUCCESS,
+                    'successTime' => date('Y-m-d H:i:s'),
+                    'currentNum' => $needNum,
+                ]);
+                $transaction->commit();
+                return true;
+            }
+
+            self::updateById($groupBuyId, [
+                'status' => self::STATUS_FAIL,
+                'failTime' => date('Y-m-d H:i:s'),
+            ]);
+            GroupBuyMemberClass::updateByCondition(
+                [
+                    'groupBuyId' => $groupBuyId,
+                    'status' => GroupBuyMemberClass::STATUS_WAIT_PAY,
+                ],
+                ['status' => GroupBuyMemberClass::STATUS_INVALID]
+            );
+            $transaction->commit();
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            noticeUtil::push('拼团到期判定失败 groupBuyId=' . $groupBuyId . ' ' . $e->getMessage(), '15280215347');
+            return false;
+        }
+
+        // 退款放在主事务外,单条失败不影响其它成员
+        self::refundPaidMembers($groupBuyId);
+        return true;
+    }
+
+    /**
+     * 对已支付成员逐单自动退款(复用 HdRefundService::addRefund)
+     *
+     * @param int $groupBuyId
+     * @return bool
+     */
+    public static function refundPaidMembers($groupBuyId)
+    {
+        $groupBuyId = intval($groupBuyId);
+        $members = GroupBuyMemberClass::getAllByCondition([
+            'groupBuyId' => $groupBuyId,
+            'status' => GroupBuyMemberClass::STATUS_PAID,
+        ], null, '*', null);
+        if (empty($members)) {
+            return true;
+        }
+        foreach ($members as $member) {
+            $orderId = intval($member['orderId'] ?? 0);
+            if ($orderId <= 0) {
+                continue;
+            }
+            $connection = Yii::$app->db;
+            $transaction = $connection->beginTransaction();
+            try {
+                $order = OrderClass::getLockById($orderId);
+                if (empty($order) || intval($order->payStatus) !== 1) {
+                    $transaction->commit();
+                    continue;
+                }
+                // 已有退款则跳过
+                if (intval($order->refund ?? 0) === OrderClass::REFUND_YES) {
+                    $transaction->commit();
+                    continue;
+                }
+                $shopId = intval($order->shopId);
+                $shop = ShopClass::getById($shopId, true);
+                if (empty($shop)) {
+                    util::fail('没有找到门店');
+                }
+                $orderSn = strval($order->orderSn ?? '');
+                $orderGoods = OrderGoodsClass::getAllByCondition(['orderSn' => $orderSn], null, '*', null, true);
+                $productData = [];
+                if (!empty($orderGoods)) {
+                    foreach ($orderGoods as $goodsItem) {
+                        $productData[] = [
+                            'productId' => $goodsItem->goodsId,
+                            'num' => $goodsItem->num,
+                            'unitPrice' => $goodsItem->unitPrice,
+                            'property' => 0,
+                        ];
+                    }
+                }
+                $post = [
+                    'price' => $order->actPrice ?? 0,
+                    'refundType' => HdRefundClass::REFUND_TYPE_MONEY_GOOD,
+                    'id' => $orderId,
+                    'shopId' => $shopId,
+                    'sjId' => intval($shop->sjId ?? 0),
+                    'shopAdminId' => 0,
+                    'mainId' => intval($order->mainId ?? 0),
+                    'shopAdminName' => '系统自动退款(拼团失败)',
+                    'product' => $productData,
+                    'remark' => '拼团失败自动退款',
+                ];
+                HdRefundService::addRefund($post, $order);
+                $transaction->commit();
+            } catch (\Exception $e) {
+                $transaction->rollBack();
+                noticeUtil::push(
+                    '拼团失败退款异常 groupBuyId=' . $groupBuyId . ' orderId=' . $orderId . ' ' . $e->getMessage(),
+                    '15280215347'
+                );
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 投递拼团到期延迟消息(复用 limitBuyDelayExchange)
+     *
+     * @param int $groupBuyId
+     * @param int $deadline unix
+     * @return bool
+     */
+    public static function scheduleExpireDelay($groupBuyId, $deadline)
+    {
+        $groupBuyId = intval($groupBuyId);
+        $deadline = intval($deadline);
+        if ($groupBuyId <= 0 || $deadline <= 0) {
+            return false;
+        }
+        try {
+            $delayMs = max(1000, ($deadline - time()) * 1000);
+            $message = serialize([
+                'type' => 'group_buy_expire',
+                'groupBuyId' => $groupBuyId,
+            ]);
+            $producer = Yii::$app->rabbitmq->getProducer('notifyProducer');
+            $producer->publish($message, 'limitBuyDelayExchange', 'groupBuyDelayRoute', [
+                'delivery_mode' => 2,
+                'content_type' => 'application/octet-stream',
+                'application_headers' => new AMQPTable([
+                    'x-delay' => intval($delayMs),
+                ]),
+            ]);
+            Yii::info('group buy expire delay: groupBuyId=' . $groupBuyId . ' delayMs=' . $delayMs, __METHOD__);
+        } catch (\Exception $e) {
+            // 延迟消息失败不阻断开团,依赖 console 兜底扫描
+            Yii::error('group buy scheduleExpireDelay fail: ' . $e->getMessage(), __METHOD__);
+        }
+        return true;
+    }
+
+    /**
+     * 团详情(含成员头像)
+     *
+     * @param int $mainId
+     * @param int $groupBuyId
+     * @return array
+     */
+    public static function getDetail($mainId, $groupBuyId)
+    {
+        $mainId = intval($mainId);
+        $groupBuyId = intval($groupBuyId);
+        $group = self::getById($groupBuyId, true);
+        if (empty($group) || intval($group->mainId) !== $mainId) {
+            util::fail('拼团不存在');
+        }
+        $activityGoods = GroupBuyGoodsClass::getById(intval($group->activityGoodsId), true);
+        $members = GroupBuyMemberClass::getAllByCondition([
+            'groupBuyId' => $groupBuyId,
+            'status' => GroupBuyMemberClass::STATUS_PAID,
+        ], 'role ASC,id ASC', '*', null);
+        $memberList = [];
+        if (!empty($members)) {
+            $customIds = array_column($members, 'customId');
+            $customs = CustomClass::getCustomByIds($customIds);
+            $customMap = [];
+            if (!empty($customs)) {
+                foreach ($customs as $c) {
+                    $customMap[intval($c['id'] ?? 0)] = $c;
+                }
+            }
+            foreach ($members as $m) {
+                $cid = intval($m['customId']);
+                $custom = $customMap[$cid] ?? null;
+                $memberList[] = [
+                    'customId' => $cid,
+                    'role' => intval($m['role']),
+                    'avatar' => is_array($custom)
+                        ? ($custom['smallAvatar'] ?? ($custom['avatar'] ?? ''))
+                        : '',
+                    'name' => is_array($custom) ? ($custom['name'] ?? '') : '',
+                ];
+            }
+        }
+        $needNum = intval($group->needNum);
+        $currentNum = intval($group->currentNum);
+        $remain = max(0, $needNum - $currentNum);
+        return [
+            'id' => intval($group->id),
+            'status' => intval($group->status),
+            'needNum' => $needNum,
+            'currentNum' => $currentNum,
+            'remainNum' => $remain,
+            'deadline' => intval($group->deadline),
+            'virtualGroup' => intval($group->virtualGroup),
+            'price' => floatval($group->price),
+            'goodsId' => intval($group->goodsId),
+            'activityGoodsId' => intval($group->activityGoodsId),
+            'leaderCustomId' => intval($group->leaderCustomId),
+            'members' => $memberList,
+            'goods' => [
+                'goodsId' => intval($group->goodsId),
+                'name' => $activityGoods ? strval($activityGoods->name) : '',
+                'cover' => $activityGoods ? strval($activityGoods->cover) : '',
+                'price' => floatval($group->price),
+                'originPrice' => $activityGoods ? floatval($activityGoods->originPrice) : 0,
+                'groupSize' => $needNum,
+            ],
+        ];
+    }
+
+    /**
+     * 某商品当前「最快成团」的进行中开放团(还差人数最少,其次即将到期)
+     *
+     * @param int $mainId
+     * @param int $goodsId
+     * @param int $activityGoodsId
+     * @return array|null
+     */
+    public static function getHottestOpenGroup($mainId, $goodsId, $activityGoodsId = 0)
+    {
+        $mainId = intval($mainId);
+        $goodsId = intval($goodsId);
+        $activityGoodsId = intval($activityGoodsId);
+        $condition = [
+            'mainId' => $mainId,
+            'goodsId' => $goodsId,
+            'status' => self::STATUS_ONGOING,
+            'deadline>' => time(),
+        ];
+        if ($activityGoodsId > 0) {
+            $condition['activityGoodsId'] = $activityGoodsId;
+        }
+        $list = self::getAllByCondition($condition, 'currentNum DESC,deadline ASC', '*', null);
+        if (empty($list)) {
+            return null;
+        }
+        foreach ($list as $row) {
+            if (intval($row['currentNum']) >= intval($row['needNum'])) {
+                continue;
+            }
+            if (intval($row['currentNum']) <= 0) {
+                continue;
+            }
+            return self::getDetail($mainId, intval($row['id']));
+        }
+        return null;
+    }
+
+    /**
+     * 我的拼团列表
+     *
+     * @param int $mainId
+     * @param int $customId
+     * @return array
+     */
+    public static function getMyList($mainId, $customId)
+    {
+        $mainId = intval($mainId);
+        $customId = intval($customId);
+        $members = GroupBuyMemberClass::getAllByCondition([
+            'mainId' => $mainId,
+            'customId' => $customId,
+            'status' => ['in', [
+                GroupBuyMemberClass::STATUS_WAIT_PAY,
+                GroupBuyMemberClass::STATUS_PAID,
+            ]],
+        ], 'id DESC', '*', null);
+        if (empty($members)) {
+            return [];
+        }
+        $result = [];
+        foreach ($members as $m) {
+            $detail = self::getDetail($mainId, intval($m['groupBuyId']));
+            $detail['myRole'] = intval($m['role']);
+            $detail['myMemberStatus'] = intval($m['status']);
+            $detail['orderId'] = intval($m['orderId']);
+            $detail['orderSn'] = strval($m['orderSn']);
+            $result[] = $detail;
+        }
+        return $result;
+    }
+}

+ 203 - 0
biz-hd/groupBuy/classes/GroupBuyGoodsClass.php

@@ -0,0 +1,203 @@
+<?php
+
+namespace bizHd\groupBuy\classes;
+
+use bizHd\base\classes\BaseClass;
+use bizHd\goods\classes\GoodsClass;
+
+/**
+ * 团购活动商品版本业务类
+ * 负责同一活动下商品版本的同步(改价新建版本、旧版隐藏)与列表读取
+ */
+class GroupBuyGoodsClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\groupBuy\models\GroupBuyGoods';
+
+    /**
+     * 同步活动商品:有 id 且关键字段未变则更新;关键字段变化或新商品则新建版本并隐藏旧版
+     *
+     * @param int $mainId
+     * @param int $activityId
+     * @param array $goodsList validateGroupBuyGoods 的结果
+     * @return bool
+     */
+    public static function syncActivityGoods($mainId, $activityId, $goodsList)
+    {
+        $mainId = intval($mainId);
+        $activityId = intval($activityId);
+        $keepIds = [];
+
+        foreach ($goodsList as $item) {
+            $goodsId = intval($item['goodsId'] ?? 0);
+            if ($goodsId <= 0) {
+                continue;
+            }
+            $payload = [
+                'activityId' => $activityId,
+                'mainId' => $mainId,
+                'goodsId' => $goodsId,
+                'specName' => strval($item['specName'] ?? ''),
+                'price' => floatval($item['price'] ?? 0),
+                'originPrice' => floatval($item['originPrice'] ?? 0),
+                'stock' => intval($item['stock'] ?? 0),
+                'limit' => intval($item['limit'] ?? 0),
+                'groupSize' => intval($item['groupSize'] ?? 3),
+                'virtualGroup' => !empty($item['virtualGroup']) ? 1 : 0,
+                'virtualMinutes' => intval($item['virtualMinutes'] ?? 0),
+                'autoRefund' => 1,
+                'status' => !empty($item['status']) ? 1 : 0,
+                'name' => strval($item['name'] ?? ''),
+                'cover' => strval($item['cover'] ?? ''),
+                'realStock' => intval($item['realStock'] ?? 0),
+                'delStatus' => 0,
+            ];
+
+            $existId = intval($item['id'] ?? 0);
+            $exist = null;
+            if ($existId > 0) {
+                $exist = self::getById($existId, true);
+                if (
+                    empty($exist)
+                    || intval($exist->mainId) !== $mainId
+                    || intval($exist->activityId) !== $activityId
+                    || intval($exist->delStatus) !== 0
+                ) {
+                    $exist = null;
+                    $existId = 0;
+                }
+            }
+            if ($existId <= 0) {
+                // 无显式 id 时按 activityId+goodsId 找当前展示版
+                $exist = self::getByCondition([
+                    'activityId' => $activityId,
+                    'mainId' => $mainId,
+                    'goodsId' => $goodsId,
+                    'status' => 1,
+                    'delStatus' => 0,
+                ], true);
+                $existId = $exist ? intval($exist->id) : 0;
+            }
+
+            if ($exist && self::isSameVersion($exist, $payload)) {
+                self::updateById($existId, $payload);
+                $keepIds[] = $existId;
+                continue;
+            }
+
+            // 关键字段变化:隐藏旧版,新建版本
+            if ($existId > 0) {
+                self::updateById($existId, ['status' => 0]);
+            }
+            // 同活动同商品其它展示版一并隐藏,避免多条 status=1
+            $others = self::getAllByCondition([
+                'activityId' => $activityId,
+                'mainId' => $mainId,
+                'goodsId' => $goodsId,
+                'status' => 1,
+                'delStatus' => 0,
+            ], null, 'id', null);
+            if (!empty($others)) {
+                foreach ($others as $other) {
+                    $oid = intval($other['id'] ?? 0);
+                    if ($oid > 0) {
+                        self::updateById($oid, ['status' => 0]);
+                    }
+                }
+            }
+            $created = self::add($payload);
+            $keepIds[] = intval($created->id ?? ($created['id'] ?? 0));
+        }
+
+        // 本次未提交的展示商品隐藏(不物理删除,保留历史订单可追溯)
+        $activeList = self::getAllByCondition([
+            'activityId' => $activityId,
+            'mainId' => $mainId,
+            'status' => 1,
+            'delStatus' => 0,
+        ], null, 'id', null);
+        if (!empty($activeList)) {
+            foreach ($activeList as $row) {
+                $id = intval($row['id'] ?? 0);
+                if ($id > 0 && !in_array($id, $keepIds, true)) {
+                    self::updateById($id, ['status' => 0]);
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 关键业务字段是否一致:价格/库存/限购/成团人数/虚拟成团配置
+     *
+     * @param object $exist
+     * @param array $payload
+     * @return bool
+     */
+    public static function isSameVersion($exist, $payload)
+    {
+        return floatval($exist->price) == floatval($payload['price'])
+            && intval($exist->stock) === intval($payload['stock'])
+            && intval($exist->limit) === intval($payload['limit'])
+            && intval($exist->groupSize) === intval($payload['groupSize'])
+            && intval($exist->virtualGroup) === intval($payload['virtualGroup'])
+            && intval($exist->virtualMinutes) === intval($payload['virtualMinutes']);
+    }
+
+    /**
+     * 读取活动下当前展示中的商品列表(后台/首页结构兼容)
+     *
+     * @param int $activityId
+     * @param int $mainId
+     * @param bool $refreshStock
+     * @return array
+     */
+    public static function getActiveGoodsByActivityId($activityId, $mainId, $refreshStock = true)
+    {
+        $activityId = intval($activityId);
+        $mainId = intval($mainId);
+        $list = self::getAllByCondition([
+            'activityId' => $activityId,
+            'mainId' => $mainId,
+            'status' => 1,
+            'delStatus' => 0,
+        ], 'id ASC', '*', null);
+        $result = [];
+        foreach ($list as $item) {
+            $row = [
+                'id' => intval($item['id']),
+                'activityGoodsId' => intval($item['id']),
+                'activityId' => $activityId,
+                'goodsId' => intval($item['goodsId']),
+                'price' => floatval($item['price']),
+                'stock' => intval($item['stock']),
+                'limit' => intval($item['limit']),
+                'groupSize' => intval($item['groupSize']),
+                'virtualGroup' => !empty($item['virtualGroup']) ? 1 : 0,
+                'virtualMinutes' => intval($item['virtualMinutes']),
+                'autoRefund' => 1,
+                'status' => !empty($item['status']) ? 1 : 0,
+                'name' => strval($item['name'] ?? ''),
+                'cover' => strval($item['cover'] ?? ''),
+                'originPrice' => floatval($item['originPrice'] ?? 0),
+                'specName' => strval($item['specName'] ?? ''),
+                'realStock' => intval($item['realStock'] ?? $item['stock'] ?? 0),
+            ];
+            if ($refreshStock) {
+                $goods = GoodsClass::getById($row['goodsId'], true);
+                if (!empty($goods) && intval($goods->mainId ?? 0) === $mainId) {
+                    $realStock = intval($goods->stock ?? 0);
+                    $row['realStock'] = $realStock;
+                    $row['cover'] = strval($goods->shortCover ?? ($goods->cover ?? $row['cover']));
+                    $row['originPrice'] = floatval($goods->price ?? $row['originPrice']);
+                    if ($realStock < $row['stock'] && $row['status'] == 1) {
+                        $row['status'] = 0;
+                        self::updateById($row['id'], ['status' => 0]);
+                        continue;
+                    }
+                }
+            }
+            $result[] = $row;
+        }
+        return $result;
+    }
+}

+ 46 - 0
biz-hd/groupBuy/classes/GroupBuyMemberClass.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace bizHd\groupBuy\classes;
+
+use bizHd\base\classes\BaseClass;
+
+/**
+ * 拼团成员业务类
+ * 维护团内成员状态;同一团同一客户唯一(表唯一索引 group_custom)
+ */
+class GroupBuyMemberClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\groupBuy\models\GroupBuyMember';
+
+    const ROLE_LEADER = 1;
+    const ROLE_MEMBER = 2;
+
+    const STATUS_WAIT_PAY = 1;
+    const STATUS_PAID = 2;
+    const STATUS_INVALID = 3;
+
+    /**
+     * 下单成功后挂接成员(待支付)
+     *
+     * @param int $groupBuyId
+     * @param int $mainId
+     * @param int $customId
+     * @param int $orderId
+     * @param string $orderSn
+     * @param int $role
+     * @return \yii\db\ActiveRecord
+     */
+    public static function bindOrderMember($groupBuyId, $mainId, $customId, $orderId, $orderSn, $role)
+    {
+        return self::add([
+            'groupBuyId' => intval($groupBuyId),
+            'mainId' => intval($mainId),
+            'orderId' => intval($orderId),
+            'orderSn' => strval($orderSn),
+            'customId' => intval($customId),
+            'role' => intval($role),
+            'status' => self::STATUS_WAIT_PAY,
+            'joinTime' => date('Y-m-d H:i:s'),
+        ]);
+    }
+}

+ 17 - 0
biz-hd/groupBuy/models/GroupBuy.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace bizHd\groupBuy\models;
+
+use bizHd\base\models\Base;
+
+/**
+ * 拼团单表模型
+ * 对应表 xhGroupBuy;一次开团对应一条记录,成员人数由 xhGroupBuyMember 维护
+ */
+class GroupBuy extends Base
+{
+    public static function tableName()
+    {
+        return 'xhGroupBuy';
+    }
+}

+ 17 - 0
biz-hd/groupBuy/models/GroupBuyActivity.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace bizHd\groupBuy\models;
+
+use bizHd\base\models\Base;
+
+/**
+ * 团购活动批次表模型
+ * 对应表 xhGroupBuyActivity,按 mainId+起止时间区分活动批次
+ */
+class GroupBuyActivity extends Base
+{
+    public static function tableName()
+    {
+        return 'xhGroupBuyActivity';
+    }
+}

+ 17 - 0
biz-hd/groupBuy/models/GroupBuyGoods.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace bizHd\groupBuy\models;
+
+use bizHd\base\models\Base;
+
+/**
+ * 团购活动商品版本表模型
+ * 对应表 xhGroupBuyGoods;同活动同商品改价会新建版本,旧版 status=0
+ */
+class GroupBuyGoods extends Base
+{
+    public static function tableName()
+    {
+        return 'xhGroupBuyGoods';
+    }
+}

+ 17 - 0
biz-hd/groupBuy/models/GroupBuyMember.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace bizHd\groupBuy\models;
+
+use bizHd\base\models\Base;
+
+/**
+ * 拼团成员表模型
+ * 对应表 xhGroupBuyMember;同一团内同一客户唯一
+ */
+class GroupBuyMember extends Base
+{
+    public static function tableName()
+    {
+        return 'xhGroupBuyMember';
+    }
+}

+ 22 - 20
biz-hd/homePageConfig/classes/HomePageModuleClass.php

@@ -259,11 +259,11 @@ class HomePageModuleClass
             ];
             if ($refreshStock) {
                 $goods = GoodsClass::getById($row['goodsId'], true);
-                if (!empty($goods) && intval($goods->mainId ?? 0) === intval($mainId)) {
+                if (!empty($goods) && intval($goods->mainId) === intval($mainId)) {
                     if ($goods->masterId > 0) {
                         $goods->name = $goods->name . '(' . $goods->specName . ')';
                     }
-                    $realStock = intval($goods->stock ?? 0);
+                    $realStock = intval($goods->stock);
                     $row['realStock'] = $realStock;
                     $row['name'] = strval($goods->name ?? $row['name']);
                     $row['cover'] = strval($goods->shortCover ?? ($goods->cover ?? $row['cover']));
@@ -401,7 +401,18 @@ class HomePageModuleClass
 
     // -------------------- 团购 --------------------
 
+    /**
+     * 读取团购专区:优先 MySQL 批次化持久化,无数据时回退 Redis
+     */
     public static function getGroupBuy($mainId, $refreshStock = true)
+    {
+        return \bizHd\groupBuy\classes\GroupBuyActivityClass::getGroupBuy($mainId, $refreshStock);
+    }
+
+    /**
+     * 仅从 Redis 读取历史单例团购配置(迁移过渡 / 无 MySQL 批次时回退)
+     */
+    public static function getGroupBuyFromRedis($mainId, $refreshStock = true)
     {
         $mainId = intval($mainId);
         if ($mainId <= 0) {
@@ -411,7 +422,6 @@ class HomePageModuleClass
         $data = self::normalizeActivityBase($saved);
         $data['enabled'] = HomePageConfigClass::getModuleEnabled($mainId, 'groupBuy');
         $data['goods'] = self::normalizeGroupBuyGoods($saved['goods'] ?? [], $mainId, $refreshStock);
-        // 按已添加商品数量 + 展开开关动态推导列数与展示数量(不依赖商家手动配置)
         $layout = self::resolveActivityLayout(count($data['goods']), $data['expand']);
         $data['layoutCols'] = $layout['layoutCols'];
         $data['displayCount'] = $layout['displayCount'];
@@ -420,7 +430,7 @@ class HomePageModuleClass
     }
 
     /**
-     * 保存团购专区;请求形态校验由 SaveGroupBuyForm 完成,此处做商品归属与库存业务校验
+     * 保存团购专区:落 MySQL 活动批次/商品版本,并同步 Redis
      *
      * @param int $mainId
      * @param array $base 已校验的活动基础字段
@@ -430,17 +440,7 @@ class HomePageModuleClass
      */
     public static function saveGroupBuy($mainId, $base, $goods = [], $enabled = 0)
     {
-        $mainId = intval($mainId);
-        if ($mainId <= 0) {
-            util::fail('无效门店');
-        }
-        if (!is_array($base)) {
-            util::fail('参数错误');
-        }
-        $goods = self::validateGroupBuyGoods($mainId, is_array($goods) ? $goods : []);
-        self::setJson($mainId, self::REDIS_GROUP_BUY, array_merge($base, ['goods' => $goods]));
-        HomePageConfigClass::updateModuleEnabled($mainId, 'groupBuy', $enabled);
-        return true;
+        return \bizHd\groupBuy\classes\GroupBuyActivityClass::saveGroupBuy($mainId, $base, $goods, $enabled);
     }
 
     /**
@@ -470,6 +470,7 @@ class HomePageModuleClass
             }
             $virtualGroup = !empty($item['virtualGroup']) ? 1 : 0;
             $virtualMinutes = intval($item['virtualMinutes'] ?? 0);
+            $name = $goods->masterId > 0 ? $goods->name . '(' . $goods->specName . ')' : $goods->name;
             $list[] = [
                 'goodsId' => $goodsId,
                 'price' => round($price, 2),
@@ -478,9 +479,9 @@ class HomePageModuleClass
                 'groupSize' => $groupSize,
                 'virtualGroup' => $virtualGroup,
                 'virtualMinutes' => $virtualMinutes,
-                'autoRefund' => !empty($item['autoRefund']) ? 1 : 0,
+                'autoRefund' => 1,
                 'status' => ($realStock < $stock) ? 0 : (!empty($item['status']) ? 1 : 0),
-                'name' => strval($goods->name ?? ($item['name'] ?? '')),
+                'name' => $name,
                 'cover' => strval($goods->shortCover ?? ($goods->cover ?? ($item['cover'] ?? ''))),
                 'originPrice' => floatval($goods->price ?? ($item['originPrice'] ?? 0)),
             ];
@@ -504,19 +505,20 @@ class HomePageModuleClass
                 'groupSize' => intval($item['groupSize'] ?? 3),
                 'virtualGroup' => !empty($item['virtualGroup']) ? 1 : 0,
                 'virtualMinutes' => intval($item['virtualMinutes'] ?? 0),
-                'autoRefund' => !empty($item['autoRefund']) ? 1 : 0,
+                // 自动退款统一开启,读取时也强制为 1,兼容历史关闭配置
+                'autoRefund' => 1,
                 'status' => !empty($item['status']) ? 1 : 0,
                 'name' => strval($item['name'] ?? ''),
                 'cover' => strval($item['cover'] ?? ''),
                 'originPrice' => floatval($item['originPrice'] ?? 0),
                 'realStock' => intval($item['stock'] ?? 0),
             ];
-            if ($refreshStock) {
+            if ($refreshStock) { // TODO 是否直接取 row 里的数据
                 $goods = GoodsClass::getById($row['goodsId'], true);
                 if (!empty($goods) && intval($goods->mainId ?? 0) === intval($mainId)) {
                     $realStock = intval($goods->stock ?? 0);
                     $row['realStock'] = $realStock;
-                    $row['name'] = strval($goods->name ?? $row['name']);
+                    //$row['name'] = strval($goods->name ?? $row['name']);
                     $row['cover'] = strval($goods->shortCover ?? ($goods->cover ?? $row['cover']));
                     $row['originPrice'] = floatval($goods->price ?? $row['originPrice']);
                     if ($realStock < $row['stock'] && $row['status'] == 1) {

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

@@ -17,6 +17,7 @@ use bizHd\custom\classes\HdClass;
 use bizHd\hb\classes\HbClass;
 use bizHd\item\classes\ItemClass;
 use bizHd\merchant\classes\SjClass;
+use bizHd\groupBuy\classes\GroupBuyClass;
 use bizHd\refund\classes\HdRefundClass;
 use bizHd\shop\classes\MainClass;
 use bizHd\shop\classes\ShopMoneyChangeClass;
@@ -357,6 +358,14 @@ class OrderClass extends BaseClass
 
         $order['today'] = date("Y-m-d");
         $order['tomorrow'] = date("Y-m-d", strtotime("+1 day"));
+
+        // 拼团订单附带团状态,供商城订单详情展示
+        $groupBuyId = intval($order['groupBuyId'] ?? 0);
+        $order['groupBuyStatus'] = 0;
+        if ($groupBuyId > 0) {
+            $groupBuy = GroupBuyClass::getById($groupBuyId);
+            $order['groupBuyStatus'] = intval($groupBuy['status'] ?? 0);
+        }
         return $order;
     }
 
@@ -658,6 +667,10 @@ class OrderClass extends BaseClass
             }
         }
 
+        // 拼团订单:支付成功后计入成团人数并判定是否满团
+        if (intval($order->groupBuyId ?? 0) > 0) {
+            GroupBuyClass::onOrderPaid($order);
+        }
     }
 
     //转化出送到时间 ssh 2020.3.15
@@ -813,6 +826,11 @@ class OrderClass extends BaseClass
                 }
             }
         }
+
+        // 拼团订单超时未支付:成员失效;无人支付则取消团
+        if (intval($order->groupBuyId ?? 0) > 0) {
+            GroupBuyClass::onOrderExpired($order);
+        }
     }
 
     // 恢复订单 -- 即:把订单的状态从取消状态变成待付款状态

+ 8 - 0
biz-hd/order/services/OrderService.php

@@ -391,6 +391,14 @@ class OrderService extends BaseService
             }
             $data['list'][$key]['sameTimeIdsNum'] = $sameNum;
 
+            // 拼团订单附带团状态,供商城订单列表展示「拼团中/成功/失败」标签
+            $groupBuyId = intval($val['groupBuyId'] ?? 0);
+            $data['list'][$key]['groupBuyId'] = $groupBuyId;
+            $data['list'][$key]['groupBuyStatus'] = 0;
+            if ($groupBuyId > 0) {
+                $groupBuy = \bizHd\groupBuy\classes\GroupBuyClass::getById($groupBuyId);
+                $data['list'][$key]['groupBuyStatus'] = intval($groupBuy['status'] ?? 0);
+            }
         }
         return $data;
     }

+ 19 - 4
biz-mall/order/services/OrderService.php

@@ -221,21 +221,36 @@ class OrderService extends BaseService
         return OrderClass::addOrder($data);
     }
 
-    //获取订单信息 ssh 2019.11.28
+    //获取订单信息 TODO 重新实现,要限制查询字段与返回字段,性能第一???? -- 商品项(商品规格)、商品数量
     public static function getOrderList($where)
     {
-        $data = self::getList('*', $where, 'addTime DESC');
+        $fields = 'id, orderSn, orderType, actPrice, goodsNum, reachDate, reachPeriod, addTime';
+        $data = self::getList($fields, $where, 'addTime DESC');
         if (empty($data['list'])) {
             return $data;
         }
         $list = $data['list'];
         $orderSns = array_values(array_filter(array_unique(array_column($list, 'orderSn'))));
-        // 商城订单明细在 xhOrderItem,不在 xhOrderGoods
+        // 花束订单明细在 xhOrderGoods,花材订单明细在 xhOrderItem,orderType=3 时两者都有
+        $goodsListMap = OrderGoodsService::getGoodsListByOrderSns($orderSns);
         $itemListMap = OrderItemService::getItemListByOrderSns($orderSns);
         $periodData = [0 => '上午', 1 => '下午', 2 => '晚上'];
         foreach ($data['list'] as $key => $val) {
             $orderSn = $val['orderSn'] ?? '';
-            $goodsInfoList = !empty($orderSn) && isset($itemListMap[$orderSn]) ? $itemListMap[$orderSn] : [];
+            $orderType = intval($val['orderType'] ?? 1);
+            $goodsInfoList = [];
+            if (!empty($orderSn)) {
+                if ($orderType == 1) {
+                    $goodsInfoList = $goodsListMap[$orderSn] ?? [];
+                } elseif ($orderType == 2) {
+                    $goodsInfoList = $itemListMap[$orderSn] ?? [];
+                } elseif ($orderType == 3) {
+                    $goodsInfoList = array_merge($goodsListMap[$orderSn] ?? [], $itemListMap[$orderSn] ?? []);
+                }
+            }
+            if(isset($val['goodsNum']) && $val['goodsNum'] == 0) {
+                $data['list'][$key]['goodsNum'] = count($goodsInfoList);
+            }
             $data['list'][$key]['goodsInfoList'] = $goodsInfoList;
             $reachTime = OrderClass::getReachTime($val);
             $data['list'][$key]['reachTime'] = $reachTime;

+ 1 - 1
common/base/classes/BaseClass.php

@@ -99,7 +99,7 @@ class BaseClass
      * 添加数据
      * @param array $data 数据数组
      * @param bool $returnObject 是否返回对象
-     * @return array|Base
+     * @return array|Base($returnObject 为 false 时,返回 array; $returnObject 为 true 时,返回 Base)
      * @throws \Exception
      */
     public static function add($data, $returnObject = false)

+ 8 - 0
common/components/rabbitmq/stockConsumer.php

@@ -8,6 +8,7 @@ namespace common\components\rabbitmq;
 
 use bizGhs\product\classes\ProductClass;
 use bizHd\birthday\classes\BirthdayGiftClass;
+use bizHd\groupBuy\classes\GroupBuyClass;
 use bizHd\product\classes\ProductClass as hdProductClass;
 use common\components\noticeUtil;
 use mikemadisonweb\rabbitmq\components\ConsumerInterface;
@@ -61,6 +62,13 @@ class stockConsumer extends baseConsumer
                         return BirthdayGiftClass::expireById($giftId);
                     });
                     break;
+                case 'group_buy_expire':
+                    $groupBuyId = intval($data['groupBuyId']);
+                    echo 'group_buy_expire --- groupBuyId=' . $groupBuyId;
+                    $result = $this->runWithDbReconnect(function () use ($groupBuyId) {
+                        return GroupBuyClass::expireById($groupBuyId);
+                    });
+                    break;
                 default:
                     noticeUtil::push("库存的消费者报错,未知 type: {$type}");
                     $result = false;

+ 12 - 1
common/config/rabbitMQ.php

@@ -81,6 +81,11 @@ $rabbitMQ = [
             'passive' => false,
             'durable' => true,
         ],
+        [
+            'name' => 'groupBuyQueue',
+            'passive' => false,
+            'durable' => true,
+        ],
     ],
 
     /**
@@ -117,7 +122,12 @@ $rabbitMQ = [
             'queue' => 'birthdayGiftQueue',
             'exchange' => 'limitBuyDelayExchange',
             'routing_keys' => ['birthdayGiftDelayRoute'],
-        ]
+        ],
+        [
+            'queue' => 'groupBuyQueue',
+            'exchange' => 'limitBuyDelayExchange',
+            'routing_keys' => ['groupBuyDelayRoute'],
+        ],
     ],
 
     /**
@@ -161,6 +171,7 @@ $rabbitMQ = [
                 //'limitBuyQueue' => '\common\components\rabbitmq\cancelLimitBuyConsumer',
                 'limitBuyQueue' => '\common\components\rabbitmq\stockConsumer',
                 'birthdayGiftQueue' => '\common\components\rabbitmq\stockConsumer', //生日礼物队列
+                'groupBuyQueue' => '\common\components\rabbitmq\stockConsumer', //拼团到期队列
             ]
         ],
         [

+ 46 - 0
console/controllers/GroupBuyController.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace console\controllers;
+
+use bizHd\groupBuy\classes\GroupBuyClass;
+use bizHd\groupBuy\models\GroupBuy;
+use common\components\noticeUtil;
+use yii\console\Controller;
+
+/**
+ * 拼团到期兜底扫描
+ * 建议 crontab:每分钟 php yii group-buy/expire
+ * 主触发依赖 RabbitMQ 延迟消息,本脚本防消息丢失漏判
+ */
+class GroupBuyController extends Controller
+{
+    /**
+     * 扫描已到期仍拼团中的团,执行虚拟成团或失败退款
+     */
+    public function actionExpire()
+    {
+        $query = new \yii\db\Query();
+        $query->from(GroupBuy::tableName());
+        $endTime = time();
+        $startTime = strtotime('-10 days');
+        $query->where(['status' => GroupBuyClass::STATUS_ONGOING])
+            ->andWhere(['>', 'deadline', $startTime])
+            ->andWhere(['<=', 'deadline', $endTime])
+            ->orderBy('deadline ASC');
+
+        foreach ($query->batch() as $batch) {
+            foreach ($batch as $row) {
+                $id = intval($row['id'] ?? 0);
+                try {
+                    GroupBuyClass::expireById($id);
+                } catch (\Exception $e) {
+                    noticeUtil::push(
+                        '拼团到期扫描报错 groupBuyId=' . $id . ' ' . $e->getMessage(),
+                        '15280215347'
+                    );
+                }
+            }
+        }
+        echo "group-buy expire done\n";
+    }
+}

+ 97 - 0
sql/20260723_group_buy.sql

@@ -0,0 +1,97 @@
+-- 拼团(团购)核心表:活动批次 / 活动商品版本 / 拼团单 / 拼团成员
+-- 执行前请确认线上库无同名表;xhOrder.groupBuyId 若已存在请跳过对应 ALTER
+
+CREATE TABLE IF NOT EXISTS `xhGroupBuyActivity` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `mainId` int(11) NOT NULL DEFAULT 0 COMMENT '中央id',
+  `title` varchar(32) NOT NULL DEFAULT '' COMMENT '活动标题',
+  `subtitle` varchar(64) NOT NULL DEFAULT '' COMMENT '活动副标题',
+  `showCountdown` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否显示倒计时',
+  `expand` tinyint(4) NOT NULL DEFAULT 0 COMMENT '商品展开',
+  `startTime` int(11) NOT NULL DEFAULT 0 COMMENT '开始时间unix',
+  `endTime` int(11) NOT NULL DEFAULT 0 COMMENT '结束时间unix',
+  `desc` varchar(500) NOT NULL DEFAULT '' COMMENT '活动说明',
+  `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1有效 0无效',
+  `delStatus` tinyint(4) NOT NULL DEFAULT 0 COMMENT '删除状态',
+  `addTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `mainId` (`mainId`),
+  KEY `main_time` (`mainId`,`startTime`,`endTime`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团购活动批次';
+
+CREATE TABLE IF NOT EXISTS `xhGroupBuyGoods` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `activityId` int(11) NOT NULL DEFAULT 0 COMMENT '活动批次id',
+  `mainId` int(11) NOT NULL DEFAULT 0 COMMENT '中央id',
+  `goodsId` int(11) NOT NULL DEFAULT 0 COMMENT '商品id(可含规格子商品)',
+  `specName` varchar(64) NOT NULL DEFAULT '' COMMENT '规格名',
+  `price` decimal(15,2) NOT NULL DEFAULT 0.00 COMMENT '团购价',
+  `originPrice` decimal(15,2) NOT NULL DEFAULT 0.00 COMMENT '原价快照',
+  `stock` int(11) NOT NULL DEFAULT 0 COMMENT '团购库存',
+  `limit` int(11) NOT NULL DEFAULT 0 COMMENT '单人限购',
+  `groupSize` tinyint(4) NOT NULL DEFAULT 3 COMMENT '成团人数2/3/5',
+  `virtualGroup` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否虚拟成团',
+  `virtualMinutes` int(11) NOT NULL DEFAULT 0 COMMENT '虚拟成团分钟数',
+  `autoRefund` tinyint(4) NOT NULL DEFAULT 1 COMMENT '失败自动退款',
+  `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1展示 0隐藏(历史版本)',
+  `name` varchar(128) NOT NULL DEFAULT '' COMMENT '商品名快照',
+  `cover` varchar(255) NOT NULL DEFAULT '' COMMENT '封面快照',
+  `realStock` int(11) NOT NULL DEFAULT 0 COMMENT '保存时真实库存快照',
+  `delStatus` tinyint(4) NOT NULL DEFAULT 0 COMMENT '删除状态',
+  `addTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `activityId` (`activityId`),
+  KEY `main_goods` (`mainId`,`goodsId`),
+  KEY `activity_goods_status` (`activityId`,`goodsId`,`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团购活动商品版本';
+
+CREATE TABLE IF NOT EXISTS `xhGroupBuy` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `mainId` int(11) NOT NULL DEFAULT 0 COMMENT '中央id',
+  `shopId` int(11) NOT NULL DEFAULT 0 COMMENT '门店id',
+  `activityId` int(11) NOT NULL DEFAULT 0 COMMENT '活动批次id',
+  `activityGoodsId` int(11) NOT NULL DEFAULT 0 COMMENT '活动商品版本id',
+  `goodsId` int(11) NOT NULL DEFAULT 0 COMMENT '商品id',
+  `leaderCustomId` int(11) NOT NULL DEFAULT 0 COMMENT '团长客户id',
+  `needNum` int(11) NOT NULL DEFAULT 0 COMMENT '成团所需人数',
+  `currentNum` int(11) NOT NULL DEFAULT 0 COMMENT '已支付人数',
+  `price` decimal(15,2) NOT NULL DEFAULT 0.00 COMMENT '团购价快照',
+  `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1拼团中 2成功 3失败 4已取消',
+  `virtualGroup` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否虚拟成团',
+  `deadline` int(11) NOT NULL DEFAULT 0 COMMENT '团截止unix',
+  `successTime` datetime DEFAULT NULL COMMENT '成团时间',
+  `failTime` datetime DEFAULT NULL COMMENT '失败时间',
+  `addTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `mainId` (`mainId`),
+  KEY `activityGoodsId` (`activityGoodsId`),
+  KEY `status_deadline` (`status`,`deadline`),
+  KEY `goods_status` (`goodsId`,`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼团单';
+
+CREATE TABLE IF NOT EXISTS `xhGroupBuyMember` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `groupBuyId` int(11) NOT NULL DEFAULT 0 COMMENT '拼团单id',
+  `mainId` int(11) NOT NULL DEFAULT 0 COMMENT '中央id',
+  `orderId` int(11) NOT NULL DEFAULT 0 COMMENT '订单id',
+  `orderSn` varchar(64) NOT NULL DEFAULT '' COMMENT '订单号',
+  `customId` int(11) NOT NULL DEFAULT 0 COMMENT '客户id',
+  `role` tinyint(4) NOT NULL DEFAULT 2 COMMENT '1团长 2成员',
+  `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1待支付 2已支付 3已失效',
+  `joinTime` datetime DEFAULT NULL COMMENT '加入时间',
+  `addTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `group_custom` (`groupBuyId`,`customId`),
+  KEY `orderId` (`orderId`),
+  KEY `orderSn` (`orderSn`),
+  KEY `customId` (`customId`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼团成员';
+
+-- 订单关联拼团单(避开已占用的 groupId 组合字段)
+ALTER TABLE `xhOrder`
+  ADD COLUMN `groupBuyId` int(11) NOT NULL DEFAULT 0 COMMENT '拼团单id' AFTER `id`,
+  ADD KEY `groupBuyId` (`groupBuyId`);