Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions src/elements/Order.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()],
Expand Down Expand Up @@ -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
Expand Down
200 changes: 200 additions & 0 deletions src/elements/conditions/orders/HasPurchasablesConditionRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace craft\commerce\elements\conditions\orders;

use Craft;
use craft\base\conditions\BaseElementSelectConditionRule;
use craft\base\ElementInterface;
use craft\commerce\elements\db\OrderQuery;
use craft\commerce\elements\Order;
use craft\commerce\elements\Variant;
use craft\commerce\Plugin;
use craft\elements\conditions\ElementConditionInterface;
use craft\elements\conditions\ElementConditionRuleInterface;
use craft\elements\db\ElementQueryInterface;
use craft\helpers\Cp;
use craft\helpers\Html;
use craft\helpers\UrlHelper;
use yii\base\InvalidConfigException;

/**
* Has Purchasables Condition Rule
*
* @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
* @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>|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,
]);
}
}
1 change: 1 addition & 0 deletions src/elements/conditions/orders/OrderCondition.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ protected function selectableConditionRules(): array
CustomerConditionRule::class,
PaidConditionRule::class,
HasPurchasableConditionRule::class,
HasPurchasablesConditionRule::class,
ItemSubtotalConditionRule::class,
ItemTotalConditionRule::class,
OrderStatusConditionRule::class,
Expand Down
Loading