diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8666c06a79..f1338b9a42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: name: ci uses: craftcms/.github/.github/workflows/ci.yml@v3 with: - php_version: '8.2' + php_version: '["8.2", "8.3"]' craft_version: '5' node_version: '20' jobs: '["ecs", "phpstan", "prettier", "tests"]' diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index f1f1a72551..ec7aa2422e 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -1,8 +1,26 @@ # Release Notes for Craft Commerce (WIP) -## Administration +### Store Management +- It is now possible to design card views for Products and Variants. ([#3809](https://github.com/craftcms/commerce/pull/3809)) +- Order conditions can now have a “Coupon Code” rule. ([#3776](https://github.com/craftcms/commerce/discussions/3776)) +- Order conditions can now have a “Payment Gateway” rule. ([#3722](https://github.com/craftcms/commerce/discussions/3722)) +- Variant conditions can now have a “Product” rule. -- Added `craft\commerce\events\CartPurgeEvent`. +### Development - Added support for `to`, `bcc`, and `cc` email fields to support environment variables. ([#3738](https://github.com/craftcms/commerce/issues/3738)) +- Added the `couponCode` order query param. - Added an `originalCart` value to the `commerce/update-cart` failed ajax response. ([#430](https://github.com/craftcms/commerce/issues/430)) -- Added a new "Payment Gateway" order condition rule. ([#3722](https://github.com/craftcms/commerce/discussions/3722)) + +### Extensibility +- Added `craft\commerce\base\InventoryItemTrait`. +- Added `craft\commerce\base\InventoryLocationTrait`. +- Added `craft\commerce\elements\conditions\orders\CouponCodeConditionRule`. +- Added `craft\commerce\elements\conditions\variants\ProductConditionRule`. +- Added `craft\commerce\elements\db\OrderQuery::$couponCode`. +- Added `craft\commerce\elements\db\OrderQuery::couponCode()`. +- Added `craft\commerce\events\CartPurgeEvent`. +- Added `craft\commerce\services\Inventory::updateInventoryLevel()`. +- Added `craft\commerce\services\Inventory::updatePurchasableInventoryLevel()`. + +### System +- Craft Commerce now requires Craft CMS 5.5 or later. diff --git a/CHANGELOG.md b/CHANGELOG.md index a29109fa59..db68d6a985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Release Notes for Craft Commerce +## Unreleased + +- Fixed a bug where a product’s default price was showing incorrectly on the Products index page. ([#3807](https://github.com/craftcms/commerce/issues/3807)) +- Fixed a bug where inline-editable Matrix fields weren’t saving content on product variants. ([#3805](https://github.com/craftcms/commerce/issues/3805)) +- Fixed a bug where order errors weren't showing on the Edit Order page. + +## 5.2.8 - 2024-12-04 + +- Fixed a bug where line items weren’t getting hyperlinked within Edit Order pages. ([#3792](https://github.com/craftcms/commerce/issues/3792)) +- Fixed a bug where Inventory pages were showing draft purchasables. +- Fixed a PHP error that could occur when creating inventory transfers. ([#3696](https://github.com/craftcms/commerce/issues/3696)) +- Fixed a bug where prices weren’t getting formatted per the user’s formatting locale, in payment models on Edit Order pages. ([#3789](https://github.com/craftcms/commerce/issues/3789)) +- Fixed a bug where store settings weren’t respecting environment variables. ([#3786](https://github.com/craftcms/commerce/issues/3786)) + +## 5.2.7 - 2024-12-02 + +- Fixed an error that occurred on the Orders index page when running Craft CMS 5.5.4 or later. ([#3793](https://github.com/craftcms/commerce/issues/3793)) +- Fixed a bug where a structured product type’s “Max Levels” setting wasn’t being respected. ([#3785](https://github.com/craftcms/commerce/issues/3785)) +- Fixed an information disclosure vulnerability. + +## 5.2.6 - 2024-11-26 + +- Fixed a bug where variant prices could be displayed incorrectly when inline editing. ([#3768](https://github.com/craftcms/commerce/issues/3768)) +- Fixed a performance degradation bug with variant queries. ([#3758](https://github.com/craftcms/commerce/issues/3758)) +- Fixed a PHP error that could occur when managing store settings. ([#3780](https://github.com/craftcms/commerce/issues/3780)) + +## 5.2.5 - 2024-11-20 + +- The `resave/products`, `resave/orders`, and `resave/carts` commands now support the `--with-fields` option. +- Fixed a SQL error that could occur when updating. ([#3778](https://github.com/craftcms/commerce/issues/3778)) + ## 5.2.4 - 2024-11-14 - Improved the performance of `craft\commerce\elements\Product::getVariants()`. ([#3578](https://github.com/craftcms/commerce/issues/3758)) diff --git a/composer.json b/composer.json index b3b4d39ca0..871b90968a 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "prefer-stable": true, "require": { "php": "^8.2", - "craftcms/cms": "^5.2.0", + "craftcms/cms": "^5.5.0", "dompdf/dompdf": "^2.0.2", "ibericode/vat": "^1.2.2", "iio/libmergepdf": "^4.0", diff --git a/composer.lock b/composer.lock index 44fc587fcd..a56e134c3b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2f3ba664672e378629a5fd88f697019c", + "content-hash": "733b4b868ad8dbf52e6ea739a7a83494", "packages": [ { "name": "bacon/bacon-qr-code", @@ -331,16 +331,16 @@ }, { "name": "craftcms/cms", - "version": "5.5.0.1", + "version": "5.5.1.1", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "5e24a0bf74ea29ea2f5c04d4c9dcc325dcbb41ba" + "reference": "2233b27fd7e80cccc3aab927ad073f5916167dba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/5e24a0bf74ea29ea2f5c04d4c9dcc325dcbb41ba", - "reference": "5e24a0bf74ea29ea2f5c04d4c9dcc325dcbb41ba", + "url": "https://api.github.com/repos/craftcms/cms/zipball/2233b27fd7e80cccc3aab927ad073f5916167dba", + "reference": "2233b27fd7e80cccc3aab927ad073f5916167dba", "shasum": "" }, "require": { @@ -454,7 +454,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2024-11-13T14:36:24+00:00" + "time": "2024-11-19T02:11:31+00:00" }, { "name": "craftcms/plugin-installer", @@ -9423,16 +9423,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.10", + "version": "1.12.11", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "fc463b5d0fe906dcf19689be692c65c50406a071" + "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/fc463b5d0fe906dcf19689be692c65c50406a071", - "reference": "fc463b5d0fe906dcf19689be692c65c50406a071", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0d1fc20a962a91be578bcfe7cf939e6e1a2ff733", + "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733", "shasum": "" }, "require": { @@ -9477,7 +9477,7 @@ "type": "github" } ], - "time": "2024-11-11T15:37:09+00:00" + "time": "2024-11-17T14:08:01+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/src/Plugin.php b/src/Plugin.php index 3e95e4d3c6..201e9b0bdb 100755 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -51,6 +51,7 @@ use craft\commerce\helpers\ProjectConfigData; use craft\commerce\linktypes\Product as ProductLinkType; use craft\commerce\migrations\Install; +use craft\commerce\models\ProductType; use craft\commerce\models\Settings; use craft\commerce\plugin\Routes; use craft\commerce\plugin\Services as CommerceServices; @@ -165,7 +166,9 @@ use craft\web\Application; use craft\web\twig\variables\CraftVariable; use Exception; +use Illuminate\Support\Collection; use yii\base\Event; +use yii\console\ExitCode; use yii\web\User; /** @@ -254,7 +257,7 @@ public static function editions(): array /** * @inheritDoc */ - public string $schemaVersion = '5.2.0.5'; + public string $schemaVersion = '5.2.7.1'; /** * @inheritdoc @@ -794,7 +797,10 @@ function(DefineBehaviorsEvent $event) { // Add Commerce info to user edit screen Event::on(UsersController::class, UsersController::EVENT_DEFINE_EDIT_SCREENS, function(DefineEditUserScreensEvent $event) { - $event->screens[CommerceUsersController::SCREEN_COMMERCE] = ['label' => Craft::t('commerce', 'Commerce')]; + // Add Commerce screen to user edit screen if the user has permission to access Commerce + if (Craft::$app->getUser()->checkPermission('accessPlugin-commerce')) { + $event->screens[CommerceUsersController::SCREEN_COMMERCE] = ['label' => Craft::t('commerce', 'Commerce')]; + } }); // Site models are instantiated early meaning we have to manually attach the behavior alongside using the event @@ -1143,12 +1149,32 @@ private function _defineResaveCommand(): void /** @var ResaveController $controller */ $controller = Craft::$app->controller; $criteria = []; + if ($controller->type !== null) { $criteria['type'] = explode(',', $controller->type); } + + // @TODO Remove this check when Commerce requires Craft 5.5 + if (version_compare(Craft::$app->getInfo()->version, '5.5.0', '>=') && !empty($controller->withFields)) { + $handles = Collection::make(self::getInstance()->getProductTypes()->getAllProductTypes()) + ->filter(fn(ProductType $productType) => $controller->hasTheFields($productType->getFieldLayout())) + ->map(fn(ProductType $productType) => $productType->handle) + ->all(); + if (isset($criteria['type'])) { + $criteria['type'] = array_intersect($criteria['type'], $handles); + } else { + $criteria['type'] = $handles; + } + + if (empty($criteria['type'])) { + $controller->output($controller->markdownToAnsi('No product types satisfy `--with-fields`.')); + return ExitCode::UNSPECIFIED_ERROR; + } + } + return $controller->resaveElements(Product::class, $criteria); }, - 'options' => ['type'], + 'options' => array_filter(['type', (property_exists(ResaveController::class, 'withFields') ? 'withFields' : null)]), 'helpSummary' => 'Re-saves Commerce products.', 'optionsHelp' => [ 'type' => 'The product type handle(s) of the products to resave.', @@ -1159,11 +1185,20 @@ private function _defineResaveCommand(): void 'action' => function(): int { /** @var ResaveController $controller */ $controller = Craft::$app->controller; + // @TODO Remove this check when Commerce requires Craft 5.5 + if (version_compare(Craft::$app->getInfo()->version, '5.5.0', '>=') && !empty($controller->withFields)) { + $fieldLayout = Craft::$app->getFields()->getLayoutByType(Order::class); + if (!$controller->hasTheFields($fieldLayout)) { + $controller->output($controller->markdownToAnsi('The order field layout doesn’t satisfy `--with-fields`.')); + return ExitCode::UNSPECIFIED_ERROR; + } + } + return $controller->resaveElements(Order::class, [ 'isCompleted' => true, ]); }, - 'options' => [], + 'options' => array_filter([(property_exists(ResaveController::class, 'withFields') ? 'withFields' : null)]), 'helpSummary' => 'Re-saves completed Commerce orders.', ]; @@ -1171,11 +1206,20 @@ private function _defineResaveCommand(): void 'action' => function(): int { /** @var ResaveController $controller */ $controller = Craft::$app->controller; + // @TODO Remove this check when Commerce requires Craft 5.5 + if (version_compare(Craft::$app->getInfo()->version, '5.5.0', '>=') && !empty($controller->withFields)) { + $fieldLayout = Craft::$app->getFields()->getLayoutByType(Order::class); + if (!$controller->hasTheFields($fieldLayout)) { + $controller->output($controller->markdownToAnsi('The order field layout doesn’t satisfy `--with-fields`.')); + return ExitCode::UNSPECIFIED_ERROR; + } + } + return $controller->resaveElements(Order::class, [ 'isCompleted' => false, ]); }, - 'options' => [], + 'options' => array_filter([(property_exists(ResaveController::class, 'withFields') ? 'withFields' : null)]), 'helpSummary' => 'Re-saves Commerce carts.', ]; }); diff --git a/src/base/InventoryItemTrait.php b/src/base/InventoryItemTrait.php new file mode 100644 index 0000000000..1bfc485d20 --- /dev/null +++ b/src/base/InventoryItemTrait.php @@ -0,0 +1,61 @@ + + * @since 5.3.0 + */ +trait InventoryItemTrait +{ + /** + * @var int|null The inventory item ID + */ + public ?int $inventoryItemId = null; + + /** + * @var InventoryItem|null The inventory item + * @see getInventoryItem() + * @see setInventoryItem() + */ + private ?InventoryItem $_inventoryItem = null; + + /** + * @param InventoryItem|null $inventoryItem + * @return void + */ + public function setInventoryItem(?InventoryItem $inventoryItem): void + { + $this->_inventoryItem = $inventoryItem; + $this->inventoryItemId = $inventoryItem?->id ?? null; + } + + /** + * @return InventoryItem|null + * @throws \yii\base\InvalidConfigException + */ + public function getInventoryItem(): ?InventoryItem + { + if (isset($this->_inventoryItem)) { + return $this->_inventoryItem; + } + + if ($this->inventoryItemId) { + $this->_inventoryItem = Plugin::getInstance()->getInventory()->getInventoryItemById($this->inventoryItemId); + + return $this->_inventoryItem; + } + + return null; + } +} diff --git a/src/base/InventoryLocationTrait.php b/src/base/InventoryLocationTrait.php new file mode 100644 index 0000000000..02888bc927 --- /dev/null +++ b/src/base/InventoryLocationTrait.php @@ -0,0 +1,61 @@ + + * @since 5.3.0 + */ +trait InventoryLocationTrait +{ + /** + * @var int|null The inventory item ID + */ + public ?int $inventoryLocationId = null; + + /** + * @var InventoryLocation|null The inventory item + * @see getInventoryLocation() + * @see setInventoryLocation() + */ + private ?InventoryLocation $_inventoryLocation = null; + + /** + * @param InventoryLocation|null $inventoryLocation + * @return void + */ + public function setInventoryLocation(?InventoryLocation $inventoryLocation): void + { + $this->_inventoryLocation = $inventoryLocation; + $this->inventoryLocationId = $inventoryLocation?->id ?? null; + } + + /** + * @return InventoryLocation|null + * @throws \yii\base\InvalidConfigException + */ + public function getInventoryLocation(): ?InventoryLocation + { + if (isset($this->_inventoryLocation)) { + return $this->_inventoryLocation; + } + + if ($this->inventoryLocationId) { + $this->_inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($this->inventoryLocationId); + + return $this->_inventoryLocation; + } + + return null; + } +} diff --git a/src/base/InventoryMovement.php b/src/base/InventoryMovement.php index 6675a1ff48..6d8ea1b6d3 100644 --- a/src/base/InventoryMovement.php +++ b/src/base/InventoryMovement.php @@ -8,7 +8,6 @@ namespace craft\commerce\base; use craft\commerce\enums\InventoryTransactionType; -use craft\commerce\models\InventoryItem; use craft\commerce\models\InventoryLocation; /** @@ -18,10 +17,7 @@ */ abstract class InventoryMovement extends Model implements InventoryMovementInterface { - /** - * @var InventoryItem The inventory item - */ - public InventoryItem $inventoryItem; + use InventoryItemTrait; /** * @var InventoryLocation @@ -90,27 +86,31 @@ public function init(): void } /** - * @inheritDoc + * @return array */ - public function isValid(): bool + protected function defineRules(): array { - return $this->validate(); + $rules = parent::defineRules(); + + $rules[] = [['inventoryItemId'], 'safe']; + + return $rules; } /** * @inheritDoc */ - public function getInventoryMovementHash(): string + public function isValid(): bool { - return $this->_inventoryMovementHash; + return $this->validate(); } /** * @inheritDoc */ - public function getInventoryItem(): InventoryItem + public function getInventoryMovementHash(): string { - return $this->inventoryItem; + return $this->_inventoryMovementHash; } /** diff --git a/src/base/InventoryMovementInterface.php b/src/base/InventoryMovementInterface.php index 2ea87f0c0a..7e29090bad 100644 --- a/src/base/InventoryMovementInterface.php +++ b/src/base/InventoryMovementInterface.php @@ -21,7 +21,7 @@ interface InventoryMovementInterface /** * @return InventoryItem */ - public function getInventoryItem(): InventoryItem; + public function getInventoryItem(): ?InventoryItem; /** * @return InventoryLocation diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index dcae3e164f..7ac8cf2de2 100644 --- a/src/base/Purchasable.php +++ b/src/base/Purchasable.php @@ -329,15 +329,30 @@ public function setAttributesFromRequest(array $values): void */ protected function inlineAttributeInputHtml(string $attribute): string { + $localizePrice = function(string $attribute) { + $price = $this->{$attribute}; + if (empty($this->getErrors($attribute))) { + if ($price === null && $attribute === 'basePromotionalPrice') { + return null; + } elseif ($price === null) { + $price = 0; + } + + $price = Craft::$app->getFormatter()->asDecimal($price); + } + + return $price; + }; + return match ($attribute) { 'availableForPurchase' => PurchasableHelper::availableForPurchaseInputHtml($this->availableForPurchase), - 'price' => Currency::moneyInputHtml($this->basePrice, [ + 'price' => Currency::moneyInputHtml($localizePrice('basePrice'), [ 'id' => 'base-price', 'name' => 'basePrice', 'currency' => $this->getStore()->getCurrency()->getCode(), 'currencyLabel' => $this->getStore()->getCurrency()->getCode(), ]), - 'promotionalPrice' => Currency::moneyInputHtml($this->basePromotionalPrice, [ + 'promotionalPrice' => Currency::moneyInputHtml($localizePrice('basePromotionalPrice'), [ 'id' => 'base-promotional-price', 'name' => 'basePromotionalPrice', 'currency' => $this->getStore()->getCurrency()->getCode(), @@ -368,6 +383,20 @@ private function _getTeller(): Teller return Plugin::getInstance()->getCurrencies()->getTeller($this->getStore()->getCurrency()); } + /** + * @inheritdoc + */ + public function __unset($name) + { + // Allow clearing of specific memoized properties + if (in_array($name, ['stock', 'shippingCategory', 'taxCategory'])) { + $this->{'_' . $name} = null; + return; + } + + parent::__unset($name); + } + /** * @inheritdoc */ @@ -934,7 +963,6 @@ public function getStock(): int return $this->_stock; } - /** * Returns the total stock across all locations this purchasable is tracked in. * @return Collection @@ -1104,6 +1132,8 @@ public function afterSave(bool $isNew): void */ public function afterPropagate(bool $isNew): void { + parent::afterPropagate($isNew); + Plugin::getInstance()->getCatalogPricing()->createCatalogPricingJob([ 'purchasableIds' => [$this->getCanonicalId()], 'storeId' => $this->getStoreId(), @@ -1259,10 +1289,28 @@ protected function attributeHtml(string $attribute): string } } + $dimensions = []; + if ($attribute === 'dimensions') { + $dimensions = array_filter([ + $this->length, + $this->width, + $this->height, + ]); + } + + if ($attribute === 'priceView') { + $price = $this->basePriceAsCurrency; + if ($this->getBasePromotionalPrice() && $this->getBasePromotionalPrice() < $this->getBasePrice()) { + $price = Html::tag('del', $price, ['style' => 'opacity: .5']) . ' ' . $this->basePromotionalPriceAsCurrency; + } + + return $price; + } + return match ($attribute) { 'sku' => (string)Html::encode($this->getSkuAsText()), 'price' => $this->basePriceAsCurrency, - 'promotionalPrice' => $this->basePromotionalPriceAsCurrency, + 'promotionalPrice' => $this->basePromotionalPrice !== null ? $this->basePromotionalPriceAsCurrency : '', 'weight' => $this->weight !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->weightUnits : '', 'length' => $this->length !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', 'width' => $this->width !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', @@ -1270,6 +1318,7 @@ protected function attributeHtml(string $attribute): string 'minQty' => (string)$this->minQty, 'maxQty' => (string)$this->maxQty, 'stock' => $stock, + 'dimensions' => !empty($dimensions) ? implode(' x ', $dimensions) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', default => parent::attributeHtml($attribute), }; } @@ -1307,6 +1356,82 @@ protected static function defineDefaultTableAttributes(string $source): array ]; } + /** + * @inheritdoc + */ + public static function attributePreviewHtml(array $attribute): mixed + { + return match ($attribute['value']) { + 'sku', 'priceView', 'dimensions', 'weight' => $attribute['placeholder'], + 'availableForPurchase', 'promotable' => Html::tag('span', '', [ + 'class' => 'checkbox-icon', + 'role' => 'img', + 'title' => $attribute['label'], + 'aria' => [ + 'label' => $attribute['label'], + ], + ]) . + Html::tag('span', $attribute['label'], [ + 'class' => 'checkbox-preview-label', + ]), + default => parent::attributePreviewHtml($attribute) + }; + } + + /** + * @inheritdoc + */ + protected static function defineDefaultCardAttributes(): array + { + return array_merge(parent::defineDefaultCardAttributes(), [ + 'sku', + 'priceView', + ]); + } + + /** + * @inheritdoc + */ + protected static function defineCardAttributes(): array + { + return array_merge(Element::defineCardAttributes(), [ + 'availableForPurchase' => [ + 'label' => Craft::t('commerce', 'Available for purchase'), + ], + 'basePrice' => [ + 'label' => Craft::t('commerce', 'Base Price'), + 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), + ], + 'basePromotionalPrice' => [ + 'label' => Craft::t('commerce', 'Base Promotional Price'), + 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), + ], + 'dimensions' => [ + 'label' => Craft::t('commerce', 'Dimensions'), + 'placeholder' => '1 x 2 x 3 ' . Plugin::getInstance()->getSettings()->dimensionUnits, + ], + 'priceView' => [ + 'label' => Craft::t('commerce', 'Price'), + 'placeholder' => Html::tag('del', '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(199.99), ['style' => 'opacity: .5']) . ' ¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), + ], + 'promotable' => [ + 'label' => Craft::t('commerce', 'Promotable'), + ], + 'sku' => [ + 'label' => Craft::t('commerce', 'SKU'), + 'placeholder' => Html::tag('code', 'SKU123'), + ], + 'stock' => [ + 'label' => Craft::t('commerce', 'Stock'), + 'placeholder' => 10, + ], + 'weight' => [ + 'label' => Craft::t('commerce', 'Weight'), + 'placeholder' => 123 . Plugin::getInstance()->getSettings()->weightUnits, + ], + ]); + } + /** * @inheritdoc */ diff --git a/src/base/StoreTrait.php b/src/base/StoreTrait.php index 8cec683c28..cc92f91216 100644 --- a/src/base/StoreTrait.php +++ b/src/base/StoreTrait.php @@ -1,4 +1,9 @@ map(function(UpdateInventoryLevel $updateInventoryLevel) { - return $updateInventoryLevel->inventoryItem->getPurchasable(); + return $this->map(function(UpdateInventoryLevel|UpdateInventoryLevelInTransfer $updateInventoryLevel) { + return $updateInventoryLevel->getInventoryItem()->getPurchasable(); })->all(); } } diff --git a/src/controllers/BaseStoreManagementController.php b/src/controllers/BaseStoreManagementController.php index f0185773a4..2a34a6c4ea 100644 --- a/src/controllers/BaseStoreManagementController.php +++ b/src/controllers/BaseStoreManagementController.php @@ -9,6 +9,7 @@ use Craft; use craft\commerce\Plugin; +use craft\web\UrlManager; use yii\base\InvalidConfigException; use yii\web\Response as YiiResponse; @@ -40,6 +41,18 @@ public function init(): void public function renderTemplate(string $template, array $variables = [], ?string $templateMode = null): YiiResponse { $variables['storeSettingsNav'] = $this->getStoreSettingsNav(); + + if (!isset($variables['storeHandle'])) { + /** @var UrlManager $urlManager */ + $urlManager = Craft::$app->getUrlManager(); + $routeParams = $urlManager->getRouteParams(); + + // Make sure store handle is always passed to the template + if (isset($routeParams['storeHandle'])) { + $variables['storeHandle'] = $routeParams['storeHandle']; + } + } + return parent::renderTemplate($template, $variables, $templateMode); } diff --git a/src/controllers/InventoryController.php b/src/controllers/InventoryController.php index 13e2653aea..8ebfb7d3af 100644 --- a/src/controllers/InventoryController.php +++ b/src/controllers/InventoryController.php @@ -242,6 +242,8 @@ public function actionInventoryLevelsTableData(): Response $inventoryQuery->leftJoin(['purchasables' => Table::PURCHASABLES], '[[ii.purchasableId]] = [[purchasables.id]]'); $inventoryQuery->addGroupBy(['[[purchasables.description]]', '[[purchasables.sku]]']); + $inventoryQuery->andWhere(['not', ['elements.id' => null]]); + if ($search) { $inventoryQuery->andWhere(['or', ['like', 'purchasables.description', $search], ['like', 'purchasables.sku', $search]]); } @@ -501,7 +503,6 @@ public function actionUpdateLevels(): Response $note = Craft::$app->getRequest()->getRequiredParam('note'); $inventoryLocationId = (int)Craft::$app->getRequest()->getRequiredParam('inventoryLocationId'); $inventoryItemIds = Craft::$app->getRequest()->getRequiredParam('ids'); - $inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($inventoryLocationId); $type = Craft::$app->getRequest()->getRequiredParam('type'); // We don't add zero amounts as transactions movements @@ -512,17 +513,16 @@ public function actionUpdateLevels(): Response $errors = []; $updateInventoryLevels = UpdateInventoryLevelCollection::make(); foreach ($inventoryItemIds as $inventoryItemId) { - $inventoryItem = Plugin::getInstance()->getInventory()->getInventoryItemById($inventoryItemId); - - $updateInventoryLevels->push(new UpdateInventoryLevel([ - 'type' => $type, - 'updateAction' => $updateAction, - 'inventoryItem' => $inventoryItem, - 'inventoryLocation' => $inventoryLocation, - 'quantity' => $quantity, - 'note' => $note, - ]) - ); + // Verbosely set property to show usages + $updateInventoryLevel = new UpdateInventoryLevel(); + $updateInventoryLevel->type = $type; + $updateInventoryLevel->updateAction = $updateAction; + $updateInventoryLevel->inventoryItemId = $inventoryItemId; + $updateInventoryLevel->inventoryLocationId = $inventoryLocationId; + $updateInventoryLevel->quantity = $quantity; + $updateInventoryLevel->note = $note; + + $updateInventoryLevels->push($updateInventoryLevel); } @@ -538,7 +538,8 @@ public function actionUpdateLevels(): Response $resultingInventoryLevels = []; foreach ($updateInventoryLevels as $updateInventoryLevel) { - $resultingInventoryLevels[] = Plugin::getInstance()->getInventory()->getInventoryLevel($updateInventoryLevel->inventoryItem, $updateInventoryLevel->inventoryLocation); + /** @var UpdateInventoryLevel $updateInventoryLevel */ + $resultingInventoryLevels[] = Plugin::getInstance()->getInventory()->getInventoryLevel($updateInventoryLevel->inventoryItemId, $updateInventoryLevel->inventoryLocationId); } return $this->asSuccess(Craft::t('commerce', 'Inventory updated.'), [ @@ -563,12 +564,9 @@ public function actionEditUpdateLevelsModal(): Response $quantity = (int)$this->request->getParam('quantity', 0); $type = $this->request->getRequiredParam('type'); - $inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($inventoryLocationId); - $inventoryLevels = []; foreach ($inventoryItemIds as $inventoryItemId) { - $item = Plugin::getInstance()->getInventory()->getInventoryItemById((int)$inventoryItemId); - $inventoryLevels[] = Plugin::getInstance()->getInventory()->getInventoryLevel($item, $inventoryLocation); + $inventoryLevels[] = Plugin::getInstance()->getInventory()->getInventoryLevel((int)$inventoryItemId, $inventoryLocationId); } $params = [ @@ -612,17 +610,14 @@ public function actionSaveInventoryMovement(): Response return $this->asSuccess(Craft::t('commerce', 'No inventory movements made.')); } - $inventoryMovement = new InventoryManualMovement( - [ - 'inventoryItem' => Plugin::getInstance()->getInventory()->getInventoryItemById($inventoryItemId), - 'fromInventoryLocation' => Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($fromInventoryLocationId), - 'toInventoryLocation' => Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($toInventoryLocationId), - 'fromInventoryTransactionType' => InventoryTransactionType::from($fromInventoryTransactionType), - 'toInventoryTransactionType' => InventoryTransactionType::from($toInventoryTransactionType), - 'quantity' => $quantity, - 'note' => $note, - ] - ); + $inventoryMovement = new InventoryManualMovement(); + $inventoryMovement->inventoryItemId = $inventoryItemId; + $inventoryMovement->fromInventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($fromInventoryLocationId); + $inventoryMovement->toInventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($toInventoryLocationId); + $inventoryMovement->fromInventoryTransactionType = InventoryTransactionType::from($fromInventoryTransactionType); + $inventoryMovement->toInventoryTransactionType = InventoryTransactionType::from($toInventoryTransactionType); + $inventoryMovement->quantity = $quantity; + $inventoryMovement->note = $note; if ($inventoryMovement->validate()) { /** @var InventoryMovementCollection $inventoryMovementCollection */ @@ -663,19 +658,16 @@ public function actionEditMovementModal(): Response $toInventoryTransactionType = $toInventoryTransactionType->value; } - $inventoryMovement = new InventoryManualMovement( - [ - 'inventoryItem' => Plugin::getInstance()->getInventory()->getInventoryItemById($inventoryItemId), - 'fromInventoryLocation' => Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($fromInventoryLocationId), - 'toInventoryLocation' => Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($toInventoryLocationId), - 'fromInventoryTransactionType' => InventoryTransactionType::from($fromInventoryTransactionType), - 'toInventoryTransactionType' => InventoryTransactionType::from($toInventoryTransactionType), - 'quantity' => $quantity, - 'note' => $note, - ] - ); - - $fromLevel = Plugin::getInstance()->getInventory()->getInventoryLevel($inventoryMovement->inventoryItem, $inventoryMovement->fromInventoryLocation); + $inventoryMovement = new InventoryManualMovement(); + $inventoryMovement->inventoryItemId = $inventoryItemId; + $inventoryMovement->fromInventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($fromInventoryLocationId); + $inventoryMovement->toInventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($toInventoryLocationId); + $inventoryMovement->fromInventoryTransactionType = InventoryTransactionType::from($fromInventoryTransactionType); + $inventoryMovement->toInventoryTransactionType = InventoryTransactionType::from($toInventoryTransactionType); + $inventoryMovement->quantity = $quantity; + $inventoryMovement->note = $note; + + $fromLevel = Plugin::getInstance()->getInventory()->getInventoryLevel($inventoryMovement->inventoryItemId, $inventoryMovement->fromInventoryLocation); $fromTotal = $fromLevel->{$fromInventoryTransactionType . 'Total'}; $movableTo = $movableTo->toArray(); @@ -703,10 +695,7 @@ public function actionUnfulfilledOrders(): Response $inventoryLocationId = Craft::$app->getRequest()->getParam('inventoryLocationId'); $inventoryItemId = Craft::$app->getRequest()->getParam('inventoryItemId'); - $inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($inventoryLocationId); - $inventoryItem = Plugin::getInstance()->getInventory()->getInventoryItemById($inventoryItemId); - - $orders = Plugin::getInstance()->getInventory()->getUnfulfilledOrders($inventoryItem, $inventoryLocation); + $orders = Plugin::getInstance()->getInventory()->getUnfulfilledOrders($inventoryItemId, $inventoryLocationId); $title = Craft::t('commerce', '{count} Unfulfilled Orders', [ 'count' => count($orders), diff --git a/src/controllers/OrdersController.php b/src/controllers/OrdersController.php index 52c382a06e..32edfcf0ea 100644 --- a/src/controllers/OrdersController.php +++ b/src/controllers/OrdersController.php @@ -259,10 +259,10 @@ public function actionFulfill(): Response $qty = (int)$fulfillment['quantity']; if ($qty != 0) { $inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($fulfillment['inventoryLocationId']); - $inventoryItem = Plugin::getInstance()->getInventory()->getInventoryItemById($fulfillment['inventoryItemId']); + $movement = new InventoryFulfillMovement(); $movement->fromInventoryLocation = $inventoryLocation; - $movement->inventoryItem = $inventoryItem; + $movement->inventoryItemId = $fulfillment['inventoryItemId']; $movement->toInventoryLocation = $inventoryLocation; $movement->fromInventoryTransactionType = InventoryTransactionType::COMMITTED; $movement->toInventoryTransactionType = InventoryTransactionType::FULFILLED; @@ -1236,12 +1236,16 @@ public function actionPaymentAmountData(): Response $paymentCurrencies = Plugin::getInstance()->getPaymentCurrencies(); $paymentCurrency = $this->request->getRequiredParam('paymentCurrency'); $paymentAmount = $this->request->getRequiredParam('paymentAmount'); + $locale = $this->request->getRequiredParam('locale'); $orderId = $this->request->getRequiredParam('orderId'); /** @var Order $order */ $order = Order::find()->id($orderId)->one(); $baseCurrency = $order->currency; - $baseCurrencyPaymentAmount = $paymentCurrencies->convertCurrency($paymentAmount, $paymentCurrency, $baseCurrency); + $paymentAmount = MoneyHelper::toMoney(['value' => $paymentAmount, 'currency' => $baseCurrency, 'locale' => $locale]); + $paymentAmount = MoneyHelper::toDecimal($paymentAmount); + + $baseCurrencyPaymentAmount = $paymentCurrencies->convertCurrency((float)$paymentAmount, $paymentCurrency, $baseCurrency); $baseCurrencyPaymentAmountAsCurrency = Craft::t('commerce', 'Pay {amount} of {currency} on the order.', ['amount' => Currency::formatAsCurrency($baseCurrencyPaymentAmount, $baseCurrency), 'currency' => $baseCurrency]); $outstandingBalance = $order->outstandingBalance; @@ -1443,6 +1447,7 @@ private function _registerJavascript(array $variables): void if ($order->hasErrors()) { $response['order']['errors'] = $order->getErrors(); + $response['errors'] = $order->getErrors(); $response['error'] = Craft::t('commerce', 'The order is not valid.'); } diff --git a/src/controllers/TransfersController.php b/src/controllers/TransfersController.php index a6fcf304ba..0c278fdb69 100644 --- a/src/controllers/TransfersController.php +++ b/src/controllers/TransfersController.php @@ -197,7 +197,7 @@ public function actionReceiveTransfer(): Response $inventoryAcceptedMovement = new InventoryTransferMovement(); $inventoryAcceptedMovement->quantity = $acceptedAmount; $inventoryAcceptedMovement->transferId = $transfer->id; - $inventoryAcceptedMovement->inventoryItem = $detail->getInventoryItem(); + $inventoryAcceptedMovement->setInventoryItem($detail->getInventoryItem()); $inventoryAcceptedMovement->toInventoryLocation = $transfer->getDestinationLocation(); $inventoryAcceptedMovement->fromInventoryLocation = $transfer->getDestinationLocation(); // we are moving from incoming to available $inventoryAcceptedMovement->toInventoryTransactionType = InventoryTransactionType::AVAILABLE; @@ -213,9 +213,9 @@ public function actionReceiveTransfer(): Response $inventoryRejectedMovement = new UpdateInventoryLevel(); $inventoryRejectedMovement->quantity = $rejectedAmount * -1; $inventoryRejectedMovement->updateAction = InventoryUpdateQuantityType::ADJUST; - $inventoryRejectedMovement->inventoryItem = $detail->getInventoryItem(); + $inventoryRejectedMovement->inventoryItemId = $detail->inventoryItemId; $inventoryRejectedMovement->transferId = $transfer->id; - $inventoryRejectedMovement->inventoryLocation = $transfer->getDestinationLocation(); + $inventoryRejectedMovement->setInventoryLocation($transfer->getDestinationLocation()); $inventoryRejectedMovement->type = InventoryTransactionType::INCOMING->value; $inventoryUpdateCollection->push($inventoryRejectedMovement); diff --git a/src/controllers/UsersController.php b/src/controllers/UsersController.php index 71a5063227..812ace7bdf 100644 --- a/src/controllers/UsersController.php +++ b/src/controllers/UsersController.php @@ -63,20 +63,23 @@ public function actionIndex(?int $userId = null): Response $edge = Plugin::getInstance()->getCarts()->getActiveCartEdgeDuration(); - $content = Html::tag('h2', Craft::t('commerce', 'Orders')) . - Html::beginTag('div', ['class' => 'commerce-user-orders']) . + $content = ''; + + if (Craft::$app->getUser()->getIdentity()->can('commerce-manageOrders')) { + $content .= Html::tag('h2', Craft::t('commerce', 'Orders')) . + Html::beginTag('div', ['class' => 'commerce-user-orders']) . Cp::elementIndexHtml(Order::class, ArrayHelper::merge($config, [ 'id' => sprintf('element-index-%s', mt_rand()), 'jsSettings' => [ 'criteria' => ['isCompleted' => true], ], ])) . - Html::endTag('div') . + Html::endTag('div') . - Html::tag('hr') . + Html::tag('hr') . - Html::tag('h2', Craft::t('commerce', 'Active Carts')) . - Html::beginTag('div', ['class' => 'commerce-user-active-carts']) . + Html::tag('h2', Craft::t('commerce', 'Active Carts')) . + Html::beginTag('div', ['class' => 'commerce-user-active-carts']) . Cp::elementIndexHtml(Order::class, ArrayHelper::merge($config, [ 'id' => sprintf('element-index-%s', mt_rand()), 'jsSettings' => [ @@ -86,12 +89,12 @@ public function actionIndex(?int $userId = null): Response ], ], ])) . - Html::endTag('div') . + Html::endTag('div') . - Html::tag('hr') . + Html::tag('hr') . - Html::tag('h2', Craft::t('commerce', 'Inactive Carts')) . - Html::beginTag('div', ['class' => 'commerce-user-active-carts']) . + Html::tag('h2', Craft::t('commerce', 'Inactive Carts')) . + Html::beginTag('div', ['class' => 'commerce-user-active-carts']) . Cp::elementIndexHtml(Order::class, ArrayHelper::merge($config, [ 'id' => sprintf('element-index-%s', mt_rand()), 'jsSettings' => [ @@ -101,7 +104,8 @@ public function actionIndex(?int $userId = null): Response ], ], ])) . - Html::endTag('div'); + Html::endTag('div'); + } if (Craft::$app->getUser()->getIdentity()->can('commerce-manageSubscriptions') and !empty(Plugin::getInstance()->getPlans()->getAllPlans())) { diff --git a/src/elements/Product.php b/src/elements/Product.php index 20526d32c8..8261a18620 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -565,6 +565,45 @@ protected static function defineDefaultTableAttributes(string $source): array return $attributes; } + /** + * @inheritdoc + */ + public static function attributePreviewHtml(array $attribute): mixed + { + return match ($attribute['value']) { + 'defaultSku' => $attribute['placeholder'], + default => parent::attributePreviewHtml($attribute) + }; + } + + /** + * @inheritdoc + */ + protected static function defineCardAttributes(): array + { + return array_merge(parent::defineCardAttributes(), [ + 'defaultPrice' => [ + 'label' => Craft::t('commerce', 'Price'), + 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), + ], + 'defaultSku' => [ + 'label' => Craft::t('commerce', 'SKU'), + 'placeholder' => Html::tag('code', 'SKU123'), + ], + ]); + } + + /** + * @inheritdoc + */ + protected static function defineDefaultCardAttributes(): array + { + return array_merge(parent::defineDefaultCardAttributes(), [ + 'defaultSku', + 'defaultPrice', + ]); + } + /** * @inheritdoc */ @@ -1065,6 +1104,18 @@ public function getVariants(bool $includeDisabled = false): VariantCollection return $this->_variants->filter(fn(Variant $variant) => $includeDisabled || ($variant->getStatus() === self::STATUS_ENABLED)); } + /** + * @return VariantCollection + * @throws InvalidConfigException + * @internal Do not use. Temporary method until we get a nested element manager provider in core. + * + * TODO: Remove this once we have a nested element manager provider interface in core. + */ + public function getAllVariants(): VariantCollection + { + return $this->getVariants(true); + } + /** * @inheritdoc */ @@ -1187,9 +1238,9 @@ public function getVariantManager(): NestedElementManager /** @phpstan-ignore-next-line */ fn(Product $product) => self::createVariantQuery($product), [ - 'attribute' => 'variants', + 'attribute' => 'allVariants', // TODO: can change this back to 'variants' once we have a nested element manager provider in core. 'propagationMethod' => $this->getType()->propagationMethod, - 'valueGetter' => fn(Product $product) => $product->getVariants(true), + 'valueSetter' => fn($variants) => $this->setVariants($variants), // TODO: can change this back to 'variants' once we have a nested element manager provider in core. ], ); } diff --git a/src/elements/Transfer.php b/src/elements/Transfer.php index f59e49e689..df1b16120e 100644 --- a/src/elements/Transfer.php +++ b/src/elements/Transfer.php @@ -82,11 +82,17 @@ public function __toString(): string ]); } + /** + * @inheritdoc + */ public static function hasDrafts(): bool { return false; } + /** + * @inheritdoc + */ protected function metadata(): array { $additionalMeta = []; @@ -191,7 +197,7 @@ public function setTransferStatus(TransferStatusType|string $status): void } /** - * @inheritDoc + * @inheritdoc */ public static function displayName(): string { @@ -199,7 +205,7 @@ public static function displayName(): string } /** - * @inheritDoc + * @inheritdoc */ public static function lowerDisplayName(): string { @@ -207,7 +213,7 @@ public static function lowerDisplayName(): string } /** - * @inheritDoc + * @inheritdoc */ public static function pluralDisplayName(): string { @@ -215,7 +221,7 @@ public static function pluralDisplayName(): string } /** - * @inheritDoc + * @inheritdoc */ public static function pluralLowerDisplayName(): string { @@ -223,7 +229,7 @@ public static function pluralLowerDisplayName(): string } /** - * @inheritDoc + * @inheritdoc */ public static function refHandle(): ?string { @@ -231,7 +237,7 @@ public static function refHandle(): ?string } /** - * @inheritDoc + * @inheritdoc */ public static function trackChanges(): bool { @@ -239,7 +245,7 @@ public static function trackChanges(): bool } /** - * @inheritDoc + * @inheritdoc */ public static function hasTitles(): bool { @@ -247,7 +253,7 @@ public static function hasTitles(): bool } /** - * @inheritDoc + * @inheritdoc */ public static function hasContent(): bool { @@ -255,7 +261,7 @@ public static function hasContent(): bool } /** - * @inheritDoc + * @inheritdoc */ public static function hasUris(): bool { @@ -263,7 +269,7 @@ public static function hasUris(): bool } /** - * @inheritDoc + * @inheritdoc */ public static function isLocalized(): bool { @@ -271,7 +277,7 @@ public static function isLocalized(): bool } /** - * @inheritDoc + * @inheritdoc */ public static function hasStatuses(): bool { @@ -280,7 +286,7 @@ public static function hasStatuses(): bool /** * @return TransferQuery - * @inheritDoc + * @inheritdoc */ public static function find(): ElementQueryInterface { @@ -288,7 +294,7 @@ public static function find(): ElementQueryInterface } /** - * @inheritDoc + * @inheritdoc */ public static function createCondition(): ElementConditionInterface { @@ -296,7 +302,7 @@ public static function createCondition(): ElementConditionInterface } /** - * @inheritDoc + * @inheritdoc */ protected static function includeSetStatusAction(): bool { @@ -331,7 +337,7 @@ protected static function defineSortOptions(): array } /** - * @inheritDoc + * @inheritdoc */ protected static function defineTableAttributes(): array { @@ -347,7 +353,7 @@ protected static function defineTableAttributes(): array } /** - * @inheritDoc + * @inheritdoc */ protected static function defineDefaultTableAttributes(string $source): array { @@ -359,7 +365,7 @@ protected static function defineDefaultTableAttributes(string $source): array } /** - * @inheritDoc + * @inheritdoc */ protected function attributeHtml(string $attribute): string { @@ -384,7 +390,7 @@ protected function attributeHtml(string $attribute): string } /** - * @inheritDoc + * @inheritdoc */ protected function defineRules(): array { @@ -436,7 +442,7 @@ public function validateLocations($attribute, $params, $validator) } /** - * @inheritDoc + * @inheritdoc */ public function getUriFormat(): ?string { @@ -480,7 +486,7 @@ protected static function defineSources(string $context = null): array /** * - * @inheritDoc + * @inheritdoc */ protected function previewTargets(): array { @@ -497,6 +503,9 @@ protected function previewTargets(): array return $previewTargets; } + /** + * @inheritdoc + */ protected function safeActionMenuItems(): array { $safeActions = parent::safeActionMenuItems(); @@ -516,9 +525,8 @@ protected function safeActionMenuItems(): array return $safeActions; } - /** - * @inheritDoc + * @inheritdoc */ protected function route(): array|string|null { @@ -533,7 +541,7 @@ protected function route(): array|string|null } /** - * @inheritDoc + * @inheritdoc */ public function canView(User $user): bool { @@ -545,7 +553,7 @@ public function canView(User $user): bool } /** - * @inheritDoc + * @inheritdoc */ public function canSave(User $user): bool { @@ -557,7 +565,7 @@ public function canSave(User $user): bool } /** - * @inheritDoc + * @inheritdoc */ public function canDuplicate(User $user): bool { @@ -565,7 +573,7 @@ public function canDuplicate(User $user): bool } /** - * @inheritDoc + * @inheritdoc */ public function canDelete(User $user): bool { @@ -583,7 +591,7 @@ public function canDelete(User $user): bool } /** - * @inheritDoc + * @inheritdoc */ public function canCreateDrafts(User $user): bool { @@ -591,7 +599,7 @@ public function canCreateDrafts(User $user): bool } /** - * @inheritDoc + * @inheritdoc */ protected function cpEditUrl(): ?string { @@ -599,7 +607,7 @@ protected function cpEditUrl(): ?string } /** - * @inheritDoc + * @inheritdoc */ public function getPostEditUrl(): ?string { @@ -607,7 +615,7 @@ public function getPostEditUrl(): ?string } /** - * @inheritDoc + * @inheritdoc */ public function prepareEditScreen(Response $response, string $containerId): void { @@ -735,7 +743,7 @@ public function addDetail(TransferDetail $detail): void } /** - * @inheritDoc + * @inheritdoc */ public function getFieldLayout(): ?FieldLayout { @@ -743,7 +751,7 @@ public function getFieldLayout(): ?FieldLayout } /** - * @inheritDoc + * @inheritdoc */ public function beforeValidate() { @@ -755,7 +763,7 @@ public function beforeValidate() } /** - * @inheritDoc + * @inheritdoc */ public function afterSave(bool $isNew): void { @@ -779,26 +787,26 @@ public function afterSave(bool $isNew): void if ($this->getTransferStatus() === TransferStatusType::PENDING && $originalTransferStatus == TransferStatusType::DRAFT->value) { $inventoryUpdateCollection = new UpdateInventoryLevelCollection(); foreach ($this->getDetails() as $detail) { - $inventoryUpdate1 = new UpdateInventoryLevelInTransfer([ - 'type' => InventoryTransactionType::INCOMING->value, - 'updateAction' => InventoryUpdateQuantityType::ADJUST, - 'inventoryItem' => $detail->getInventoryItem(), - 'transferId' => $this->id, - 'inventoryLocation' => $this->getDestinationLocation(), - 'quantity' => $detail->quantity, - 'note' => Craft::t('commerce', 'Incoming transfer from Transfer ID: ') . $this->id, - ]); + $inventoryUpdate1 = new UpdateInventoryLevelInTransfer(); + $inventoryUpdate1->type = InventoryTransactionType::INCOMING->value; + $inventoryUpdate1->updateAction = InventoryUpdateQuantityType::ADJUST; + $inventoryUpdate1->inventoryItemId = $detail->inventoryItemId; + $inventoryUpdate1->transferId = $this->id; + $inventoryUpdate1->inventoryLocationId = $this->destinationLocationId; + $inventoryUpdate1->quantity = $detail->quantity; + $inventoryUpdate1->note = Craft::t('commerce', 'Incoming transfer from Transfer ID: ') . $this->id; + $inventoryUpdateCollection->push($inventoryUpdate1); - $inventoryUpdate2 = new UpdateInventoryLevelInTransfer([ - 'type' => 'onHand', - 'updateAction' => InventoryUpdateQuantityType::ADJUST, - 'inventoryItem' => $detail->getInventoryItem(), - 'transferId' => $this->id, - 'inventoryLocation' => $this->getOriginLocation(), - 'quantity' => $detail->quantity * -1, - 'note' => Craft::t('commerce', 'Outgoing transfer from Transfer ID: ') . $this->id, - ]); + $inventoryUpdate2 = new UpdateInventoryLevelInTransfer(); + $inventoryUpdate2->type = 'onHand'; + $inventoryUpdate2->updateAction = InventoryUpdateQuantityType::ADJUST; + $inventoryUpdate2->inventoryItemId = $detail->inventoryItemId; + $inventoryUpdate2->transferId = $this->id; + $inventoryUpdate2->inventoryLocationId = $this->originLocationId; + $inventoryUpdate2->quantity = $detail->quantity * -1; + $inventoryUpdate2->note = Craft::t('commerce', 'Outgoing transfer from Transfer ID: ') . $this->id; + $inventoryUpdateCollection->push($inventoryUpdate2); } diff --git a/src/elements/Variant.php b/src/elements/Variant.php index f0ddc22b28..7ef4f42117 100755 --- a/src/elements/Variant.php +++ b/src/elements/Variant.php @@ -740,6 +740,14 @@ public function canView(User $user): bool return $product->canView($user); } + /** + * @inheritdoc + */ + public function getCpEditUrl(): ?string + { + return $this->getOwner() ? $this->getOwner()->getCpEditUrl() : null; + } + /** * @inheritdoc */ @@ -1060,6 +1068,7 @@ public function afterSave(bool $isNew): void parent::afterSave($isNew); if (!$this->propagating && $this->isDefault && $ownerId && $this->duplicateOf === null) { + // @TODO - this data is now joined in on the product query so can be removed at the next breaking change $defaultData = [ 'defaultVariantId' => $this->id, 'defaultSku' => $this->getSkuAsText(), @@ -1363,6 +1372,18 @@ protected static function defineSearchableAttributes(): array return [...parent::defineSearchableAttributes(), ...['productTitle']]; } + /** + * @inheritdoc + */ + protected static function defineCardAttributes(): array + { + return array_merge(parent::defineCardAttributes(), [ + 'product' => [ + 'label' => Craft::t('commerce', 'Product'), + ], + ]); + } + /** * @inheritdoc */ diff --git a/src/elements/conditions/orders/CouponCodeConditionRule.php b/src/elements/conditions/orders/CouponCodeConditionRule.php new file mode 100644 index 0000000000..a6c231899f --- /dev/null +++ b/src/elements/conditions/orders/CouponCodeConditionRule.php @@ -0,0 +1,57 @@ + + * @since 5.3.0 + */ +class CouponCodeConditionRule extends OrderTextValuesAttributeConditionRule +{ + public string $orderAttribute = 'couponCode'; + + /** + * @inheritdoc + */ + public function getLabel(): string + { + return Craft::t('commerce', 'Coupon Code'); + } + + /** + * @inheritdoc + */ + protected function matchValue(mixed $value): bool + { + switch ($this->operator) { + case self::OPERATOR_EMPTY: + return !$value; + case self::OPERATOR_NOT_EMPTY: + return (bool)$value; + } + + if ($this->value === '') { + return true; + } + + return match ($this->operator) { + self::OPERATOR_EQ => strcasecmp($value, $this->value) === 0, + self::OPERATOR_NE => strcasecmp($value, $this->value) !== 0, + self::OPERATOR_BEGINS_WITH => is_string($value) && StringHelper::startsWith($value, $this->value, false), + self::OPERATOR_ENDS_WITH => is_string($value) && StringHelper::endsWith($value, $this->value, false), + self::OPERATOR_CONTAINS => is_string($value) && StringHelper::contains($value, $this->value, false), + default => throw new InvalidConfigException("Invalid operator: $this->operator"), + }; + } +} diff --git a/src/elements/conditions/orders/DiscountOrderCondition.php b/src/elements/conditions/orders/DiscountOrderCondition.php index 14c7a730ad..4b35288076 100644 --- a/src/elements/conditions/orders/DiscountOrderCondition.php +++ b/src/elements/conditions/orders/DiscountOrderCondition.php @@ -5,6 +5,7 @@ use craft\commerce\base\HasStoreInterface; use craft\commerce\base\StoreTrait; use craft\elements\db\ElementQueryInterface; +use craft\helpers\ArrayHelper; use yii\base\NotSupportedException; /** @@ -41,7 +42,12 @@ protected function config(): array */ protected function selectableConditionRules(): array { - return array_merge(parent::selectableConditionRules(), []); + $rules = array_merge(parent::selectableConditionRules(), []); + + // We don't need the condition to have the coupon code rule + ArrayHelper::removeValue($rules, CouponCodeConditionRule::class); + + return $rules; } /** diff --git a/src/elements/conditions/orders/OrderCondition.php b/src/elements/conditions/orders/OrderCondition.php index dd9e894c1c..15330cc8d0 100644 --- a/src/elements/conditions/orders/OrderCondition.php +++ b/src/elements/conditions/orders/OrderCondition.php @@ -30,8 +30,9 @@ protected function selectableConditionRules(): array { return array_merge(parent::selectableConditionRules(), [ DateOrderedConditionRule::class, - CustomerConditionRule::class, CompletedConditionRule::class, + CouponCodeConditionRule::class, + CustomerConditionRule::class, PaidConditionRule::class, HasPurchasableConditionRule::class, ItemSubtotalConditionRule::class, diff --git a/src/elements/conditions/variants/ProductConditionRule.php b/src/elements/conditions/variants/ProductConditionRule.php new file mode 100644 index 0000000000..c2464ab969 --- /dev/null +++ b/src/elements/conditions/variants/ProductConditionRule.php @@ -0,0 +1,68 @@ + + * @since 5.3.0 + */ +class ProductConditionRule extends BaseElementSelectConditionRule implements ElementConditionRuleInterface +{ + /** + * @inheritdoc + */ + protected function elementType(): string + { + return Product::class; + } + + /** + * @inheritdoc + */ + public function getLabel(): string + { + return Craft::t('commerce', 'Product'); + } + + /** + * @inheritdoc + */ + public function getExclusiveQueryParams(): array + { + return ['product', 'productId', 'primaryOwnerId', 'primaryOwner', 'owner', 'ownerId']; + } + + /** + * @inheritdoc + */ + public function modifyQuery(ElementQueryInterface $query): void + { + /** @var VariantQuery $query */ + $query->ownerId($this->getElementId()); + } + + /** + * @inheritdoc + */ + public function matchElement(ElementInterface $element): bool + { + /** @var Variant $element */ + return $element->getOwnerId() == $this->getElementId(); + } +} diff --git a/src/elements/conditions/variants/VariantCondition.php b/src/elements/conditions/variants/VariantCondition.php index 311b7baffd..49c3530b53 100644 --- a/src/elements/conditions/variants/VariantCondition.php +++ b/src/elements/conditions/variants/VariantCondition.php @@ -25,6 +25,7 @@ class VariantCondition extends ElementCondition protected function selectableConditionRules(): array { return array_merge(parent::selectableConditionRules(), [ + ProductConditionRule::class, SkuConditionRule::class, ]); } diff --git a/src/elements/db/OrderQuery.php b/src/elements/db/OrderQuery.php index fe483afcce..e91c433b43 100644 --- a/src/elements/db/OrderQuery.php +++ b/src/elements/db/OrderQuery.php @@ -59,6 +59,12 @@ class OrderQuery extends ElementQuery */ public mixed $reference = null; + /** + * @var mixed The order reference of the resulting order. + * @used-by couponCode() + */ + public mixed $couponCode = null; + /** * @var mixed The email address the resulting orders must have. */ @@ -372,6 +378,48 @@ public function reference(mixed $value): OrderQuery return $this; } + /** + * Narrows the query results based on the order's coupon code. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `':empty:'` | that don’t have a coupon code. + * | `':notempty:'` | that have a coupon code. + * | `'Foo'` | with a coupon code of `Foo`. + * | `'Foo*'` | with a coupon code that begins with `Foo`. + * | `'*Foo'` | with a coupon code that ends with `Foo`. + * | `'*Foo*'` | with a coupon code that contains `Foo`. + * | `'not *Foo*'` | with a coupon code that doesn’t contain `Foo`. + * | `['*Foo*', '*Bar*']` | with a coupon code that contains `Foo` or `Bar`. + * | `['not', '*Foo*', '*Bar*']` | with a coupon code that doesn’t contain `Foo` or `Bar`. + * + * --- + * + * ```twig + * {# Fetch the requested {element} #} + * {% set {element-var} = {twig-method} + * .reference('foo') + * .one() %} + * ``` + * + * ```php + * // Fetch the requested {element} + * ${element-var} = {php-method} + * ->reference('foo') + * ->one(); + * ``` + * + * @param string|null $value The property value + * @return static self reference + */ + public function couponCode(mixed $value): OrderQuery + { + $this->couponCode = $value; + return $this; + } + /** * Narrows the query results based on the customers’ email addresses. * @@ -1602,6 +1650,11 @@ protected function beforePrepare(): bool $this->subQuery->andWhere(Db::parseParam('commerce_orders.reference', $this->reference)); } + if (isset($this->couponCode)) { + // Coupon code criteria is case-insensitive like in the adjuster + $this->subQuery->andWhere(Db::parseParam('commerce_orders.couponCode', $this->couponCode, caseInsensitive: true)); + } + if (isset($this->email) && $this->email) { // Join and search the users table for email address $this->subQuery->leftJoin(CraftTable::USERS . ' users', '[[users.id]] = [[commerce_orders.customerId]]'); diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php index 28be43d670..745a542cf7 100644 --- a/src/elements/db/ProductQuery.php +++ b/src/elements/db/ProductQuery.php @@ -768,18 +768,20 @@ protected function beforePrepare(): bool 'commerce_products.postDate', 'commerce_products.expiryDate', 'subquery.price as defaultPrice', - 'commerce_products.defaultPrice as defaultBasePrice', + 'purchasablesstores.basePrice as defaultBasePrice', 'commerce_products.defaultVariantId', - 'commerce_products.defaultSku', - 'commerce_products.defaultWeight', - 'commerce_products.defaultLength', - 'commerce_products.defaultWidth', - 'commerce_products.defaultHeight', + 'purchasables.sku as defaultSku', + 'purchasables.weight as defaultWeight', + 'purchasables.length as defaultLength', + 'purchasables.width as defaultWidth', + 'purchasables.height as defaultHeight', 'sitestores.storeId', ]); // Join in sites stores to get product's store for current request $this->query->leftJoin(['sitestores' => Table::SITESTORES], '[[elements_sites.siteId]] = [[sitestores.siteId]]'); + $this->query->leftJoin(['purchasables' => Table::PURCHASABLES], '[[purchasables.id]] = [[commerce_products.defaultVariantId]]'); + $this->query->leftJoin(['purchasablesstores' => Table::PURCHASABLES_STORES], '[[purchasablesstores.purchasableId]] = [[commerce_products.defaultVariantId]] and [[sitestores.storeId]] = [[purchasablesstores.storeId]]'); $this->subQuery->addSelect(['catalogprices.price']); diff --git a/src/elements/db/PurchasableQuery.php b/src/elements/db/PurchasableQuery.php index 9f054c08da..bbda2206c6 100755 --- a/src/elements/db/PurchasableQuery.php +++ b/src/elements/db/PurchasableQuery.php @@ -670,7 +670,7 @@ protected function afterPrepare(): bool $this->subQuery->leftJoin(['purchasables_stores' => Table::PURCHASABLES_STORES], '[[purchasables_stores.storeId]] = [[sitestores.storeId]] AND [[purchasables_stores.purchasableId]] = [[commerce_purchasables.id]]'); $this->subQuery->leftJoin(['catalogprices' => $catalogPricesQuery], '[[catalogprices.purchasableId]] = [[commerce_purchasables.id]] AND [[catalogprices.storeId]] = [[sitestores.storeId]]'); - $this->subQuery->leftJoin(['inventoryitems' => Table::INVENTORYITEMS], '[[inventoryitems.purchasableId]] = [[commerce_purchasables.id]] OR [[inventoryitems.purchasableId]] = [[elements.canonicalId]]'); + $this->subQuery->leftJoin(['inventoryitems' => Table::INVENTORYITEMS], '[[inventoryitems.purchasableId]] = [[commerce_purchasables.id]]'); return parent::afterPrepare(); } @@ -705,7 +705,7 @@ protected function beforePrepare(): bool $this->query->leftJoin(Table::SITESTORES . ' sitestores', '[[elements_sites.siteId]] = [[sitestores.siteId]]'); $this->query->leftJoin(Table::PURCHASABLES_STORES . ' purchasables_stores', '[[purchasables_stores.storeId]] = [[sitestores.storeId]] AND [[purchasables_stores.purchasableId]] = [[commerce_purchasables.id]]'); - $this->query->leftJoin(['inventoryitems' => Table::INVENTORYITEMS], '[[inventoryitems.purchasableId]] = [[commerce_purchasables.id]] OR [[inventoryitems.purchasableId]] = [[elements.canonicalId]]'); + $this->query->leftJoin(['inventoryitems' => Table::INVENTORYITEMS], '[[inventoryitems.purchasableId]] = [[commerce_purchasables.id]]'); $this->subQuery->addSelect([ 'catalogprices.price', diff --git a/src/fieldlayoutelements/PurchasableStockField.php b/src/fieldlayoutelements/PurchasableStockField.php index f6f4e7159e..eeb28f0b8d 100644 --- a/src/fieldlayoutelements/PurchasableStockField.php +++ b/src/fieldlayoutelements/PurchasableStockField.php @@ -52,6 +52,12 @@ class PurchasableStockField extends BaseNativeField */ public function inputHtml(ElementInterface $element = null, bool $static = false): ?string { + // If this is a revision get the canonical element to show the stock for. + // @TODO re-evaluate this when we have a better way to handle revisions and inventory. + if ($element->getIsRevision()) { + $element = $element->getCanonical(); + } + $view = Craft::$app->getView(); $view->registerAssetBundle(InventoryAsset::class); diff --git a/src/migrations/Install.php b/src/migrations/Install.php index defc1bbf0c..756520a8ec 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -857,17 +857,17 @@ public function createTables(): void 'handle' => $this->string()->notNull(), 'primary' => $this->boolean()->notNull(), 'currency' => $this->string()->notNull()->defaultValue('USD'), - 'autoSetCartShippingMethodOption' => $this->boolean()->notNull()->defaultValue(false), - 'autoSetNewCartAddresses' => $this->boolean()->notNull()->defaultValue(false), - 'autoSetPaymentSource' => $this->boolean()->notNull()->defaultValue(false), - 'allowEmptyCartOnCheckout' => $this->boolean()->notNull()->defaultValue(false), - 'allowCheckoutWithoutPayment' => $this->boolean()->notNull()->defaultValue(false), - 'allowPartialPaymentOnCheckout' => $this->boolean()->notNull()->defaultValue(false), - 'requireShippingAddressAtCheckout' => $this->boolean()->notNull()->defaultValue(false), - 'requireBillingAddressAtCheckout' => $this->boolean()->notNull()->defaultValue(false), - 'requireShippingMethodSelectionAtCheckout' => $this->boolean()->notNull()->defaultValue(false), - 'useBillingAddressForTax' => $this->boolean()->notNull()->defaultValue(false), - 'validateOrganizationTaxIdAsVatId' => $this->boolean()->notNull()->defaultValue(false), + 'autoSetCartShippingMethodOption' => $this->string()->notNull()->defaultValue('false'), + 'autoSetNewCartAddresses' => $this->string()->notNull()->defaultValue('false'), + 'autoSetPaymentSource' => $this->string()->notNull()->defaultValue('false'), + 'allowEmptyCartOnCheckout' => $this->string()->notNull()->defaultValue('false'), + 'allowCheckoutWithoutPayment' => $this->string()->notNull()->defaultValue('false'), + 'allowPartialPaymentOnCheckout' => $this->string()->notNull()->defaultValue('false'), + 'requireShippingAddressAtCheckout' => $this->string()->notNull()->defaultValue('false'), + 'requireBillingAddressAtCheckout' => $this->string()->notNull()->defaultValue('false'), + 'requireShippingMethodSelectionAtCheckout' => $this->string()->notNull()->defaultValue('false'), + 'useBillingAddressForTax' => $this->string()->notNull()->defaultValue('false'), + 'validateOrganizationTaxIdAsVatId' => $this->string()->notNull()->defaultValue('false'), 'orderReferenceFormat' => $this->string(), 'freeOrderPaymentStrategy' => $this->string()->defaultValue('complete'), 'minimumTotalPriceStrategy' => $this->string()->defaultValue('default'), diff --git a/src/migrations/m240308_133451_tidy_shipping_categories.php b/src/migrations/m240308_133451_tidy_shipping_categories.php index e4b99e486c..38fd250bd1 100644 --- a/src/migrations/m240308_133451_tidy_shipping_categories.php +++ b/src/migrations/m240308_133451_tidy_shipping_categories.php @@ -25,9 +25,9 @@ public function safeUp(): bool $this->dropForeignKeyIfExists(Table::SHIPPINGCATEGORIES, ['storeId']); - $this->addForeignKey(null, Table::SHIPPINGCATEGORIES, ['storeId'], Table::STORES, ['id'], 'CASCADE'); - $this->alterColumn(Table::SHIPPINGCATEGORIES, 'storeId', $this->integer()->notNull()); + + $this->addForeignKey(null, Table::SHIPPINGCATEGORIES, ['storeId'], Table::STORES, ['id'], 'CASCADE'); return true; } diff --git a/src/migrations/m240313_131445_tidy_shipping_methods.php b/src/migrations/m240313_131445_tidy_shipping_methods.php index a9a0c0d3bd..01746a7a83 100644 --- a/src/migrations/m240313_131445_tidy_shipping_methods.php +++ b/src/migrations/m240313_131445_tidy_shipping_methods.php @@ -25,10 +25,10 @@ public function safeUp(): bool $this->dropForeignKeyIfExists(Table::SHIPPINGMETHODS, ['storeId']); - $this->addForeignKey(null, Table::SHIPPINGMETHODS, ['storeId'], Table::STORES, ['id'], 'CASCADE'); - $this->alterColumn(Table::SHIPPINGMETHODS, 'storeId', $this->integer()->notNull()); + $this->addForeignKey(null, Table::SHIPPINGMETHODS, ['storeId'], Table::STORES, ['id'], 'CASCADE'); + return true; } diff --git a/src/migrations/m241128_174712_fix_maxLevels_structured_productTypes.php b/src/migrations/m241128_174712_fix_maxLevels_structured_productTypes.php new file mode 100644 index 0000000000..f07f69385d --- /dev/null +++ b/src/migrations/m241128_174712_fix_maxLevels_structured_productTypes.php @@ -0,0 +1,43 @@ +from(Table::PRODUCTTYPES) + ->where(['isStructure' => true]) + ->andWhere(['not', ['maxLevels' => null]]) + ->collect(); + + // Loop through and update the `maxLevels` column in the `structures` table + $structuredProductTypesWithMaxLevels->each(function($productType) { + $this->update(CraftTable::STRUCTURES, ['maxLevels' => $productType['maxLevels']], ['id' => $productType['structureId']]); + }); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m241128_174712_fix_maxLevels_structured_productTypes cannot be reverted.\n"; + return false; + } +} diff --git a/src/migrations/m241204_091901_fix_store_environment_variables.php b/src/migrations/m241204_091901_fix_store_environment_variables.php new file mode 100644 index 0000000000..279ac79e65 --- /dev/null +++ b/src/migrations/m241204_091901_fix_store_environment_variables.php @@ -0,0 +1,92 @@ +from(Table::STORES) + ->all(); + + // Get the store settings for each store from the project config + $storeSettings = \Craft::$app->getProjectConfig()->get('commerce.stores'); + + + // Store properties to update + $storeProperties = [ + 'autoSetNewCartAddresses', + 'autoSetCartShippingMethodOption', + 'autoSetPaymentSource', + 'allowEmptyCartOnCheckout', + 'allowCheckoutWithoutPayment', + 'allowPartialPaymentOnCheckout', + 'requireShippingAddressAtCheckout', + 'requireBillingAddressAtCheckout', + 'requireShippingMethodSelectionAtCheckout', + 'useBillingAddressForTax', + 'validateOrganizationTaxIdAsVatId', + ]; + + // Update stores env var DB columns + foreach ($storeProperties as $storeProperty) { + $this->alterColumn(Table::STORES, $storeProperty, $this->string()->notNull()->defaultValue('false')); + } + + // Loop through each store and update values in the DB to match the PC values + foreach ($stores as $store) { + $storeSettingsForStore = $storeSettings[$store['uid']] ?? null; + + // If there isn't data in the PC for this store, skip it + if (!$storeSettingsForStore) { + continue; + } + + $updateData = []; + foreach ($storeProperties as $storeProperty) { + // If there isn't data in the PC for this store property, skip it + if (!isset($storeSettingsForStore[$storeProperty])) { + continue; + } + + // Parse the value from the PC + $envVarValue = App::parseBooleanEnv($storeSettingsForStore[$storeProperty]); + if ($envVarValue === null) { + continue; + } + + $updateData[$storeProperty] = $storeSettingsForStore[$storeProperty]; + } + + if (empty($updateData)) { + continue; + } + + $this->update(Table::STORES, $updateData, ['id' => $store['id']]); + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m241204_091901_fix_store_environment_variables cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/inventory/InventoryManualMovement.php b/src/models/inventory/InventoryManualMovement.php index e3e98f5b3d..7d0ed0a7ec 100644 --- a/src/models/inventory/InventoryManualMovement.php +++ b/src/models/inventory/InventoryManualMovement.php @@ -83,7 +83,7 @@ public function fromLocationAfterQuantity(): int ->from(Table::INVENTORYTRANSACTIONS) ->where([ 'type' => $this->fromInventoryTransactionType->value, - 'inventoryItemId' => $this->inventoryItem->id, + 'inventoryItemId' => $this->inventoryItemId, 'inventoryLocationId' => $this->fromInventoryLocation->id, ]) ->params([':quantity' => $this->quantity]) @@ -112,7 +112,7 @@ public function toLocationAfterQuantity(): int ->from(Table::INVENTORYTRANSACTIONS) ->where([ 'type' => $this->toInventoryTransactionType->value, - 'inventoryItemId' => $this->inventoryItem->id, + 'inventoryItemId' => $this->inventoryItemId, 'inventoryLocationId' => $this->toInventoryLocation->id, ]) ->params([':quantity' => $this->quantity]) diff --git a/src/models/inventory/InventoryTransferMovement.php b/src/models/inventory/InventoryTransferMovement.php index 558c52cc13..cbe7d8506d 100644 --- a/src/models/inventory/InventoryTransferMovement.php +++ b/src/models/inventory/InventoryTransferMovement.php @@ -9,12 +9,4 @@ */ class InventoryTransferMovement extends InventoryMovement { - /** - * @return array - */ - public function defineRules(): array - { - $rules = parent::defineRules(); - return $rules; - } } diff --git a/src/models/inventory/UpdateInventoryLevel.php b/src/models/inventory/UpdateInventoryLevel.php index 57f8574059..38ac79cd63 100644 --- a/src/models/inventory/UpdateInventoryLevel.php +++ b/src/models/inventory/UpdateInventoryLevel.php @@ -3,10 +3,10 @@ namespace craft\commerce\models\inventory; use craft\base\Model; +use craft\commerce\base\InventoryItemTrait; +use craft\commerce\base\InventoryLocationTrait; use craft\commerce\enums\InventoryTransactionType; use craft\commerce\enums\InventoryUpdateQuantityType; -use craft\commerce\models\InventoryItem; -use craft\commerce\models\InventoryLocation; /** * Update (Set and Adjust) Inventory Quantity model @@ -15,6 +15,8 @@ */ class UpdateInventoryLevel extends Model { + use InventoryItemTrait, InventoryLocationTrait; + /** * The type is the set of InventoryTransactionType values, plus the `onHand` type. * @var string The inventory update type. @@ -32,16 +34,6 @@ class UpdateInventoryLevel extends Model */ public InventoryUpdateQuantityType $updateAction; - /** - * @var InventoryItem The inventory item - */ - public InventoryItem $inventoryItem; - - /** - * @var InventoryLocation The inventory location. - */ - public InventoryLocation $inventoryLocation; - /** * @var int The quantity to update. */ diff --git a/src/models/inventory/UpdateInventoryLevelInTransfer.php b/src/models/inventory/UpdateInventoryLevelInTransfer.php index 675e25da1e..e85aee3cd9 100644 --- a/src/models/inventory/UpdateInventoryLevelInTransfer.php +++ b/src/models/inventory/UpdateInventoryLevelInTransfer.php @@ -3,10 +3,10 @@ namespace craft\commerce\models\inventory; use craft\base\Model; +use craft\commerce\base\InventoryItemTrait; +use craft\commerce\base\InventoryLocationTrait; use craft\commerce\enums\InventoryTransactionType; use craft\commerce\enums\InventoryUpdateQuantityType; -use craft\commerce\models\InventoryItem; -use craft\commerce\models\InventoryLocation; /** * Update (Set and Adjust) Inventory Quantity model @@ -15,6 +15,8 @@ */ class UpdateInventoryLevelInTransfer extends Model { + use InventoryItemTrait, InventoryLocationTrait; + /** * The type is the set of InventoryTransactionType values, plus the `onHand` type. * @var string The inventory update type. @@ -32,16 +34,6 @@ class UpdateInventoryLevelInTransfer extends Model */ public InventoryUpdateQuantityType $updateAction; - /** - * @var InventoryItem The inventory item - */ - public InventoryItem $inventoryItem; - - /** - * @var InventoryLocation The inventory location. - */ - public InventoryLocation $inventoryLocation; - /** * @var int The quantity to update. */ diff --git a/src/services/Inventory.php b/src/services/Inventory.php index 93107c4fba..9150a2f392 100644 --- a/src/services/Inventory.php +++ b/src/services/Inventory.php @@ -61,7 +61,7 @@ public function getInventoryLevelsForPurchasable(Purchasable $purchasable): Coll $storeInventoryLocations = Plugin::getInstance()->getInventoryLocations()->getInventoryLocations($storeId); foreach ($storeInventoryLocations as $inventoryLocation) { - $inventoryLevel = $this->getInventoryLevel($purchasable->getInventoryItem(), $inventoryLocation); + $inventoryLevel = $this->getInventoryLevel($purchasable->inventoryItemId, $inventoryLocation->id); if (!$inventoryLevel) { continue; @@ -115,17 +115,20 @@ public function getInventoryItemsByIds(array $ids): Collection /** * Returns an inventory level model which is the sum of all inventory movements types for an item in a location. * - * @param InventoryItem $inventoryItem - * @param InventoryLocation $inventoryLocation + * @param InventoryItem|int $inventoryItem + * @param InventoryLocation|int $inventoryLocation * @param bool $withTrashed * @return ?InventoryLevel */ - public function getInventoryLevel(InventoryItem $inventoryItem, InventoryLocation $inventoryLocation, bool $withTrashed = false): ?InventoryLevel + public function getInventoryLevel(InventoryItem|int $inventoryItem, InventoryLocation|int $inventoryLocation, bool $withTrashed = false): ?InventoryLevel { + $inventoryItemId = $inventoryItem instanceof InventoryItem ? $inventoryItem->id : $inventoryItem; + $inventoryLocationId = $inventoryLocation instanceof InventoryLocation ? $inventoryLocation->id : $inventoryLocation; + $result = $this->getInventoryLevelQuery(withTrashed: $withTrashed) ->andWhere([ - 'inventoryLocationId' => $inventoryLocation->id, - 'inventoryItemId' => $inventoryItem->id, + 'inventoryLocationId' => $inventoryLocationId, + 'inventoryItemId' => $inventoryItemId, ])->one(); if (!$result) { @@ -207,6 +210,7 @@ public function getInventoryLocationLevels(InventoryLocation $inventoryLocation, { $levels = $this->getInventoryLevelQuery(withTrashed: $withTrashed) ->andWhere(['inventoryLocationId' => $inventoryLocation->id]) + ->andWhere(['not', ['elements.id' => null]]) ->collect(); $inventoryItems = Plugin::getInstance()->getInventory()->getInventoryItemsByIds($levels->pluck('inventoryItemId')->unique()->toArray()); @@ -262,8 +266,12 @@ public function getInventoryLevelQuery(?int $limit = null, ?int $offset = null, ->limit($limit) ->offset($offset); + $query->leftJoin( + ['elements' => CraftTable::ELEMENTS], + '[[ii.purchasableId]] = [[elements.id]] AND [[elements.draftId]] IS NULL AND [[elements.revisionId]] IS NULL' + ); + if (!$withTrashed) { - $query->leftJoin(['elements' => CraftTable::ELEMENTS], '[[ii.purchasableId]] = [[elements.id]]'); $query->andWhere(['elements.dateDeleted' => null]); } @@ -317,6 +325,59 @@ public function executeUpdateInventoryLevels(UpdateInventoryLevelCollection $upd } } + /** + * @param int $inventoryItemId + * @param int $quantity + * @param array $updateInventoryLevelAttributes + * @return void + * @throws Exception + * @throws InvalidConfigException + * @since 5.3.0 + */ + public function updateInventoryLevel(int $inventoryItemId, int $quantity, array $updateInventoryLevelAttributes = []) + { + $updateInventoryLevelAttributes += [ + 'quantity' => $quantity, + 'updateAction' => InventoryUpdateQuantityType::SET, + 'inventoryLocationId' => Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations()->first()->id, + 'type' => InventoryTransactionType::AVAILABLE->value, + ]; + + $updateInventoryLevel = new UpdateInventoryLevel($updateInventoryLevelAttributes); + $updateInventoryLevel->inventoryItemId = $inventoryItemId; + + $updateInventoryLevels = UpdateInventoryLevelCollection::make(); + $updateInventoryLevels->push($updateInventoryLevel); + + Plugin::getInstance()->getInventory()->executeUpdateInventoryLevels($updateInventoryLevels); + } + + /** + * @param Purchasable $purchasable + * @param int $quantity + * @param array $updateInventoryLevelAttributes + * @return void + * @throws Exception + * @throws InvalidConfigException + * @throws \craft\errors\DeprecationException + * @since 5.3.0 + */ + public function updatePurchasableInventoryLevel(Purchasable $purchasable, int $quantity, array $updateInventoryLevelAttributes = []) + { + $updateInventoryLevelAttributes += [ + 'quantity' => $quantity, + 'updateAction' => InventoryUpdateQuantityType::SET, + 'inventoryItemId' => $purchasable->inventoryItemId, + 'inventoryLocationId' => $purchasable->getStore()->getInventoryLocations()->first()->id, + 'type' => InventoryTransactionType::AVAILABLE->value, + ]; + + $this->updateInventoryLevel($purchasable->inventoryItemId, $quantity, $updateInventoryLevelAttributes); + + // Clear the stock cache for the class instance + unset($purchasable->stock); + } + /** * @param UpdateInventoryLevel|UpdateInventoryLevelInTransfer $updateInventoryLevel * @return bool @@ -335,8 +396,8 @@ private function _setInventoryLevel(UpdateInventoryLevel|UpdateInventoryLevelInT ->from($tableName) ->where([ 'type' => $types, - 'inventoryItemId' => $updateInventoryLevel->inventoryItem->id, - 'inventoryLocationId' => $updateInventoryLevel->inventoryLocation->id, + 'inventoryItemId' => $updateInventoryLevel->inventoryItemId, + 'inventoryLocationId' => $updateInventoryLevel->inventoryLocationId, ]) ->params([':quantity' => $updateInventoryLevel->quantity]) ->scalar(); @@ -349,8 +410,8 @@ private function _setInventoryLevel(UpdateInventoryLevel|UpdateInventoryLevelInT $data = [ 'quantity' => $quantityQuery, 'type' => $type, - 'inventoryItemId' => $updateInventoryLevel->inventoryItem->id, - 'inventoryLocationId' => $updateInventoryLevel->inventoryLocation->id, + 'inventoryItemId' => $updateInventoryLevel->inventoryItemId, + 'inventoryLocationId' => $updateInventoryLevel->inventoryLocationId, 'note' => $updateInventoryLevel->note, 'movementHash' => $this->getMovementHash(), 'dateCreated' => Db::prepareDateForDb(new \DateTime()), @@ -384,8 +445,8 @@ private function _adjustInventoryLevel(UpdateInventoryLevel|UpdateInventoryLevel ->insert($tableName, [ 'quantity' => $updateInventoryLevel->quantity, 'type' => $type, - 'inventoryItemId' => $updateInventoryLevel->inventoryItem->id, - 'inventoryLocationId' => $updateInventoryLevel->inventoryLocation->id, + 'inventoryItemId' => $updateInventoryLevel->inventoryItemId, + 'inventoryLocationId' => $updateInventoryLevel->inventoryLocationId, 'movementHash' => $this->getMovementHash(), 'dateCreated' => Db::prepareDateForDb(new \DateTime()), 'note' => $updateInventoryLevel->note, @@ -483,13 +544,16 @@ public function getMovementHash(): string } /** - * @param InventoryItem $inventoryItem - * @param InventoryLocation $inventoryLocation + * @param InventoryItem|int $inventoryItem + * @param InventoryLocation|int $inventoryLocation * @return array */ - public function getUnfulfilledOrders(InventoryItem $inventoryItem, InventoryLocation $inventoryLocation): array + public function getUnfulfilledOrders(InventoryItem|int $inventoryItem, InventoryLocation|int $inventoryLocation): array { - $inventoryLevel = $this->getInventoryLevel($inventoryItem, $inventoryLocation); + $inventoryItemId = $inventoryItem instanceof InventoryItem ? $inventoryItem->id : $inventoryItem; + $inventoryLocationId = $inventoryLocation instanceof InventoryLocation ? $inventoryLocation->id : $inventoryLocation; + + $inventoryLevel = $this->getInventoryLevel($inventoryItemId, $inventoryLocationId); if ($inventoryLevel->committedTotal <= 0) { return []; @@ -502,8 +566,8 @@ public function getUnfulfilledOrders(InventoryItem $inventoryItem, InventoryLoca ->leftJoin(['orders' => Table::ORDERS], '[[lineItems.orderId]] = [[orders.id]]') ->leftJoin(['it' => Table::INVENTORYTRANSACTIONS], '[[it.lineItemId]] = [[lineItems.id]]') ->where(['orders.isCompleted' => true]) - ->andWhere(['it.inventoryItemId' => $inventoryItem->id]) - ->andWhere(['it.inventoryLocationId' => $inventoryLocation->id]) + ->andWhere(['it.inventoryItemId' => $inventoryItemId]) + ->andWhere(['it.inventoryLocationId' => $inventoryLocationId]) ->andWhere(['it.type' => InventoryTransactionType::COMMITTED->value]) ->groupBy(['lineItems.orderId', 'lineItems.id']) ->having(['>=', 'SUM(it.quantity)', 'lineItems.qty']) @@ -615,6 +679,7 @@ public function getInventoryFulfillmentLevels(Order $order): Collection */ public function orderCompleteHandler(Order $order) { + /** @var Collection[] $allInventoryLevels */ $allInventoryLevels = []; $qtyLineItem = []; foreach ($order->getLineItems() as $lineItem) { @@ -637,7 +702,7 @@ public function orderCompleteHandler(Order $order) $selectedInventoryLevelForItem = []; /** * @var int $purchasableId - * @var InventoryLevel $inventoryLevels + * @var Collection $inventoryLevels */ foreach ($allInventoryLevels as $purchasableId => $inventoryLevels) { foreach ($inventoryLevels as $level) { @@ -680,15 +745,16 @@ public function orderCompleteHandler(Order $order) $availableTotalByPurchasableIdAndLocationId[$lineItem->purchasableId . '-' . $level->inventoryLocationId] -= $lineItem->qty; } - $movements->push(new InventoryCommittedMovement([ - 'inventoryItem' => $level->getInventoryItem(), - 'fromInventoryLocation' => $level->getInventoryLocation(), - 'toInventoryLocation' => $level->getInventoryLocation(), - 'fromInventoryTransactionType' => InventoryTransactionType::AVAILABLE, - 'toInventoryTransactionType' => InventoryTransactionType::COMMITTED, - 'quantity' => $lineItem->qty, - 'lineItemId' => $lineItem->id, - ])); + $inventoryCommittedMovement = new InventoryCommittedMovement(); + $inventoryCommittedMovement->inventoryItemId = $level->inventoryItemId; + $inventoryCommittedMovement->fromInventoryLocation = $level->getInventoryLocation(); + $inventoryCommittedMovement->toInventoryLocation = $level->getInventoryLocation(); + $inventoryCommittedMovement->fromInventoryTransactionType = InventoryTransactionType::AVAILABLE; + $inventoryCommittedMovement->toInventoryTransactionType = InventoryTransactionType::COMMITTED; + $inventoryCommittedMovement->quantity = $lineItem->qty; + $inventoryCommittedMovement->lineItemId = $lineItem->id; + + $movements->push($inventoryCommittedMovement); } } @@ -713,15 +779,16 @@ public function orderCompleteHandler(Order $order) $availableTotalByPurchasableIdAndLocationId[$purchasableId . '-' . $level->inventoryLocationId] -= $qtyToReserve; - $movements->push(new InventoryManualMovement([ - 'inventoryItem' => $level->getInventoryItem(), - 'fromInventoryLocation' => $level->getInventoryLocation(), - 'toInventoryLocation' => $level->getInventoryLocation(), - 'fromInventoryTransactionType' => InventoryTransactionType::AVAILABLE, - 'toInventoryTransactionType' => InventoryTransactionType::RESERVED, - 'quantity' => $qtyToReserve, - 'lineItemId' => $lineItemId, - ])); + $inventoryManualMovement = new InventoryManualMovement(); + $inventoryManualMovement->inventoryItemId = $level->inventoryItemId; + $inventoryManualMovement->fromInventoryLocation = $level->getInventoryLocation(); + $inventoryManualMovement->toInventoryLocation = $level->getInventoryLocation(); + $inventoryManualMovement->fromInventoryTransactionType = InventoryTransactionType::AVAILABLE; + $inventoryManualMovement->toInventoryTransactionType = InventoryTransactionType::RESERVED; + $inventoryManualMovement->quantity = $qtyToReserve; + $inventoryManualMovement->lineItemId = $lineItemId; + + $movements->push($inventoryManualMovement); $qty -= $qtyToReserve; if ($qty <= 0) { diff --git a/src/services/InventoryLocations.php b/src/services/InventoryLocations.php index c92e806797..c5d4375c81 100644 --- a/src/services/InventoryLocations.php +++ b/src/services/InventoryLocations.php @@ -180,7 +180,7 @@ public function executeDeactivateInventoryLocation(DeactivateInventoryLocation $ $inventoryMovement = new InventoryLocationDeactivatedMovement(); $inventoryMovement->fromInventoryLocation = $deactivateInventoryLocation->inventoryLocation; $inventoryMovement->toInventoryLocation = $deactivateInventoryLocation->destinationInventoryLocation; - $inventoryMovement->inventoryItem = $inventoryLevel->getInventoryItem(); + $inventoryMovement->inventoryItemId = $inventoryLevel->inventoryItemId; $inventoryMovement->quantity = $inventoryLevel->getTotal($type); $inventoryMovement->fromInventoryTransactionType = $type; $inventoryMovement->toInventoryTransactionType = $type; @@ -283,6 +283,7 @@ private function _createInventoryLocationsQuery(bool $withTrashed = false): Quer 'dateCreated', 'dateUpdated', ]) + ->orderBy(['name' => SORT_ASC]) ->from([Table::INVENTORYLOCATIONS]); if (!$withTrashed) { diff --git a/src/services/LineItems.php b/src/services/LineItems.php index e7627fde24..812c1994cd 100644 --- a/src/services/LineItems.php +++ b/src/services/LineItems.php @@ -444,6 +444,7 @@ public function create(Order $order, array $params = [], LineItemType $type = Li } $params['class'] = LineItem::class; + /** @var LineItem $lineItem */ $lineItem = Craft::createObject($params); if ($lineItem->type === LineItemType::Purchasable) { diff --git a/src/services/Plans.php b/src/services/Plans.php index 472d3a4caf..31611c5dad 100644 --- a/src/services/Plans.php +++ b/src/services/Plans.php @@ -129,6 +129,7 @@ public function getPlansByGatewayId(int $gatewayId): array * * @return Plan[] * @deprecated in 4.0. Use [[getPlansByGatewayId]] instead. + * TODO: remove in 6.0 */ public function getAllGatewayPlans(int $gatewayId): array { diff --git a/src/services/ProductTypes.php b/src/services/ProductTypes.php index ec5cd855da..d771561364 100755 --- a/src/services/ProductTypes.php +++ b/src/services/ProductTypes.php @@ -475,7 +475,7 @@ public function handleChangedProductType(ConfigEvent $event): void $structureUid = $data['structure']['uid']; $structure = Craft::$app->getStructures()->getStructureByUid($structureUid, true) ?? new Structure(['uid' => $structureUid]); $isNewStructure = empty($structure->id); - $structure->maxLevels = $data['structure']['maxLevels'] ?? null; + $structure->maxLevels = $data['maxLevels'] ?? null; Craft::$app->getStructures()->saveStructure($structure); $productTypeRecord->structureId = $structure->id; } else { diff --git a/src/templates/_components/gateways/_modalWrapper.twig b/src/templates/_components/gateways/_modalWrapper.twig index 06ab826d5a..948ff011b4 100644 --- a/src/templates/_components/gateways/_modalWrapper.twig +++ b/src/templates/_components/gateways/_modalWrapper.twig @@ -20,9 +20,15 @@ {{ formHtml|raw }}
- Payment Amount + {{ "Payment Amount"|t('commerce') }}
- + + {% set currencies = craft.commerce.paymentCurrencies.getAllPaymentCurrencies() %} {% set primaryCurrency = craft.commerce.paymentCurrencies.getPrimaryPaymentCurrency() %} @@ -77,7 +83,8 @@ function updatePrice(form) { - var price = form.find("input.paymentAmount").val(); + var price = form.find("input[name='paymentAmount[value]']").val(); + var locale = form.find("input[name='paymentAmount[locale]']").val(); $.ajax({ type: "POST", @@ -89,6 +96,7 @@ data: { 'action' : 'commerce/orders/payment-amount-data', 'paymentAmount': price, + 'locale': locale, 'paymentCurrency': form.find(".paymentCurrency").val(), 'orderId' : orderId }, diff --git a/src/templates/orders/_index.twig b/src/templates/orders/_index.twig index aee4c0eb10..972f6ff4bf 100644 --- a/src/templates/orders/_index.twig +++ b/src/templates/orders/_index.twig @@ -22,8 +22,8 @@ {% block initJs %} Craft.elementIndex = Craft.createElementIndex('{{ elementType|e("js") }}', $('#page-container'), { - elementTypeName: '{{ elementInstance.displayName()|e("js") }}', - elementTypePluralName: '{{ elementInstance.pluralDisplayName()|e("js") }}', + elementTypeName: '{{ elementDisplayName ?? (elementInstance.displayName())|e("js") }}', + elementTypePluralName: '{{ elementPluralDisplayName ?? (elementInstance.pluralDisplayName())|e("js") }}', context: '{{ context }}', storageKey: 'elementindex.{{ elementType|e("js") }}', toolbarSelector: '#toolbar', diff --git a/src/templates/settings/producttypes/_edit.twig b/src/templates/settings/producttypes/_edit.twig index a290e42450..d607cb77d1 100644 --- a/src/templates/settings/producttypes/_edit.twig +++ b/src/templates/settings/producttypes/_edit.twig @@ -408,6 +408,7 @@ {{ forms.fieldLayoutDesignerField({ fieldLayout: productType.getProductFieldLayout(), + withCardViewDesigner: true, }) }}
@@ -417,6 +418,7 @@ {% namespace "variant-layout" %} {{ forms.fieldLayoutDesignerField({ fieldLayout: productType.getVariantFieldLayout(), + withCardViewDesigner: true, }) }} {% endnamespace %} diff --git a/tests/unit/elements/order/OrderQueryTest.php b/tests/unit/elements/order/OrderQueryTest.php index 2c06a75e6c..9c6720864e 100644 --- a/tests/unit/elements/order/OrderQueryTest.php +++ b/tests/unit/elements/order/OrderQueryTest.php @@ -63,6 +63,45 @@ public function emailDataProvider(): array ]; } + /** + * @param string $couponCode + * @param int $count + * @return void + * @dataProvider couponCodeDataProvider + */ + public function testCouponCode(?string $couponCode, int $count): void + { + $ordersFixture = $this->tester->grabFixture('orders'); + /** @var Order $order */ + $order = $ordersFixture->getElement('completed-new'); + + // Temporarily add a coupon code to an order + \craft\commerce\records\Order::updateAll(['couponCode' => 'foo'], ['id' => $order->id]); + + $orderQuery = Order::find(); + $orderQuery->couponCode($couponCode); + + self::assertCount($count, $orderQuery->all()); + + // Remove temporary coupon code + \craft\commerce\records\Order::updateAll(['couponCode' => null], ['id' => $order->id]); + } + + /** + * @return array[] + */ + public function couponCodeDataProvider(): array + { + return [ + 'normal' => ['foo', 1], + 'case-insensitive' => ['fOo', 1], + 'using-null' => [null, 3], + 'empty-code' => [':empty:', 2], + 'not-empty-code' => [':notempty:', 1], + 'no-results' => ['nope', 0], + ]; + } + /** * @param mixed $handle * @param int $count diff --git a/tests/unit/elements/order/conditions/CouponCodeConditionRuleTest.php b/tests/unit/elements/order/conditions/CouponCodeConditionRuleTest.php new file mode 100644 index 0000000000..1905ef5bb6 --- /dev/null +++ b/tests/unit/elements/order/conditions/CouponCodeConditionRuleTest.php @@ -0,0 +1,162 @@ + + * @since 5.3.0 + */ +class CouponCodeConditionRuleTest extends Unit +{ + /** + * @return array + */ + public function _fixtures(): array + { + return [ + 'orders' => [ + 'class' => OrdersFixture::class, + ], + ]; + } + + /** + * @group Order + * @dataProvider matchElementDataProvider + */ + public function testMatchElement(?string $coupon, string $operator = '=', ?string $orderCoupon = null, bool $expectedMatch = true): void + { + $condition = $this->_createCondition($coupon, $operator); + + $ordersFixture = $this->tester->grabFixture('orders'); + /** @var Order $order */ + $order = $ordersFixture->getElement('completed-new'); + + if ($orderCoupon) { + $order->couponCode = $orderCoupon; + } + + $match = $condition->matchElement($order); + + if ($expectedMatch) { + self::assertTrue($match); + } else { + self::assertFalse($match); + } + } + + /** + * @return array[] + */ + public function matchElementDataProvider(): array + { + return [ + 'match-equals' => ['coupon1', '=', 'coupon1', true], + 'match-equals-case-insensitive' => ['coupon1', '=', 'cOuPoN1', true], + 'no-match-equals' => ['coupon1', '=', 'coupon2', false], + 'no-match-equals-case-insensitive' => ['coupon1', '=', 'cOuPoN2', false], + 'no-match-equals-null' => ['coupon1', '=', null, false], + 'match-contains' => ['coupon1', '**', 'coupon1', true], + 'match-contains-case-insensitive' => ['coupon1', '**', 'cOuPoN1', true], + 'no-match-contains' => ['coupon1', '**', 'coupon2', false], + 'no-match-contains-case-insensitive' => ['coupon1', '**', 'cOuPoN2', false], + 'match-begins-with' => ['coupon', 'bw', 'coupon1', true], + 'match-begins-with-case-insensitive' => ['coupon', 'bw', 'cOuPoN1', true], + 'no-match-begins-with' => ['coupon', 'bw', 'foocoupon2', false], + 'no-match-begins-with-case-insensitive' => ['coupon', 'bw', 'foocOuPoN2', false], + 'match-ends-with' => ['pon1', 'ew', 'coupon1', true], + 'match-ends-with-case-insensitive' => ['pon1', 'ew', 'cOuPoN1', true], + 'no-match-ends-with' => ['pon2', 'ew', 'coupon2foo', false], + 'no-match-ends-with-case-insensitive' => ['pon2', 'ew', 'cOuPoN2foo', false], + ]; + } + + /** + * @group Order + * @dataProvider modifyQueryDataProvider + */ + public function testModifyQuery(?string $coupon, string $operator = '=', ?string $orderCoupon = null, int $expectedResults = 0): void + { + $condition = $this->_createCondition($coupon, $operator); + $orderFixture = $this->tester->grabFixture('orders'); + /** @var Order $order */ + $order = $orderFixture->getElement('completed-new'); + + // Temporarily add a coupon code to an order + \craft\commerce\records\Order::updateAll(['couponCode' => $orderCoupon], ['id' => $order->id]); + + $query = Order::find(); + $condition->modifyQuery($query); + + self::assertCount($expectedResults, $query->ids()); + + if ($expectedResults > 0) { + self::assertContainsEquals($order->id, $query->ids()); + } else { + self::assertEmpty($query->ids()); + } + + // Remove temporary coupon code + \craft\commerce\records\Order::updateAll(['couponCode' => null], ['id' => $order->id]); + } + + /** + * @return array[] + */ + public function modifyQueryDataProvider(): array + { + return [ + 'match-equals' => ['coupon1', '=', 'coupon1', 1], + 'match-equals-case-insensitive' => ['coupon1', '=', 'cOuPoN1', 1], + 'no-match-equals' => ['coupon1', '=', 'coupon2', 0], + 'no-match-equals-case-insensitive' => ['coupon1', '=', 'cOuPoN2', 0], + 'no-match-equals-null' => ['coupon1', '=', null, 0], + 'match-contains' => ['coupon1', '**', 'coupon1', 1], + 'match-contains-case-insensitive' => ['coupon1', '**', 'cOuPoN1', 1], + 'no-match-contains' => ['coupon1', '**', 'coupon2', 0], + 'no-match-contains-case-insensitive' => ['coupon1', '**', 'cOuPoN2', 0], + 'match-begins-with' => ['coupon', 'bw', 'coupon1', 1], + 'match-begins-with-case-insensitive' => ['coupon', 'bw', 'cOuPoN1', 1], + 'no-match-begins-with' => ['coupon', 'bw', 'foocoupon2', 0], + 'no-match-begins-with-case-insensitive' => ['coupon', 'bw', 'foocOuPoN2', 0], + 'match-ends-with' => ['pon1', 'ew', 'coupon1', 1], + 'match-ends-with-case-insensitive' => ['pon1', 'ew', 'cOuPoN1', 1], + 'no-match-ends-with' => ['pon2', 'ew', 'coupon2foo', 0], + 'no-match-ends-with-case-insensitive' => ['pon2', 'ew', 'cOuPoN2foo', 0], + ]; + } + + /** + * @param string|null $value + * @param string|null $operator + * @return OrderCondition + */ + private function _createCondition(?string $value, ?string $operator = null): OrderCondition + { + $condition = Order::createCondition(); + /** @var CouponCodeConditionRule $rule */ + $rule = \Craft::$app->getConditions()->createConditionRule(CouponCodeConditionRule::class); + $rule->value = $value; + + if ($operator) { + $rule->operator = $operator; + } + + $condition->addConditionRule($rule); + + return $condition; + } +} diff --git a/tests/unit/elements/order/conditions/OrderConditionTest.php b/tests/unit/elements/order/conditions/OrderConditionTest.php index b040a3cc97..fd5cdec51f 100644 --- a/tests/unit/elements/order/conditions/OrderConditionTest.php +++ b/tests/unit/elements/order/conditions/OrderConditionTest.php @@ -9,6 +9,7 @@ use Codeception\Test\Unit; use craft\commerce\elements\conditions\orders\CompletedConditionRule; +use craft\commerce\elements\conditions\orders\CouponCodeConditionRule; use craft\commerce\elements\conditions\orders\CustomerConditionRule; use craft\commerce\elements\conditions\orders\DateOrderedConditionRule; use craft\commerce\elements\conditions\orders\HasPurchasableConditionRule; @@ -66,8 +67,9 @@ public function testConditionRuleTypes(): void $rules = array_keys($rules); self::assertContains(DateOrderedConditionRule::class, $rules); - self::assertContains(CustomerConditionRule::class, $rules); self::assertContains(CompletedConditionRule::class, $rules); + self::assertContains(CouponCodeConditionRule::class, $rules); + self::assertContains(CustomerConditionRule::class, $rules); self::assertContains(PaidConditionRule::class, $rules); self::assertContains(HasPurchasableConditionRule::class, $rules); self::assertContains(ItemSubtotalConditionRule::class, $rules); diff --git a/tests/unit/services/InventoryTest.php b/tests/unit/services/InventoryTest.php new file mode 100644 index 0000000000..78ae95fb76 --- /dev/null +++ b/tests/unit/services/InventoryTest.php @@ -0,0 +1,99 @@ + + * @since 5.3.0 + */ +class InventoryTest extends Unit +{ + /** + * @return array + */ + public function _fixtures(): array + { + return [ + 'products' => [ + 'class' => ProductFixture::class, + ], + ]; + } + + /** + * @param array $updateConfigs + * @param int $expected + * @return void + * @throws DeprecationException + * @throws InvalidConfigException + * @throws Exception + * @dataProvider setStockLevelDataProvider + */ + public function testUpdatePurchasableInventoryLevel(array $updateConfigs, int $expected): void + { + $variant = Variant::find()->sku('rad-hood')->one(); + $originalStock = $variant->getStock(); + + foreach ($updateConfigs as $updateConfig) { + $qty = $updateConfig['quantity']; + unset($updateConfig['quantity']); + + Plugin::getInstance()->getInventory()->updatePurchasableInventoryLevel($variant, $qty, $updateConfig); + } + + self::assertEquals($expected, $variant->getStock()); + + Plugin::getInstance()->getInventory()->updatePurchasableInventoryLevel($variant, $originalStock); + } + + /** + * @return array[] + */ + public function setStockLevelDataProvider(): array + { + return [ + 'simple-single-arg' => [ + [ + ['quantity' => 10], + ], + 'expected' => 10, + ], + 'set-and-adjust' => [ + [ + ['quantity' => 10], + ['quantity' => 2, 'updateAction' => InventoryUpdateQuantityType::ADJUST], + ], + 'expected' => 12, + ], + 'just-adjust' => [ + [ + ['quantity' => 2, 'updateAction' => InventoryUpdateQuantityType::ADJUST], + ], + 'expected' => 2, + ], + 'set-and-adjust-negative' => [ + [ + ['quantity' => 10], + ['quantity' => -2, 'updateAction' => InventoryUpdateQuantityType::ADJUST], + ], + 'expected' => 8, + ], + ]; + } +}