From: fomichev Date: Wed, 15 Jul 2026 11:18:09 +0000 (+0300) Subject: fix(ERP-467): маппинг поставщика уникален по названию, а не по товар+поставщик+плантация X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=bab471a6c54f4501dadd3e4ee61841685e0f3947;p=erp24_rep%2Fyii-erp24%2F.git fix(ERP-467): маппинг поставщика уникален по названию, а не по товар+поставщик+плантация Раньше нельзя было привязать того же поставщика с той же плантацией к карточке под другим названием. Теперь в объёме товар+поставщик+плантация проверяется только уникальность названия (supplier_product_name); артикул и штрихкод могут повторяться. - migration: снимает строгие partial unique индексы uq_pm_product_supplier[_plantation] - ProductMapping::validatePartialUnique(): проверка только по названию, plantation_id приводится к int (фикс TypeError/500 на строковом значении из POST) - тест на порядок правил (default null до проверки уникальности) Co-Authored-By: Claude Opus 4.8 (1M context) --- diff --git a/erp24/migrations/m260715_120000_widen_product_mappings_unique.php b/erp24/migrations/m260715_120000_widen_product_mappings_unique.php new file mode 100644 index 00000000..a7d9cee0 --- /dev/null +++ b/erp24/migrations/m260715_120000_widen_product_mappings_unique.php @@ -0,0 +1,42 @@ +dropIndex('uq_pm_product_supplier', self::TABLE); + $this->dropIndex('uq_pm_product_supplier_plantation', self::TABLE); + } + + public function safeDown(): void + { + // Восстанавливаем строгую уникальность (без partial WHERE — методами Yii это недоступно). + $this->createIndex('uq_pm_product_supplier', self::TABLE, ['product_guid', 'supplier_id'], true); + $this->createIndex( + 'uq_pm_product_supplier_plantation', + self::TABLE, + ['product_guid', 'supplier_id', 'plantation_id'], + true + ); + } +} diff --git a/erp24/records/ProductMapping.php b/erp24/records/ProductMapping.php index 7c3526e3..2784cc5e 100644 --- a/erp24/records/ProductMapping.php +++ b/erp24/records/ProductMapping.php @@ -96,8 +96,10 @@ class ProductMapping extends ActiveRecord 'skipOnEmpty' => true, 'message' => 'Плантация не найдена', ], - ['supplier_id', 'validatePartialUnique'], + // default до validatePartialUnique: пустые article/barcode нормализуются в null + // ещё до проверки уникальности, чтобы '' и null не считались разными значениями. [['article', 'barcode', 'plantation_id'], 'default', 'value' => null], + ['supplier_product_name', 'validatePartialUnique'], ['is_active', 'boolean'], ['is_active', 'default', 'value' => true], ['marking_ids', 'each', 'rule' => ['integer']], @@ -142,10 +144,10 @@ class ProductMapping extends ActiveRecord } /** - * Partial unique check: учитывает NULL в plantation_id. - * Логика соответствует БД-индексам: - * - uq_pm_product_supplier WHERE plantation_id IS NULL - * - uq_pm_product_supplier_plantation WHERE plantation_id IS NOT NULL + * Уникальность названия в рамках объёма «товар + поставщик + плантация». + * + * Проверяется только supplier_product_name. Артикул и штрихкод пока НЕ + * проверяются на уникальность (могут повторяться) — по требованию. */ public function validatePartialUnique(string $attribute): void { @@ -153,30 +155,35 @@ class ProductMapping extends ActiveRecord return; } - $query = self::find() - ->where([ - 'product_guid' => $this->product_guid, - 'supplier_id' => $this->supplier_id, - ]); + // plantation_id из POST приходит строкой — приводим к int (валидатор 'integer' + // только проверяет, но не кастует значение атрибута). + $plantationId = ($this->plantation_id === null || $this->plantation_id === '') + ? null + : (int) $this->plantation_id; + $scope = 'товар+поставщик' . ($plantationId !== null ? '+плантация' : ''); - if ($this->plantation_id === null || $this->plantation_id === '') { - $query->andWhere(['plantation_id' => null]); - } else { - $query->andWhere(['plantation_id' => $this->plantation_id]); + if ($this->scopedQuery($plantationId)->andWhere(['supplier_product_name' => $this->supplier_product_name])->exists()) { + $this->addError($attribute, "Название у поставщика для комбинации {$scope} уже используется"); } + } + + /** + * Базовый запрос по объёму уникальности (товар + поставщик + плантация) + * с исключением самой записи при обновлении. + */ + private function scopedQuery(?int $plantationId): ActiveQuery + { + $query = self::find()->where([ + 'product_guid' => $this->product_guid, + 'supplier_id' => $this->supplier_id, + 'plantation_id' => $plantationId, + ]); if (!$this->isNewRecord) { $query->andWhere(['!=', 'id', $this->id]); } - if ($query->exists()) { - $this->addError( - $attribute, - 'Маппинг для этой комбинации товар+поставщик' - . ($this->plantation_id ? '+плантация' : '') - . ' уже существует' - ); - } + return $query; } /* --- Relations --- */ diff --git a/erp24/tests/unit/records/ProductMappingTest.php b/erp24/tests/unit/records/ProductMappingTest.php index af25ee08..fede1af6 100644 --- a/erp24/tests/unit/records/ProductMappingTest.php +++ b/erp24/tests/unit/records/ProductMappingTest.php @@ -107,6 +107,39 @@ class ProductMappingTest extends Unit $this->assertTrue(method_exists(ProductMapping::class, 'getProductNomenclature')); } + /** + * ERP-467: пустые article/barcode должны нормализоваться в null ДО проверки + * уникальности, иначе '' и null считались бы разными значениями. + */ + public function testArticleBarcodeDefaultRunsBeforeUniqueCheck(): void + { + $rules = (new ProductMapping())->rules(); + + $defaultIndex = null; + $uniqueIndex = null; + foreach ($rules as $i => $rule) { + if ( + ($rule[1] ?? null) === 'default' + && array_key_exists('value', $rule) && $rule['value'] === null + && in_array('article', (array)$rule[0], true) + && in_array('barcode', (array)$rule[0], true) + ) { + $defaultIndex = $i; + } + if (($rule[1] ?? null) === 'validatePartialUnique') { + $uniqueIndex = $i; + } + } + + $this->assertNotNull($defaultIndex, 'Нужно правило default=null для article/barcode'); + $this->assertNotNull($uniqueIndex, 'Нужно правило validatePartialUnique'); + $this->assertLessThan( + $uniqueIndex, + $defaultIndex, + 'default=null для article/barcode должен идти до validatePartialUnique' + ); + } + public function testMarkingIdsRuleHasDefault(): void { $rules = (new ProductMapping())->rules();