PluginProbe
Tiered Pricing Table for WooCommerce / 8.1.0
Tiered Pricing Table for WooCommerce v8.1.0
8.1.0 8.0.2 7.1.7 7.1.5 7.1.4 7.1.1 6.5.0 6.4.0 6.1.0 trunk 1.0 1.1 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.2.0 2.2.1 2.2.2 2.2.3 2.3.0 2.3.1 2.3.2 All 92 releases
tier-pricing-table / src / Addons / GlobalTieredPricing / RuleOverlaps.php

RuleOverlaps.php in Tiered Pricing Table for WooCommerce 8.1.0, at src/Addons/GlobalTieredPricing/RuleOverlaps.php

297 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php namespace TierPricingTable\Addons\GlobalTieredPricing;
2
3 /**
4 * Finds global pricing rules that can meet on the same product for the same customer at the same time,
5 * so the admin learns which rule wins instead of finding out on the storefront.
6 *
7 * Scopes are compared the way the matcher reads them: a rule without product limits covers every product
8 * except its exclusions, a rule without customer limits covers every customer (visitors included) except
9 * its exclusions. Listed products are resolved to their terms and listed customers to their roles through
10 * the callbacks, so the check stays testable.
11 */
12 class RuleOverlaps {
13
14 /**
15 * @var callable(int): array{categories: int[], tags: int[], brands: int[]}
16 */
17 protected $productTerms;
18
19 /**
20 * @var callable(int): string[]
21 */
22 protected $userRoles;
23
24 /**
25 * @var int
26 */
27 protected $now;
28
29 public function __construct( ?callable $productTerms = null, ?callable $userRoles = null, ?int $now = null ) {
30 $this->productTerms = $productTerms ? $productTerms : array( $this, 'readProductTerms' );
31 $this->userRoles = $userRoles ? $userRoles : array( $this, 'readUserRoles' );
32 $this->now = null === $now ? time() : $now;
33 }
34
35 /**
36 * Rules among $rules that overlap with $rule, in the order given (the rule itself is skipped).
37 *
38 * @param GlobalPricingRule[] $rules
39 *
40 * @return GlobalPricingRule[]
41 */
42 public function findOverlapping( GlobalPricingRule $rule, array $rules ): array {
43 $overlapping = array();
44
45 foreach ( $rules as $other ) {
46 if ( (int) $other->id === (int) $rule->id ) {
47 continue;
48 }
49
50 if ( $this->overlap( $rule, $other ) ) {
51 $overlapping[] = $other;
52 }
53 }
54
55 return $overlapping;
56 }
57
58 /**
59 * The overlapping rules with a higher priority than $rule, so $rule is skipped for the products and
60 * customers it shares with them.
61 *
62 * @param GlobalPricingRule[] $rules
63 *
64 * @return GlobalPricingRule[]
65 */
66 public function findOverriding( GlobalPricingRule $rule, array $rules ): array {
67 return array_values( array_filter( $this->findOverlapping( $rule, $rules ), function ( GlobalPricingRule $other ) use ( $rule ) {
68 return $other->outranks( $rule );
69 } ) );
70 }
71
72 public function overlap( GlobalPricingRule $a, GlobalPricingRule $b ): bool {
73 return self::schedulesIntersect( $a, $b, $this->now ) && $this->customersIntersect( $a, $b ) && $this->productsIntersect( $a, $b );
74 }
75
76 /**
77 * Whether the two date windows still share a moment from now on (open ends count as unbounded).
78 * Windows that only met in the past, an ended campaign for instance, are no overlap any more.
79 */
80 public static function schedulesIntersect( GlobalPricingRule $a, GlobalPricingRule $b, ?int $now = null ): bool {
81 $now = null === $now ? time() : $now;
82 $start = max( $a->getStartTimestamp() ?? PHP_INT_MIN, $b->getStartTimestamp() ?? PHP_INT_MIN );
83 $end = min( $a->getEndTimestamp() ?? PHP_INT_MAX, $b->getEndTimestamp() ?? PHP_INT_MAX );
84
85 return $start <= $end && $end >= $now;
86 }
87
88 protected function productsIntersect( GlobalPricingRule $a, GlobalPricingRule $b ): bool {
89 $aLimited = $this->hasProductLimits( $a );
90 $bLimited = $this->hasProductLimits( $b );
91
92 if ( ! $aLimited && ! $bLimited ) {
93 return true;
94 }
95
96 if ( ! $aLimited ) {
97 return ! $this->productScopeExcludedBy( $b, $a );
98 }
99
100 if ( ! $bLimited ) {
101 return ! $this->productScopeExcludedBy( $a, $b );
102 }
103
104 return $this->productScopesShare( $a, $b );
105 }
106
107 protected function hasProductLimits( GlobalPricingRule $rule ): bool {
108 return ! empty( $rule->getIncludedProducts() ) || ! empty( $rule->getEffectiveIncludedCategories() )
109 || ! empty( $rule->getIncludedProductTags() ) || ! empty( $rule->getIncludedProductBrands() );
110 }
111
112 /**
113 * Whether everything the limited rule includes is excluded by the open (store-wide) rule.
114 */
115 protected function productScopeExcludedBy( GlobalPricingRule $limited, GlobalPricingRule $open ): bool {
116 $dimensions = array(
117 array( $limited->getEffectiveIncludedCategories(), $open->getEffectiveExcludedCategories() ),
118 array( $limited->getIncludedProductTags(), $open->getExcludedProductTags() ),
119 array( $limited->getIncludedProductBrands(), $open->getExcludedProductBrands() ),
120 );
121
122 foreach ( $dimensions as list( $included, $excluded ) ) {
123 if ( array_diff( self::ints( $included ), self::ints( $excluded ) ) ) {
124 return false;
125 }
126 }
127
128 foreach ( self::ints( $limited->getIncludedProducts() ) as $productId ) {
129 if ( ! $this->productExcludedBy( $productId, $open ) ) {
130 return false;
131 }
132 }
133
134 return true;
135 }
136
137 /**
138 * Two limited rules meet when they share a product, category, tag or brand, or when a product listed
139 * in one falls under the other's categories, tags or brands.
140 */
141 protected function productScopesShare( GlobalPricingRule $a, GlobalPricingRule $b ): bool {
142 if ( array_intersect( self::ints( $a->getIncludedProducts() ), self::ints( $b->getIncludedProducts() ) )
143 || array_intersect( self::ints( $a->getEffectiveIncludedCategories() ), self::ints( $b->getEffectiveIncludedCategories() ) )
144 || array_intersect( self::ints( $a->getIncludedProductTags() ), self::ints( $b->getIncludedProductTags() ) )
145 || array_intersect( self::ints( $a->getIncludedProductBrands() ), self::ints( $b->getIncludedProductBrands() ) ) ) {
146 return true;
147 }
148
149 foreach ( array( array( $a, $b ), array( $b, $a ) ) as list( $listing, $other ) ) {
150 foreach ( self::ints( $listing->getIncludedProducts() ) as $productId ) {
151 if ( $this->productExcludedBy( $productId, $other ) ) {
152 continue;
153 }
154
155 $terms = $this->terms( $productId );
156
157 if ( array_intersect( $terms['categories'], self::ints( $other->getEffectiveIncludedCategories() ) )
158 || array_intersect( $terms['tags'], self::ints( $other->getIncludedProductTags() ) )
159 || array_intersect( $terms['brands'], self::ints( $other->getIncludedProductBrands() ) ) ) {
160 return true;
161 }
162 }
163 }
164
165 return false;
166 }
167
168 /**
169 * Whether a product never matches a rule because of the rule's exclusions.
170 */
171 protected function productExcludedBy( int $productId, GlobalPricingRule $rule ): bool {
172 if ( in_array( $productId, self::ints( $rule->getExcludedProducts() ), true ) ) {
173 return true;
174 }
175
176 $terms = $this->terms( $productId );
177
178 return (bool) ( array_intersect( $terms['categories'], self::ints( $rule->getEffectiveExcludedCategories() ) )
179 || array_intersect( $terms['tags'], self::ints( $rule->getExcludedProductTags() ) )
180 || array_intersect( $terms['brands'], self::ints( $rule->getExcludedProductBrands() ) ) );
181 }
182
183 protected function customersIntersect( GlobalPricingRule $a, GlobalPricingRule $b ): bool {
184 $aLimited = $this->hasCustomerLimits( $a );
185 $bLimited = $this->hasCustomerLimits( $b );
186
187 if ( ! $aLimited && ! $bLimited ) {
188 return true;
189 }
190
191 if ( ! $aLimited ) {
192 return ! $this->customerScopeExcludedBy( $b, $a );
193 }
194
195 if ( ! $bLimited ) {
196 return ! $this->customerScopeExcludedBy( $a, $b );
197 }
198
199 if ( array_intersect( $a->getIncludedUserRoles(), $b->getIncludedUserRoles() )
200 || array_intersect( self::ints( $a->getIncludedUsers() ), self::ints( $b->getIncludedUsers() ) ) ) {
201 return true;
202 }
203
204 // a customer listed in one rule who has a role the other rule targets
205 foreach ( array( array( $a, $b ), array( $b, $a ) ) as list( $listing, $other ) ) {
206 foreach ( self::ints( $listing->getIncludedUsers() ) as $userId ) {
207 if ( $this->userExcludedBy( $userId, $other ) ) {
208 continue;
209 }
210
211 if ( array_intersect( $this->roles( $userId ), $other->getIncludedUserRoles() ) ) {
212 return true;
213 }
214 }
215 }
216
217 return false;
218 }
219
220 protected function hasCustomerLimits( GlobalPricingRule $rule ): bool {
221 return ! empty( $rule->getIncludedUserRoles() ) || ! empty( $rule->getIncludedUsers() );
222 }
223
224 /**
225 * Whether every customer the limited rule targets is excluded by the open (every-customer) rule.
226 */
227 protected function customerScopeExcludedBy( GlobalPricingRule $limited, GlobalPricingRule $open ): bool {
228 if ( array_diff( $limited->getIncludedUserRoles(), $open->getExcludedUserRoles() ) ) {
229 return false;
230 }
231
232 foreach ( self::ints( $limited->getIncludedUsers() ) as $userId ) {
233 if ( ! $this->userExcludedBy( $userId, $open ) ) {
234 return false;
235 }
236 }
237
238 return true;
239 }
240
241 protected function userExcludedBy( int $userId, GlobalPricingRule $rule ): bool {
242 if ( in_array( $userId, self::ints( $rule->getExcludedUsers() ), true ) ) {
243 return true;
244 }
245
246 return (bool) array_intersect( $this->roles( $userId ), $rule->getExcludedUserRoles() );
247 }
248
249 protected function terms( int $productId ): array {
250 $terms = (array) call_user_func( $this->productTerms, $productId );
251
252 return array(
253 'categories' => self::ints( $terms['categories'] ?? array() ),
254 'tags' => self::ints( $terms['tags'] ?? array() ),
255 'brands' => self::ints( $terms['brands'] ?? array() ),
256 );
257 }
258
259 protected function roles( int $userId ): array {
260 return array_values( array_map( 'strval', (array) call_user_func( $this->userRoles, $userId ) ) );
261 }
262
263 protected static function ints( array $values ): array {
264 return array_values( array_filter( array_map( 'intval', $values ) ) );
265 }
266
267 /**
268 * Default product resolver: the parent's terms for a variation, the product's own otherwise.
269 */
270 public function readProductTerms( int $productId ): array {
271 static $cache = array();
272
273 if ( ! isset( $cache[ $productId ] ) ) {
274 $product = wc_get_product( $productId );
275 $parent = $product && $product->get_parent_id() ? wc_get_product( $product->get_parent_id() ) : $product;
276 $brands = $parent ? wp_get_post_terms( $parent->get_id(), 'product_brand', array( 'fields' => 'ids' ) ) : array();
277
278 $cache[ $productId ] = array(
279 'categories' => $parent ? (array) $parent->get_category_ids() : array(),
280 'tags' => $parent ? (array) $parent->get_tag_ids() : array(),
281 'brands' => is_wp_error( $brands ) ? array() : (array) $brands,
282 );
283 }
284
285 return $cache[ $productId ];
286 }
287
288 /**
289 * Default customer resolver.
290 */
291 public function readUserRoles( int $userId ): array {
292 $user = get_userdata( $userId );
293
294 return $user ? (array) $user->roles : array();
295 }
296 }
297