PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.14
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.14
1.4.14 1.4.13 1.4.12 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.54 1.3.53 1.3.52 1.3.51 1.3.50 1.3.49 1.3.48 1.3.47 1.3.46 1.3.45 All 177 releases
disco / app / Utility / QuantityCounter.php

QuantityCounter.php in Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO 1.4.14, at app/Utility/QuantityCounter.php

247 lines 8.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package Disco
4 * @subpackage \App\Utility
5 */
6
7 namespace Disco\App\Utility;
8
9 /**
10 * Quantity Counter.
11 *
12 * Shared "Count Quantity As" eligibility engine used by both the Bundle
13 * discount rule and the BOGO rule. Given the cart, a campaign and a single
14 * discount rule, it decides which cart units qualify for that rule and how
15 * many units within each line item are eligible.
16 *
17 * The three counting modes (campaign level, {@see Config::get_count_quantity_as()}):
18 *
19 * - `separate` Each cart line item is evaluated on its own quantity.
20 * - `combined` All applicable line items are pooled into one total.
21 * - `variations` Line items are pooled per parent product (all variations of
22 * the same variable product count together); different parent
23 * products are never pooled with each other.
24 *
25 * Eligible unit count once the pool reaches `min` (see {@see self::eligible_total()}):
26 * - pool below `min` → not eligible, 0 units.
27 * - explicit `min`..`max` range → eligible pool capped at `max`; the
28 * excess gets no discount and is NOT
29 * re-evaluated as a second bundle.
30 * - recursive rule → whole bundles of `min` units.
31 * - Bundle / BOGO, no `max` → a single bundle of `min` units
32 * (Bundle has no "max product": min 2
33 * with 3 in cart discounts 2, 1 left).
34 * - Bulk, no `max` → every qualifying unit above `min`.
35 *
36 * When a pool is capped, units are handed out in cart line-item order (the
37 * order WooCommerce returns from {@see \WC_Cart::get_cart()}), filling each
38 * line up to its own quantity until the cap is consumed. This mirrors how the
39 * rest of Disco iterates the cart.
40 *
41 * @package Disco
42 * @subpackage Disco\App\Utility
43 * @category Intention
44 */
45 class QuantityCounter {
46
47 /**
48 * Resolve which cart units qualify for a single discount rule.
49 *
50 * @param \WC_Cart $cart Cart object.
51 * @param \Disco\App\Utility\Config $campaign Campaign config (mode + applicability).
52 * @param array $rule Single discount rule; reads `min` and optional `max`.
53 * @return array<int|string, array{item: array, eligible_qty: int, line_qty: int, group: string}>
54 * Keyed by cart item key. Only line items with at least one eligible unit are returned.
55 */
56 public function get_eligible_units( \WC_Cart $cart, Config $campaign, array $rule ): array {//phpcs:ignore
57 $count_quantity_as = $campaign->get_count_quantity_as();
58 $discount_intent = $campaign->get_discount_intent();
59 $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
60 $maximum_quantity = ! empty( $rule['max'] ) ? absint( $rule['max'] ) : 0; // phpcs:ignore -- 0 == unbounded
61 $is_recursive = ! empty( $rule['recursive'] ) && 'yes' === $rule['recursive']; // phpcs:ignore
62
63 $applicable_lines = $this->collect_applicable_lines( $cart, $campaign );
64
65 if ( empty( $applicable_lines ) ) {
66 return array();
67 }
68
69 $line_groups = $this->group_lines( $applicable_lines, $count_quantity_as );
70 $eligible_units = array();
71
72 foreach ( $line_groups as $group_key => $group_lines ) {
73 $pool_quantity = 0;
74
75 foreach ( $group_lines as $line ) {
76 $pool_quantity += $line['quantity'];
77 }
78
79 // Below the minimum threshold: nothing in this pool qualifies.
80 if ( $minimum_quantity > 0 && $pool_quantity < $minimum_quantity ) {
81 continue;
82 }
83
84 $eligible_quantity = $this->eligible_total( $pool_quantity, $minimum_quantity, $maximum_quantity, $is_recursive, $discount_intent );
85
86 if ( $eligible_quantity <= 0 ) {
87 continue;
88 }
89
90 // Hand out eligible units in cart line-item order up to the cap.
91 $remaining_quantity = $eligible_quantity;
92
93 foreach ( $group_lines as $line ) {
94 if ( $remaining_quantity <= 0 ) {
95 break;
96 }
97
98 $line_eligible_quantity = (int) min( $line['quantity'], $remaining_quantity );
99 $remaining_quantity -= $line_eligible_quantity;
100
101 $eligible_units[ $line['key'] ] = array(
102 'item' => $line['item'],
103 'eligible_qty' => $line_eligible_quantity,
104 'line_qty' => (int) $line['quantity'],
105 'group' => (string) $group_key,
106 );
107 }
108 }
109
110 return $eligible_units;
111 }
112
113 /**
114 * Decide how many units of a qualifying pool are eligible for discount.
115 *
116 * Mirrors the per-line semantics the Calc layer already applies in
117 * `separate` mode, so pooled counting behaves identically once the
118 * threshold is reached:
119 *
120 * - Recursive rule whole bundles of `min` (e.g. min 2, pool 6 → 6).
121 * - Explicit `max` range eligible pool capped at `max` (spec range case).
122 * - Bulk (no max) every qualifying unit above the threshold.
123 * - Bundle / BOGO (no max) a single bundle of `min` units; the rest of the
124 * pool gets no discount (Bundle has no "max product":
125 * min 2 with 3 in cart discounts 2, 1 excluded).
126 *
127 * @param int $pool_quantity Pooled quantity for the group.
128 * @param int $minimum_quantity Rule minimum.
129 * @param int $maximum_quantity Rule maximum (0 == unbounded).
130 * @param bool $is_recursive Whether the rule repeats per bundle.
131 * @param string $discount_intent Campaign discount intent.
132 */
133 private function eligible_total(
134 int $pool_quantity,
135 int $minimum_quantity,
136 int $maximum_quantity,
137 bool $is_recursive,
138 string $discount_intent
139 ): int {
140 if ( $is_recursive && $minimum_quantity > 0 ) {
141 return (int) ( floor( $pool_quantity / $minimum_quantity ) * $minimum_quantity );
142 }
143
144 if ( $maximum_quantity > 0 ) {
145 return (int) min( $pool_quantity, $maximum_quantity );
146 }
147
148 if ( 'Bulk' === $discount_intent ) {
149 return $pool_quantity;
150 }
151
152 if ( $minimum_quantity > 0 ) {
153 return (int) min( $pool_quantity, $minimum_quantity );
154 }
155
156 return $pool_quantity;
157 }
158
159 /**
160 * Collect the cart line items this campaign applies to, in cart order.
161 *
162 * Mirrors the applicability filtering used elsewhere in the engine: Disco
163 * free items are skipped, and each line must pass both the product
164 * applicability check and the campaign condition filters.
165 *
166 * @param \WC_Cart $cart Cart object.
167 * @param \Disco\App\Utility\Config $campaign Campaign config.
168 * @return array<int, array{key: string, quantity: int, product_id: int, parent_id: int, item: array}>
169 */
170 private function collect_applicable_lines( \WC_Cart $cart, Config $campaign ): array {
171 $applicable_lines = array();
172 $cart_items = $cart->get_cart();
173
174 if ( ! is_array( $cart_items ) ) {
175 return $applicable_lines;
176 }
177
178 foreach ( $cart_items as $key => $item ) {
179 if ( ! empty( $item['is_free_product'] ) ) {
180 continue;
181 }
182
183 $effective_product_id = (int) $item['product_id'];
184
185 if ( ! empty( $item['variation_id'] ) ) {
186 $effective_product_id = (int) $item['variation_id'];
187 }
188
189 if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
190 continue;
191 }
192
193 $product = wc_get_product( $effective_product_id );
194
195 if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
196 continue;
197 }
198
199 $applicable_lines[] = array(
200 'key' => (string) $key,
201 'quantity' => (int) $item['quantity'],
202 'product_id' => (int) $item['product_id'],
203 'parent_id' => (int) $item['product_id'],
204 'item' => $item,
205 );
206 }
207
208 return $applicable_lines;
209 }
210
211 /**
212 * Group applicable lines according to the counting mode.
213 *
214 * @param array $applicable_lines Applicable lines.
215 * @param string $count_quantity_as Counting mode.
216 */
217 private function group_lines( array $applicable_lines, string $count_quantity_as ): array {
218 $line_groups = array();
219
220 foreach ( $applicable_lines as $applicable_line ) {
221 switch ( $count_quantity_as ) {
222 case 'combined':
223 // Every applicable line shares one pool.
224 $group_key = 'all';
225 break; // phpcs:ignore
226
227 case 'variations':
228 // Pool per parent product; the cart item's product_id is the
229 // parent for variations and the product itself for simple ones.
230 $group_key = 'parent_' . $applicable_line['parent_id'];
231 break; // phpcs:ignore
232
233 case 'separate':
234 default:
235 // Each line is its own pool.
236 $group_key = 'line_' . $applicable_line['key'];
237 break; // phpcs:ignore
238 }
239
240 $line_groups[ $group_key ][] = $applicable_line;
241 }
242
243 return $line_groups;
244 }
245
246 }
247