GroupBuyController.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. <?php
  2. namespace mall\controllers;
  3. use bizHd\goods\classes\GoodsClass;
  4. use bizHd\groupBuy\classes\GroupBuyActivityClass;
  5. use bizHd\groupBuy\classes\GroupBuyClass;
  6. use bizHd\groupBuy\classes\GroupBuyMemberClass;
  7. use bizHd\order\classes\PsMethodClass;
  8. use bizHd\order\services\OrderService;
  9. use bizHd\shop\classes\ShopClass;
  10. use bizMall\pictext\classes\PicTextGoodsClass;
  11. use common\components\business;
  12. use common\components\dict;
  13. use common\components\orderSn;
  14. use common\components\stringUtil;
  15. use common\components\util;
  16. use mall\models\groupBuy\CreateGroupBuyForm;
  17. use mall\models\groupBuy\JoinGroupBuyForm;
  18. use Yii;
  19. /**
  20. * 商城端拼团接口
  21. * 落地页 / 团详情 / 开团 / 参团 / 我的拼团
  22. * 分享直达或换店后仍带旧 hdId 时,按 account 恢复门店并建客,与 get-home 进店闭环一致
  23. * 开团/参团订单按团购价结算,不叠加会员折扣、门店满减、红包
  24. * 配送附加费与跑腿距离运费与混合结算同源(PsMethodClass::calcFeeForMallContext / calcMixRunnerFreight)
  25. */
  26. class GroupBuyController extends BaseController
  27. {
  28. public $guestAccess = [
  29. 'goods-landing',
  30. 'detail',
  31. ];
  32. /**
  33. * 团购商品落地页:活动商品信息 + 商品详情图文 + 当前最快成团开放团
  34. * GET goodsId
  35. * 返回字段与商品详情页对齐:picTextGoods(标题/content JSON)供前端 rich-media-viewer 渲染
  36. * 登录态按 account 恢复门店并建客,回传 hdId / inviteBound,与 get-home 进店闭环一致
  37. */
  38. public function actionGoodsLanding()
  39. {
  40. $ctx = $this->recoverShopAndResolveHd();
  41. $mainId = $ctx['mainId'];
  42. $goodsId = intval(Yii::$app->request->get('goodsId', 0));
  43. if ($goodsId <= 0) {
  44. util::fail('请选择商品');
  45. }
  46. $row = GroupBuyActivityClass::requireActiveGoodsRowByGoodsId($mainId, intval($this->shopId), $goodsId);
  47. $goods = GoodsClass::getById($goodsId, true);
  48. $cover = strval($row['cover'] ?? '');
  49. $coverUrl = $cover;
  50. if ($cover !== '' && strpos($cover, 'http') !== 0) {
  51. $formatted = business::formatUploadImg($cover);
  52. $coverUrl = $formatted['url'] ?? $cover;
  53. }
  54. // 轮播图兜底:保留商品图集;详情正文以 picTextGoods 为准(与 GoodsController::actionDetail 一致)
  55. $detailImages = [];
  56. $picTextGoods = [];
  57. if (!empty($goods)) {
  58. $pics = $goods->detail ?? ($goods->pic ?? '');
  59. if (is_string($pics) && $pics !== '') {
  60. $decoded = json_decode($pics, true);
  61. if (is_array($decoded)) {
  62. foreach ($decoded as $p) {
  63. if (is_string($p) && $p !== '') {
  64. if (strpos($p, 'http') === 0) {
  65. $detailImages[] = $p;
  66. } else {
  67. $fmt = business::formatUploadImg($p);
  68. $detailImages[] = $fmt['url'] ?? $p;
  69. }
  70. }
  71. }
  72. }
  73. }
  74. // 规格商品详情图文挂在主商品上,与商品详情接口取值规则一致
  75. $picTextGoodsId = empty($goods->masterId) ? $goodsId : intval($goods->masterId);
  76. $picTextRow = PicTextGoodsClass::getByCondition([
  77. 'mainId' => $mainId,
  78. 'goodsId' => $picTextGoodsId,
  79. ], true);
  80. if (!empty($picTextRow)) {
  81. $picTextGoods = $picTextRow;
  82. }
  83. }
  84. $openGroup = GroupBuyClass::getHottestOpenGroup(
  85. $mainId,
  86. $goodsId,
  87. intval($row['activityGoodsId'] ?? 0)
  88. );
  89. util::success([
  90. 'activityGoodsId' => intval($row['activityGoodsId']),
  91. 'activityId' => intval($row['activityId']),
  92. 'goodsId' => $goodsId,
  93. 'name' => strval($row['name']),
  94. 'cover' => $cover,
  95. 'coverUrl' => $coverUrl,
  96. 'price' => floatval($row['price']),
  97. 'originPrice' => floatval($row['originPrice']),
  98. 'groupSize' => intval($row['groupSize']),
  99. 'stock' => intval($row['stock']),
  100. 'limit' => intval($row['limit']),
  101. 'virtualGroup' => intval($row['virtualGroup']),
  102. 'virtualMinutes' => intval($row['virtualMinutes']),
  103. 'autoRefund' => 1,
  104. 'startTime' => intval($row['startTime']),
  105. 'endTime' => intval($row['endTime']),
  106. 'title' => strval($row['title']),
  107. 'subtitle' => strval($row['subtitle']),
  108. 'desc' => strval($row['desc']),
  109. 'showCountdown' => intval($row['showCountdown']),
  110. 'detailImages' => $detailImages,
  111. 'picTextGoods' => $picTextGoods,
  112. // 花束是否包运费:结算页跑腿模式(bouquetIncluded)与混合单同源判断
  113. 'freightType' => !empty($goods) ? intval($goods->freightType ?? 0) : 0,
  114. 'openGroup' => $openGroup,
  115. // 供 mallApp 写入本地 hdId,避免后续开团/参团仍带旧店 hdId
  116. 'hdId' => $ctx['hdId'],
  117. 'inviteBound' => $ctx['inviteBound'],
  118. ]);
  119. }
  120. /**
  121. * 拼团详情
  122. * GET id
  123. * 游客可浏览;已登录则按 account 恢复门店并建客,回传 hdId 供前端缓存
  124. */
  125. public function actionDetail()
  126. {
  127. $ctx = $this->recoverShopAndResolveHd();
  128. $mainId = $ctx['mainId'];
  129. $id = intval(Yii::$app->request->get('id', 0));
  130. if ($id <= 0) {
  131. util::fail('请选择拼团');
  132. }
  133. $detail = GroupBuyClass::getDetail($mainId, $id);
  134. // 附带活动商品落地信息,便于分享进入后完整展示
  135. $landing = GroupBuyActivityClass::getActiveGoodsRowById($mainId, intval($this->shopId), intval($detail['activityGoodsId']));
  136. if (empty($landing)) {
  137. $landing = GroupBuyActivityClass::getActiveGoodsRowByGoodsId($mainId, intval($this->shopId), intval($detail['goodsId']));
  138. }
  139. if (!empty($landing) && !isset($landing['freightType'])) {
  140. $landingGoodsId = intval($landing['goodsId'] ?? ($detail['goodsId'] ?? 0));
  141. $landingGoods = $landingGoodsId > 0 ? GoodsClass::getById($landingGoodsId, true) : null;
  142. // 分享进详情也要带包运费标记,否则结算页跑腿会误走平台报价
  143. $landing['freightType'] = !empty($landingGoods) ? intval($landingGoods->freightType ?? 0) : 0;
  144. }
  145. $detail['activityGoods'] = $landing;
  146. $detail['hdId'] = $ctx['hdId'];
  147. $detail['inviteBound'] = $ctx['inviteBound'];
  148. util::success($detail);
  149. }
  150. /**
  151. * 我要开团:创建团 + 创建待支付订单 + 挂接团长成员
  152. */
  153. public function actionCreate()
  154. {
  155. $form = new CreateGroupBuyForm();
  156. $form->loadAndValidate();
  157. $ctx = $this->ensureCustomerContext();
  158. $mainId = $ctx['mainId'];
  159. $custom = $this->custom;
  160. $customId = intval($custom->id);
  161. $activityGoodsId = intval($form->activityGoodsId);
  162. $goodsNum = max(1, intval($form->goodsNum));
  163. $row = GroupBuyActivityClass::requireActiveGoodsRowById($mainId, intval($this->shopId), $activityGoodsId);
  164. GroupBuyClass::assertPurchaseLimit($mainId, $customId, $row, $goodsNum);
  165. $connection = Yii::$app->db;
  166. $transaction = $connection->beginTransaction();
  167. try {
  168. $group = GroupBuyClass::createGroup($mainId, intval($this->shopId), $customId, $row);
  169. $groupBuyId = intval($group['id']);
  170. $order = $this->createGroupBuyOrder($form, $row, $groupBuyId, $goodsNum);
  171. GroupBuyMemberClass::bindOrderMember(
  172. $groupBuyId,
  173. $mainId,
  174. intval($this->shopId),
  175. $customId,
  176. intval($order->id),
  177. strval($order->orderSn),
  178. GroupBuyMemberClass::ROLE_LEADER
  179. );
  180. $transaction->commit();
  181. util::success([
  182. 'groupBuyId' => $groupBuyId,
  183. 'orderSn' => $order->orderSn,
  184. 'totalPrice' => $order->actPrice,
  185. 'id' => $order->id,
  186. ]);
  187. } catch (\Exception $e) {
  188. $transaction->rollBack();
  189. Yii::error('开团失败:' . $e->getMessage(), __METHOD__);
  190. $msg = $e->getMessage();
  191. if ($msg !== '' && mb_strlen($msg) < 80 && strpos($msg, 'SQLSTATE') === false) {
  192. util::fail($msg);
  193. }
  194. util::fail('开团失败');
  195. }
  196. }
  197. /**
  198. * 我要参团:加入已有团 + 创建待支付订单
  199. */
  200. public function actionJoin()
  201. {
  202. $form = new JoinGroupBuyForm();
  203. $form->loadAndValidate();
  204. $ctx = $this->ensureCustomerContext();
  205. $mainId = $ctx['mainId'];
  206. $custom = $this->custom;
  207. $customId = intval($custom->id);
  208. $groupBuyId = intval($form->groupBuyId);
  209. $goodsNum = max(1, intval($form->goodsNum));
  210. $connection = Yii::$app->db;
  211. $transaction = $connection->beginTransaction();
  212. try {
  213. $group = GroupBuyClass::assertCanJoin($mainId, $groupBuyId, $customId);
  214. $row = GroupBuyActivityClass::getActiveGoodsRowById($mainId, intval($this->shopId), intval($group->activityGoodsId));
  215. if (empty($row)) {
  216. // 活动结束后仍允许在截止前参团:用团上快照价
  217. $row = [
  218. 'activityId' => intval($group->activityId),
  219. 'activityGoodsId' => intval($group->activityGoodsId),
  220. 'goodsId' => intval($group->goodsId),
  221. 'price' => floatval($group->price),
  222. 'limit' => 1,
  223. 'endTime' => intval($group->deadline),
  224. 'groupSize' => intval($group->needNum),
  225. 'virtualGroup' => intval($group->virtualGroup),
  226. 'virtualMinutes' => 0,
  227. ];
  228. }
  229. GroupBuyClass::assertPurchaseLimit($mainId, $customId, $row, $goodsNum);
  230. $order = $this->createGroupBuyOrder($form, $row, $groupBuyId, $goodsNum, $custom);
  231. GroupBuyMemberClass::bindOrderMember(
  232. $groupBuyId,
  233. $mainId,
  234. intval($this->shopId),
  235. $customId,
  236. intval($order->id),
  237. strval($order->orderSn),
  238. GroupBuyMemberClass::ROLE_MEMBER
  239. );
  240. $transaction->commit();
  241. util::success([
  242. 'groupBuyId' => $groupBuyId,
  243. 'orderSn' => $order->orderSn,
  244. 'totalPrice' => $order->actPrice,
  245. 'id' => $order->id,
  246. ]);
  247. } catch (\Exception $e) {
  248. $transaction->rollBack();
  249. Yii::error('参团失败:' . $e->getMessage(), __METHOD__);
  250. $msg = $e->getMessage();
  251. if ($msg !== '' && mb_strlen($msg) < 80 && strpos($msg, 'SQLSTATE') === false) {
  252. util::fail($msg);
  253. }
  254. util::fail('参团失败');
  255. }
  256. }
  257. /**
  258. * 我的拼团列表
  259. * 换店后可能仍带旧 hdId,先恢复门店并绑定当前店顾客再查列表
  260. */
  261. public function actionMyList()
  262. {
  263. $ctx = $this->ensureCustomerContext();
  264. util::success([
  265. 'list' => GroupBuyClass::getMyList($ctx['mainId'], intval($this->custom->id)),
  266. ]);
  267. }
  268. /**
  269. * 组装并创建拼团订单(核价以后端活动商品为准)
  270. * 团购价已是活动优惠价:跳过会员折、门店满减、红包;运费/包装费与混合结算同源计算
  271. * 跑腿(sendType=2)走与混合单相同的 calcMixRunnerFreight,不信任前端报价
  272. *
  273. * @param object $form
  274. * @param array $row
  275. * @param int $groupBuyId
  276. * @param int $goodsNum
  277. * @return object
  278. */
  279. private function createGroupBuyOrder($form, $row, $groupBuyId, $goodsNum, $currenCustom = [])
  280. {
  281. $shop = $this->shop;
  282. $openShop = ShopClass::getOpenShop($shop);
  283. if ($openShop == 0) {
  284. util::fail('已休店');
  285. }
  286. $mainId = intval($this->mainId);
  287. $custom = $this->custom;
  288. if (empty($custom) && !empty($currenCustom)) {
  289. $custom = $currenCustom;
  290. }
  291. if (empty($custom)) {
  292. util::fail('请先登录');
  293. }
  294. $hd = $this->hd;
  295. $hdId = !empty($hd) ? intval($hd->id ?? 0) : 0;
  296. $hdName = !empty($hd) ? strval($hd->name ?? '') : '';
  297. $user = $this->user;
  298. $saleGoodsId = intval($row['goodsId']);
  299. $goodsInfo = GoodsClass::getById($saleGoodsId, true);
  300. if (empty($goodsInfo) || intval($goodsInfo->mainId) !== $mainId) {
  301. util::fail('没有找到商品');
  302. }
  303. $stock = intval($goodsInfo->stock ?? 0);
  304. if ($stock < $goodsNum) {
  305. util::fail('库存不足');
  306. }
  307. $unitPrice = floatval($row['price'] ?? 0);
  308. if ($unitPrice <= 0) {
  309. util::fail('团购价格异常');
  310. }
  311. $goodsPrice = bcmul($unitPrice, $goodsNum, 2);
  312. $sendType = intval($form->sendType ?? dict::getDict('sendType', 'shopGet'));
  313. // 商家配送(0)/跑腿(2)必须有收花人与坐标,与混合结算一致
  314. if (in_array($sendType, [dict::getDict('sendType', 'carGet'), dict::getDict('sendType', 'thirdSend')], true)) {
  315. if (empty($form->receiveUserName)) {
  316. util::fail('请填写收花人名字');
  317. }
  318. if (empty($form->receiveMobile) || !stringUtil::isMobile($form->receiveMobile)) {
  319. util::fail('请填写正确的收花人手机号');
  320. }
  321. if (empty($form->long) || empty($form->lat)) {
  322. util::fail('请先完善地址');
  323. }
  324. }
  325. // 商家配送(0)/跑腿(2)必填配送时间;自取与物流/快递不强制(与混合结算一致)
  326. if (in_array($sendType, [0, 2], true) && empty($form->reachPeriod)) {
  327. util::fail('请选择配送时间');
  328. }
  329. $customInfo = PsMethodClass::buildCustomInfo($hd, $custom);
  330. $additionalCosts = PsMethodClass::calcFeeForMallContext(
  331. $this->shopId,
  332. $mainId,
  333. $sendType,
  334. $goodsPrice,
  335. $goodsNum,
  336. intval($custom->id),
  337. $customInfo
  338. );
  339. $unMeetSendCost = $additionalCosts['sendCost'] ?? 0;
  340. $packingFee = $additionalCosts['packCost'] ?? 0;
  341. $psMethodError = PsMethodClass::checkLimit(
  342. $this->shopId,
  343. $mainId,
  344. $sendType,
  345. $goodsPrice,
  346. $goodsNum,
  347. $packingFee,
  348. $unMeetSendCost,
  349. intval($custom->id),
  350. $customInfo
  351. );
  352. if ($psMethodError !== '') {
  353. util::fail($psMethodError);
  354. }
  355. $freightType = intval($goodsInfo->freightType ?? 0);
  356. $resolvedProduct = [[
  357. 'productId' => $saleGoodsId,
  358. 'unitType' => 0,
  359. 'property' => 0,
  360. 'freightType' => $freightType,
  361. 'unitPrice' => $unitPrice,
  362. 'num' => $goodsNum,
  363. ]];
  364. $runnerSendCost = 0;
  365. $sendDistance = 0;
  366. $freightSource = '';
  367. // 跑腿距离运费与混合单同源;未达门槛附加运费仍由 calcFee 单独给出
  368. if ($sendType == dict::getDict('sendType', 'thirdSend')) {
  369. $mapSet = \biz\shop\classes\ShopClass::hasIntraCity($shop);
  370. $openIntraCity = $mapSet['openIntraCity'] ?? 0;
  371. $runnerPost = [
  372. 'receiveUserName' => strval($form->receiveUserName ?? ''),
  373. 'receiveMobile' => strval($form->receiveMobile ?? ''),
  374. 'address' => strval($form->address ?? ''),
  375. 'floor' => strval($form->floor ?? ''),
  376. 'city' => strval($form->city ?? ''),
  377. 'dist' => strval($form->dist ?? ''),
  378. 'long' => $form->long ?? '',
  379. 'lat' => $form->lat ?? '',
  380. 'deliveryPlatform' => strval($form->deliveryPlatform ?? ''),
  381. 'deliveryBracketContent' => strval($form->deliveryBracketContent ?? ''),
  382. 'remark' => strval($form->remark ?? ''),
  383. ];
  384. $runnerResult = PsMethodClass::calcMixRunnerFreight(
  385. $shop,
  386. $this->shopId,
  387. $mainId,
  388. $runnerPost,
  389. $resolvedProduct,
  390. 0,
  391. $goodsPrice,
  392. $openIntraCity,
  393. 0,
  394. $goodsNum,
  395. 'PT-GB-',
  396. '团购单'
  397. );
  398. $runnerSendCost = $runnerResult['sendCost'];
  399. $sendDistance = $runnerResult['sendDistance'];
  400. $freightSource = $runnerResult['freightSource'];
  401. }
  402. $sendCost = bcadd($unMeetSendCost, $runnerSendCost, 2);
  403. $modifyPrice = bcadd($goodsPrice, $sendCost, 2);
  404. $modifyPrice = bcadd($modifyPrice, $packingFee, 2);
  405. $post = [
  406. 'sendType' => $sendType,
  407. 'receiveUserName' => strval($form->receiveUserName ?? ''),
  408. 'receiveMobile' => strval($form->receiveMobile ?? ''),
  409. 'address' => strval($form->address ?? ''),
  410. 'floor' => strval($form->floor ?? ''),
  411. 'city' => strval($form->city ?? ''),
  412. 'dist' => strval($form->dist ?? ''),
  413. 'province' => strval($form->province ?? ''),
  414. 'long' => strval($form->long ?? ''),
  415. 'lat' => strval($form->lat ?? ''),
  416. 'showAddress' => strval($form->showAddress ?? ''),
  417. 'remark' => strval($form->remark ?? ''),
  418. 'reachDate' => !empty($form->reachDate) ? $form->reachDate : date('Y-m-d'),
  419. 'reachPeriod' => strval($form->reachPeriod ?? ''),
  420. 'sendCost' => $sendCost,
  421. 'packingFee' => $packingFee,
  422. 'sendDistance' => $sendDistance,
  423. 'freightSource' => $freightSource,
  424. 'runnerSendCost' => $runnerSendCost,
  425. 'needCard' => intval($form->needCard ?? 0),
  426. 'cardInfo' => intval($form->needCard ?? 0) === 1 ? strval($form->cardInfo ?? '') : '',
  427. 'anonymity' => intval($form->anonymity ?? 0),
  428. 'sjId' => intval($this->sjId ?: ($shop->sjId ?? 0)),
  429. 'shopId' => $this->shopId,
  430. 'mainId' => $mainId,
  431. 'userId' => $this->userId,
  432. 'customId' => intval($custom->id),
  433. 'customName' => strval($custom->name ?? ''),
  434. 'customNamePy' => stringUtil::py(strval($custom->name ?? '')),
  435. 'hdId' => $hdId,
  436. 'hdName' => $hdName,
  437. 'bookName' => strval($user->name ?? ''),
  438. 'bookMobile' => !empty($form->bookMobile) && stringUtil::isMobile($form->bookMobile)
  439. ? $form->bookMobile
  440. : strval($user->mobile ?? ''),
  441. 'payWay' => 0,
  442. 'store' => 0,
  443. 'modifyPrice' => $modifyPrice,
  444. 'deadline' => time() + 1800,
  445. 'orderSn' => orderSn::getOrderSn(),
  446. 'fromType' => dict::getDict('fromType', 'mall'),
  447. 'cash' => 0,
  448. 'needPrint' => dict::getDict('needPrint', 'need'),
  449. 'groupBuyId' => intval($groupBuyId),
  450. // 拼团价已是活动价:跳过会员折、门店满减、红包,避免再叠优惠
  451. 'skipMemberDiscount' => 1,
  452. 'skipShopMeetCut' => 1,
  453. 'hbId' => 0,
  454. 'hbAmount' => 0,
  455. 'product' => $resolvedProduct,
  456. ];
  457. return OrderService::createHdOrder($post, $custom, 0);
  458. }
  459. }