GiftCartSummary.php 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. <?php
  2. namespace Longyi\FreeGift\Services;
  3. use Longyi\FreeGift\ApiResource\GiftBannerData;
  4. use Longyi\FreeGift\ApiResource\GiftGroupData;
  5. use Longyi\FreeGift\ApiResource\GiftProductData;
  6. use Longyi\FreeGift\Config\Gift;
  7. use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
  8. use Longyi\FreeGift\Repositories\FreeGiftRuleRepository;
  9. use Webkul\Product\ProductImage;
  10. use Webkul\Product\Repositories\ProductRepository;
  11. /**
  12. * 购物车的赠品候选快照(规则名额 + 每个赠品是否已加购)。
  13. *
  14. * 只读 free_gift_items / cart_items 的现状,不触发重算 —— 调用前购物车
  15. * 应已经过 collectTotals()(readCart / 加购 / 更新数量内部都会跑)。
  16. *
  17. * 被两处复用:
  18. * - GraphQL 的 giftCandidates / giftProducts / giftBanners(GiftCartProvider)
  19. * - 购物车接口本身的 CartData::freeGifts(直接内嵌,省一次请求)
  20. */
  21. class GiftCartSummary
  22. {
  23. public function __construct(
  24. protected FreeGiftItemRepository $giftItemRepository,
  25. protected FreeGiftRuleRepository $giftRuleRepository,
  26. protected ProductRepository $productRepository,
  27. protected ProductImage $productImage,
  28. protected GiftProductResolver $productResolver
  29. ) {}
  30. /**
  31. * @return array{0: GiftGroupData[], 1: GiftProductData[], 2: GiftBannerData[]}
  32. */
  33. public function forCart(int $cartId): array
  34. {
  35. [$groups, $gifts] = $this->buildGroups($cartId);
  36. return [$groups, $gifts, $this->buildBanners($groups)];
  37. }
  38. /**
  39. * 只取赠品候选列表(购物车接口内嵌用)。
  40. *
  41. * @return GiftProductData[]
  42. */
  43. public function gifts(int $cartId): array
  44. {
  45. return $this->buildGroups($cartId)[1];
  46. }
  47. /**
  48. * 按规则聚合:groups 只放名额信息,gifts 是全部候选(带 rule_id / cart_item_id)。
  49. *
  50. * @return array{0: GiftGroupData[], 1: GiftProductData[]}
  51. */
  52. protected function buildGroups(int $cartId): array
  53. {
  54. $groups = [];
  55. $gifts = [];
  56. $allowedByRule = [];
  57. $reservedByRule = [];
  58. $giftsByRule = [];
  59. foreach ($this->giftItemRepository->getByCart($cartId) as $row) {
  60. if ($row->allowed_qty < 1) {
  61. continue;
  62. }
  63. $giftRule = $this->giftRuleRepository->findByCartRuleId($row->rule_id);
  64. if (! $giftRule) {
  65. continue;
  66. }
  67. $resolved = $this->resolveRowProduct($row);
  68. if (! $resolved) {
  69. continue;
  70. }
  71. $product = $resolved['product'];
  72. $variant = $resolved['variant'];
  73. $ruleId = (int) $row->rule_id;
  74. $ruleName = $giftRule->cart_rule?->name;
  75. if (! isset($groups[$ruleId])) {
  76. $group = new GiftGroupData;
  77. $group->id = (string) $ruleId;
  78. $group->rule_id = $ruleId;
  79. $group->rule_name = $ruleName;
  80. $group->type = (int) $row->rule_type;
  81. $group->auto_add = (bool) $row->auto_add;
  82. $group->allowed_qty = 0;
  83. $group->reserved_qty = 0;
  84. $group->remaining_qty = 0;
  85. $groups[$ruleId] = $group;
  86. $allowedByRule[$ruleId] = [];
  87. $reservedByRule[$ruleId] = 0.0;
  88. }
  89. $allowedByRule[$ruleId][] = (float) $row->allowed_qty;
  90. $reservedByRule[$ruleId] += (float) $row->reserved_qty;
  91. $image = $this->productImage->getProductBaseImage($product);
  92. $gift = new GiftProductData;
  93. $gift->id = $ruleId.':'.$row->sku;
  94. $gift->sku = $row->sku;
  95. $gift->rule_id = $ruleId;
  96. $gift->rule_name = $ruleName;
  97. $gift->product_id = (int) $product->id;
  98. $gift->name = $variant->name ?? $product->name;
  99. $gift->url = route('shop.product_or_category.index', $product->url_key);
  100. $gift->image = $image['medium_image_url'] ?? null;
  101. $gift->price = core()->formatPrice(
  102. $variant ? $this->productResolver->variantPrice($variant) : (float) $product->price
  103. );
  104. $gift->allowed_qty = (float) $row->allowed_qty;
  105. $gift->reserved_qty = (float) $row->reserved_qty;
  106. // 「一组合计送 N 个」的规则下面会按整组名额修正,这里先放逐行差值
  107. $gift->remaining_qty = max(0, (float) $row->allowed_qty - (float) $row->reserved_qty);
  108. $gift->cart_item_id = $row->cart_item_id ? (int) $row->cart_item_id : null;
  109. $gift->is_deleted = (bool) $row->is_item_deleted;
  110. $gifts[] = $gift;
  111. $giftsByRule[$ruleId][] = $gift;
  112. }
  113. foreach ($groups as $ruleId => $group) {
  114. $allowed = $allowedByRule[$ruleId] ?: [0.0];
  115. // 「一组合计送 N 个」:整组共用 N 个名额,N 取单条的允许量而不是求和
  116. $group->allowed_qty = $group->type === Gift::TYPE_ONE
  117. ? (float) max($allowed)
  118. : (float) array_sum($allowed);
  119. $group->reserved_qty = (float) ($reservedByRule[$ruleId] ?? 0);
  120. $group->remaining_qty = max(0, $group->allowed_qty - $group->reserved_qty);
  121. /*
  122. * 「一组合计送 N 个」:名额是整组共享的,逐候选的 remaining_qty 也必须
  123. * 按组修正。否则名额已经被某个 SKU 用掉的组里,其余候选还会各自显示
  124. * "可领 N 件",接口就变成「已送 1 件 + 还能领 1 件」,
  125. * 前端展示出来就像送了 2 件。
  126. *
  127. * 已加购 / 被用户删掉的候选一律 0;剩下的名额按顺序分配给未加购的候选。
  128. */
  129. if ($group->type === Gift::TYPE_ONE) {
  130. $remaining = $group->remaining_qty;
  131. foreach ($giftsByRule[$ruleId] ?? [] as $gift) {
  132. if ($gift->reserved_qty > 0 || $gift->cart_item_id || $gift->is_deleted) {
  133. $gift->remaining_qty = 0;
  134. continue;
  135. }
  136. $take = min((float) $gift->allowed_qty, $remaining);
  137. $gift->remaining_qty = max(0, $take);
  138. $remaining -= $take;
  139. }
  140. }
  141. }
  142. return [array_values($groups), $gifts];
  143. }
  144. /**
  145. * 把 free_gift_items 的一行解析成「商品 + 变体」。
  146. *
  147. * 优先按 SKU 解析(flexible_variant 能拿到具体变体与变体价),
  148. * 解析不到再退回 product_id(历史数据 / 规则里 SKU 写错的情况)。
  149. *
  150. * @return array{product: object, variant: ?\Longyi\Core\Models\ProductVariant}|null
  151. */
  152. protected function resolveRowProduct($row): ?array
  153. {
  154. $resolved = $this->productResolver->resolve((string) $row->sku);
  155. if ($resolved) {
  156. return $resolved;
  157. }
  158. $product = $row->product_id ? $this->productRepository->find((int) $row->product_id) : null;
  159. return $product ? ['product' => $product, 'variant' => null] : null;
  160. }
  161. /**
  162. * 需要展示 Banner 的规则(受总开关 + 规则自身 show_banner 控制)。
  163. *
  164. * @param GiftGroupData[] $groups
  165. * @return GiftBannerData[]
  166. */
  167. protected function buildBanners(array $groups): array
  168. {
  169. $banners = [];
  170. if (! core()->getConfigData('sales.free_gift.general.show_banner')) {
  171. return $banners;
  172. }
  173. foreach ($groups as $group) {
  174. $giftRule = $this->giftRuleRepository->findByCartRuleId($group->rule_id);
  175. if (! $giftRule || ! $giftRule->show_banner) {
  176. continue;
  177. }
  178. $banner = new GiftBannerData;
  179. $banner->id = (string) $group->rule_id;
  180. $banner->rule_id = $group->rule_id;
  181. $banner->text = $giftRule->banner_alt ?: trans('free_gift::app.cart.banner-default');
  182. $banner->image = $giftRule->bannerImageUrl();
  183. $banner->allowed_qty = $group->allowed_qty;
  184. $banner->remaining_qty = $group->remaining_qty;
  185. $banners[] = $banner;
  186. }
  187. return $banners;
  188. }
  189. }