← All changes
|
src/Addons/GlobalTieredPricing/GlobalPricingRule.php
+430
-90
6.4.0
→
8.1.0
View file →
| @@ -3,8 +3,10 @@ | ||
| 3 | 3 | namespace TierPricingTable\Addons\GlobalTieredPricing; |
| 4 | 4 | |
| 5 | 5 | use TierPricingTable\Addons\GlobalTieredPricing\PricingRule\RuleSettings; |
| 6 | 6 | use TierPricingTable\Forms\Form; |
| 7 | +use DateTime; | |
| 8 | +use DateTimeZone; | |
| 7 | 9 | use Exception; |
| 8 | 10 | use WC_Product; |
| 9 | 11 | use WP_User; |
| 10 | 12 | class GlobalPricingRule { |
| @@ -113,8 +115,15 @@ | ||
| 113 | 115 | */ |
| 114 | 116 | public $excludedProductCategories = array(); |
| 115 | 117 | |
| 116 | 118 | /** |
| 119 | + * Whether the included and excluded categories cover their subcategories too. | |
| 120 | + * | |
| 121 | + * @var bool | |
| 122 | + */ | |
| 123 | + public $includeSubcategories = false; | |
| 124 | + | |
| 125 | + /** | |
| 117 | 126 | * Included tags |
| 118 | 127 | * |
| 119 | 128 | * @var array |
| 120 | 129 | */ |
| @@ -185,15 +194,72 @@ | ||
| 185 | 194 | |
| 186 | 195 | /** |
| 187 | 196 | * Product minimum purchase quantity |
| 188 | 197 | * |
| 189 | - * @var int | |
| 198 | + * @var int|null | |
| 190 | 199 | */ |
| 191 | 200 | public $minimum; |
| 192 | 201 | |
| 202 | + /** | |
| 203 | + * Mix and match minimum quantity | |
| 204 | + * | |
| 205 | + * @var bool|null | |
| 206 | + */ | |
| 207 | + public $mixAndMatchMinQuantity = null; | |
| 208 | + | |
| 193 | 209 | public $priorityOptions; |
| 194 | 210 | |
| 211 | + const DEFAULT_PRIORITY = 10; | |
| 212 | + | |
| 195 | 213 | /** |
| 214 | + * The pseudo role of a visitor who is not logged in, selectable in the role lists. The same key the | |
| 215 | + * wholesale registration add-on uses (NonLoggedInUsers\Roles::GUEST). | |
| 216 | + */ | |
| 217 | + const GUEST_ROLE = 'guest'; | |
| 218 | + | |
| 219 | + /** | |
| 220 | + * The roles the matcher reads for a user: a visitor who is not logged in has the guest pseudo role. | |
| 221 | + */ | |
| 222 | + public static function rolesOf( WP_User $user ) : array { | |
| 223 | + return ( $user->ID ? (array) $user->roles : array(self::GUEST_ROLE) ); | |
| 224 | + } | |
| 225 | + | |
| 226 | + /** | |
| 227 | + * Whether a role key may be stored in the rule's role lists: an existing WordPress role or the guest pseudo role. | |
| 228 | + */ | |
| 229 | + public static function isSelectableRole( $role ) : bool { | |
| 230 | + return self::GUEST_ROLE === $role || function_exists( 'wp_roles' ) && array_key_exists( $role, wp_roles()->roles ); | |
| 231 | + } | |
| 232 | + | |
| 233 | + /** | |
| 234 | + * Order among global rules: the lowest number is applied first; equal numbers, the newest rule first. | |
| 235 | + * | |
| 236 | + * @var int | |
| 237 | + */ | |
| 238 | + public $priority = self::DEFAULT_PRIORITY; | |
| 239 | + | |
| 240 | + /** | |
| 241 | + * First day the rule applies (Y-m-d in the site's timezone, inclusive), null = no start. | |
| 242 | + * | |
| 243 | + * @var ?string | |
| 244 | + */ | |
| 245 | + public $startDate = null; | |
| 246 | + | |
| 247 | + /** | |
| 248 | + * Last day the rule applies (Y-m-d in the site's timezone, inclusive), null = no end. | |
| 249 | + * | |
| 250 | + * @var ?string | |
| 251 | + */ | |
| 252 | + public $endDate = null; | |
| 253 | + | |
| 254 | + /** | |
| 255 | + * post_date_gmt of the rule: orders rules with the same priority (newest first). | |
| 256 | + * | |
| 257 | + * @var string | |
| 258 | + */ | |
| 259 | + public $createdAt = ''; | |
| 260 | + | |
| 261 | + /** | |
| 196 | 262 | * Array with custom data from 3rd-party addons |
| 197 | 263 | * |
| 198 | 264 | * @var array |
| 199 | 265 | */ |
| @@ -319,8 +385,10 @@ | ||
| 319 | 385 | $salePrice = $data['sale_price'] ?? null; |
| 320 | 386 | $discount = $data['discount'] ?? null; |
| 321 | 387 | $discountType = $data['discount_type'] ?? 'sale_price'; |
| 322 | 388 | $minimum = $data['minimum'] ?? null; |
| 389 | + $mixAndMatchValue = $data['mix_and_match_minimum'] ?? ''; | |
| 390 | + $mixAndMatch = ( $mixAndMatchValue === '' ? null : $mixAndMatchValue === 'yes' ); | |
| 323 | 391 | $taxStatus = $data['tax_status'] ?? ''; |
| 324 | 392 | $taxClass = $data['tax_class'] ?? ''; |
| 325 | 393 | $self = new self(); |
| 326 | 394 | $self->setTaxStatus( $taxStatus ); |
| @@ -334,8 +402,12 @@ | ||
| 334 | 402 | $self->setTieredPricingType( $tieredPricingType ); |
| 335 | 403 | $self->setPercentageTieredPricingRules( $percentageRules ); |
| 336 | 404 | $self->setFixedTieredPricingRules( $fixedRules ); |
| 337 | 405 | $self->setMinimum( ( Form::isEmpty( $minimum ) ? null : (int) $minimum ) ); |
| 406 | + $self->setMixAndMatchMinQuantity( $mixAndMatch ); | |
| 407 | + $self->setPriority( $data['priority'] ?? self::DEFAULT_PRIORITY ); | |
| 408 | + $self->setStartDate( $data['start_date'] ?? null ); | |
| 409 | + $self->setEndDate( $data['end_date'] ?? null ); | |
| 338 | 410 | return $self; |
| 339 | 411 | } |
| 340 | 412 | |
| 341 | 413 | /** |
| @@ -343,13 +415,19 @@ | ||
| 343 | 415 | * |
| 344 | 416 | * @throws Exception |
| 345 | 417 | */ |
| 346 | 418 | public function validatePricing() { |
| 347 | - $valid = !Form::isEmpty( $this->getRegularPrice() ); | |
| 348 | - $valid = $valid || !Form::isEmpty( $this->getSalePrice() ); | |
| 419 | + // only the fields of the chosen pricing type reach the storefront: a discount left over from | |
| 420 | + // "percentage" changes nothing in "flat" mode, and the other way round | |
| 421 | + if ( 'percentage' === $this->getPricingType() ) { | |
| 422 | + $valid = (float) $this->getDiscount() > 0; | |
| 423 | + } else { | |
| 424 | + $valid = !Form::isEmpty( $this->getRegularPrice() ) || !Form::isEmpty( $this->getSalePrice() ); | |
| 425 | + } | |
| 349 | 426 | $valid = $valid || !empty( $this->getTieredPricingRules() ); |
| 350 | 427 | $valid = $valid || !Form::isEmpty( $this->getMinimum() ); |
| 351 | - $valid = $valid || !Form::isEmpty( $this->getDiscount() ); | |
| 428 | + // a mix & match choice alone changes how product-level minimums count across variations | |
| 429 | + $valid = $valid || !is_null( $this->getMixAndMatchMinQuantity() ); | |
| 352 | 430 | $valid = $valid || $this->getSettings()->getPriorityType() === 'flexible'; |
| 353 | 431 | $valid = apply_filters( 'tiered_pricing_table/global_pricing/validation', $valid, $this ); |
| 354 | 432 | if ( !$valid ) { |
| 355 | 433 | throw new Exception(esc_html__( 'The pricing rule does not affect either prices or product quantity. The rule will be skipped.', 'tier-pricing-table' )); |
| @@ -372,8 +450,16 @@ | ||
| 372 | 450 | public function setMinimum( ?int $minimum ) { |
| 373 | 451 | $this->minimum = ( intval( $minimum ) > 1 ? $minimum : null ); |
| 374 | 452 | } |
| 375 | 453 | |
| 454 | + public function getMixAndMatchMinQuantity() : ?bool { | |
| 455 | + return $this->mixAndMatchMinQuantity; | |
| 456 | + } | |
| 457 | + | |
| 458 | + public function setMixAndMatchMinQuantity( ?bool $mixAndMatch ) { | |
| 459 | + $this->mixAndMatchMinQuantity = $mixAndMatch; | |
| 460 | + } | |
| 461 | + | |
| 376 | 462 | public function getIncludedProductCategories() : array { |
| 377 | 463 | return $this->includedProductCategories; |
| 378 | 464 | } |
| 379 | 465 | |
| @@ -388,8 +474,45 @@ | ||
| 388 | 474 | public function setExcludedProductCategories( array $excludedProductCategories ) { |
| 389 | 475 | $this->excludedProductCategories = $excludedProductCategories; |
| 390 | 476 | } |
| 391 | 477 | |
| 478 | + public function isIncludeSubcategories() : bool { | |
| 479 | + return $this->includeSubcategories; | |
| 480 | + } | |
| 481 | + | |
| 482 | + public function setIncludeSubcategories( bool $includeSubcategories ) { | |
| 483 | + $this->includeSubcategories = $includeSubcategories; | |
| 484 | + } | |
| 485 | + | |
| 486 | + /** | |
| 487 | + * The included categories as the matcher reads them: with every subcategory when the switch is on. | |
| 488 | + */ | |
| 489 | + public function getEffectiveIncludedCategories() : array { | |
| 490 | + return ( $this->includeSubcategories ? self::withSubcategories( $this->includedProductCategories ) : $this->includedProductCategories ); | |
| 491 | + } | |
| 492 | + | |
| 493 | + /** | |
| 494 | + * The excluded categories as the matcher reads them: with every subcategory when the switch is on. | |
| 495 | + */ | |
| 496 | + public function getEffectiveExcludedCategories() : array { | |
| 497 | + return ( $this->includeSubcategories ? self::withSubcategories( $this->excludedProductCategories ) : $this->excludedProductCategories ); | |
| 498 | + } | |
| 499 | + | |
| 500 | + /** | |
| 501 | + * The ids plus all their descendant product categories. | |
| 502 | + */ | |
| 503 | + public static function withSubcategories( array $ids ) : array { | |
| 504 | + $all = array(); | |
| 505 | + foreach ( array_map( 'intval', $ids ) as $id ) { | |
| 506 | + $all[] = $id; | |
| 507 | + $children = get_term_children( $id, 'product_cat' ); | |
| 508 | + if ( is_array( $children ) ) { | |
| 509 | + $all = array_merge( $all, array_map( 'intval', $children ) ); | |
| 510 | + } | |
| 511 | + } | |
| 512 | + return array_values( array_unique( $all ) ); | |
| 513 | + } | |
| 514 | + | |
| 392 | 515 | public function getIncludedProductTags() : array { |
| 393 | 516 | return $this->includedProductTags; |
| 394 | 517 | } |
| 395 | 518 | |
| @@ -468,8 +591,168 @@ | ||
| 468 | 591 | public function setExcludedUsers( array $excludedUsers ) { |
| 469 | 592 | $this->excludedUsers = $excludedUsers; |
| 470 | 593 | } |
| 471 | 594 | |
| 595 | + public function getPriority() : int { | |
| 596 | + return $this->priority; | |
| 597 | + } | |
| 598 | + | |
| 599 | + /** | |
| 600 | + * @param mixed $priority Anything non-numeric (an empty meta row, for instance) falls back to the default. | |
| 601 | + */ | |
| 602 | + public function setPriority( $priority ) { | |
| 603 | + $this->priority = ( is_numeric( $priority ) ? max( 0, (int) $priority ) : self::DEFAULT_PRIORITY ); | |
| 604 | + } | |
| 605 | + | |
| 606 | + public function getStartDate() : ?string { | |
| 607 | + return $this->startDate; | |
| 608 | + } | |
| 609 | + | |
| 610 | + public function setStartDate( $date ) { | |
| 611 | + $this->startDate = self::normalizeDate( $date ); | |
| 612 | + } | |
| 613 | + | |
| 614 | + public function getEndDate() : ?string { | |
| 615 | + return $this->endDate; | |
| 616 | + } | |
| 617 | + | |
| 618 | + public function setEndDate( $date ) { | |
| 619 | + $this->endDate = self::normalizeDate( $date ); | |
| 620 | + } | |
| 621 | + | |
| 622 | + public function hasSchedule() : bool { | |
| 623 | + return null !== $this->startDate || null !== $this->endDate; | |
| 624 | + } | |
| 625 | + | |
| 626 | + public function getCreatedAt() : string { | |
| 627 | + return $this->createdAt; | |
| 628 | + } | |
| 629 | + | |
| 630 | + public function setCreatedAt( string $createdAt ) { | |
| 631 | + $this->createdAt = $createdAt; | |
| 632 | + } | |
| 633 | + | |
| 634 | + /** | |
| 635 | + * A "Y-m-d" string or null; anything else (a partial date, "0000-00-00", a timestamp) is dropped. | |
| 636 | + */ | |
| 637 | + protected static function normalizeDate( $date ) : ?string { | |
| 638 | + $date = ( is_string( $date ) ? trim( $date ) : '' ); | |
| 639 | + if ( '' === $date ) { | |
| 640 | + return null; | |
| 641 | + } | |
| 642 | + $parsed = DateTime::createFromFormat( '!Y-m-d', $date ); | |
| 643 | + return ( $parsed && $parsed->format( 'Y-m-d' ) === $date ? $date : null ); | |
| 644 | + } | |
| 645 | + | |
| 646 | + /** | |
| 647 | + * 00:00:00 of the start date in the site's timezone, as a timestamp; null without a start date. | |
| 648 | + */ | |
| 649 | + public function getStartTimestamp() : ?int { | |
| 650 | + return ( null === $this->startDate ? null : self::dayBoundary( $this->startDate, false ) ); | |
| 651 | + } | |
| 652 | + | |
| 653 | + /** | |
| 654 | + * 23:59:59 of the end date in the site's timezone, as a timestamp; null without an end date. | |
| 655 | + */ | |
| 656 | + public function getEndTimestamp() : ?int { | |
| 657 | + return ( null === $this->endDate ? null : self::dayBoundary( $this->endDate, true ) ); | |
| 658 | + } | |
| 659 | + | |
| 660 | + protected static function dayBoundary( string $date, bool $endOfDay ) : int { | |
| 661 | + $timezone = ( function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone('UTC') ); | |
| 662 | + $moment = new DateTime($date . (( $endOfDay ? ' 23:59:59' : ' 00:00:00' )), $timezone); | |
| 663 | + return $moment->getTimestamp(); | |
| 664 | + } | |
| 665 | + | |
| 666 | + /** | |
| 667 | + * "active", "scheduled" (starts later) or "expired" (ended) at the given moment (default: now). | |
| 668 | + */ | |
| 669 | + public function getScheduleStatus( ?int $now = null ) : string { | |
| 670 | + $now = ( null === $now ? time() : $now ); | |
| 671 | + $start = $this->getStartTimestamp(); | |
| 672 | + $end = $this->getEndTimestamp(); | |
| 673 | + if ( null !== $start && $now < $start ) { | |
| 674 | + return 'scheduled'; | |
| 675 | + } | |
| 676 | + if ( null !== $end && $now > $end ) { | |
| 677 | + return 'expired'; | |
| 678 | + } | |
| 679 | + return 'active'; | |
| 680 | + } | |
| 681 | + | |
| 682 | + public function isWithinSchedule( ?int $now = null ) : bool { | |
| 683 | + return 'active' === $this->getScheduleStatus( $now ); | |
| 684 | + } | |
| 685 | + | |
| 686 | + const EFFECTIVE_STATUSES = array( | |
| 687 | + 'active', | |
| 688 | + 'scheduled', | |
| 689 | + 'expired', | |
| 690 | + 'suspended', | |
| 691 | + 'skipped' | |
| 692 | + ); | |
| 693 | + | |
| 694 | + /** | |
| 695 | + * What the storefront makes of a published rule, in order of precedence: "suspended", "expired", | |
| 696 | + * "skipped" (changes neither prices nor quantity limits), "scheduled" (starts later) or "active". | |
| 697 | + */ | |
| 698 | + public function getEffectiveStatus( ?int $now = null ) : string { | |
| 699 | + if ( $this->isSuspended() ) { | |
| 700 | + return 'suspended'; | |
| 701 | + } | |
| 702 | + $schedule = $this->getScheduleStatus( $now ); | |
| 703 | + if ( 'expired' === $schedule ) { | |
| 704 | + return 'expired'; | |
| 705 | + } | |
| 706 | + // shown before "scheduled" so an empty campaign rule gets fixed before it starts | |
| 707 | + if ( !$this->isValidPricing() ) { | |
| 708 | + return 'skipped'; | |
| 709 | + } | |
| 710 | + return $schedule; | |
| 711 | + } | |
| 712 | + | |
| 713 | + /** | |
| 714 | + * Whether a higher-priority rule can take products and customers away from this one: only a rule | |
| 715 | + * that is (or will be) in force. A suspended, expired or skipped rule is not applied anyway. | |
| 716 | + */ | |
| 717 | + public function canBeOverridden( ?int $now = null ) : bool { | |
| 718 | + return in_array( $this->getEffectiveStatus( $now ), array('active', 'scheduled'), true ); | |
| 719 | + } | |
| 720 | + | |
| 721 | + /** | |
| 722 | + * usort() callback: the order the rules are matched in. Lowest priority number first; among equal | |
| 723 | + * numbers the newest rule first (today's behaviour for rules that never had a priority), then by ID. | |
| 724 | + */ | |
| 725 | + public static function compareOrder( GlobalPricingRule $a, GlobalPricingRule $b ) : int { | |
| 726 | + if ( $a->getPriority() !== $b->getPriority() ) { | |
| 727 | + return $a->getPriority() <=> $b->getPriority(); | |
| 728 | + } | |
| 729 | + if ( $a->getCreatedAt() !== $b->getCreatedAt() ) { | |
| 730 | + return strcmp( $b->getCreatedAt(), $a->getCreatedAt() ); | |
| 731 | + } | |
| 732 | + return (int) $b->id <=> (int) $a->id; | |
| 733 | + } | |
| 734 | + | |
| 735 | + /** | |
| 736 | + * Whether this rule has a higher priority than another one (and so is applied where both match). | |
| 737 | + */ | |
| 738 | + public function outranks( GlobalPricingRule $other ) : bool { | |
| 739 | + return self::compareOrder( $this, $other ) < 0; | |
| 740 | + } | |
| 741 | + | |
| 742 | + /** | |
| 743 | + * The rule's title, or "Rule #ID" for a rule saved without one. | |
| 744 | + */ | |
| 745 | + public static function titleOf( int $id ) : string { | |
| 746 | + $title = get_the_title( $id ); | |
| 747 | + /* translators: %d: rule ID */ | |
| 748 | + return ( '' !== trim( (string) $title ) ? $title : sprintf( __( 'Rule #%d', 'tier-pricing-table' ), $id ) ); | |
| 749 | + } | |
| 750 | + | |
| 751 | + public function getTitle() : string { | |
| 752 | + return self::titleOf( (int) $this->id ); | |
| 753 | + } | |
| 754 | + | |
| 472 | 755 | public function getSettings() : RuleSettings { |
| 473 | 756 | if ( !$this->priorityOptions ) { |
| 474 | 757 | $this->priorityOptions = new RuleSettings($this); |
| 475 | 758 | } |
| @@ -477,64 +760,74 @@ | ||
| 477 | 760 | } |
| 478 | 761 | |
| 479 | 762 | public function asArray() : array { |
| 480 | 763 | return array( |
| 481 | - 'pricing_type' => $this->getPricingType(), | |
| 482 | - 'regular_price' => $this->getRegularPrice(), | |
| 483 | - 'sale_price' => $this->getSalePrice(), | |
| 484 | - 'discount' => $this->getDiscount(), | |
| 485 | - 'discount_type' => $this->getDiscountType(), | |
| 486 | - 'applying_type' => $this->getApplyingType(), | |
| 487 | - 'tiered_pricing_type' => $this->getTieredPricingType(), | |
| 488 | - 'percentage_rules' => $this->getPercentageTieredPricingRules(), | |
| 489 | - 'fixed_rules' => $this->getFixedTieredPricingRules(), | |
| 490 | - 'minimum' => $this->getMinimum(), | |
| 491 | - 'tax_status' => $this->getTaxStatus(), | |
| 492 | - 'tax_class' => $this->getTaxClass(), | |
| 493 | - 'included_categories' => $this->getIncludedProductCategories(), | |
| 494 | - 'included_tags' => $this->getIncludedProductTags(), | |
| 495 | - 'included_brands' => $this->getIncludedProductBrands(), | |
| 496 | - 'included_products' => $this->getIncludedProducts(), | |
| 497 | - 'included_users' => $this->getIncludedUsers(), | |
| 498 | - 'included_users_role' => $this->getIncludedUserRoles(), | |
| 499 | - 'excluded_categories' => $this->getExcludedProductCategories(), | |
| 500 | - 'excluded_tags' => $this->getExcludedProductTags(), | |
| 501 | - 'excluded_brands' => $this->getExcludedProductBrands(), | |
| 502 | - 'excluded_products' => $this->getExcludedProducts(), | |
| 503 | - 'excluded_users' => $this->getExcludedUsers(), | |
| 504 | - 'excluded_users_role' => $this->getExcludedUserRoles(), | |
| 505 | - 'rule_id' => $this->getId(), | |
| 506 | - 'is_suspended' => $this->isSuspended(), | |
| 764 | + 'pricing_type' => $this->getPricingType(), | |
| 765 | + 'regular_price' => $this->getRegularPrice(), | |
| 766 | + 'sale_price' => $this->getSalePrice(), | |
| 767 | + 'discount' => $this->getDiscount(), | |
| 768 | + 'discount_type' => $this->getDiscountType(), | |
| 769 | + 'applying_type' => $this->getApplyingType(), | |
| 770 | + 'tiered_pricing_type' => $this->getTieredPricingType(), | |
| 771 | + 'percentage_rules' => $this->getPercentageTieredPricingRules(), | |
| 772 | + 'fixed_rules' => $this->getFixedTieredPricingRules(), | |
| 773 | + 'minimum' => $this->getMinimum(), | |
| 774 | + 'mix_and_match_minimum' => $this->getMixAndMatchMinQuantity(), | |
| 775 | + 'tax_status' => $this->getTaxStatus(), | |
| 776 | + 'tax_class' => $this->getTaxClass(), | |
| 777 | + 'priority' => $this->getPriority(), | |
| 778 | + 'start_date' => $this->getStartDate(), | |
| 779 | + 'end_date' => $this->getEndDate(), | |
| 780 | + 'included_categories' => $this->getIncludedProductCategories(), | |
| 781 | + 'include_subcategories' => $this->isIncludeSubcategories(), | |
| 782 | + 'included_tags' => $this->getIncludedProductTags(), | |
| 783 | + 'included_brands' => $this->getIncludedProductBrands(), | |
| 784 | + 'included_products' => $this->getIncludedProducts(), | |
| 785 | + 'included_users' => $this->getIncludedUsers(), | |
| 786 | + 'included_users_role' => $this->getIncludedUserRoles(), | |
| 787 | + 'excluded_categories' => $this->getExcludedProductCategories(), | |
| 788 | + 'excluded_tags' => $this->getExcludedProductTags(), | |
| 789 | + 'excluded_brands' => $this->getExcludedProductBrands(), | |
| 790 | + 'excluded_products' => $this->getExcludedProducts(), | |
| 791 | + 'excluded_users' => $this->getExcludedUsers(), | |
| 792 | + 'excluded_users_role' => $this->getExcludedUserRoles(), | |
| 793 | + 'rule_id' => $this->getId(), | |
| 794 | + 'is_suspended' => $this->isSuspended(), | |
| 507 | 795 | ); |
| 508 | 796 | } |
| 509 | 797 | |
| 510 | 798 | public function save() { |
| 511 | 799 | $dataToUpdate = array( |
| 512 | - '_tpt_pricing_type' => $this->getPricingType(), | |
| 513 | - '_tpt_regular_price' => $this->getRegularPrice(), | |
| 514 | - '_tpt_sale_price' => $this->getSalePrice(), | |
| 515 | - '_tpt_discount' => $this->getDiscount(), | |
| 516 | - '_tpt_discount_type' => $this->getDiscountType(), | |
| 517 | - '_tpt_applying_type' => $this->getApplyingType(), | |
| 518 | - '_tpt_tiered_pricing_type' => $this->getTieredPricingType(), | |
| 519 | - '_tpt_percentage_rules' => $this->getPercentageTieredPricingRules(), | |
| 520 | - '_tpt_fixed_rules' => $this->getFixedTieredPricingRules(), | |
| 521 | - '_tpt_minimum' => $this->getMinimum(), | |
| 522 | - '_tpt_tax_status' => $this->getTaxStatus(), | |
| 523 | - '_tpt_tax_class' => $this->getTaxClass(), | |
| 524 | - '_tpt_included_categories' => $this->getIncludedProductCategories(), | |
| 525 | - '_tpt_included_tags' => $this->getIncludedProductTags(), | |
| 526 | - '_tpt_included_brands' => $this->getIncludedProductBrands(), | |
| 527 | - '_tpt_included_products' => $this->getIncludedProducts(), | |
| 528 | - '_tpt_included_users' => $this->getIncludedUsers(), | |
| 529 | - '_tpt_included_user_roles' => $this->getIncludedUserRoles(), | |
| 530 | - '_tpt_excluded_categories' => $this->getExcludedProductCategories(), | |
| 531 | - '_tpt_excluded_tags' => $this->getExcludedProductTags(), | |
| 532 | - '_tpt_excluded_brands' => $this->getExcludedProductBrands(), | |
| 533 | - '_tpt_excluded_products' => $this->getExcludedProducts(), | |
| 534 | - '_tpt_excluded_users' => $this->getExcludedUsers(), | |
| 535 | - '_tpt_excluded_user_roles' => $this->getExcludedUserRoles(), | |
| 536 | - '_tpt_is_suspended' => wc_bool_to_string( $this->isSuspended() ), | |
| 800 | + '_tpt_pricing_type' => $this->getPricingType(), | |
| 801 | + '_tpt_regular_price' => $this->getRegularPrice(), | |
| 802 | + '_tpt_sale_price' => $this->getSalePrice(), | |
| 803 | + '_tpt_discount' => $this->getDiscount(), | |
| 804 | + '_tpt_discount_type' => $this->getDiscountType(), | |
| 805 | + '_tpt_applying_type' => $this->getApplyingType(), | |
| 806 | + '_tpt_tiered_pricing_type' => $this->getTieredPricingType(), | |
| 807 | + '_tpt_percentage_rules' => $this->getPercentageTieredPricingRules(), | |
| 808 | + '_tpt_fixed_rules' => $this->getFixedTieredPricingRules(), | |
| 809 | + '_tpt_minimum' => $this->getMinimum(), | |
| 810 | + '_tpt_mix_and_match_minimum' => ( is_null( $this->getMixAndMatchMinQuantity() ) ? '' : wc_bool_to_string( $this->getMixAndMatchMinQuantity() ) ), | |
| 811 | + '_tpt_tax_status' => $this->getTaxStatus(), | |
| 812 | + '_tpt_tax_class' => $this->getTaxClass(), | |
| 813 | + '_tpt_priority' => $this->getPriority(), | |
| 814 | + '_tpt_start_date' => (string) $this->getStartDate(), | |
| 815 | + '_tpt_end_date' => (string) $this->getEndDate(), | |
| 816 | + '_tpt_included_categories' => $this->getIncludedProductCategories(), | |
| 817 | + '_tpt_include_subcategories' => wc_bool_to_string( $this->isIncludeSubcategories() ), | |
| 818 | + '_tpt_included_tags' => $this->getIncludedProductTags(), | |
| 819 | + '_tpt_included_brands' => $this->getIncludedProductBrands(), | |
| 820 | + '_tpt_included_products' => $this->getIncludedProducts(), | |
| 821 | + '_tpt_included_users' => $this->getIncludedUsers(), | |
| 822 | + '_tpt_included_user_roles' => $this->getIncludedUserRoles(), | |
| 823 | + '_tpt_excluded_categories' => $this->getExcludedProductCategories(), | |
| 824 | + '_tpt_excluded_tags' => $this->getExcludedProductTags(), | |
| 825 | + '_tpt_excluded_brands' => $this->getExcludedProductBrands(), | |
| 826 | + '_tpt_excluded_products' => $this->getExcludedProducts(), | |
| 827 | + '_tpt_excluded_users' => $this->getExcludedUsers(), | |
| 828 | + '_tpt_excluded_user_roles' => $this->getExcludedUserRoles(), | |
| 829 | + '_tpt_is_suspended' => wc_bool_to_string( $this->isSuspended() ), | |
| 537 | 830 | ); |
| 538 | 831 | foreach ( $dataToUpdate as $key => $value ) { |
| 539 | 832 | update_post_meta( $this->getId(), $key, $value ); |
| 540 | 833 | } |
| @@ -542,19 +835,23 @@ | ||
| 542 | 835 | |
| 543 | 836 | public static function build( $ruleId ) : self { |
| 544 | 837 | // Simple data to read |
| 545 | 838 | $dataToRead = array( |
| 546 | - '_tpt_pricing_type' => 'pricing_type', | |
| 547 | - '_tpt_sale_price' => 'sale_price', | |
| 548 | - '_tpt_regular_price' => 'regular_price', | |
| 549 | - '_tpt_discount' => 'discount', | |
| 550 | - '_tpt_discount_type' => 'discount_type', | |
| 551 | - '_tpt_applying_type' => 'applying_type', | |
| 552 | - '_tpt_tiered_pricing_type' => 'tiered_pricing_type', | |
| 553 | - '_tpt_minimum' => 'minimum', | |
| 554 | - '_tpt_tax_status' => 'tax_status', | |
| 555 | - '_tpt_tax_class' => 'tax_class', | |
| 556 | - '_tpt_is_suspended' => 'is_suspended', | |
| 839 | + '_tpt_pricing_type' => 'pricing_type', | |
| 840 | + '_tpt_sale_price' => 'sale_price', | |
| 841 | + '_tpt_regular_price' => 'regular_price', | |
| 842 | + '_tpt_discount' => 'discount', | |
| 843 | + '_tpt_discount_type' => 'discount_type', | |
| 844 | + '_tpt_applying_type' => 'applying_type', | |
| 845 | + '_tpt_tiered_pricing_type' => 'tiered_pricing_type', | |
| 846 | + '_tpt_minimum' => 'minimum', | |
| 847 | + '_tpt_mix_and_match_minimum' => 'mix_and_match_minimum', | |
| 848 | + '_tpt_tax_status' => 'tax_status', | |
| 849 | + '_tpt_tax_class' => 'tax_class', | |
| 850 | + '_tpt_is_suspended' => 'is_suspended', | |
| 851 | + '_tpt_priority' => 'priority', | |
| 852 | + '_tpt_start_date' => 'start_date', | |
| 853 | + '_tpt_end_date' => 'end_date', | |
| 557 | 854 | ); |
| 558 | 855 | $data = array(); |
| 559 | 856 | foreach ( $dataToRead as $key => $name ) { |
| 560 | 857 | $data[$name] = get_post_meta( $ruleId, $key, true ); |
| @@ -559,29 +856,27 @@ | ||
| 559 | 856 | foreach ( $dataToRead as $key => $name ) { |
| 560 | 857 | $data[$name] = get_post_meta( $ruleId, $key, true ); |
| 561 | 858 | } |
| 562 | 859 | $priceRule = self::fromArray( $data ); |
| 860 | + $priceRule->setCreatedAt( (string) get_post_field( 'post_date_gmt', $ruleId ) ); | |
| 563 | 861 | $existingRoles = wp_roles()->roles; |
| 564 | 862 | $includedCategoriesIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_categories', true ) ) ); |
| 565 | 863 | $includedTagsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_tags', true ) ) ); |
| 566 | 864 | $includedBrandsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_brands', true ) ) ); |
| 567 | 865 | $includedProductsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_products', true ) ) ); |
| 568 | - $includedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_included_user_roles', true ), function ( $role ) use($existingRoles) { | |
| 569 | - return array_key_exists( $role, $existingRoles ); | |
| 570 | - } ); | |
| 866 | + $includedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_included_user_roles', true ), array(self::class, 'isSelectableRole') ); | |
| 571 | 867 | $includedUsers = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_users', true ) ) ); |
| 572 | 868 | $excludedCategoriesIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_categories', true ) ) ); |
| 573 | 869 | $excludedTagsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_tags', true ) ) ); |
| 574 | 870 | $excludedBrandsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_brands', true ) ) ); |
| 575 | 871 | $excludedProductsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_products', true ) ) ); |
| 576 | - $excludedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_excluded_user_roles', true ), function ( $role ) use($existingRoles) { | |
| 577 | - return array_key_exists( $role, $existingRoles ); | |
| 578 | - } ); | |
| 872 | + $excludedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_excluded_user_roles', true ), array(self::class, 'isSelectableRole') ); | |
| 579 | 873 | $excludedUsers = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_users', true ) ) ); |
| 580 | 874 | $isSuspended = get_post_meta( $ruleId, '_tpt_is_suspended', true ) === 'yes'; |
| 581 | 875 | $priceRule->setPercentageTieredPricingRules( self::readPricingRules( 'percentage', $ruleId ) ); |
| 582 | 876 | $priceRule->setFixedTieredPricingRules( self::readPricingRules( 'fixed', $ruleId ) ); |
| 583 | 877 | $priceRule->setIncludedProductCategories( $includedCategoriesIds ); |
| 878 | + $priceRule->setIncludeSubcategories( 'yes' === get_post_meta( $ruleId, '_tpt_include_subcategories', true ) ); | |
| 584 | 879 | $priceRule->setIncludedProductTags( $includedTagsIds ); |
| 585 | 880 | $priceRule->setIncludedProductBrands( $includedBrandsIds ); |
| 586 | 881 | $priceRule->setIncludedUsers( $includedUsers ); |
| 587 | 882 | $priceRule->setIncludedUsersRole( $includedUsersRole ); |
| @@ -647,24 +942,28 @@ | ||
| 647 | 942 | * 1. Check for product exclusion |
| 648 | 943 | * |
| 649 | 944 | * If the product in exclusion - pricing rule does not match immediately |
| 650 | 945 | */ |
| 651 | - if ( !empty( $this->getExcludedProducts() ) ) { | |
| 652 | - if ( in_array( $product->get_id(), $this->getExcludedProducts() ) || in_array( $parentProduct->get_id(), $this->getExcludedProducts() ) ) { | |
| 946 | + $excludedProducts = $this->translateIds( $this->getExcludedProducts(), 'product' ); | |
| 947 | + if ( !empty( $excludedProducts ) ) { | |
| 948 | + if ( in_array( $product->get_id(), $excludedProducts ) || in_array( $parentProduct->get_id(), $excludedProducts ) ) { | |
| 653 | 949 | return false; |
| 654 | 950 | } |
| 655 | 951 | } |
| 656 | - if ( !empty( $this->getExcludedProductCategories() ) ) { | |
| 657 | - if ( !empty( array_intersect( $parentProduct->get_category_ids(), $this->getExcludedProductCategories() ) ) ) { | |
| 952 | + $excludedProductCategories = $this->translateIds( $this->getEffectiveExcludedCategories(), 'product_cat' ); | |
| 953 | + if ( !empty( $excludedProductCategories ) ) { | |
| 954 | + if ( !empty( array_intersect( $parentProduct->get_category_ids(), $excludedProductCategories ) ) ) { | |
| 658 | 955 | return false; |
| 659 | 956 | } |
| 660 | 957 | } |
| 661 | - if ( !empty( $this->getExcludedProductTags() ) ) { | |
| 662 | - if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $this->getExcludedProductTags() ) ) ) { | |
| 958 | + $excludedProductTags = $this->translateIds( $this->getExcludedProductTags(), 'product_tag' ); | |
| 959 | + if ( !empty( $excludedProductTags ) ) { | |
| 960 | + if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $excludedProductTags ) ) ) { | |
| 663 | 961 | return false; |
| 664 | 962 | } |
| 665 | 963 | } |
| 666 | - if ( !empty( $this->getExcludedProductBrands() ) ) { | |
| 964 | + $excludedProductBrands = $this->translateIds( $this->getExcludedProductBrands(), 'product_brand' ); | |
| 965 | + if ( !empty( $excludedProductBrands ) ) { | |
| 667 | 966 | $productBrands = wp_get_post_terms( $parentProduct->get_id(), 'product_brand', array( |
| 668 | 967 | 'fields' => 'ids', |
| 669 | 968 | ) ); |
| 670 | 969 | if ( is_wp_error( $productBrands ) ) { |
| @@ -669,9 +968,9 @@ | ||
| 669 | 968 | ) ); |
| 670 | 969 | if ( is_wp_error( $productBrands ) ) { |
| 671 | 970 | $productBrands = []; |
| 672 | 971 | } |
| 673 | - if ( !empty( array_intersect( $productBrands, $this->getExcludedProductBrands() ) ) ) { | |
| 972 | + if ( !empty( array_intersect( $productBrands, $excludedProductBrands ) ) ) { | |
| 674 | 973 | return false; |
| 675 | 974 | } |
| 676 | 975 | } |
| 677 | 976 | /** |
| @@ -681,10 +980,11 @@ | ||
| 681 | 980 | */ |
| 682 | 981 | if ( in_array( $user->ID, $this->getExcludedUsers() ) ) { |
| 683 | 982 | return false; |
| 684 | 983 | } |
| 984 | + $userRoles = self::rolesOf( $user ); | |
| 685 | 985 | foreach ( $this->getExcludedUserRoles() as $role ) { |
| 686 | - if ( in_array( $role, $user->roles ) ) { | |
| 986 | + if ( in_array( $role, $userRoles ) ) { | |
| 687 | 987 | return false; |
| 688 | 988 | } |
| 689 | 989 | } |
| 690 | 990 | /** |
| @@ -693,27 +993,31 @@ | ||
| 693 | 993 | * If yes - match rule only for selected product/product categories |
| 694 | 994 | */ |
| 695 | 995 | $productMatched = false; |
| 696 | 996 | $productLimitations = false; |
| 697 | - if ( !empty( $this->getIncludedProducts() ) ) { | |
| 997 | + $includedProducts = $this->translateIds( $this->getIncludedProducts(), 'product' ); | |
| 998 | + if ( !empty( $includedProducts ) ) { | |
| 698 | 999 | $productLimitations = true; |
| 699 | - if ( in_array( $product->get_id(), $this->getIncludedProducts() ) || in_array( $parentProduct->get_id(), $this->getIncludedProducts() ) ) { | |
| 1000 | + if ( in_array( $product->get_id(), $includedProducts ) || in_array( $parentProduct->get_id(), $includedProducts ) ) { | |
| 700 | 1001 | $productMatched = true; |
| 701 | 1002 | } |
| 702 | 1003 | } |
| 703 | - if ( !empty( $this->getIncludedProductCategories() ) ) { | |
| 1004 | + $includedProductCategories = $this->translateIds( $this->getEffectiveIncludedCategories(), 'product_cat' ); | |
| 1005 | + if ( !empty( $includedProductCategories ) ) { | |
| 704 | 1006 | $productLimitations = true; |
| 705 | - if ( !empty( array_intersect( $parentProduct->get_category_ids(), $this->getIncludedProductCategories() ) ) ) { | |
| 1007 | + if ( !empty( array_intersect( $parentProduct->get_category_ids(), $includedProductCategories ) ) ) { | |
| 706 | 1008 | $productMatched = true; |
| 707 | 1009 | } |
| 708 | 1010 | } |
| 709 | - if ( !empty( $this->getIncludedProductTags() ) ) { | |
| 1011 | + $includedProductTags = $this->translateIds( $this->getIncludedProductTags(), 'product_tag' ); | |
| 1012 | + if ( !empty( $includedProductTags ) ) { | |
| 710 | 1013 | $productLimitations = true; |
| 711 | - if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $this->getIncludedProductTags() ) ) ) { | |
| 1014 | + if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $includedProductTags ) ) ) { | |
| 712 | 1015 | $productMatched = true; |
| 713 | 1016 | } |
| 714 | 1017 | } |
| 715 | - if ( !empty( $this->getIncludedProductBrands() ) ) { | |
| 1018 | + $includedProductBrands = $this->translateIds( $this->getIncludedProductBrands(), 'product_brand' ); | |
| 1019 | + if ( !empty( $includedProductBrands ) ) { | |
| 716 | 1020 | $productLimitations = true; |
| 717 | 1021 | $productBrands = wp_get_post_terms( $parentProduct->get_id(), 'product_brand', array( |
| 718 | 1022 | 'fields' => 'ids', |
| 719 | 1023 | ) ); |
| @@ -719,9 +1023,9 @@ | ||
| 719 | 1023 | ) ); |
| 720 | 1024 | if ( is_wp_error( $productBrands ) ) { |
| 721 | 1025 | $productBrands = []; |
| 722 | 1026 | } |
| 723 | - if ( !empty( array_intersect( $productBrands, $this->getIncludedProductBrands() ) ) ) { | |
| 1027 | + if ( !empty( array_intersect( $productBrands, $includedProductBrands ) ) ) { | |
| 724 | 1028 | $productMatched = true; |
| 725 | 1029 | } |
| 726 | 1030 | } |
| 727 | 1031 | // There is product limitation and the product/category does not match the rule |
| @@ -740,12 +1044,48 @@ | ||
| 740 | 1044 | if ( in_array( $user->ID, $this->getIncludedUsers() ) ) { |
| 741 | 1045 | return true; |
| 742 | 1046 | } |
| 743 | 1047 | foreach ( $this->getIncludedUserRoles() as $role ) { |
| 744 | - if ( in_array( $role, $user->roles ) ) { | |
| 1048 | + if ( in_array( $role, $userRoles ) ) { | |
| 745 | 1049 | return true; |
| 746 | 1050 | } |
| 747 | 1051 | } |
| 748 | 1052 | return false; |
| 1053 | + } | |
| 1054 | + | |
| 1055 | + /** | |
| 1056 | + * Map a rule's stored object IDs to the language currently being viewed. | |
| 1057 | + * | |
| 1058 | + * A rule is created in a single language and stores the product/term IDs of that language. The | |
| 1059 | + * product being priced (and the term IDs returned by WooCommerce for it) are in the current | |
| 1060 | + * language, so under WPML/Polylang a raw ID comparison would never match on a translation. This | |
| 1061 | + * translates the stored IDs into the current language via the shared `wpml_object_id` filter | |
| 1062 | + * (implemented by both WPML and Polylang) so the comparison works across every translation. | |
| 1063 | + * | |
| 1064 | + * When no multilingual plugin is active the filter has no listeners, so the IDs are returned | |
| 1065 | + * unchanged with no overhead. | |
| 1066 | + * | |
| 1067 | + * @param int[] $ids Object IDs as stored on the rule. | |
| 1068 | + * @param string $type Element type: a post type ('product') or taxonomy ('product_cat', | |
| 1069 | + * 'product_tag', 'product_brand'). | |
| 1070 | + * | |
| 1071 | + * @return int[] | |
| 1072 | + */ | |
| 1073 | + protected function translateIds( array $ids, string $type ) : array { | |
| 1074 | + if ( empty( $ids ) || !has_filter( 'wpml_object_id' ) ) { | |
| 1075 | + return $ids; | |
| 1076 | + } | |
| 1077 | + $translated = array(); | |
| 1078 | + foreach ( $ids as $id ) { | |
| 1079 | + // The `true` argument keeps the original ID when the object has no translation in the | |
| 1080 | + // current language, preserving the previous behaviour for untranslated objects. | |
| 1081 | + $translated[] = (int) apply_filters( | |
| 1082 | + 'wpml_object_id', | |
| 1083 | + $id, | |
| 1084 | + $type, | |
| 1085 | + true | |
| 1086 | + ); | |
| 1087 | + } | |
| 1088 | + return $translated; | |
| 749 | 1089 | } |
| 750 | 1090 | |
| 751 | 1091 | } |