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

Merge remote-tracking branch 'origin/redesign‌-260706' into redesign‌-260706

ouyang 2 дней назад
Родитель
Сommit
a11e770732

+ 117 - 21
app-mall/controllers/GroupBuyController.php

@@ -7,6 +7,7 @@ use bizHd\goods\classes\GoodsClass;
 use bizHd\groupBuy\classes\GroupBuyActivityClass;
 use bizHd\groupBuy\classes\GroupBuyClass;
 use bizHd\groupBuy\classes\GroupBuyMemberClass;
+use bizHd\order\classes\PsMethodClass;
 use bizHd\order\services\OrderService;
 use bizHd\shop\classes\ShopClass;
 use bizMall\hd\classes\HdClass;
@@ -23,7 +24,8 @@ use Yii;
 /**
  * 商城端拼团接口
  * 落地页 / 团详情 / 开团 / 参团 / 我的拼团
- * 开团/参团订单按团购价结算,不叠加会员折扣、门店满减等优惠
+ * 开团/参团订单按团购价结算,不叠加会员折扣、门店满减、红包
+ * 配送附加费与跑腿距离运费与混合结算同源(PsMethodClass::calcFeeForMallContext / calcMixRunnerFreight)
  */
 class GroupBuyController extends BaseController
 {
@@ -113,6 +115,8 @@ class GroupBuyController extends BaseController
             'showCountdown' => intval($row['showCountdown']),
             'detailImages' => $detailImages,
             'picTextGoods' => $picTextGoods,
+            // 花束是否包运费:结算页跑腿模式(bouquetIncluded)与混合单同源判断
+            'freightType' => !empty($goods) ? intval($goods->freightType ?? 0) : 0,
             'openGroup' => $openGroup,
         ]);
     }
@@ -137,6 +141,12 @@ class GroupBuyController extends BaseController
         if (empty($landing)) {
             $landing = GroupBuyActivityClass::getActiveGoodsRowByGoodsId($mainId, intval($this->shopId), intval($detail['goodsId']));
         }
+        if (!empty($landing) && !isset($landing['freightType'])) {
+            $landingGoodsId = intval($landing['goodsId'] ?? ($detail['goodsId'] ?? 0));
+            $landingGoods = $landingGoodsId > 0 ? GoodsClass::getById($landingGoodsId, true) : null;
+            // 分享进详情也要带包运费标记,否则结算页跑腿会误走平台报价
+            $landing['freightType'] = !empty($landingGoods) ? intval($landingGoods->freightType ?? 0) : 0;
+        }
         $detail['activityGoods'] = $landing;
 
         $currentShopId = intval(Yii::$app->request->get('account', 0));
@@ -313,7 +323,8 @@ class GroupBuyController extends BaseController
 
     /**
      * 组装并创建拼团订单(核价以后端活动商品为准)
-     * 团购价已是活动优惠价:跳过会员折、门店满减,避免再叠折扣
+     * 团购价已是活动优惠价:跳过会员折、门店满减、红包;运费/包装费与混合结算同源计算
+     * 跑腿(sendType=2)走与混合单相同的 calcMixRunnerFreight,不信任前端报价
      *
      * @param object $form
      * @param array $row
@@ -333,6 +344,9 @@ class GroupBuyController extends BaseController
         if (empty($custom) && !empty($currenCustom)) {
             $custom = $currenCustom;
         }
+        if (empty($custom)) {
+            util::fail('请先登录');
+        }
         $hd = $this->hd;
         $user = $this->user;
         $saleGoodsId = intval($row['goodsId']);
@@ -350,20 +364,101 @@ class GroupBuyController extends BaseController
         }
         $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')) {
+        // 商家配送(0)/跑腿(2)必须有收花人与坐标,与混合结算一致
+        if (in_array($sendType, [dict::getDict('sendType', 'carGet'), dict::getDict('sendType', 'thirdSend')], true)) {
             if (empty($form->receiveUserName)) {
                 util::fail('请填写收花人名字');
             }
             if (empty($form->receiveMobile) || !stringUtil::isMobile($form->receiveMobile)) {
                 util::fail('请填写正确的收花人手机号');
             }
+            if (empty($form->long) || empty($form->lat)) {
+                util::fail('请先完善地址');
+            }
+        }
+        // 商家配送(0)/跑腿(2)必填配送时间;自取与物流/快递不强制(与混合结算一致)
+        if (in_array($sendType, [0, 2], true) && empty($form->reachPeriod)) {
+            util::fail('请选择配送时间');
+        }
+
+        $customInfo = PsMethodClass::buildCustomInfo($hd, $custom);
+        $additionalCosts = PsMethodClass::calcFeeForMallContext(
+            $this->shopId,
+            $mainId,
+            $sendType,
+            $goodsPrice,
+            $goodsNum,
+            intval($custom->id),
+            $customInfo
+        );
+        $unMeetSendCost = $additionalCosts['sendCost'] ?? 0;
+        $packingFee = $additionalCosts['packCost'] ?? 0;
+        $psMethodError = PsMethodClass::checkLimit(
+            $this->shopId,
+            $mainId,
+            $sendType,
+            $goodsPrice,
+            $goodsNum,
+            $packingFee,
+            $unMeetSendCost,
+            intval($custom->id),
+            $customInfo
+        );
+        if ($psMethodError !== '') {
+            util::fail($psMethodError);
         }
 
+        $freightType = intval($goodsInfo->freightType ?? 0);
+        $resolvedProduct = [[
+            'productId' => $saleGoodsId,
+            'unitType' => 0,
+            'property' => 0,
+            'freightType' => $freightType,
+            'unitPrice' => $unitPrice,
+            'num' => $goodsNum,
+        ]];
+        $runnerSendCost = 0;
+        $sendDistance = 0;
+        $freightSource = '';
+        // 跑腿距离运费与混合单同源;未达门槛附加运费仍由 calcFee 单独给出
+        if ($sendType == dict::getDict('sendType', 'thirdSend')) {
+            $mapSet = \biz\shop\classes\ShopClass::hasIntraCity($shop);
+            $openIntraCity = $mapSet['openIntraCity'] ?? 0;
+            $runnerPost = [
+                'receiveUserName' => strval($form->receiveUserName ?? ''),
+                'receiveMobile' => strval($form->receiveMobile ?? ''),
+                'address' => strval($form->address ?? ''),
+                'floor' => strval($form->floor ?? ''),
+                'city' => strval($form->city ?? ''),
+                'dist' => strval($form->dist ?? ''),
+                'long' => $form->long ?? '',
+                'lat' => $form->lat ?? '',
+                'deliveryPlatform' => strval($form->deliveryPlatform ?? ''),
+                'deliveryBracketContent' => strval($form->deliveryBracketContent ?? ''),
+                'remark' => strval($form->remark ?? ''),
+            ];
+            $runnerResult = PsMethodClass::calcMixRunnerFreight(
+                $shop,
+                $this->shopId,
+                $mainId,
+                $runnerPost,
+                $resolvedProduct,
+                0,
+                $goodsPrice,
+                $openIntraCity,
+                0,
+                $goodsNum,
+                'PT-GB-',
+                '团购单'
+            );
+            $runnerSendCost = $runnerResult['sendCost'];
+            $sendDistance = $runnerResult['sendDistance'];
+            $freightSource = $runnerResult['freightSource'];
+        }
+        $sendCost = bcadd($unMeetSendCost, $runnerSendCost, 2);
+        $modifyPrice = bcadd($goodsPrice, $sendCost, 2);
+        $modifyPrice = bcadd($modifyPrice, $packingFee, 2);
+
         $post = [
             'sendType' => $sendType,
             'receiveUserName' => strval($form->receiveUserName ?? ''),
@@ -371,6 +466,8 @@ class GroupBuyController extends BaseController
             'address' => strval($form->address ?? ''),
             'floor' => strval($form->floor ?? ''),
             'city' => strval($form->city ?? ''),
+            'dist' => strval($form->dist ?? ''),
+            'province' => strval($form->province ?? ''),
             'long' => strval($form->long ?? ''),
             'lat' => strval($form->lat ?? ''),
             'showAddress' => strval($form->showAddress ?? ''),
@@ -378,7 +475,13 @@ class GroupBuyController extends BaseController
             'reachDate' => !empty($form->reachDate) ? $form->reachDate : date('Y-m-d'),
             'reachPeriod' => strval($form->reachPeriod ?? ''),
             'sendCost' => $sendCost,
-            'sendDistance' => 0,
+            'packingFee' => $packingFee,
+            'sendDistance' => $sendDistance,
+            'freightSource' => $freightSource,
+            'runnerSendCost' => $runnerSendCost,
+            'needCard' => intval($form->needCard ?? 0),
+            'cardInfo' => intval($form->needCard ?? 0) === 1 ? strval($form->cardInfo ?? '') : '',
+            'anonymity' => intval($form->anonymity ?? 0),
             'sjId' => intval($this->sjId ?: ($shop->sjId ?? 0)),
             'shopId' => $this->shopId,
             'mainId' => $mainId,
@@ -394,27 +497,20 @@ class GroupBuyController extends BaseController
                 : strval($user->mobile ?? ''),
             'payWay' => 0,
             'store' => 0,
-            'modifyPrice' => stringUtil::calcAdd($sendCost, $goodsPrice),
+            'modifyPrice' => $modifyPrice,
             'deadline' => time() + 1800,
             'orderSn' => orderSn::getOrderSn(),
             'fromType' => dict::getDict('fromType', 'mall'),
             'cash' => 0,
             'needPrint' => dict::getDict('needPrint', 'need'),
             'groupBuyId' => intval($groupBuyId),
-            // 拼团价已是活动价:跳过会员折和门店满减,避免 createHdOrder/addOrder 再叠折扣
+            // 拼团价已是活动价:跳过会员折、门店满减、红包,避免再叠优惠
             'skipMemberDiscount' => 1,
             'skipShopMeetCut' => 1,
-            'product' => [[
-                'productId' => $saleGoodsId,
-                'unitType' => 0,
-                'property' => 0,
-                'unitPrice' => $unitPrice,
-                'num' => $goodsNum,
-            ]],
+            'hbId' => 0,
+            'hbAmount' => 0,
+            'product' => $resolvedProduct,
         ];
-        if ($sendType != 1 && empty($post['reachPeriod'])) {
-            util::fail('请选择配送时间');
-        }
         return OrderService::createHdOrder($post, $custom, 0);
     }
 }

+ 130 - 121
app-mall/controllers/OrderController.php

@@ -613,11 +613,20 @@ class OrderController extends BaseController
             util::fail('请选择规格');
         }
         $stock = $goodsInfo->stock ?? 0;
-        if ($stock <= 0) {
-            util::fail('库存不足');
-        }
         $saleGoodsId = $specGoodsId > 0 ? $specGoodsId : $goodsId;
         $activityType = strval($post['activityType'] ?? '');
+        if (bccomp($goodsNum, floatval($stock), 2) > 0) {
+            // 库存不足返回商品标识,供结算页红框高亮(与限购超限同形态)
+            $this->failStockExceed([
+                'productId' => $saleGoodsId,
+                'goodsId' => $goodsId,
+                'specGoodsId' => $specGoodsId,
+                'property' => 0,
+                'activityType' => $activityType,
+                'stock' => $stock,
+                'num' => $goodsNum,
+            ]);
+        }
 
         //事务处理
         $connection = Yii::$app->db;
@@ -669,14 +678,27 @@ class OrderController extends BaseController
                 }
                 $seckillStock = floatval($seckillRow['stock'] ?? 0);
                 $soldCount = HomePageModuleClass::getSeckillSoldCount($mainId, $seckillActivityGoodsId);
-                if (bcadd($soldCount, $goodsNum, 2) > $seckillStock) {
-                    util::fail('秒杀库存不足,请重新选择');
+                $seckillRemain = bcsub($seckillStock, $soldCount, 2);
+                if (bccomp($seckillRemain, '0', 2) < 0) {
+                    $seckillRemain = '0';
+                }
+                if (bccomp($goodsNum, $seckillRemain, 2) > 0) {
+                    $this->failStockExceed([
+                        'productId' => $saleGoodsId,
+                        'goodsId' => $goodsId,
+                        'specGoodsId' => $specGoodsId,
+                        'property' => 0,
+                        'activityType' => 'seckill',
+                        'stock' => $seckillRemain,
+                        'num' => $goodsNum,
+                    ]);
                 }
                 $seckillLimit = floatval($seckillRow['limit'] ?? 0);
                 if ($seckillLimit > 0) {
                     $boughtCount = HomePageModuleClass::getSeckillCustomBoughtCount($mainId, $seckillActivityGoodsId, $customId);
                     if (bcadd($boughtCount, $goodsNum, 2) > $seckillLimit) {
-                        util::fail("秒杀商品每人限购{$seckillLimit}件,已超出可购买数量");
+                        // 返回超限商品,供结算页红框高亮;code=-1 与花材限购错误对齐
+                        HomePageModuleClass::failSeckillLimitExceed($saleGoodsId, $goodsId, $specGoodsId, $seckillLimit, $boughtCount, $goodsNum);
                     }
                 }
             }
@@ -955,6 +977,9 @@ class OrderController extends BaseController
             if ($property === 1) {
                 $itemRows[$idx] = $row;
             } elseif ($property === 0) {
+                if (strval($row['activityType'] ?? '') === 'groupBuy') {
+                    util::fail('团购商品请从开团页提交订单');
+                }
                 $goodsRows[$idx] = $row;
             } else {
                 util::fail('商品类型错误');
@@ -1062,6 +1087,19 @@ class OrderController extends BaseController
                         util::fail('有花材信息没有找到');
                     }
                     $num = $element['num'] ?? 0;
+                    $itemStock = floatval($product['stock'] ?? 0);
+                    if (bccomp(floatval($num), $itemStock, 2) > 0) {
+                        // 花材库存不足也返回 productId,确认页/购物车按限购同款红框提示超出量
+                        $this->failStockExceed([
+                            'productId' => $productId,
+                            'goodsId' => 0,
+                            'specGoodsId' => 0,
+                            'property' => 1,
+                            'activityType' => '',
+                            'stock' => $itemStock,
+                            'num' => $num,
+                        ]);
+                    }
                     $limitKey = \bizHd\product\classes\ProductClass::LIMIT_BUY_KEY . $productId;
                     $hasBuyNum = Yii::$app->redis->executeCommand('HGET', [$limitKey, $hdCustomId]);
                     $hasBuyNum = $num + ($hasBuyNum == null ? 0 : $hasBuyNum);
@@ -1138,8 +1176,16 @@ class OrderController extends BaseController
                     }
                     $stock = $goodsInfo->stock;
                     $stockSet = $goodsInfo->stockSet;
-                    if ($stockSet == 1 && $stock <= 0) {
-                        util::fail('花束库存不足');
+                    if ($stockSet == 1 && bccomp($goodsNum, floatval($stock), 2) > 0) {
+                        $this->failStockExceed([
+                            'productId' => $saleGoodsId,
+                            'goodsId' => $goodsId,
+                            'specGoodsId' => $specGoodsId,
+                            'property' => 0,
+                            'activityType' => strval($element['activityType'] ?? ''),
+                            'stock' => $stock,
+                            'num' => $goodsNum,
+                        ]);
                     }
 
                     // 秒杀商品:不信任前端传的价格,独立回查秒杀配置核实活动状态/库存/限购后再核价
@@ -1156,14 +1202,27 @@ class OrderController extends BaseController
                         }
                         $seckillStock = floatval($seckillRow['stock']);
                         $soldCount = HomePageModuleClass::getSeckillSoldCount($mainId, $seckillActivityGoodsId);
-                        if (bcadd($soldCount, $goodsNum, 2) > $seckillStock) {
-                            util::fail('秒杀库存不足,请重新选择');
+                        $seckillRemain = bcsub($seckillStock, $soldCount, 2);
+                        if (bccomp($seckillRemain, '0', 2) < 0) {
+                            $seckillRemain = '0';
+                        }
+                        if (bccomp($goodsNum, $seckillRemain, 2) > 0) {
+                            $this->failStockExceed([
+                                'productId' => $saleGoodsId,
+                                'goodsId' => $goodsId,
+                                'specGoodsId' => $specGoodsId,
+                                'property' => 0,
+                                'activityType' => 'seckill',
+                                'stock' => $seckillRemain,
+                                'num' => $goodsNum,
+                            ]);
                         }
                         $seckillLimit = floatval($seckillRow['limit'] ?? 0);
                         if ($seckillLimit > 0) {
                             $boughtCount = HomePageModuleClass::getSeckillCustomBoughtCount($mainId, $seckillActivityGoodsId, $hdCustomId); // TODO 同一个商品,如果缓存没有合理清除,就会一直限购
                             if (bcadd($boughtCount, $goodsNum, 2) > $seckillLimit) {
-                                util::fail("秒杀商品每人限购{$seckillLimit}件,已超出可购买数量");
+                                // 返回超限商品,供混合结算页/购物车红框高亮;code=-1 与花材限购错误对齐
+                                HomePageModuleClass::failSeckillLimitExceed($saleGoodsId, $goodsId, $specGoodsId, $seckillLimit, $boughtCount, $goodsNum);
                             }
                         }
                     }
@@ -1301,118 +1360,26 @@ class OrderController extends BaseController
 
             // 跑腿费(sendType=2 实际收取部分):不参与会员折/门店满减基数,随单传给折扣落地层剔除
             $runnerSendCost = 0;
-            // 跑腿 style=2:按包运费花束 / 不包运费花束 / 纯花材满减 + 跑腿或自定义(异常兜底)
+            // 跑腿 style=2:与团购开团/参团共用 PsMethodClass::calcMixRunnerFreight
             if (isset($post['sendType']) && (int)$post['sendType'] === 2) {
-                if ((int)$openIntraCity === 2) {
-                    util::fail('不能使用跑腿');
-                }
-                if (empty($post['long']) || empty($post['lat'])) {
-                    util::fail('位置错误');
-                }
-                $shopLat = $shop->lat ?? '';
-                $shopLong = $shop->long ?? '';
-                if (empty($shopLat) || empty($shopLong)) {
-                    util::fail('商家门店地址定位未设置,编号263');
-                }
-
-                $psConfig = PsMethodClass::getConfig($this->shopId, $this->mainId, 2);
-                if (empty($psConfig) || (int)($psConfig['status'] ?? 0) !== 1) {
-                    util::fail('跑腿配送方式未启用');
-                }
-                $freightMode = PsMethodClass::resolveMixRunnerFreightMode($resolvedProduct);
-                $calcType = (int)($psConfig['calcType'] ?? 0);
-
-                $quoteCustom = (object)[
-                    'name' => $post['receiveUserName'] ?? '',
-                    'mobile' => $post['receiveMobile'] ?? '',
-                    'fullAddress' => $post['address'] ?? ($post['fullAddress'] ?? ''),
-                    'floor' => $post['floor'] ?? '',
-                    'dist' => $post['dist'] ?? '',
-                    'lat' => $post['lat'],
-                    'long' => $post['long'],
-                    'address' => $post['address'] ?? '',
-                    'city' => $post['city'] ?? '',
-                ];
-                $distanceRespond = mapUtil::calcShopDistanceByEleBike($quoteCustom, $shop, 0);
-                $distanceMeters = (float)($distanceRespond['distance'] ?? 0);
-                $distanceKm = (float)($distanceRespond['distanceKm'] ?? 0);
-                if ($distanceMeters <= 0 && $distanceKm <= 0) {
-                    util::fail('算运费失败,请完善收货地址');
-                }
-                if ($distanceMeters <= 0 && $distanceKm > 0) {
-                    $distanceMeters = $distanceKm * 1000;
-                }
-
-                $sendCost = 0;
-                $freightSource = '';
-
-                if ($freightMode === 'bouquetIncluded') {
-                    // A:含包运费花束 → hsFreeKm / hsPerKmPrice
-                    $sendCost = PsMethodClass::calcBouquetFreightByDistance($psConfig, $distanceMeters);
-                    $freightSource = 'bouquetIncluded';
-                } elseif ($freightMode === 'flowerOnly'
-                    && PsMethodClass::matchReduceRuleFree($psConfig, $distanceMeters, $itemBigNum, $goodsOnlyPrice)) {
-                    // C:纯花材命中满减 → 免跑腿
-                    $sendCost = 0;
-                    $freightSource = 'reduceFree';
-                } else {
-                    // B 或不免满减的 C:按 calcType 跑腿或自定义;跑腿异常最终自定义兜底
-                    if ($calcType === 1) {
-                        try {
-                            $sendCost = PsMethodClass::calcCustomFreightByDistance($psConfig, $distanceKm > 0 ? $distanceKm : ($distanceMeters / 1000));
-                            $freightSource = 'custom';
-                        } catch (\Exception $e) {
-                            util::fail($e->getMessage());
-                        }
-                    } else {
-                        $runnerOk = false;
-                        if ((int)$openIntraCity === 1 && !empty($post['deliveryPlatform'])) {
-                            $prefix = 'PT-MIX-' . $this->mainId . '-';
-                            $orderSn = $prefix . round(microtime(true) * 1000);
-                            $ghsInfo = ['mainId' => $this->mainId, 'shopId' => $this->shopId];
-                            try {
-                                $quoteResult = DeliveryQuoteUtil::getDeliveryQuote([
-                                    'productList' => $resolvedProduct,
-                                    'deliveryPlatform' => $post['deliveryPlatform'],
-                                    'deliveryBracketContent' => $post['deliveryBracketContent'] ?? '',
-                                    'ghsInfo' => $ghsInfo,
-                                    'custom' => $quoteCustom,
-                                    'order' => [
-                                        'orderSn' => $orderSn,
-                                        'goodsType' => $quoteGoodsType,
-                                        'itemTotalAmount' => $goodsOnlyPrice,
-                                        'remark' => $post['remark'] ?? '',
-                                    ],
-                                    'mainId' => $this->mainId,
-                                    'productCount' => $totalNum,
-                                    'shConfig' => $psConfig,
-                                ]);
-                                $sendCost = $quoteResult['sendCost'];
-                                $distanceMeters = (float)($quoteResult['sendDistance'] ?? $distanceMeters);
-                                $freightSource = 'platform';
-                                $runnerOk = true;
-                            } catch (\Exception $e) {
-                                $runnerOk = false;
-                                noticeUtil::push('商城混合单跑腿报价异常,改走自定义:' . $e->getMessage());
-                            }
-                        }
-                        if (!$runnerOk) {
-                            try {
-                                $sendCost = PsMethodClass::calcCustomFreightByDistance(
-                                    $psConfig,
-                                    $distanceKm > 0 ? $distanceKm : ($distanceMeters / 1000)
-                                );
-                                $freightSource = 'customFallback';
-                            } catch (\Exception $e) {
-                                util::fail('跑腿计算异常且自定义计费不可用,请更换配送方式');
-                            }
-                        }
-                    }
-                }
-
+                $runnerResult = PsMethodClass::calcMixRunnerFreight(
+                    $shop,
+                    $this->shopId,
+                    $this->mainId,
+                    $post,
+                    $resolvedProduct,
+                    $itemBigNum,
+                    $goodsOnlyPrice,
+                    $openIntraCity,
+                    $quoteGoodsType,
+                    $totalNum,
+                    'PT-MIX-',
+                    '混合单'
+                );
+                $sendCost = $runnerResult['sendCost'];
                 $post['sendCost'] = $sendCost;
-                $post['sendDistance'] = $distanceMeters;
-                $post['freightSource'] = $freightSource;
+                $post['sendDistance'] = $runnerResult['sendDistance'];
+                $post['freightSource'] = $runnerResult['freightSource'];
                 $modifyPrice = bcadd($modifyPrice, $sendCost, 2);
                 $runnerSendCost = $sendCost;
             }
@@ -2103,6 +2070,48 @@ class OrderController extends BaseController
         return true;
     }
 
+    /**
+     * 库存不足:以 code=-1 返回商品标识、剩余库存与超出数量。
+     * 与限购超限同形态,供混合结算页/购物车红框高亮对应商品并提示超出多少。
+     *
+     * @param array $opts productId/goodsId/specGoodsId/property/activityType/stock/num
+     */
+    protected function failStockExceed($opts)
+    {
+        $productId = intval($opts['productId'] ?? 0);
+        $goodsId = intval($opts['goodsId'] ?? 0);
+        $specGoodsId = intval($opts['specGoodsId'] ?? 0);
+        $property = intval($opts['property'] ?? 0);
+        $activityType = strval($opts['activityType'] ?? '');
+        $stock = floatval($opts['stock'] ?? 0);
+        $num = floatval($opts['num'] ?? 0);
+        if (bccomp($stock, '0', 2) < 0) {
+            $stock = 0;
+        }
+        $exceed = bcsub($num, $stock, 2);
+        if (bccomp($exceed, '0', 2) < 0) {
+            $exceed = '0';
+        }
+        $stockShow = rtrim(rtrim(sprintf('%.2f', $stock), '0'), '.');
+        if ($stockShow === '') {
+            $stockShow = '0';
+        }
+        $msg = $activityType === 'seckill'
+            ? "秒杀商品库存不足,剩余{$stockShow}"
+            : "商品库存不足,剩余{$stockShow}";
+        util::error(-1, $msg, [
+            'productId' => $productId,
+            'goodsId' => $goodsId,
+            'specGoodsId' => $specGoodsId,
+            'property' => $property,
+            'activityType' => $activityType,
+            'reason' => 'stock',
+            'stock' => floatval($stock),
+            'num' => $num,
+            'exceed' => floatval($exceed),
+        ]);
+    }
+
     //获取详情 ssh 20220512
     public function actionInfo()
     {

+ 19 - 2
app-mall/models/groupBuy/CreateGroupBuyForm.php

@@ -6,6 +6,8 @@ use mall\models\BaseForm;
 
 /**
  * 开团下单表单校验
+ * 结算页提交:校验团购商品、数量、配送方式及收货信息
+ * 跑腿字段与混合单 create-mix-order 对齐,供后端按同一套规则重算运费
  */
 class CreateGroupBuyForm extends BaseForm
 {
@@ -17,6 +19,8 @@ class CreateGroupBuyForm extends BaseForm
     public $address;
     public $floor;
     public $city;
+    public $dist;
+    public $province;
     public $long;
     public $lat;
     public $showAddress;
@@ -24,6 +28,13 @@ class CreateGroupBuyForm extends BaseForm
     public $reachDate;
     public $reachPeriod;
     public $bookMobile;
+    public $needCard;
+    public $cardInfo;
+    public $anonymity;
+    /** 跑腿平台标识,如 shansong/didi;后端报价用,不信任前端金额 */
+    public $deliveryPlatform;
+    /** 跑腿平台多报价识别 id(货拉拉车型、滴滴括号内容等) */
+    public $deliveryBracketContent;
 
     public function rules()
     {
@@ -33,8 +44,12 @@ class CreateGroupBuyForm extends BaseForm
             ['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'],
+            ['sendType', 'integer', 'min' => 0, 'max' => 4, 'message' => '配送方式无效', 'tooSmall' => '配送方式无效', 'tooBig' => '配送方式无效'],
+            ['needCard', 'default', 'value' => 0],
+            ['needCard', 'integer'],
+            ['anonymity', 'default', 'value' => 0],
+            ['anonymity', 'integer'],
+            [['receiveUserName', 'receiveMobile', 'address', 'floor', 'city', 'dist', 'province', 'long', 'lat', 'showAddress', 'remark', 'reachDate', 'reachPeriod', 'bookMobile', 'cardInfo', 'deliveryPlatform', 'deliveryBracketContent'], 'safe'],
         ];
     }
 
@@ -44,6 +59,8 @@ class CreateGroupBuyForm extends BaseForm
             'activityGoodsId' => '团购商品',
             'goodsNum' => '数量',
             'sendType' => '配送方式',
+            'deliveryPlatform' => '跑腿平台',
+            'deliveryBracketContent' => '跑腿报价标识',
         ];
     }
 }

+ 25 - 2
app-mall/models/groupBuy/JoinGroupBuyForm.php

@@ -6,6 +6,8 @@ use mall\models\BaseForm;
 
 /**
  * 参团下单表单校验
+ * 结算页提交:校验拼团、数量、配送方式及收货信息(与开团字段对齐)
+ * 跑腿字段与混合单 create-mix-order 对齐,供后端按同一套规则重算运费
  */
 class JoinGroupBuyForm extends BaseForm
 {
@@ -13,12 +15,27 @@ class JoinGroupBuyForm extends BaseForm
     public $goodsNum;
     public $sendType;
     public $account;
+    public $receiveUserName;
     public $receiveMobile;
     public $address;
     public $floor;
     public $city;
+    public $dist;
+    public $province;
     public $long;
     public $lat;
+    public $showAddress;
+    public $remark;
+    public $reachDate;
+    public $reachPeriod;
+    public $bookMobile;
+    public $needCard;
+    public $cardInfo;
+    public $anonymity;
+    /** 跑腿平台标识,如 shansong/didi;后端报价用,不信任前端金额 */
+    public $deliveryPlatform;
+    /** 跑腿平台多报价识别 id(货拉拉车型、滴滴括号内容等) */
+    public $deliveryBracketContent;
 
     public function rules()
     {
@@ -28,8 +45,12 @@ class JoinGroupBuyForm extends BaseForm
             ['goodsNum', 'default', 'value' => 1],
             ['goodsNum', 'integer', 'min' => 1, 'max' => 99, 'message' => '购买数量无效'],
             ['sendType', 'default', 'value' => 1],
-            ['sendType', 'integer'],
-            [['receiveMobile', 'address', 'floor', 'city', 'long', 'lat', ], 'safe'],
+            ['sendType', 'integer', 'min' => 0, 'max' => 4, 'message' => '配送方式无效', 'tooSmall' => '配送方式无效', 'tooBig' => '配送方式无效'],
+            ['needCard', 'default', 'value' => 0],
+            ['needCard', 'integer'],
+            ['anonymity', 'default', 'value' => 0],
+            ['anonymity', 'integer'],
+            [['account', 'receiveUserName', 'receiveMobile', 'address', 'floor', 'city', 'dist', 'province', 'long', 'lat', 'showAddress', 'remark', 'reachDate', 'reachPeriod', 'bookMobile', 'cardInfo', 'deliveryPlatform', 'deliveryBracketContent'], 'safe'],
         ];
     }
 
@@ -39,6 +60,8 @@ class JoinGroupBuyForm extends BaseForm
             'groupBuyId' => '拼团',
             'goodsNum' => '数量',
             'sendType' => '配送方式',
+            'deliveryPlatform' => '跑腿平台',
+            'deliveryBracketContent' => '跑腿报价标识',
         ];
     }
 }

+ 54 - 0
biz-hd/homePageConfig/classes/HomePageModuleClass.php

@@ -375,6 +375,60 @@ class HomePageModuleClass
         return floatval($val ?? 0);
     }
 
+    /**
+     * 组装秒杀单人限购超限的返回 data,供结算页/购物车按商品高亮并展示超出件数。
+     * productId 与前端提交的花束 productId(规格 id 优先,否则主商品 id)对齐。
+     *
+     * @param int $saleGoodsId 实际售卖商品 id
+     * @param int $goodsId 主商品 id
+     * @param int $specGoodsId 规格 id
+     * @param float $seckillLimit 单人限购件数
+     * @param float $boughtCount 该客户已购件数
+     * @param float $goodsNum 本单购买件数
+     * @return array
+     */
+    public static function buildSeckillLimitExceedData($saleGoodsId, $goodsId, $specGoodsId, $seckillLimit, $boughtCount, $goodsNum)
+    {
+        $seckillLimit = floatval($seckillLimit);
+        $boughtCount = floatval($boughtCount);
+        $goodsNum = floatval($goodsNum);
+        $wantTotal = bcadd($boughtCount, $goodsNum, 2);
+        $exceed = bcsub($wantTotal, $seckillLimit, 2);
+        if (bccomp($exceed, '0', 2) < 0) {
+            $exceed = '0';
+        }
+        return [
+            'productId' => intval($saleGoodsId),
+            'goodsId' => intval($goodsId),
+            'specGoodsId' => intval($specGoodsId),
+            'property' => 0,
+            'activityType' => 'seckill',
+            'reason' => 'limit',
+            'limitBuy' => $seckillLimit,
+            'boughtCount' => $boughtCount,
+            'num' => $goodsNum,
+            'exceed' => floatval($exceed),
+        ];
+    }
+
+    /**
+     * 秒杀单人限购超限:以 code=-1 返回商品标识与超出数量,与花材 handleLimitBuy 的错误形态对齐。
+     * 前端据此红框高亮对应商品,而不是只弹一句笼统提示。
+     *
+     * @param int $saleGoodsId 实际售卖商品 id
+     * @param int $goodsId 主商品 id
+     * @param int $specGoodsId 规格 id
+     * @param float $seckillLimit 单人限购件数
+     * @param float $boughtCount 该客户已购件数
+     * @param float $goodsNum 本单购买件数
+     */
+    public static function failSeckillLimitExceed($saleGoodsId, $goodsId, $specGoodsId, $seckillLimit, $boughtCount, $goodsNum)
+    {
+        $data = self::buildSeckillLimitExceedData($saleGoodsId, $goodsId, $specGoodsId, $seckillLimit, $boughtCount, $goodsNum);
+        $limitShow = rtrim(rtrim(sprintf('%.2f', $data['limitBuy']), '0'), '.');
+        util::error(-1, "秒杀商品每人限购{$limitShow}件,已超出可购买数量", $data);
+    }
+
     /**
      * 预占秒杀名额:库存/限购校验通过后调用,累加"已售"与"该客户已购"计数,
      * 并记录本次请求的回滚快照——下单失败时通过 rollbackSeckillReservationSnapshot 撤销

+ 156 - 2
biz-hd/order/classes/PsMethodClass.php

@@ -1,15 +1,19 @@
 <?php
 /**
  * 用途:花店配送方式配置业务逻辑类
- * 谁用:花店系统 hdApp 配送方式设置页、商城混合结算附加费预览/下单
- * 解决什么问题:读写 xhPsMethod / xhPsExplain / xhPsReduceRule;附加费支持按自然日或冷却秒重收
+ * 谁用:花店系统 hdApp 配送方式设置页、商城混合结算/团购开团参团附加费预览与跑腿计费
+ * 解决什么问题:读写 xhPsMethod / xhPsExplain / xhPsReduceRule;附加费支持按自然日或冷却秒重收;混合单与团购单共用跑腿距离运费
  */
 
 namespace bizHd\order\classes;
 
 use bizGhs\express\classes\DeliveryAuthTokenClass;
 use bizHd\base\classes\BaseClass;
+use common\components\delivery\util\DeliveryQuoteUtil;
 use common\components\dict;
+use common\components\mapUtil;
+use common\components\noticeUtil;
+use common\components\util;
 use Yii;
 
 class PsMethodClass extends BaseClass
@@ -359,6 +363,156 @@ class PsMethodClass extends BaseClass
         return 'flowerOnly';
     }
 
+    /**
+     * 商城混合单/团购单共用:计算跑腿距离运费(不含未达门槛附加运费)
+     * 谁用:OrderController::actionCreateMixOrder、GroupBuyController::createGroupBuyOrder
+     * 解决:包运费花束 / 不包运费花束 / 纯花材满减 + 平台报价或自定义(异常兜底),两处口径必须一致
+     *
+     * @param object $shop 门店(需 lat/long)
+     * @param int $shopId 门店 ID
+     * @param int $mainId 商户 ID
+     * @param array $post 下单字段:收货坐标、deliveryPlatform、deliveryBracketContent、remark
+     * @param array $resolvedProduct 已核价商品行,需含 property、freightType
+     * @param float $itemBigNum 花材扎数(纯花材满减用,团购花束传 0)
+     * @param float $goodsOnlyPrice 纯商品金额(满减门槛、平台报价用)
+     * @param int $openIntraCity ShopClass::hasIntraCity 的 0/1/2
+     * @param int $quoteGoodsType 平台报价商品类型:0 花束 1 花材
+     * @param float $totalNum 商品总数量
+     * @param string $quoteOrderPrefix 平台询价临时单号前缀,区分混合单/团购单
+     * @param string $quoteSceneLabel 报价异常通知文案场景,如「混合单」「团购单」
+     * @return array ['sendCost'=>float,'sendDistance'=>float,'freightSource'=>string]
+     */
+    public static function calcMixRunnerFreight(
+        $shop,
+        $shopId,
+        $mainId,
+        array $post,
+        array $resolvedProduct,
+        $itemBigNum,
+        $goodsOnlyPrice,
+        $openIntraCity,
+        $quoteGoodsType,
+        $totalNum,
+        $quoteOrderPrefix = 'PT-MIX-',
+        $quoteSceneLabel = '混合单'
+    ) {
+        if ((int)$openIntraCity === 2) {
+            util::fail('不能使用跑腿');
+        }
+        if (empty($post['long']) || empty($post['lat'])) {
+            util::fail('位置错误');
+        }
+        $shopLat = $shop->lat ?? '';
+        $shopLong = $shop->long ?? '';
+        if (empty($shopLat) || empty($shopLong)) {
+            util::fail('商家门店地址定位未设置,编号263');
+        }
+
+        $psConfig = self::getConfig($shopId, $mainId, 2);
+        if (empty($psConfig) || (int)($psConfig['status'] ?? 0) !== 1) {
+            util::fail('跑腿配送方式未启用');
+        }
+        $freightMode = self::resolveMixRunnerFreightMode($resolvedProduct);
+        $calcType = (int)($psConfig['calcType'] ?? 0);
+
+        $quoteCustom = (object)[
+            'name' => $post['receiveUserName'] ?? '',
+            'mobile' => $post['receiveMobile'] ?? '',
+            'fullAddress' => $post['address'] ?? ($post['fullAddress'] ?? ''),
+            'floor' => $post['floor'] ?? '',
+            'dist' => $post['dist'] ?? '',
+            'lat' => $post['lat'],
+            'long' => $post['long'],
+            'address' => $post['address'] ?? '',
+            'city' => $post['city'] ?? '',
+        ];
+        $distanceRespond = mapUtil::calcShopDistanceByEleBike($quoteCustom, $shop, 0);
+        $distanceMeters = (float)($distanceRespond['distance'] ?? 0);
+        $distanceKm = (float)($distanceRespond['distanceKm'] ?? 0);
+        if ($distanceMeters <= 0 && $distanceKm <= 0) {
+            util::fail('算运费失败,请完善收货地址');
+        }
+        if ($distanceMeters <= 0 && $distanceKm > 0) {
+            $distanceMeters = $distanceKm * 1000;
+        }
+
+        $sendCost = 0;
+        $freightSource = '';
+
+        if ($freightMode === 'bouquetIncluded') {
+            // 含包运费花束:按 hsFreeKm / hsPerKmPrice,不再询跑腿平台
+            $sendCost = self::calcBouquetFreightByDistance($psConfig, $distanceMeters);
+            $freightSource = 'bouquetIncluded';
+        } elseif ($freightMode === 'flowerOnly'
+            && self::matchReduceRuleFree($psConfig, $distanceMeters, $itemBigNum, $goodsOnlyPrice)) {
+            // 纯花材命中满减:免跑腿
+            $sendCost = 0;
+            $freightSource = 'reduceFree';
+        } else {
+            // 不包运费花束,或未满减的纯花材:calcType=1 自定义,否则平台报价、异常自定义兜底
+            if ($calcType === 1) {
+                try {
+                    $sendCost = self::calcCustomFreightByDistance(
+                        $psConfig,
+                        $distanceKm > 0 ? $distanceKm : ($distanceMeters / 1000)
+                    );
+                    $freightSource = 'custom';
+                } catch (\Exception $e) {
+                    util::fail($e->getMessage());
+                }
+            } else {
+                $runnerOk = false;
+                if ((int)$openIntraCity === 1 && !empty($post['deliveryPlatform'])) {
+                    $prefix = $quoteOrderPrefix . $mainId . '-';
+                    $orderSn = $prefix . round(microtime(true) * 1000);
+                    $ghsInfo = ['mainId' => $mainId, 'shopId' => $shopId];
+                    try {
+                        $quoteResult = DeliveryQuoteUtil::getDeliveryQuote([
+                            'productList' => $resolvedProduct,
+                            'deliveryPlatform' => $post['deliveryPlatform'],
+                            'deliveryBracketContent' => $post['deliveryBracketContent'] ?? '',
+                            'ghsInfo' => $ghsInfo,
+                            'custom' => $quoteCustom,
+                            'order' => [
+                                'orderSn' => $orderSn,
+                                'goodsType' => $quoteGoodsType,
+                                'itemTotalAmount' => $goodsOnlyPrice,
+                                'remark' => $post['remark'] ?? '',
+                            ],
+                            'mainId' => $mainId,
+                            'productCount' => $totalNum,
+                            'shConfig' => $psConfig,
+                        ]);
+                        $sendCost = $quoteResult['sendCost'];
+                        $distanceMeters = (float)($quoteResult['sendDistance'] ?? $distanceMeters);
+                        $freightSource = 'platform';
+                        $runnerOk = true;
+                    } catch (\Exception $e) {
+                        $runnerOk = false;
+                        noticeUtil::push('商城' . $quoteSceneLabel . '跑腿报价异常,改走自定义:' . $e->getMessage());
+                    }
+                }
+                if (!$runnerOk) {
+                    try {
+                        $sendCost = self::calcCustomFreightByDistance(
+                            $psConfig,
+                            $distanceKm > 0 ? $distanceKm : ($distanceMeters / 1000)
+                        );
+                        $freightSource = 'customFallback';
+                    } catch (\Exception $e) {
+                        util::fail('跑腿计算异常且自定义计费不可用,请更换配送方式');
+                    }
+                }
+            }
+        }
+
+        return [
+            'sendCost' => $sendCost,
+            'sendDistance' => $distanceMeters,
+            'freightSource' => $freightSource,
+        ];
+    }
+
     /**
      * 跑腿保存/启用前校验,不通过时抛异常
      * @param int $mainId 商户 ID

+ 7 - 2
biz-hd/order/services/OrderService.php

@@ -233,14 +233,19 @@ class OrderService extends BaseService
         //美团服务费和手续费
         $serviceFee = isset($data['serviceFee']) && $data['serviceFee'] > 0 ? $data['serviceFee'] : 0;
         $totalPrice = bcadd($totalPrice, $serviceFee, 2);
-        //扣除红包的减免
+        //扣除红包的减免;拼团单不使用红包,避免团购价再被抵扣
+        $isGroupBuyOrder = intval($data['groupBuyId'] ?? 0) > 0;
         $hbAmount = isset($data['hbAmount']) && $data['hbAmount'] > 0 ? $data['hbAmount'] : 0;
+        if ($isGroupBuyOrder) {
+            $hbAmount = 0;
+            $data['hbAmount'] = 0;
+            $data['hbId'] = 0;
+        }
         $totalPrice = bcsub($totalPrice, $hbAmount, 2);
 
         $modifyPrice = isset($data['modifyPrice']) && $data['modifyPrice'] > 0 ? $data['modifyPrice'] : 0;
         // skipMemberDiscount:前端(mall 混合单 / hd 开单页)已写入折后 modifyPrice 时跳过,避免二次会员折
         // 拼团单只按团购价结算,不再叠加会员折扣
-        $isGroupBuyOrder = intval($data['groupBuyId'] ?? 0) > 0;
         $skipMemberDiscount = !empty($data['skipMemberDiscount']) || $isGroupBuyOrder;
         unset($data['skipMemberDiscount']);
         $memberDiscount = $custom->discount ?? 1;

+ 2 - 0
sql/20260706_redesign.sql

@@ -1,3 +1,5 @@
+已上
+
 -- ouyang 2026-7-17 10:11:34
 ALTER TABLE xhHd
     add COLUMN `homeRule` tinyint(4) NOT NULL DEFAULT '0' COMMENT '上门遵循规则 0遵循总设置 1遵循自定义' after `updateTime`;

+ 4 - 2
sql/20260714_goods_use_case.sql

@@ -1,3 +1,5 @@
+已上
+
 CREATE TABLE `xhGoodsUseCase` (
   `id` int(11) NOT NULL AUTO_INCREMENT,
   `mainId` int(11) NOT NULL DEFAULT 0 COMMENT '中央id',
@@ -27,11 +29,11 @@ CREATE TABLE `xhGoodsUseCaseRelation` (
 
 
 ALTER TABLE xhHd
-  add COLUMN `homeRule` tinyint(4) NOT NULL DEFAULT '0' COMMENT '上门遵循规则 0遵循总设置 1遵循自定义' after `updateTime`;
+  add COLUMN `homeRule` tinyint(4) NOT NULL DEFAULT '0' COMMENT '上门遵循规则 0遵循总设置 1遵循自定义' after `updateTime`,ALGORITHM=INPLACE, LOCK=NONE;
 
 
 ALTER TABLE xhCustom
-  add COLUMN `homeRule` tinyint(4) NOT NULL DEFAULT '0' COMMENT '上门遵循规则 0遵循总设置 1遵循自定义' after `updateTime`;
+  add COLUMN `homeRule` tinyint(4) NOT NULL DEFAULT '0' COMMENT '上门遵循规则 0遵循总设置 1遵循自定义' after `updateTime`,ALGORITHM=INPLACE, LOCK=NONE;
 
 
 -- 商品表添加索引

+ 2 - 0
sql/20260723_group_buy.sql

@@ -1,3 +1,5 @@
+已上
+
 -- 拼团(团购)核心表:活动批次 / 活动商品版本 / 拼团单 / 拼团成员
 -- 执行前请确认线上库无同名表;xhOrder.groupBuyId 若已存在请跳过对应 ALTER
 

+ 2 - 0
sql/20260730_seckill.sql

@@ -1,3 +1,5 @@
+已上
+
 -- 秒杀核心表:活动批次 / 活动商品版本,以及订单秒杀标识字段
 -- 执行前请确认线上库无同名表;xhOrder.hasSeckill / xhOrderGoods.seckillGoodsId 若已存在请跳过对应 ALTER
 

+ 2 - 0
sql/20260804_hb_upgrade.sql

@@ -1,3 +1,5 @@
+已上
+
 -- 红包功能升级:适用范围外表 / 自动发红包规则 / 发放日志 / 到账提示标记
 -- 执行前请确认线上库无同名表;ALTER 若字段已存在请跳过对应语句
 

+ 2 - 0
sql/20260811_home_page_module_config.sql

@@ -1,3 +1,5 @@
+已上
+
 -- 门店首页模块配置落库:原仅存 Redis 的 modules/topNav/banner/navGrid/hot/new/pullGoods
 -- 统一按门店(shopId)维度落一张通用配置表,moduleKey 区分具体模块,configValue 存 JSON
 

+ 2 - 0
sql/20260825_hd_refund_mall_apply.sql

@@ -1,3 +1,5 @@
+已上
+
 -- 商城顾客售后并入零售退款主表 xhHdRefund
 -- 用途:顾客申请落待审核记录(status=0),商家审核通过后再走资金/库存
 -- 执行前请确认 xhHdRefund 尚无 userId/hdId/product/rejectReason 字段

+ 2 - 0
sql/20260828_apply_gen_template.sql

@@ -1,3 +1,5 @@
+已上
+
 -- 开店申请增加「是否给零售店生成样例模板」标记
 -- 用途:hdApp/ghsApp 申请开店勾选后写入 xhApply,审核/自动通过时按该字段决定是否复制模板店数据
 -- 执行前请确认 xhApply 尚无 genTemplate 字段