From: fomichev Date: Wed, 15 Jul 2026 11:46:05 +0000 (+0300) Subject: refactor(ERP-467): DB-бэкстоп уникальности названия + чистка мёртвого теста X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=c74681d37008a89b4a9abe02d7a2a9fbcb35ce8c;p=erp24_rep%2Fyii-erp24%2F.git refactor(ERP-467): DB-бэкстоп уникальности названия + чистка мёртвого теста - миграция: expression-индекс uq_pm_product_supplier_name (product_guid, supplier_id, COALESCE(plantation_id,0), supplier_product_name) вместо удалённых строгих индексов — защита от гонок/bulk-путей - ProductMapping: уточнён комментарий к default=null (нормализация для БД) - тесты: удалён testArticleBarcodeDefaultRunsBeforeUniqueCheck (охранял неактуальный порядок правил) 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 index a7d9cee0..affb5672 100644 --- a/erp24/migrations/m260715_120000_widen_product_mappings_unique.php +++ b/erp24/migrations/m260715_120000_widen_product_mappings_unique.php @@ -5,32 +5,42 @@ declare(strict_types=1); use yii\db\Migration; /** - * ERP-467: Уникальность маппинга по названию/артикулу/штрихкоду. + * ERP-467: Уникальность маппинга по названию у поставщика. * * Раньше маппинг был уникален по (product_guid, supplier_id[, plantation_id]), * поэтому к одной карточке нельзя было привязать того же поставщика с той же - * плантацией под другим названием. Убираем эти строгие индексы — теперь в рамках - * объёма «товар + поставщик + плантация» на уникальность проверяется только - * название (ProductMapping::validatePartialUnique()); артикул и штрихкод могут - * повторяться. + * плантацией под другим названием. Меняем объём уникальности: в рамках + * «товар + поставщик + плантация» уникальным должно быть название + * (supplier_product_name); артикул и штрихкод могут повторяться. * - * Partial/expression UNIQUE-индекс PostgreSQL (WHERE ... / COALESCE(...)) нельзя - * выразить методами Yii2 Migration, поэтому DB-бэкстопа здесь нет — валидатора - * достаточно для ручной формы справочника. + * PHP-валидатор (ProductMapping::validatePartialUnique) прикрывает форму, но + * оставляем и DB-бэкстоп от гонок/bulk-путей. COALESCE(plantation_id, 0) + * схлопывает оба сценария (NULL и не-NULL плантация) в один expression-индекс + * — fluent-методами Yii2 такой индекс не выразить, поэтому здесь execute(). */ class m260715_120000_widen_product_mappings_unique extends Migration { private const TABLE = 'erp24.product_mappings'; + private const NAME_INDEX = 'uq_pm_product_supplier_name'; public function safeUp(): void { $this->dropIndex('uq_pm_product_supplier', self::TABLE); $this->dropIndex('uq_pm_product_supplier_plantation', self::TABLE); + + // Старые индексы гарантировали уникальность (товар, поставщик, плантация), + // так что дублей названий в этом объёме нет — CREATE не упадёт. + $this->execute( + 'CREATE UNIQUE INDEX ' . self::NAME_INDEX . ' ON ' . self::TABLE + . ' (product_guid, supplier_id, COALESCE(plantation_id, 0), supplier_product_name)' + ); } public function safeDown(): void { - // Восстанавливаем строгую уникальность (без partial WHERE — методами Yii это недоступно). + // Внимание: откат упадёт, если за время работы нового индекса накопились + // строки, нарушающие старую строгую уникальность (товар+поставщик[+плантация]). + $this->dropIndex(self::NAME_INDEX, self::TABLE); $this->createIndex('uq_pm_product_supplier', self::TABLE, ['product_guid', 'supplier_id'], true); $this->createIndex( 'uq_pm_product_supplier_plantation', diff --git a/erp24/records/ProductMapping.php b/erp24/records/ProductMapping.php index 2784cc5e..fa796bdf 100644 --- a/erp24/records/ProductMapping.php +++ b/erp24/records/ProductMapping.php @@ -96,8 +96,8 @@ class ProductMapping extends ActiveRecord 'skipOnEmpty' => true, 'message' => 'Плантация не найдена', ], - // default до validatePartialUnique: пустые article/barcode нормализуются в null - // ещё до проверки уникальности, чтобы '' и null не считались разными значениями. + // Пустые необязательные поля нормализуем в null перед сохранением, + // чтобы в БД не попадали '' вместо NULL. [['article', 'barcode', 'plantation_id'], 'default', 'value' => null], ['supplier_product_name', 'validatePartialUnique'], ['is_active', 'boolean'], diff --git a/erp24/tests/unit/records/ProductMappingTest.php b/erp24/tests/unit/records/ProductMappingTest.php index fede1af6..af25ee08 100644 --- a/erp24/tests/unit/records/ProductMappingTest.php +++ b/erp24/tests/unit/records/ProductMappingTest.php @@ -107,39 +107,6 @@ 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();