]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
fix(ERP-467): маппинг поставщика уникален по названию, а не по товар+поставщик+плантация
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 15 Jul 2026 11:18:09 +0000 (14:18 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 15 Jul 2026 11:18:09 +0000 (14:18 +0300)
Раньше нельзя было привязать того же поставщика с той же плантацией к карточке
под другим названием. Теперь в объёме товар+поставщик+плантация проверяется
только уникальность названия (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) <noreply@anthropic.com>
erp24/migrations/m260715_120000_widen_product_mappings_unique.php [new file with mode: 0644]
erp24/records/ProductMapping.php
erp24/tests/unit/records/ProductMappingTest.php

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 (file)
index 0000000..a7d9cee
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+/**
+ * ERP-467: Уникальность маппинга по названию/артикулу/штрихкоду.
+ *
+ * Раньше маппинг был уникален по (product_guid, supplier_id[, plantation_id]),
+ * поэтому к одной карточке нельзя было привязать того же поставщика с той же
+ * плантацией под другим названием. Убираем эти строгие индексы — теперь в рамках
+ * объёма «товар + поставщик + плантация» на уникальность проверяется только
+ * название (ProductMapping::validatePartialUnique()); артикул и штрихкод могут
+ * повторяться.
+ *
+ * Partial/expression UNIQUE-индекс PostgreSQL (WHERE ... / COALESCE(...)) нельзя
+ * выразить методами Yii2 Migration, поэтому DB-бэкстопа здесь нет — валидатора
+ * достаточно для ручной формы справочника.
+ */
+class m260715_120000_widen_product_mappings_unique extends Migration
+{
+    private const TABLE = 'erp24.product_mappings';
+
+    public function safeUp(): void
+    {
+        $this->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
+        );
+    }
+}
index 7c3526e31356ef0bdd57b9850f2fd5af44849549..2784cc5ea75e84a644e79f2af15ca2fbb4d574fa 100644 (file)
@@ -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 --- */
index af25ee080d1ffcc3fbdc0d5950dfe2aab701b25c..fede1af685fc8669f135101f9762e4eb7733dd4a 100644 (file)
@@ -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();