diff --git a/src/elements/Order.php b/src/elements/Order.php index d912957597..5646f1eab5 100644 --- a/src/elements/Order.php +++ b/src/elements/Order.php @@ -17,6 +17,7 @@ use craft\commerce\base\GatewayInterface; use craft\commerce\base\HasStoreInterface; use craft\commerce\base\Purchasable; +use craft\commerce\base\PurchasableInterface; use craft\commerce\base\ShippingMethodInterface; use craft\commerce\base\StoreTrait; use craft\commerce\behaviors\CurrencyAttributeBehavior; @@ -1586,9 +1587,8 @@ protected function defineRules(): array // Are the addresses both being set to each other. [ ['billingAddress', 'shippingAddress'], 'validateAddressReuse', - 'when' => fn($model) => - /** @var Order $model */ - !$model->isCompleted, + 'when' => fn($model) => /** @var Order $model */ + !$model->isCompleted, ], [['shippingAddress'], 'validateOrganizationTaxIdAsVatId', 'when' => fn(Order $order) => $order->getStore()->getValidateOrganizationTaxIdAsVatId() && !$order->getStore()->getUseBillingAddressForTax()], @@ -2929,6 +2929,55 @@ public function hasLineItems(): bool return (bool)$this->getLineItems(); } + /** + * Returns whether the order contains the given purchasable IDs. + * + * @param mixed $purchasableIds One or more purchasable IDs or purchasable models to check for. + * @param string $match The match mode: + * - `'any'` – returns `true` if the order contains at least one of the given purchasable IDs. This is default that matches the OrderQuery hasPurchasables param. + * - `'all'` – returns `true` if the order contains all the given purchasable IDs (order may contain others). + * - `'exact'` – returns `true` only if the order contains exactly the given purchasable IDs and nothing else. + * Returns `false` if the order has custom line items (null purchasable ID). + * @return bool + */ + public function hasPurchasables(mixed $purchasableIds, string $match = 'any'): bool + { + if (!is_array($purchasableIds)) { + $purchasableIds = [$purchasableIds]; + } + + $orderPurchasableIds = collect($this->getLineItems()) + ->pluck('purchasableId') + ->filter(fn($id) => $id !== null); + + $requestedIds = collect($purchasableIds) + ->map(fn($id) => $id instanceof PurchasableInterface ? $id->getId() : $id) + ->filter(fn($id) => $id !== null); + + if ($match === 'any') { + return $orderPurchasableIds->intersect($requestedIds)->isNotEmpty(); + } + + if ($match === 'exact') { + // If there are custom line items (null purchasableId), the order + // has purchasables beyond what was specified, so it can't be exact. + $hasCustomLineItems = collect($this->getLineItems()) + ->pluck('purchasableId') + ->contains(null); + + if ($hasCustomLineItems) { + return false; + } + + return $orderPurchasableIds->diff($requestedIds)->isEmpty() + && $requestedIds->diff($orderPurchasableIds)->isEmpty(); + } + + // 'all' — every requested purchasable must exist in the order + return $requestedIds->every(fn($id) => $orderPurchasableIds->contains($id)); + } + + /** * @return int * @throws InvalidConfigException diff --git a/src/elements/conditions/orders/HasPurchasablesConditionRule.php b/src/elements/conditions/orders/HasPurchasablesConditionRule.php new file mode 100644 index 0000000000..ee1171ef85 --- /dev/null +++ b/src/elements/conditions/orders/HasPurchasablesConditionRule.php @@ -0,0 +1,200 @@ + + * @since 4.2.0 + * + * @method array|string|null paramValue(?callable $normalizeValue = null) + */ +class HasPurchasablesConditionRule extends BaseElementSelectConditionRule implements ElementConditionRuleInterface +{ + /** + * @var string + */ + public string $purchasableType = Variant::class; + + /** + * @var string The match mode: 'any', 'all', or 'exact'. + */ + public string $match = 'any'; + + /** + * @inheritdoc + */ + public function getLabel(): string + { + return Craft::t('commerce', 'Has Purchasables'); + } + + /** + * @inheritdoc + */ + public function getExclusiveQueryParams(): array + { + return ['hasPurchasable']; + } + + /** + * @inheritdoc + */ + protected function elementType(): string + { + return $this->purchasableType; + } + + /** + * @inheritdoc + */ + public function modifyQuery(ElementQueryInterface $query): void + { + if (empty($this->getElementIds())) { + return; + } + + /** @var OrderQuery $query */ + $query->hasPurchasables($this->getElementIds()); + } + + /** + * @inheritdoc + */ + public function matchElement(ElementInterface $element): bool + { + /** @var Order $element */ + return $element->hasPurchasables($this->getElementIds(), $this->match); + } + + /** + * @inheritdoc + */ + protected function allowMultiple(): bool + { + return true; + } + + public function getConfig(): array + { + return array_merge(parent::getConfig(), [ + 'purchasableType' => $this->purchasableType, + 'match' => $this->match, + ]); + } + + protected function defineRules(): array + { + $rules = parent::defineRules(); + $rules[] = [['purchasableType', 'match'], 'safe']; + + return $rules; + } + + /** + * @inheritdoc + */ + protected function inputHtml(): string + { + $purchasableTypeId = 'purchasable-type'; + $matchId = 'match'; + return Html::hiddenLabel($this->getLabel(), $purchasableTypeId) . + Html::tag('div', + Cp::selectHtml([ + 'id' => $purchasableTypeId, + 'name' => 'purchasableType', + 'options' => $this->_purchasableTypeOptions(), + 'value' => $this->purchasableType, + 'inputAttributes' => [ + 'hx' => [ + 'post' => UrlHelper::actionUrl('conditions/render'), + ], + ], + ]) . + Cp::selectHtml([ + 'id' => $matchId, + 'name' => 'match', + 'options' => $this->_matchOptions(), + 'value' => $this->match, + 'inputAttributes' => [ + 'hx' => [ + 'post' => UrlHelper::actionUrl('conditions/render'), + ], + ], + ]) . + parent::inputHtml(), + [ + 'class' => ['flex', 'flex-start'], + ] + ); + } + + + protected function selectionCondition(): ?ElementConditionInterface + { + return Craft::$app->getConditions()->createCondition(['class' => OrderCondition::class]); + } + + /** + * @return array + * @throws InvalidConfigException + */ + private function _purchasableTypeOptions(): array + { + $options = []; + + foreach (Plugin::getInstance()->getPurchasables()->getAllPurchasableElementTypes() as $elementType) { + /** @var string|ElementInterface $elementType */ + /** @phpstan-var class-string|ElementInterface $elementType */ + $options[] = [ + 'value' => $elementType, + 'label' => $elementType::displayName(), + ]; + } + + return $options; + } + + /** + * @return array + */ + private function _matchOptions(): array + { + return [ + ['value' => 'any', 'label' => Craft::t('commerce', 'any')], + ['value' => 'all', 'label' => Craft::t('commerce', 'all')], + ['value' => 'exact', 'label' => Craft::t('commerce', 'exact')], + ]; + } + + /** + * @inerhitdoc + */ + protected function elementSelectConfig(): array + { + return array_merge(parent::elementSelectConfig(), [ + 'showSiteMenu' => true, + ]); + } +} diff --git a/src/elements/conditions/orders/OrderCondition.php b/src/elements/conditions/orders/OrderCondition.php index 15330cc8d0..e8ec345f75 100644 --- a/src/elements/conditions/orders/OrderCondition.php +++ b/src/elements/conditions/orders/OrderCondition.php @@ -35,6 +35,7 @@ protected function selectableConditionRules(): array CustomerConditionRule::class, PaidConditionRule::class, HasPurchasableConditionRule::class, + HasPurchasablesConditionRule::class, ItemSubtotalConditionRule::class, ItemTotalConditionRule::class, OrderStatusConditionRule::class,