Skip to content

Commit 94bece0

Browse files
committed
Merge branch '5.x' of github.com:craftcms/commerce into 5.x
2 parents e86d8d6 + 0c9b158 commit 94bece0

5 files changed

Lines changed: 262 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
## Unreleased
44

5+
- Fixed a bug where variants weren’t being saved for product types with a Variant SKU Format that could cause duplicate SKUs. ([#4249](https://github.com/craftcms/commerce/issues/4249))
56
- Fixed a PHP error that occurred when marking an inventory transfer as pending. ([#4267](https://github.com/craftcms/commerce/issues/4267))
67
- Improved the performance of migrations when upgrading to 5.x. ([#4277](https://github.com/craftcms/commerce/issues/4277))
8+
- Fixed a bug where variants' sort order wasn’t being respected when reordering with disabled variants. ([#4270](https://github.com/craftcms/commerce/issues/4270))
79

810
## 5.6.1.1 - 2026-03-27
911

src/elements/Product.php

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use craft\commerce\base\HasStoreInterface;
1515
use craft\commerce\base\StoreTrait;
1616
use craft\commerce\behaviors\CurrencyAttributeBehavior;
17+
use craft\commerce\db\Table;
1718
use craft\commerce\elements\actions\CreateDiscount;
1819
use craft\commerce\elements\actions\CreateSale;
1920
use craft\commerce\elements\conditions\products\ProductCondition;
@@ -27,6 +28,7 @@
2728
use craft\commerce\Plugin;
2829
use craft\commerce\records\Product as ProductRecord;
2930
use craft\controllers\ElementIndexesController;
31+
use craft\controllers\NestedElementsController;
3032
use craft\db\Query;
3133
use craft\elements\actions\CopyReferenceTag;
3234
use craft\elements\actions\Delete;
@@ -50,6 +52,7 @@
5052
use craft\helpers\ElementHelper;
5153
use craft\helpers\Html;
5254
use craft\helpers\Json;
55+
use craft\helpers\Sequence;
5356
use craft\helpers\StringHelper;
5457
use craft\helpers\UrlHelper;
5558
use craft\models\FieldLayout;
@@ -1166,13 +1169,13 @@ public function getCheapestVariant(bool $includeDisabled = false): ?Variant
11661169
}
11671170

11681171
/**
1169-
* Returns an array of the product's variants.
1172+
* Returns a collection of the product's variants.
11701173
*
1171-
* @param bool $includeDisabled
1174+
* @param bool|null $includeDisabled
11721175
* @return VariantCollection
11731176
* @throws InvalidConfigException
11741177
*/
1175-
public function getVariants(bool $includeDisabled = false): VariantCollection
1178+
public function getVariants(?bool $includeDisabled = null): VariantCollection
11761179
{
11771180
if ($this->_variants === null) {
11781181
if (!$this->id) {
@@ -1212,6 +1215,10 @@ public function getVariants(bool $includeDisabled = false): VariantCollection
12121215
});
12131216
}
12141217

1218+
// When reordering variants we need to make sure disabled variants are included when calculating sort order
1219+
// @TODO: Remove in 6.0 when updating `getVariants()` to start returning an element query instance.
1220+
$includeDisabled ??= Craft::$app->controller instanceof NestedElementsController;
1221+
12151222
return $this->_variants->filter(fn(Variant $variant) => $includeDisabled || ($variant->getStatus() === self::STATUS_ENABLED));
12161223
}
12171224

@@ -1778,6 +1785,34 @@ public function beforeValidate(): bool
17781785
Craft::error('Craft Commerce could not generate the supplied SKU format: ' . $e->getMessage(), __METHOD__);
17791786
$variant->sku = '';
17801787
}
1788+
1789+
if ($variant->sku) {
1790+
$skuExistsQuery = function(string $sku, ?int $id) {
1791+
$query = (new Query())
1792+
->select(['sku'])
1793+
->from(Table::PURCHASABLES)
1794+
->where(['sku' => $sku]);
1795+
1796+
// Make sure it isn't for the purchasable we are currently saving
1797+
if ($id) {
1798+
$query->andWhere(['not', ['id' => $id]]);
1799+
}
1800+
1801+
return $query;
1802+
};
1803+
1804+
// Ensure there isn't a clash with an existing SKU when using auto formats
1805+
if ($skuExistsQuery($variant->sku, $variant->id)->exists()) {
1806+
// If there is a clash, we need to append a number to the end.
1807+
$baseSku = $variant->sku;
1808+
do {
1809+
$seq = Sequence::next('sku::' . $baseSku);
1810+
$newSku = $baseSku . '-' . $seq;
1811+
} while ($skuExistsQuery($newSku, $variant->id)->exists());
1812+
1813+
$variant->sku = $newSku;
1814+
}
1815+
}
17811816
}
17821817
}
17831818

src/elements/Variant.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
use craft\helpers\Db;
4343
use craft\helpers\ElementHelper;
4444
use craft\helpers\Html;
45+
use craft\helpers\Sequence;
4546
use craft\helpers\UrlHelper;
4647
use craft\models\FieldLayout;
4748
use Throwable;
@@ -725,6 +726,32 @@ public function updateSku(Product $product): void
725726
$language = Craft::$app->language;
726727
Craft::$app->language = $this->getSite()->language;
727728
$this->sku = Craft::$app->getView()->renderObjectTemplate($type->skuFormat, $this);
729+
730+
$skuExistsQuery = function(string $sku, ?int $id) {
731+
$query = (new Query())
732+
->select(['sku'])
733+
->from(Table::PURCHASABLES)
734+
->where(['sku' => $sku]);
735+
736+
// Make sure it isn't for the purchasable we are currently saving
737+
if ($id) {
738+
$query->andWhere(['not', ['id' => $id]]);
739+
}
740+
741+
return $query;
742+
};
743+
744+
// Ensure there isn't a clash with an existing SKU when using auto formats
745+
if ($skuExistsQuery($this->getSku(), $this->id)->exists()) {
746+
// If there is a clash, we need to append a number to the end.
747+
do {
748+
$seq = Sequence::next('sku::' . $this->sku);
749+
$newSku = $this->sku . '-' . $seq;
750+
} while ($skuExistsQuery($newSku, $this->id)->exists());
751+
752+
$this->sku = $newSku;
753+
}
754+
728755
Craft::$app->language = $language;
729756
}
730757
}

tests/unit/elements/product/ProductGetVariantsTest.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
namespace craftcommercetests\unit\elements\product;
99

1010
use Codeception\Test\Unit;
11+
use Craft;
1112
use craft\commerce\elements\db\VariantQuery;
1213
use craft\commerce\elements\Product;
1314
use craft\commerce\elements\Variant;
1415
use craft\commerce\elements\VariantCollection;
16+
use craft\controllers\NestedElementsController;
1517
use craftcommercetests\fixtures\ProductFixture;
1618
use ReflectionClass;
1719

@@ -188,4 +190,88 @@ public function testGetVariantsIncludeDisabledParameter(): void
188190
}
189191
self::assertTrue($hasDisabledVariant);
190192
}
193+
194+
/**
195+
* Tests every combination of the nullable $includeDisabled parameter against the
196+
* NestedElementsController detection introduced alongside the signature change.
197+
* Also asserts that the internal $_variants collection is never mutated by the filter.
198+
*
199+
* @dataProvider getVariantsNullableIncludeDisabledDataProvider
200+
*/
201+
public function testGetVariantsNullableIncludeDisabled(?bool $includeDisabled, bool $useNestedElementsController, int $expectedCount): void
202+
{
203+
$originalController = Craft::$app->controller;
204+
205+
try {
206+
if ($useNestedElementsController) {
207+
$mockController = $this->getMockBuilder(NestedElementsController::class)
208+
->disableOriginalConstructor()
209+
->getMock();
210+
Craft::$app->controller = $mockController;
211+
}
212+
213+
$product = new Product();
214+
$product->typeId = 2000;
215+
216+
$enabled = new Variant();
217+
$enabled->enabled = true;
218+
$enabled->sku = 'enabled-sku';
219+
220+
$disabled = new Variant();
221+
$disabled->enabled = false;
222+
$disabled->sku = 'disabled-sku';
223+
224+
$product->setVariants([$enabled, $disabled]);
225+
226+
$result = $product->getVariants($includeDisabled);
227+
self::assertCount($expectedCount, $result);
228+
229+
// The internal collection must never be mutated by the filter —
230+
// regardless of which parameter was passed, all set variants must be retained.
231+
$reflection = new ReflectionClass($product);
232+
$variantsProperty = $reflection->getProperty('_variants');
233+
$variantsProperty->setAccessible(true);
234+
235+
/** @var VariantCollection $internalVariants */
236+
$internalVariants = $variantsProperty->getValue($product);
237+
self::assertInstanceOf(VariantCollection::class, $internalVariants);
238+
self::assertCount(2, $internalVariants, '_variants must retain all variants regardless of the filter applied');
239+
} finally {
240+
// Clean up so the controller state does not leak into subsequent tests
241+
Craft::$app->controller = $originalController;
242+
}
243+
}
244+
245+
/**
246+
* @return array<string, array{includeDisabled: bool|null, useNestedElementsController: bool, expectedCount: int}>
247+
*/
248+
public function getVariantsNullableIncludeDisabledDataProvider(): array
249+
{
250+
return [
251+
// null resolves to false when no NestedElementsController is active
252+
'null-no-controller-excludes-disabled' => [
253+
'includeDisabled' => null,
254+
'useNestedElementsController' => false,
255+
'expectedCount' => 1,
256+
],
257+
// null resolves to true when NestedElementsController is the active controller
258+
'null-nested-elements-controller-includes-disabled' => [
259+
'includeDisabled' => null,
260+
'useNestedElementsController' => true,
261+
'expectedCount' => 2,
262+
],
263+
// Explicit false must override the NestedElementsController detection
264+
'explicit-false-with-nested-elements-controller' => [
265+
'includeDisabled' => false,
266+
'useNestedElementsController' => true,
267+
'expectedCount' => 1,
268+
],
269+
// Explicit true must work even without a special controller
270+
'explicit-true-without-controller' => [
271+
'includeDisabled' => true,
272+
'useNestedElementsController' => false,
273+
'expectedCount' => 2,
274+
],
275+
];
276+
}
191277
}

tests/unit/elements/product/ProductTest.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
use craft\commerce\db\Table;
1212
use craft\commerce\elements\Product;
1313
use craft\commerce\elements\Variant;
14+
use craft\commerce\Plugin;
1415
use craft\db\Query;
16+
use craft\db\Table as CraftTable;
1517
use craftcommercetests\fixtures\ProductFixture;
1618
use DateTime;
19+
use ReflectionClass;
1720

1821
/**
1922
* ProductTest
@@ -357,4 +360,110 @@ public function testSaveProductAndVariants(): void
357360
// Remove the product
358361
\Craft::$app->getElements()->deleteElementById($product->id, Product::class, null, true);
359362
}
363+
364+
/**
365+
* @group Product
366+
*/
367+
public function testSkuFormatGeneratesSkuWhenEmpty(): void
368+
{
369+
$this->setProductTypeSkuFormat(2001, 'generated-sku-from-format');
370+
371+
$product = new Product();
372+
$product->title = 'SKU Format Test Product';
373+
$product->typeId = 2001;
374+
$product->enabled = false;
375+
376+
$variant = new Variant();
377+
$variant->title = 'Test Variant';
378+
// SKU intentionally not set — should be generated from skuFormat
379+
380+
$product->setVariants([$variant]);
381+
$product->validate();
382+
383+
self::assertEquals('generated-sku-from-format', $variant->sku);
384+
385+
$this->setProductTypeSkuFormat(2001, null);
386+
}
387+
388+
/**
389+
* @group Product
390+
*/
391+
public function testSkuFormatDeduplicatesWhenCollisionExists(): void
392+
{
393+
// Fixture variant 'rad-hood' already exists in the PURCHASABLES table.
394+
// Reset the sequence so the suffix is always predictable (-1).
395+
$this->resetSkuSequence('rad-hood');
396+
$this->setProductTypeSkuFormat(2001, 'rad-hood');
397+
398+
$product = new Product();
399+
$product->title = 'Collision Test Product';
400+
$product->typeId = 2001;
401+
$product->enabled = false;
402+
403+
$variant = new Variant();
404+
$variant->title = 'Test Variant';
405+
// No SKU — format generates 'rad-hood' which collides with the fixture variant
406+
407+
$product->setVariants([$variant]);
408+
$product->validate();
409+
410+
self::assertEquals('rad-hood-1', $variant->sku);
411+
412+
$this->setProductTypeSkuFormat(2001, null);
413+
}
414+
415+
/**
416+
* @group Product
417+
*/
418+
public function testSkuFormatDeduplicatesMultipleVariantsWithCollision(): void
419+
{
420+
// Fixture variant 'hct-white' already exists in the PURCHASABLES table.
421+
// Reset the sequence so suffixes are always predictable (-1, -2).
422+
$this->resetSkuSequence('hct-white');
423+
$this->setProductTypeSkuFormat(2001, 'hct-white');
424+
425+
$product = new Product();
426+
$product->title = 'Multi-Variant Collision Test';
427+
$product->typeId = 2001;
428+
$product->enabled = false;
429+
430+
$variant1 = new Variant();
431+
$variant1->title = 'Variant One';
432+
433+
$variant2 = new Variant();
434+
$variant2->title = 'Variant Two';
435+
436+
$product->setVariants([$variant1, $variant2]);
437+
$product->validate();
438+
439+
self::assertEquals('hct-white-1', $variant1->sku);
440+
self::assertEquals('hct-white-2', $variant2->sku);
441+
442+
$this->setProductTypeSkuFormat(2001, null);
443+
}
444+
445+
private function resetSkuSequence(string $baseSku): void
446+
{
447+
\Craft::$app->getDb()->createCommand()
448+
->delete(CraftTable::SEQUENCES, ['name' => 'sku::' . $baseSku])
449+
->execute();
450+
}
451+
452+
private function setProductTypeSkuFormat(int $typeId, ?string $skuFormat): void
453+
{
454+
$productTypesService = Plugin::getInstance()->getProductTypes();
455+
// Ensure types are loaded into cache
456+
$productTypesService->getAllProductTypes();
457+
458+
$reflection = new ReflectionClass($productTypesService);
459+
$prop = $reflection->getProperty('_allProductTypes');
460+
$prop->setAccessible(true);
461+
462+
foreach ($prop->getValue($productTypesService) as $type) {
463+
if ($type->id === $typeId) {
464+
$type->skuFormat = $skuFormat;
465+
return;
466+
}
467+
}
468+
}
360469
}

0 commit comments

Comments
 (0)