--- /dev/null
+<?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
+ );
+ }
+}
'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']],
}
/**
- * 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
{
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 --- */
$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();