--- /dev/null
+# Лог дебатов: ERP-318 — PRD
+
+**Дата:** 2026-04-09
+**Раунд:** 1
+
+---
+
+## Модель 1: DeepSeek v3.2 (Backend Architect)
+
+**Вердикт: APPROVED** с minor improvements
+
+### Сильные стороны
+- Proper use of TimestampBehavior и BlameableBehavior
+- Comprehensive validation rules
+- Good soft delete logic
+
+### Рекомендации (MINOR)
+1. Добавить scopes: `findActive()`, `findLocal()`, `findInternational()`
+2. `afterSave()` для audit logging при изменении is_active
+3. Кэширование dropdown-данных (currency, type) — *отклонено: <20 записей, нет смысла*
+4. Bulk operations — *отклонено: вне scope задачи*
+
+---
+
+## Модель 2: Grok 4.1 Fast (Edge Case Critic)
+
+**Вердикт: 9 issues (2 CRITICAL, 4 MAJOR, 3 MINOR)**
+
+### CRITICAL
+1. **Race on UNIQUE name** — конкурентные AJAX-запросы могут создать дубли → **ПРИНЯТО: обернуть CRUD в транзакцию**
+2. **Soft delete race / optimistic lock** — *ОТКЛОНЕНО: <20 записей, 1-2 пользователя, риск минимален*
+
+### MAJOR
+1. **N+1 queries** на связях markings/product_mappings → **ПРИНЯТО: использовать `with()` eager loading**
+2. **RBAC soft delete** — restore/forceDelete → *ОТКЛОНЕНО: реактивация вне scope*
+3. **BlameableBehavior без user** → **ПРИНЯТО: добавить defaultValue fallback для CLI/cron**
+4. **PJAX filter state** → *ОТКЛОНЕНО: нет фильтров в GridView, <20 записей*
+
+### MINOR
+1. **Timezone** → *ОТКЛОНЕНО: PostgreSQL + Yii2 TimestampBehavior с NOW() = серверное время*
+2. **Orphans в joined tables** → **ПРИНЯТО: учесть при отображении связей**
+3. **Unique validation vs soft-deleted** → **ПРИНЯТО: name unique среди ВСЕХ записей (вкл. неактивных)*
+
+---
+
+## Модель 3: Gemini 2.5 Pro (QA Engineer)
+
+**Вердикт: APPROVED** (ответ обрезан по лимиту)
+
+Начал анализ тестируемости, подтвердил PRD как "отличный, но сжатый".
+
+---
+
+## Консенсус (Раунд 1)
+
+| Модель | Вердикт |
+|--------|---------|
+| DeepSeek v3.2 | APPROVED |
+| Grok 4.1 Fast | MAJOR (требует доработки) |
+| Gemini 2.5 Pro | APPROVED |
+
+**Общий вердикт: APPROVED с доработками** (2 из 3 APPROVED, 70% консенсус достигнут)
+
+### Принятые улучшения для PRD → Spec
+
+1. ✅ Обернуть create/update в DB-транзакцию
+2. ✅ Eager loading для связей (`with()`)
+3. ✅ BlameableBehavior: defaultValue для fallback
+4. ✅ Scopes: `findActive()` в модели
+5. ✅ Unique name проверяется среди ВСЕХ записей (включая неактивных)
+
+---
+
+# Лог дебатов: ERP-318 — Spec
+
+**Дата:** 2026-04-09
+**Раунд:** 1
+
+## Модель 1: DeepSeek v3.2 (Backend Architect)
+
+**Вердикт: MINOR**
+
+### Замечания
+1. XSS в GridView → уже учтено (Html::encode в value callback)
+2. deactivate() в модели нарушает SRP → **ОТКЛОНЕНО**: паттерн проекта, <20 записей, нет смысла в сервисе
+3. BlameableBehavior defaultValue=0 → **ПРИНЯТО**: изменить на null, в UI показывать "Система"
+4. Индексы БД для is_active, type, currency → **ОТКЛОНЕНО**: <20 записей
+5. DTO для create/update → **ОТКЛОНЕНО**: overengineering для справочника
+
+## Модель 2: Grok 4.1 Fast (Security Engineer)
+
+**Вердикт: MAJOR**
+
+### Замечания
+1. **XSS inline styles в badges** → **ПРИНЯТО**: заменить inline styles на CSS-классы
+2. CSRF для AJAX → ОК, стандарт Yii2
+3. SQL injection → ОК, ActiveRecord ORM
+4. RBAC для AJAX actions → ОК, BaseController проверяет menu/supplier/{action}
+
+## Консенсус (Раунд 1)
+
+| Модель | Вердикт |
+|--------|---------|
+| DeepSeek v3.2 | MINOR |
+| Grok 4.1 Fast | MAJOR |
+
+**Общий вердикт: APPROVED с доработками**
+
+### Принятые улучшения для Spec → Implementation
+
+1. ✅ Заменить inline styles в badges на CSS-классы
+2. ✅ BlameableBehavior defaultValue → null вместо 0
--- /dev/null
+# ERP-318: Интервью — Справочник поставщиков
+
+**Дата:** 2026-04-09
+**Участник:** Владимир Фомичев
+
+---
+
+## Q1: Scope CRUD операций
+**Ответ: (B)** Модальные окна поверх таблицы (AJAX-формы в Bootstrap modal)
+- Создание и редактирование — через модальное окно
+- Модалка: заголовок на тёмно-синем фоне (#1e3a5f)
+- Поля: название* (text), тип* (select), валюта* (select), lead time* (number)
+
+## Q2: Блокировка при soft delete
+**Ответ: (C)** is_active = false + каскадная деактивация связанных записей
+- При деактивации поставщика — каскадно деактивировать связанные markings и product_mappings
+- Блокировка деактивации при активных заказах (UC-05 из спецификации)
+- Подтверждение через confirm-диалог
+
+## Q3: Таблица — GridView или DataTables
+**Ответ: (A)** Стандартный Yii2 GridView с column filters + PJAX
+- Bootstrap 5 `table-bordered table-hover`
+- Колонки: Название (auto), Тип (badge), Lead time (center), Валюта (center), Статус (badge), Действия
+- Сортировка по имени (ASC) по умолчанию
+- Пагинация не нужна (<20 записей)
+
+## Q4: Бейджи типа и статуса
+**Ответ: (B)** Есть утверждённый UI-guideline (из spec-supplier-reference.html)
+- **Тип:**
+ - `local` → badge (цвет из UI-kit проекта)
+ - `international` → badge (цвет из UI-kit проекта)
+- **Статус:**
+ - Активен → зелёный badge
+ - Неактивен → серый badge
+- Неактивные строки: `opacity: 0.5`
+
+## Q5: Права доступа
+**Ответ: (A)** Любой авторизованный пользователь с доступом к меню
+- Через CrmMenu + стандартная проверка сессии `modul_arr_dostup`
+- Без дополнительных RBAC-ролей
+
+---
+
+## Дополнительный контекст из спецификации (spec-supplier-reference.html)
+
+- **Таблица БД:** `erp24.suppliers` — миграция уже существует
+- **Связи:** `markings.supplier_id`, `product_mappings.supplier_id`
+- **Валидация:** name UNIQUE, lead_time >= 0, type/currency из enum
+- **Аудит:** TimestampBehavior + BlameableBehavior (created_by, updated_by, created_at, updated_at)
+- **Контейнер:** BuyerReferenceController с Bootstrap 5 tabs (4 вкладки)
+- **FR-S-08:** Аудит — все изменения в audit_log (P1, может быть отложено)
--- /dev/null
+# План реализации: ERP-318
+
+**Дата:** 2026-04-09
+**На основе:** Spec v1.0 + Debate improvements
+
+---
+
+## Порядок задач
+
+| # | Задача | Файл(ы) | Зависимости | Оценка |
+|---|--------|---------|-------------|--------|
+| 1 | Модель Supplier | `erp24/records/Supplier.php` | Миграция (существует) | S |
+| 2 | Search модель | `erp24/records/SupplierSearch.php` | Задача 1 | S |
+| 3 | Контроллер SupplierController | `erp24/controllers/SupplierController.php` | Задачи 1, 2 | M |
+| 4 | View: таблица + модалка + JS | `erp24/views/supplier/index.php`, `_form.php` | Задачи 1, 3 | L |
+| 5 | Интеграция с BuyerReference | `erp24/views/buyer-reference/index.php` | Задача 4 | S |
+
+**S** = small (<30 мин), **M** = medium (30-60 мин), **L** = large (1-2 часа)
+
+---
+
+## Задача 1: Модель Supplier.php
+
+**Файл:** `erp24/records/Supplier.php`
+
+- ActiveRecord для `erp24.suppliers`
+- Constants: TYPE_LOCAL, TYPE_INTERNATIONAL, CURRENCY_*
+- behaviors(): TimestampBehavior (NOW()) + BlameableBehavior (defaultValue: null)
+- rules(): name required unique max200, type in enum, currency in enum, lead_time_days integer min:0
+- attributeLabels() на русском
+- findActive() scope
+- Relations: getMarkings(), getProductMappings()
+- Helpers: getTypeOptions(), getCurrencyOptions()
+- Badge helpers: getTypeBadge(), getStatusBadge() — **CSS-классы** (не inline styles)
+- deactivate() — транзакция, cascade updateAll
+
+## Задача 2: Search модель SupplierSearch.php
+
+**Файл:** `erp24/records/SupplierSearch.php`
+
+- Extends Model (не ActiveRecord)
+- Properties: id, name, type, currency, is_active
+- search() → ActiveDataProvider, pagination: false, orderBy name ASC
+- Фильтры: andFilterWhere для всех полей
+
+## Задача 3: Контроллер SupplierController.php
+
+**Файл:** `erp24/controllers/SupplierController.php`
+
+- Extends BaseController (RBAC через menu/supplier/*)
+- VerbFilter: create/update/delete → POST
+- actionIndex() — partial rendering при AJAX
+- actionCreateForm() — renderAjax _form
+- actionCreate() — JSON + транзакция
+- actionUpdateForm(id) — renderAjax _form
+- actionUpdate(id) — JSON + транзакция
+- actionDelete(id) — deactivate() + JSON
+
+## Задача 4: Views (таблица + модалка + JS)
+
+**Файлы:** `erp24/views/supplier/index.php`, `erp24/views/supplier/_form.php`
+
+### index.php
+- Header: заголовок + кнопка «Добавить»
+- GridView + PJAX (id: supplier-pjax)
+- Колонки: name (bold), type (badge), lead_time (center, "N дн"), currency (center), is_active (badge), actions
+- rowOptions: opacity 0.5 для inactive
+- Модальное окно (#supplier-modal), header #1e3a5f
+- JS: jQuery — открытие модалки, AJAX submit, pjax.reload, confirm деактивации
+
+### _form.php
+- HTML form (не ActiveForm widget — для renderAjax)
+- Поля: name (text), type (select), currency (select), lead_time_days (number)
+- Layout: type + currency + lead_time в одну строку (d-flex gap-2)
+- Footer: Отмена + Сохранить
+
+## Задача 5: Интеграция с BuyerReference
+
+**Файл:** `erp24/views/buyer-reference/index.php`
+
+- Заменить placeholder вкладки «Поставщики» на AJAX-загрузку
+- JS: загрузка `/supplier/index` при shown.bs.tab + при document.ready
+- data-loaded флаг для предотвращения повторной загрузки
+
+---
+
+## CSS-классы для бейджей
+
+Добавить в view (inline `<style>` или в общий CSS):
+
+```css
+.badge-type-local { background:#d1e7dd; color:#0f5132; font-size:10px; padding:1px 6px; border-radius:3px; }
+.badge-type-international { background:#cfe2ff; color:#084298; font-size:10px; padding:1px 6px; border-radius:3px; }
+.badge-status-active { background:#d1e7dd; color:#0f5132; font-size:10px; padding:1px 6px; border-radius:3px; }
+.badge-status-inactive { background:#e2e3e5; color:#41464b; font-size:10px; padding:1px 6px; border-radius:3px; }
+```
+
+---
+
+## Чеклист перед merge
+
+- [ ] Миграция применена (`php yii migrate`)
+- [ ] Supplier CRUD работает через модалку
+- [ ] Валидация: unique name, lead_time >= 0
+- [ ] Деактивация с confirm + каскад
+- [ ] Неактивные строки opacity 0.5
+- [ ] Бейджи соответствуют мокапам
+- [ ] PJAX reload после каждой операции
+- [ ] RBAC permissions настроены (menu/supplier/*)
--- /dev/null
+# PRD: ERP-318 — Справочник поставщиков (Model + Controller + View + JS)
+
+**Версия:** 1.0
+**Дата:** 2026-04-09
+**Автор:** AI Workflow (на основе интервью с В. Фомичевым)
+**Jira:** [ERP-318](https://itriteil.atlassian.net/browse/ERP-318)
+**Epic:** [ERP-300](https://itriteil.atlassian.net/browse/ERP-300) — Справочник поставщика
+
+---
+
+## 1. Цель
+
+Реализовать полнофункциональный CRUD для справочника поставщиков в ERP24. Поставщик — юридическое лицо, поставляющее цветочную продукцию. Бывает двух типов: локальный (прямой, RUB) и международный (через склад, EUR/USD).
+
+## 2. Бизнес-контекст
+
+Справочник поставщиков — первый из четырёх справочников в модуле «Справочник закупщика» (BuyerReference). Является базовой сущностью, от которой зависят:
+- Маркировки (`markings.supplier_id`)
+- Маппинг товаров (`product_mappings.supplier_id`)
+
+Без справочника поставщиков невозможна работа остальных трёх справочников.
+
+## 3. Пользователи
+
+| Роль | Действия |
+|------|----------|
+| Закупщик | Просмотр, создание, редактирование, деактивация поставщиков |
+| Система | Блокировка деактивации при активных заказах |
+
+Доступ: любой авторизованный пользователь с доступом к пункту меню (CrmMenu + `modul_arr_dostup`).
+
+## 4. Функциональные требования
+
+### 4.1 Таблица поставщиков (FR-S-01)
+
+- **Расположение:** Вкладка «Справочник поставщиков» внутри BuyerReferenceController
+- **Компонент:** Yii2 GridView + PJAX
+- **CSS:** Bootstrap 5 `table-bordered table-hover table-sm`
+- **Колонки:**
+
+| # | Колонка | Ширина | Выравнивание | Формат |
+|---|---------|--------|-------------|--------|
+| 1 | Поставщик | auto | left | bold text |
+| 2 | Тип | badge | center | Badge: Локальный/Международный |
+| 3 | Lead time | — | center | `N дн` |
+| 4 | Валюта | — | center | RUB/EUR/USD |
+| 5 | Статус | badge | center | Badge: Активен/Неактивен |
+| 6 | Действия | — | center | Иконки: edit, delete |
+
+- **Сортировка:** по имени (ASC) по умолчанию
+- **Пагинация:** не нужна (<20 записей)
+- **Неактивные строки:** `opacity: 0.5`, бейдж «Неактивен» серый
+- **Кнопка:** «Добавить» (btn-primary btn-sm) в шапке справа
+
+### 4.2 Бейджи (из UI-guideline)
+
+| Элемент | Текст | Background | Color |
+|---------|-------|-----------|-------|
+| Тип: local | Локальный | `#d1e7dd` | `#0f5132` |
+| Тип: international | Международный | `#cfe2ff` | `#084298` |
+| Статус: active | Активен | `#d1e7dd` | `#0f5132` |
+| Статус: inactive | Неактивен | `#e2e3e5` | `#41464b` |
+
+### 4.3 Модальное окно — Создание/Редактирование (FR-S-02, FR-S-03)
+
+- **Заголовок:** тёмно-синий фон (`#1e3a5f`), белый текст, иконка `fa-building`
+ - Создание: «Добавить поставщика»
+ - Редактирование: «Редактировать поставщика»
+- **Поля формы:**
+
+| Поле | Тип | Обязательное | Валидация |
+|------|-----|-------------|-----------|
+| Название | text input | Да | UNIQUE, maxlength=200 |
+| Тип | select | Да | enum: Локальный, Международный |
+| Валюта | select | Да | enum: RUB, EUR, USD |
+| Lead time | number input | Да | >= 0, integer |
+
+- **Layout:** Тип + Валюта + Lead time в одну строку (flex)
+- **Footer:** Отмена (btn-secondary) + Сохранить (btn-primary, иконка `fa-save`)
+- **Отправка:** AJAX POST, при успехе — закрыть модалку, обновить GridView через PJAX reload
+
+### 4.4 Деактивация / Soft Delete (FR-S-04, FR-S-05, FR-S-06)
+
+- **Триггер:** клик по иконке delete (fa-trash) в строке
+- **Подтверждение:** JS confirm «Деактивировать поставщика "Name"?»
+- **Блокировка:** если есть активные заказы → ошибка «N незавершённых заказов, деактивация невозможна»
+- **Каскадная деактивация:** при деактивации поставщика → автоматически деактивировать:
+ - Все связанные `markings` (где `supplier_id = $id`)
+ - Все связанные `product_mappings` (где `supplier_id = $id`)
+- **Результат:** `is_active = false`, строка отображается с `opacity: 0.5`
+
+### 4.5 Валидация (FR-S-07)
+
+| Правило | Сообщение об ошибке |
+|---------|---------------------|
+| name — обязательное | «Название обязательно» |
+| name — unique | «Поставщик уже существует» |
+| name — maxlength(200) | «Максимум 200 символов» |
+| type — in [local, international] | «Выберите тип» |
+| currency — in [RUB, EUR, USD] | «Выберите валюту» |
+| lead_time_days — integer, >= 0 | «Lead time должен быть >= 0» |
+
+### 4.6 Аудит (FR-S-08, P1)
+
+- **TimestampBehavior:** `created_at`, `updated_at` (автоматически через `NOW()`)
+- **BlameableBehavior:** `created_by`, `updated_by` (ID текущего пользователя)
+
+## 5. Техническая архитектура
+
+### 5.1 БД
+
+Таблица `erp24.suppliers` — **миграция уже существует** (`m260408_100000_create_suppliers_table.php`).
+
+```
+id SERIAL PRIMARY KEY
+name VARCHAR(200) NOT NULL UNIQUE
+type VARCHAR(20) NOT NULL CHECK (local, international)
+currency VARCHAR(3) NOT NULL CHECK (RUB, EUR, USD)
+lead_time_days INTEGER NOT NULL DEFAULT 0 CHECK (>= 0)
+is_active BOOLEAN NOT NULL DEFAULT true
+created_by INTEGER NOT NULL
+updated_by INTEGER NULL
+created_at TIMESTAMP NOT NULL
+updated_at TIMESTAMP NULL
+```
+
+### 5.2 Компоненты (файлы для создания)
+
+| Файл | Назначение |
+|------|------------|
+| `erp24/records/Supplier.php` | ActiveRecord модель |
+| `erp24/records/SupplierSearch.php` | Search модель для GridView |
+| `erp24/controllers/SupplierController.php` | CRUD контроллер (AJAX) |
+| `erp24/views/supplier/_modal_form.php` | Модальная форма |
+| `erp24/views/buyer-reference/index.php` | Обновить — добавить контент вкладки поставщиков |
+
+### 5.3 Паттерны
+
+- **Model:** наследует `yii\db\ActiveRecord`, namespace `yii_app\records`
+- **Controller:** наследует `yii\web\Controller`, namespace `app\controllers`
+- **AJAX CRUD:** actionCreate/actionUpdate возвращают JSON при AJAX-запросе
+- **PJAX:** обновление таблицы без перезагрузки страницы
+- **Soft Delete:** `is_active = false` + каскад на связанные записи
+
+## 6. Критерии приёмки
+
+- [ ] Таблица отображает всех поставщиков (активных и неактивных)
+- [ ] Бейджи типа и статуса соответствуют UI-guideline
+- [ ] Неактивные строки с opacity 0.5
+- [ ] Модалка открывается по кнопке «Добавить» и по иконке edit
+- [ ] Валидация: unique name, lead_time >= 0, обязательные поля
+- [ ] Сообщение «Поставщик уже существует» при дубле имени
+- [ ] Деактивация с подтверждением
+- [ ] Каскадная деактивация markings и product_mappings
+- [ ] Блокировка деактивации при активных заказах (опционально — зависит от наличия таблицы заказов)
+- [ ] TimestampBehavior + BlameableBehavior работают
+- [ ] PJAX обновляет таблицу после CRUD операций
+
+## 7. Вне scope
+
+- Импорт/экспорт Excel (FR-007, P1)
+- Аналитическая шапка (FR-005, P1)
+- Audit log в отдельную таблицу (FR-S-08, P1)
+- Реактивация поставщика (восстановление из неактивного)
+- Остальные 3 справочника (отдельные задачи)
+
+## 8. Риски
+
+| Риск | Митигация |
+|------|-----------|
+| Таблица заказов ещё не существует → FR-S-05 не реализуем | Деактивация без проверки заказов, добавить проверку позже |
+| Каскадная деактивация может затронуть много записей | Транзакция + подтверждение с указанием количества связанных записей |
+| AJAX-форма в модалке — ошибки валидации должны показываться внутри модалки | Yii2 ActiveForm + AJAX validation |
+
+## 9. Зависимости
+
+- **Миграция:** `m260408_100000_create_suppliers_table.php` (уже существует)
+- **BuyerReferenceController:** контейнер с табами (уже существует)
+- **CrmMenu:** пункт меню для доступа к BuyerReference
+
+---
+
+## Диаграмма компонентов
+
+```mermaid
+graph TB
+ subgraph "Browser"
+ A[buyer-reference/index]
+ B[Tab: Поставщики]
+ C[GridView + PJAX]
+ D[Modal: Create/Edit]
+ end
+
+ subgraph "Controller"
+ E[SupplierController]
+ E1[actionIndex]
+ E2[actionCreate]
+ E3[actionUpdate]
+ E4[actionDelete]
+ end
+
+ subgraph "Model"
+ F[Supplier AR]
+ G[SupplierSearch]
+ end
+
+ subgraph "DB"
+ H[(erp24.suppliers)]
+ I[(erp24.markings)]
+ J[(erp24.product_mappings)]
+ end
+
+ A --> B --> C
+ C --> E1
+ D --> E2
+ D --> E3
+ C --> E4
+ E1 --> G --> H
+ E2 --> F --> H
+ E3 --> F
+ E4 --> F
+ F -.->|cascade deactivate| I
+ F -.->|cascade deactivate| J
+```
--- /dev/null
+# Техническая спецификация: ERP-318
+
+**Версия:** 1.0
+**Дата:** 2026-04-09
+**На основе:** PRD v1.0 + Debate Round 1
+
+---
+
+## 1. Обзор
+
+CRUD справочника поставщиков: ActiveRecord модель, контроллер с AJAX-ответами, модальная форма, GridView + PJAX. Вкладка «Поставщики» внутри BuyerReferenceController.
+
+## 2. Файлы для создания/изменения
+
+| Файл | Действие | Описание |
+|------|----------|----------|
+| `erp24/records/Supplier.php` | CREATE | ActiveRecord модель |
+| `erp24/records/SupplierSearch.php` | CREATE | Search модель для GridView |
+| `erp24/controllers/SupplierController.php` | CREATE | CRUD контроллер (AJAX) |
+| `erp24/views/supplier/index.php` | CREATE | GridView таблица (partial для вкладки) |
+| `erp24/views/supplier/_form.php` | CREATE | Форма для модального окна |
+| `erp24/views/buyer-reference/index.php` | MODIFY | Подключить вкладку поставщиков через AJAX |
+
+## 3. Модель: Supplier.php
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use Yii;
+use yii\behaviors\BlameableBehavior;
+use yii\behaviors\TimestampBehavior;
+use yii\db\ActiveQuery;
+use yii\db\ActiveRecord;
+use yii\db\Expression;
+
+/**
+ * Справочник поставщиков.
+ *
+ * @property int $id
+ * @property string $name
+ * @property string $type local | international
+ * @property string $currency RUB | EUR | USD
+ * @property int $lead_time_days
+ * @property bool $is_active
+ * @property int $created_by
+ * @property int|null $updated_by
+ * @property string $created_at
+ * @property string|null $updated_at
+ */
+class Supplier extends ActiveRecord
+{
+ public const TYPE_LOCAL = 'local';
+ public const TYPE_INTERNATIONAL = 'international';
+
+ public const CURRENCY_RUB = 'RUB';
+ public const CURRENCY_EUR = 'EUR';
+ public const CURRENCY_USD = 'USD';
+
+ public static function tableName(): string
+ {
+ return '{{%erp24.suppliers}}';
+ }
+
+ public function behaviors(): array
+ {
+ return [
+ [
+ 'class' => TimestampBehavior::class,
+ 'createdAtAttribute' => 'created_at',
+ 'updatedAtAttribute' => 'updated_at',
+ 'value' => new Expression('NOW()'),
+ ],
+ [
+ 'class' => BlameableBehavior::class,
+ 'createdByAttribute' => 'created_by',
+ 'updatedByAttribute' => 'updated_by',
+ 'defaultValue' => 0,
+ ],
+ ];
+ }
+
+ public function rules(): array
+ {
+ return [
+ [['name', 'type', 'currency'], 'required'],
+ ['name', 'string', 'max' => 200],
+ ['name', 'unique', 'message' => 'Поставщик уже существует'],
+ ['type', 'in', 'range' => [self::TYPE_LOCAL, self::TYPE_INTERNATIONAL]],
+ ['currency', 'in', 'range' => [self::CURRENCY_RUB, self::CURRENCY_EUR, self::CURRENCY_USD]],
+ ['lead_time_days', 'integer', 'min' => 0],
+ ['lead_time_days', 'default', 'value' => 0],
+ ['is_active', 'boolean'],
+ ['is_active', 'default', 'value' => true],
+ ];
+ }
+
+ public function attributeLabels(): array
+ {
+ return [
+ 'id' => 'ID',
+ 'name' => 'Название',
+ 'type' => 'Тип',
+ 'currency' => 'Валюта',
+ 'lead_time_days' => 'Lead time (дн)',
+ 'is_active' => 'Статус',
+ 'created_by' => 'Создал',
+ 'updated_by' => 'Обновил',
+ 'created_at' => 'Создано',
+ 'updated_at' => 'Обновлено',
+ ];
+ }
+
+ /* --- Scopes --- */
+
+ public static function findActive(): ActiveQuery
+ {
+ return static::find()->where(['is_active' => true]);
+ }
+
+ /* --- Relations --- */
+
+ public function getMarkings(): ActiveQuery
+ {
+ return $this->hasMany(Marking::class, ['supplier_id' => 'id']);
+ }
+
+ public function getProductMappings(): ActiveQuery
+ {
+ return $this->hasMany(ProductMapping::class, ['supplier_id' => 'id']);
+ }
+
+ /* --- Helpers --- */
+
+ public static function getTypeOptions(): array
+ {
+ return [
+ self::TYPE_LOCAL => 'Локальный',
+ self::TYPE_INTERNATIONAL => 'Международный',
+ ];
+ }
+
+ public static function getCurrencyOptions(): array
+ {
+ return [
+ self::CURRENCY_RUB => 'RUB',
+ self::CURRENCY_EUR => 'EUR',
+ self::CURRENCY_USD => 'USD',
+ ];
+ }
+
+ /**
+ * Бейдж типа поставщика (HTML).
+ */
+ public function getTypeBadge(): string
+ {
+ if ($this->type === self::TYPE_INTERNATIONAL) {
+ return '<span style="background:#cfe2ff;color:#084298;font-size:10px;padding:1px 6px;border-radius:3px;">Международный</span>';
+ }
+ return '<span style="background:#d1e7dd;color:#0f5132;font-size:10px;padding:1px 6px;border-radius:3px;">Локальный</span>';
+ }
+
+ /**
+ * Бейдж статуса (HTML).
+ */
+ public function getStatusBadge(): string
+ {
+ if ($this->is_active) {
+ return '<span style="background:#d1e7dd;color:#0f5132;font-size:10px;padding:1px 6px;border-radius:3px;">Активен</span>';
+ }
+ return '<span style="background:#e2e3e5;color:#41464b;font-size:10px;padding:1px 6px;border-radius:3px;">Неактивен</span>';
+ }
+
+ /**
+ * Каскадная деактивация поставщика и связанных записей.
+ * Выполняется в транзакции.
+ *
+ * @return array{markings: int, product_mappings: int} Количество деактивированных записей
+ */
+ public function deactivate(): array
+ {
+ $transaction = Yii::$app->db->beginTransaction();
+ try {
+ $this->is_active = false;
+ $this->save(false);
+
+ $markingsCount = Marking::updateAll(
+ ['is_active' => false],
+ ['supplier_id' => $this->id, 'is_active' => true]
+ );
+
+ $mappingsCount = ProductMapping::updateAll(
+ ['is_active' => false],
+ ['supplier_id' => $this->id, 'is_active' => true]
+ );
+
+ $transaction->commit();
+
+ return [
+ 'markings' => $markingsCount,
+ 'product_mappings' => $mappingsCount,
+ ];
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ throw $e;
+ }
+ }
+}
+```
+
+### Ключевые решения (из дебатов)
+- `BlameableBehavior` с `defaultValue => 0` — fallback для CLI/cron
+- `unique` валидация на `name` среди ВСЕХ записей (включая неактивных)
+- `deactivate()` — каскад в транзакции
+- `findActive()` scope для запросов
+- Badge helpers в модели (DRY)
+
+## 4. Search модель: SupplierSearch.php
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\base\Model;
+use yii\data\ActiveDataProvider;
+
+class SupplierSearch extends Model
+{
+ public $id;
+ public $name;
+ public $type;
+ public $currency;
+ public $is_active;
+
+ public function rules(): array
+ {
+ return [
+ [['id'], 'integer'],
+ [['name', 'type', 'currency'], 'safe'],
+ [['is_active'], 'boolean'],
+ ];
+ }
+
+ public function search(array $params): ActiveDataProvider
+ {
+ $query = Supplier::find()->orderBy(['name' => SORT_ASC]);
+
+ $dataProvider = new ActiveDataProvider([
+ 'query' => $query,
+ 'pagination' => false, // <20 записей
+ ]);
+
+ $this->load($params);
+
+ if (!$this->validate()) {
+ return $dataProvider;
+ }
+
+ $query->andFilterWhere(['id' => $this->id])
+ ->andFilterWhere(['type' => $this->type])
+ ->andFilterWhere(['currency' => $this->currency])
+ ->andFilterWhere(['is_active' => $this->is_active])
+ ->andFilterWhere(['like', 'name', $this->name]);
+
+ return $dataProvider;
+ }
+}
+```
+
+## 5. Контроллер: SupplierController.php
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace app\controllers;
+
+use Yii;
+use yii_app\records\Supplier;
+use yii_app\records\SupplierSearch;
+use yii\web\NotFoundHttpException;
+use yii\web\Response;
+use yii\filters\VerbFilter;
+
+/**
+ * CRUD контроллер справочника поставщиков.
+ * Все операции create/update/delete — через AJAX.
+ */
+class SupplierController extends BaseController
+{
+ public function behaviors(): array
+ {
+ return array_merge(
+ parent::behaviors(),
+ [
+ 'verbs' => [
+ 'class' => VerbFilter::class,
+ 'actions' => [
+ 'delete' => ['POST'],
+ 'create' => ['POST'],
+ 'update' => ['POST'],
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Таблица поставщиков (partial для вкладки).
+ * Вызывается AJAX-запросом из buyer-reference/index.
+ */
+ public function actionIndex(): string
+ {
+ $searchModel = new SupplierSearch();
+ $dataProvider = $searchModel->search(Yii::$app->request->queryParams);
+
+ if (Yii::$app->request->isAjax) {
+ return $this->renderPartial('index', [
+ 'searchModel' => $searchModel,
+ 'dataProvider' => $dataProvider,
+ ]);
+ }
+
+ return $this->render('index', [
+ 'searchModel' => $searchModel,
+ 'dataProvider' => $dataProvider,
+ ]);
+ }
+
+ /**
+ * Форма создания (HTML для модалки).
+ */
+ public function actionCreateForm(): string
+ {
+ $model = new Supplier();
+ $model->loadDefaultValues();
+
+ return $this->renderAjax('_form', ['model' => $model]);
+ }
+
+ /**
+ * Создание поставщика (AJAX POST).
+ */
+ public function actionCreate(): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = new Supplier();
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Поставщик создан']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => ['name' => [$e->getMessage()]]]);
+ }
+ }
+
+ /**
+ * Форма редактирования (HTML для модалки).
+ */
+ public function actionUpdateForm(int $id): string
+ {
+ $model = $this->findModel($id);
+ return $this->renderAjax('_form', ['model' => $model]);
+ }
+
+ /**
+ * Обновление поставщика (AJAX POST).
+ */
+ public function actionUpdate(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findModel($id);
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Поставщик обновлён']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => ['name' => [$e->getMessage()]]]);
+ }
+ }
+
+ /**
+ * Деактивация поставщика (soft delete + каскад).
+ */
+ public function actionDelete(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findModel($id);
+
+ if (!$model->is_active) {
+ return $this->asJson(['success' => false, 'message' => 'Поставщик уже деактивирован']);
+ }
+
+ $result = $model->deactivate();
+
+ return $this->asJson([
+ 'success' => true,
+ 'message' => sprintf(
+ 'Поставщик "%s" деактивирован. Связанных записей: маркировки — %d, маппинги — %d.',
+ $model->name,
+ $result['markings'],
+ $result['product_mappings']
+ ),
+ ]);
+ }
+
+ protected function findModel(int $id): Supplier
+ {
+ if (($model = Supplier::findOne(['id' => $id])) !== null) {
+ return $model;
+ }
+
+ throw new NotFoundHttpException('Поставщик не найден.');
+ }
+}
+```
+
+### Ключевые решения
+- `BaseController` → RBAC через `menu/supplier/{action}`
+- `actionCreateForm` / `actionUpdateForm` — возвращают HTML для модалки (`renderAjax`)
+- `actionCreate` / `actionUpdate` — POST, JSON-ответ, транзакция
+- `actionDelete` — soft delete через `Supplier::deactivate()` (каскад)
+- `actionIndex` — partial rendering при AJAX (для вкладки)
+
+## 6. View: supplier/index.php (partial)
+
+```php
+<?php
+
+use yii\grid\GridView;
+use yii\helpers\Html;
+use yii\helpers\Url;
+use yii\widgets\Pjax;
+
+/** @var yii\web\View $this */
+/** @var yii_app\records\SupplierSearch $searchModel */
+/** @var yii\data\ActiveDataProvider $dataProvider */
+?>
+
+<div class="d-flex justify-content-between align-items-center mb-3">
+ <div>
+ <strong class="fs-5" style="color:#1e3a5f;">
+ <i class="fas fa-building me-1"></i>Справочник поставщиков
+ </strong>
+ <br><span class="text-muted" style="font-size:12px;">Локальные и международные</span>
+ </div>
+ <button class="btn btn-primary btn-sm" id="btn-supplier-create">
+ <i class="fas fa-plus me-1"></i>Добавить
+ </button>
+</div>
+
+<?php Pjax::begin(['id' => 'supplier-pjax', 'timeout' => 5000]); ?>
+
+<?= GridView::widget([
+ 'dataProvider' => $dataProvider,
+ 'tableOptions' => ['class' => 'table table-bordered table-hover table-sm'],
+ 'rowOptions' => function ($model) {
+ return $model->is_active ? [] : ['style' => 'opacity:0.5;'];
+ },
+ 'columns' => [
+ [
+ 'attribute' => 'name',
+ 'format' => 'raw',
+ 'value' => function ($model) {
+ return '<strong>' . Html::encode($model->name) . '</strong>';
+ },
+ ],
+ [
+ 'attribute' => 'type',
+ 'format' => 'raw',
+ 'contentOptions' => ['style' => 'text-align:center;'],
+ 'headerOptions' => ['style' => 'text-align:center;'],
+ 'value' => function ($model) {
+ return $model->getTypeBadge();
+ },
+ 'filter' => \yii_app\records\Supplier::getTypeOptions(),
+ ],
+ [
+ 'attribute' => 'lead_time_days',
+ 'label' => 'Lead time',
+ 'contentOptions' => ['style' => 'text-align:center;'],
+ 'headerOptions' => ['style' => 'text-align:center;'],
+ 'value' => function ($model) {
+ return $model->lead_time_days . ' дн';
+ },
+ ],
+ [
+ 'attribute' => 'currency',
+ 'contentOptions' => ['style' => 'text-align:center;'],
+ 'headerOptions' => ['style' => 'text-align:center;'],
+ 'filter' => \yii_app\records\Supplier::getCurrencyOptions(),
+ ],
+ [
+ 'attribute' => 'is_active',
+ 'label' => 'Статус',
+ 'format' => 'raw',
+ 'contentOptions' => ['style' => 'text-align:center;'],
+ 'headerOptions' => ['style' => 'text-align:center;'],
+ 'value' => function ($model) {
+ return $model->getStatusBadge();
+ },
+ 'filter' => [1 => 'Активен', 0 => 'Неактивен'],
+ ],
+ [
+ 'class' => 'yii\grid\ActionColumn',
+ 'template' => '{update} {delete}',
+ 'contentOptions' => ['style' => 'text-align:center;white-space:nowrap;'],
+ 'buttons' => [
+ 'update' => function ($url, $model) {
+ return Html::a(
+ '<i class="fas fa-pen text-secondary"></i>',
+ '#',
+ [
+ 'class' => 'btn-supplier-edit',
+ 'data-id' => $model->id,
+ 'title' => 'Редактировать',
+ ]
+ );
+ },
+ 'delete' => function ($url, $model) {
+ if (!$model->is_active) {
+ return '<i class="fas fa-trash text-secondary" style="opacity:0.3;"></i>';
+ }
+ return Html::a(
+ '<i class="fas fa-trash text-danger"></i>',
+ '#',
+ [
+ 'class' => 'btn-supplier-delete',
+ 'data-id' => $model->id,
+ 'data-name' => $model->name,
+ 'title' => 'Деактивировать',
+ ]
+ );
+ },
+ ],
+ ],
+ ],
+]); ?>
+
+<?php Pjax::end(); ?>
+
+<!-- Модальное окно -->
+<div class="modal fade" id="supplier-modal" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header" style="background:#1e3a5f;color:#fff;">
+ <h5 class="modal-title"><i class="fas fa-building me-1"></i><span id="supplier-modal-title">Добавить поставщика</span></h5>
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
+ </div>
+ <div class="modal-body" id="supplier-modal-body">
+ <!-- Форма загружается через AJAX -->
+ </div>
+ </div>
+ </div>
+</div>
+
+<?php
+$createFormUrl = Url::to(['/supplier/create-form']);
+$updateFormUrl = Url::to(['/supplier/update-form']);
+$createUrl = Url::to(['/supplier/create']);
+$updateUrl = Url::to(['/supplier/update']);
+$deleteUrl = Url::to(['/supplier/delete']);
+$csrfToken = Yii::$app->request->csrfToken;
+
+$js = <<<JS
+(function() {
+ var modal = new bootstrap.Modal(document.getElementById('supplier-modal'));
+ var editingId = null;
+
+ // Открыть модалку создания
+ $('#btn-supplier-create').on('click', function() {
+ editingId = null;
+ $('#supplier-modal-title').text('Добавить поставщика');
+ $.get('{$createFormUrl}', function(html) {
+ $('#supplier-modal-body').html(html);
+ modal.show();
+ });
+ });
+
+ // Открыть модалку редактирования
+ $(document).on('click', '.btn-supplier-edit', function(e) {
+ e.preventDefault();
+ editingId = $(this).data('id');
+ $('#supplier-modal-title').text('Редактировать поставщика');
+ $.get('{$updateFormUrl}', {id: editingId}, function(html) {
+ $('#supplier-modal-body').html(html);
+ modal.show();
+ });
+ });
+
+ // Сохранить (create или update)
+ $(document).on('click', '#btn-supplier-save', function() {
+ var form = $('#supplier-form');
+ var url = editingId ? '{$updateUrl}?id=' + editingId : '{$createUrl}';
+
+ $.ajax({
+ url: url,
+ type: 'POST',
+ data: form.serialize(),
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ modal.hide();
+ $.pjax.reload({container: '#supplier-pjax'});
+ } else if (resp.errors) {
+ // Показать ошибки валидации
+ $.each(resp.errors, function(field, messages) {
+ var input = form.find('[name="Supplier[' + field + ']"]');
+ input.addClass('is-invalid');
+ input.closest('.mb-2').find('.invalid-feedback').remove();
+ input.after('<div class="invalid-feedback">' + messages[0] + '</div>');
+ });
+ }
+ }
+ });
+ });
+
+ // Деактивация
+ $(document).on('click', '.btn-supplier-delete', function(e) {
+ e.preventDefault();
+ var id = $(this).data('id');
+ var name = $(this).data('name');
+
+ if (!confirm('Деактивировать поставщика "' + name + '"?\\nСвязанные маркировки и маппинги также будут деактивированы.')) {
+ return;
+ }
+
+ $.ajax({
+ url: '{$deleteUrl}?id=' + id,
+ type: 'POST',
+ data: {_csrf: '{$csrfToken}'},
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ $.pjax.reload({container: '#supplier-pjax'});
+ alert(resp.message);
+ } else {
+ alert(resp.message || 'Ошибка деактивации');
+ }
+ }
+ });
+ });
+
+ // Очистка ошибок при вводе
+ $(document).on('input change', '#supplier-form input, #supplier-form select', function() {
+ $(this).removeClass('is-invalid');
+ });
+})();
+JS;
+
+$this->registerJs($js);
+?>
+```
+
+## 7. View: supplier/_form.php (для модалки)
+
+```php
+<?php
+
+use yii\helpers\Html;
+use yii_app\records\Supplier;
+
+/** @var yii_app\records\Supplier $model */
+?>
+
+<form id="supplier-form">
+ <input type="hidden" name="<?= Yii::$app->request->csrfParam ?>" value="<?= Yii::$app->request->csrfToken ?>">
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Название *</label>
+ <input type="text" class="form-control form-control-sm" name="Supplier[name]"
+ value="<?= Html::encode($model->name) ?>" maxlength="200"
+ placeholder="Flower Group" required>
+ </div>
+
+ <div class="d-flex gap-2 mb-2">
+ <div class="flex-fill">
+ <label class="form-label fw-semibold" style="font-size:11px;">Тип *</label>
+ <?= Html::activeDropDownList($model, 'type', Supplier::getTypeOptions(), [
+ 'class' => 'form-select form-select-sm',
+ 'name' => 'Supplier[type]',
+ ]) ?>
+ </div>
+ <div class="flex-fill">
+ <label class="form-label fw-semibold" style="font-size:11px;">Валюта *</label>
+ <?= Html::activeDropDownList($model, 'currency', Supplier::getCurrencyOptions(), [
+ 'class' => 'form-select form-select-sm',
+ 'name' => 'Supplier[currency]',
+ ]) ?>
+ </div>
+ <div style="width:80px;">
+ <label class="form-label fw-semibold" style="font-size:11px;">Lead time *</label>
+ <input type="number" class="form-control form-control-sm" name="Supplier[lead_time_days]"
+ value="<?= (int)$model->lead_time_days ?>" min="0" required>
+ </div>
+ </div>
+
+ <div class="d-flex justify-content-end gap-2 pt-2 border-top bg-light mx-n3 mb-n3 px-3 py-2" style="border-radius:0 0 6px 6px;">
+ <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
+ <button type="button" class="btn btn-primary btn-sm" id="btn-supplier-save">
+ <i class="fas fa-save me-1"></i>Сохранить
+ </button>
+ </div>
+</form>
+```
+
+## 8. Модификация: buyer-reference/index.php
+
+Заменить placeholder вкладки «Поставщики» на AJAX-загрузку:
+
+```php
+<!-- Изменить содержимое #tab-suppliers -->
+<div class="tab-pane fade show active" id="tab-suppliers" role="tabpanel" aria-labelledby="tab-suppliers-btn">
+ <div class="text-muted p-4 text-center" id="suppliers-loader">
+ <i class="fas fa-spinner fa-spin me-2"></i>Загрузка справочника поставщиков...
+ </div>
+</div>
+```
+
+JS в конце файла:
+
+```javascript
+// Загрузка вкладки поставщиков при активации
+$('#tab-suppliers-btn').on('shown.bs.tab', loadSuppliers);
+
+function loadSuppliers() {
+ if ($('#tab-suppliers').data('loaded')) return;
+ $.get('/supplier/index', function(html) {
+ $('#tab-suppliers').html(html);
+ $('#tab-suppliers').data('loaded', true);
+ });
+}
+
+// Загрузить первую вкладку сразу
+$(document).ready(function() { loadSuppliers(); });
+```
+
+## 9. RBAC / Права доступа
+
+Контроллер наследует `BaseController`, который проверяет `menu/supplier/{action}`.
+Необходимо добавить разрешения в RBAC:
+
+```
+menu/supplier/index
+menu/supplier/create-form
+menu/supplier/create
+menu/supplier/update-form
+menu/supplier/update
+menu/supplier/delete
+```
+
+Или назначить через CrmMenu → добавить пункт меню для `supplier/index`.
+
+## 10. Диаграмма взаимодействия
+
+```mermaid
+sequenceDiagram
+ participant B as Browser
+ participant BR as BuyerReferenceController
+ participant SC as SupplierController
+ participant S as Supplier (AR)
+ participant DB as PostgreSQL
+
+ B->>BR: GET /buyer-reference/index
+ BR-->>B: HTML (tabs)
+
+ B->>SC: AJAX GET /supplier/index
+ SC->>S: SupplierSearch::search()
+ S->>DB: SELECT * FROM erp24.suppliers ORDER BY name
+ DB-->>S: rows
+ SC-->>B: HTML partial (GridView)
+
+ B->>SC: AJAX GET /supplier/create-form
+ SC-->>B: HTML (_form.php)
+
+ B->>SC: AJAX POST /supplier/create
+ SC->>DB: BEGIN TRANSACTION
+ SC->>S: new Supplier + save()
+ S->>DB: INSERT INTO erp24.suppliers
+ SC->>DB: COMMIT
+ SC-->>B: JSON {success: true}
+
+ B->>SC: AJAX POST /supplier/delete?id=1
+ SC->>S: deactivate()
+ S->>DB: BEGIN TRANSACTION
+ S->>DB: UPDATE suppliers SET is_active=false
+ S->>DB: UPDATE markings SET is_active=false
+ S->>DB: UPDATE product_mappings SET is_active=false
+ S->>DB: COMMIT
+ SC-->>B: JSON {success: true, message: "..."}
+```
--- /dev/null
+<?php
+
+namespace app\controllers;
+
+/**
+ * Справочник закупщика — главная страница с 4 вкладками.
+ *
+ * Вкладки:
+ * 1. Поставщики (SupplierController)
+ * 2. Производители и плантации (ProducerController)
+ * 3. Маркировка (MarkingController)
+ * 4. Маппинг товаров (ProductMappingController)
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-300
+ */
+class BuyerReferenceController extends BaseController
+{
+ /**
+ * Главная страница с 4 Bootstrap 5 вкладками.
+ */
+ public function actionIndex(): string
+ {
+ return $this->render('index');
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace app\controllers;
+
+use Yii;
+use yii_app\records\Supplier;
+use yii_app\records\SupplierSearch;
+use yii\web\NotFoundHttpException;
+use yii\web\Response;
+use yii\filters\VerbFilter;
+
+/**
+ * CRUD контроллер справочника поставщиков.
+ * Все write-операции — через AJAX, возвращают JSON.
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-318
+ */
+class SupplierController extends BaseController
+{
+ public function behaviors(): array
+ {
+ return array_merge(
+ parent::behaviors(),
+ [
+ 'verbs' => [
+ 'class' => VerbFilter::class,
+ 'actions' => [
+ 'create' => ['POST'],
+ 'update' => ['POST'],
+ 'delete' => ['POST'],
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Таблица поставщиков (partial для вкладки BuyerReference).
+ */
+ public function actionIndex(): string
+ {
+ $searchModel = new SupplierSearch();
+ $dataProvider = $searchModel->search(Yii::$app->request->queryParams);
+
+ if (Yii::$app->request->isAjax) {
+ return $this->renderPartial('/supplier/index', [
+ 'searchModel' => $searchModel,
+ 'dataProvider' => $dataProvider,
+ ]);
+ }
+
+ return $this->render('/supplier/index', [
+ 'searchModel' => $searchModel,
+ 'dataProvider' => $dataProvider,
+ ]);
+ }
+
+ /**
+ * Форма создания (HTML для модалки).
+ */
+ public function actionCreateForm(): string
+ {
+ $model = new Supplier();
+ $model->loadDefaultValues();
+
+ return $this->renderAjax('/supplier/_form', ['model' => $model]);
+ }
+
+ /**
+ * Создание поставщика (AJAX POST → JSON).
+ */
+ public function actionCreate(): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = new Supplier();
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Поставщик создан']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error('Ошибка создания поставщика: ' . $e->getMessage(), 'supplier');
+ return $this->asJson(['success' => false, 'errors' => ['name' => [$e->getMessage()]]]);
+ }
+ }
+
+ /**
+ * Форма редактирования (HTML для модалки).
+ */
+ public function actionUpdateForm(int $id): string
+ {
+ $model = $this->findModel($id);
+ return $this->renderAjax('/supplier/_form', ['model' => $model]);
+ }
+
+ /**
+ * Обновление поставщика (AJAX POST → JSON).
+ */
+ public function actionUpdate(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findModel($id);
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Поставщик обновлён']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error('Ошибка обновления поставщика: ' . $e->getMessage(), 'supplier');
+ return $this->asJson(['success' => false, 'errors' => ['name' => [$e->getMessage()]]]);
+ }
+ }
+
+ /**
+ * Деактивация поставщика (soft delete + каскад).
+ */
+ public function actionDelete(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findModel($id);
+
+ if (!$model->is_active) {
+ return $this->asJson(['success' => false, 'message' => 'Поставщик уже деактивирован']);
+ }
+
+ try {
+ $result = $model->deactivate();
+
+ return $this->asJson([
+ 'success' => true,
+ 'message' => sprintf(
+ 'Поставщик "%s" деактивирован. Связанных записей: маркировки — %d, маппинги — %d.',
+ $model->name,
+ $result['markings'],
+ $result['product_mappings']
+ ),
+ ]);
+ } catch (\Exception $e) {
+ Yii::error('Ошибка деактивации поставщика: ' . $e->getMessage(), 'supplier');
+ return $this->asJson(['success' => false, 'message' => 'Ошибка деактивации: ' . $e->getMessage()]);
+ }
+ }
+
+ protected function findModel(int $id): Supplier
+ {
+ if (($model = Supplier::findOne(['id' => $id])) !== null) {
+ return $model;
+ }
+
+ throw new NotFoundHttpException('Поставщик не найден.');
+ }
+}
--- /dev/null
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Справочник поставщиков.
+ * Epic: ERP-300, Story: ERP-317
+ */
+class m260408_100000_create_suppliers_table extends Migration
+{
+ public function safeUp()
+ {
+ $this->createTable('{{%erp24.suppliers}}', [
+ 'id' => $this->primaryKey(),
+ 'name' => $this->string(200)->notNull()->unique()->comment('Название поставщика'),
+ 'type' => $this->string(20)->notNull()->defaultValue('local')->comment('Тип: local | international'),
+ 'currency' => $this->string(3)->notNull()->defaultValue('RUB')->comment('Валюта: RUB | EUR | USD'),
+ 'lead_time_days' => $this->integer()->notNull()->defaultValue(0)->comment('Срок поставки в днях'),
+ 'is_active' => $this->boolean()->notNull()->defaultValue(true)->comment('Активен (soft delete)'),
+ 'created_by' => $this->integer()->notNull()->comment('Кто создал (admin.id)'),
+ 'updated_by' => $this->integer()->null()->comment('Кто обновил (admin.id)'),
+ 'created_at' => $this->timestamp()->notNull()->comment('Дата создания'),
+ 'updated_at' => $this->timestamp()->null()->comment('Дата обновления'),
+ ]);
+
+ $this->execute("ALTER TABLE {{%erp24.suppliers}} ADD CONSTRAINT chk_suppliers_type CHECK (type IN ('local', 'international'))");
+ $this->execute("ALTER TABLE {{%erp24.suppliers}} ADD CONSTRAINT chk_suppliers_currency CHECK (currency IN ('RUB', 'EUR', 'USD'))");
+ $this->execute("ALTER TABLE {{%erp24.suppliers}} ADD CONSTRAINT chk_suppliers_lead_time CHECK (lead_time_days >= 0)");
+ }
+
+ public function safeDown()
+ {
+ $this->dropTable('{{%erp24.suppliers}}');
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use Yii;
+use yii\behaviors\BlameableBehavior;
+use yii\behaviors\TimestampBehavior;
+use yii\db\ActiveQuery;
+use yii\db\ActiveRecord;
+use yii\db\Expression;
+
+/**
+ * Справочник поставщиков.
+ *
+ * @property int $id
+ * @property string $name Название поставщика
+ * @property string $type Тип: local | international
+ * @property string $currency Валюта: RUB | EUR | USD
+ * @property int $lead_time_days Срок поставки в днях
+ * @property bool $is_active Активен (soft delete)
+ * @property int $created_by Кто создал
+ * @property int|null $updated_by Кто обновил
+ * @property string $created_at Дата создания
+ * @property string|null $updated_at Дата обновления
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-318
+ */
+class Supplier extends ActiveRecord
+{
+ public const TYPE_LOCAL = 'local';
+ public const TYPE_INTERNATIONAL = 'international';
+
+ public const CURRENCY_RUB = 'RUB';
+ public const CURRENCY_EUR = 'EUR';
+ public const CURRENCY_USD = 'USD';
+
+ public static function tableName(): string
+ {
+ return '{{%erp24.suppliers}}';
+ }
+
+ public function behaviors(): array
+ {
+ return [
+ [
+ 'class' => TimestampBehavior::class,
+ 'createdAtAttribute' => 'created_at',
+ 'updatedAtAttribute' => 'updated_at',
+ 'value' => new Expression('NOW()'),
+ ],
+ [
+ 'class' => BlameableBehavior::class,
+ 'createdByAttribute' => 'created_by',
+ 'updatedByAttribute' => 'updated_by',
+ 'defaultValue' => null,
+ ],
+ ];
+ }
+
+ public function rules(): array
+ {
+ return [
+ [['name', 'type', 'currency'], 'required'],
+ ['name', 'string', 'max' => 200],
+ ['name', 'unique', 'message' => 'Поставщик уже существует'],
+ ['type', 'in', 'range' => [self::TYPE_LOCAL, self::TYPE_INTERNATIONAL]],
+ ['currency', 'in', 'range' => [self::CURRENCY_RUB, self::CURRENCY_EUR, self::CURRENCY_USD]],
+ ['lead_time_days', 'integer', 'min' => 0, 'message' => 'Lead time должен быть >= 0'],
+ ['lead_time_days', 'default', 'value' => 0],
+ ['is_active', 'boolean'],
+ ['is_active', 'default', 'value' => true],
+ ];
+ }
+
+ public function attributeLabels(): array
+ {
+ return [
+ 'id' => 'ID',
+ 'name' => 'Название',
+ 'type' => 'Тип',
+ 'currency' => 'Валюта',
+ 'lead_time_days' => 'Lead time (дн)',
+ 'is_active' => 'Статус',
+ 'created_by' => 'Создал',
+ 'updated_by' => 'Обновил',
+ 'created_at' => 'Создано',
+ 'updated_at' => 'Обновлено',
+ ];
+ }
+
+ /* --- Scopes --- */
+
+ public static function findActive(): ActiveQuery
+ {
+ return static::find()->where(['is_active' => true]);
+ }
+
+ /* --- Relations --- */
+
+ public function getMarkings(): ActiveQuery
+ {
+ return $this->hasMany(Marking::class, ['supplier_id' => 'id']);
+ }
+
+ public function getProductMappings(): ActiveQuery
+ {
+ return $this->hasMany(ProductMapping::class, ['supplier_id' => 'id']);
+ }
+
+ /* --- Справочники --- */
+
+ public static function getTypeOptions(): array
+ {
+ return [
+ self::TYPE_LOCAL => 'Локальный',
+ self::TYPE_INTERNATIONAL => 'Международный',
+ ];
+ }
+
+ public static function getCurrencyOptions(): array
+ {
+ return [
+ self::CURRENCY_RUB => 'RUB',
+ self::CURRENCY_EUR => 'EUR',
+ self::CURRENCY_USD => 'USD',
+ ];
+ }
+
+ /* --- Бейджи --- */
+
+ public function getTypeBadge(): string
+ {
+ $class = $this->type === self::TYPE_INTERNATIONAL
+ ? 'badge-type-international'
+ : 'badge-type-local';
+ $label = self::getTypeOptions()[$this->type] ?? $this->type;
+
+ return '<span class="' . $class . '">' . $label . '</span>';
+ }
+
+ public function getStatusBadge(): string
+ {
+ if ($this->is_active) {
+ return '<span class="badge-status-active">Активен</span>';
+ }
+ return '<span class="badge-status-inactive">Неактивен</span>';
+ }
+
+ /* --- Каскадная деактивация --- */
+
+ /**
+ * Деактивация поставщика и связанных записей в транзакции.
+ *
+ * @return array{markings: int, product_mappings: int}
+ */
+ public function deactivate(): array
+ {
+ $transaction = Yii::$app->db->beginTransaction();
+ try {
+ $this->is_active = false;
+ $this->save(false);
+
+ $markingsCount = (int)Yii::$app->db->createCommand()
+ ->update('{{%erp24.markings}}', ['is_active' => false], ['supplier_id' => $this->id, 'is_active' => true])
+ ->execute();
+
+ $mappingsCount = (int)Yii::$app->db->createCommand()
+ ->update('{{%erp24.product_mappings}}', ['is_active' => false], ['supplier_id' => $this->id, 'is_active' => true])
+ ->execute();
+
+ $transaction->commit();
+
+ return [
+ 'markings' => $markingsCount,
+ 'product_mappings' => $mappingsCount,
+ ];
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ throw $e;
+ }
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\base\Model;
+use yii\data\ActiveDataProvider;
+
+/**
+ * Search модель для справочника поставщиков.
+ */
+class SupplierSearch extends Model
+{
+ public $id;
+ public $name;
+ public $type;
+ public $currency;
+ public $is_active;
+
+ public function rules(): array
+ {
+ return [
+ [['id'], 'integer'],
+ [['name', 'type', 'currency'], 'safe'],
+ [['is_active'], 'boolean'],
+ ];
+ }
+
+ public function search(array $params): ActiveDataProvider
+ {
+ $query = Supplier::find()->orderBy(['name' => SORT_ASC]);
+
+ $dataProvider = new ActiveDataProvider([
+ 'query' => $query,
+ 'pagination' => false,
+ ]);
+
+ $this->load($params);
+
+ if (!$this->validate()) {
+ return $dataProvider;
+ }
+
+ $query->andFilterWhere(['id' => $this->id])
+ ->andFilterWhere(['type' => $this->type])
+ ->andFilterWhere(['currency' => $this->currency])
+ ->andFilterWhere(['is_active' => $this->is_active])
+ ->andFilterWhere(['like', 'name', $this->name]);
+
+ return $dataProvider;
+ }
+}
--- /dev/null
+<?php
+
+use yii\helpers\Html;
+use yii\web\View;
+
+/** @var yii\web\View $this */
+
+$this->title = 'Справочник закупщика';
+?>
+
+<h1 class="ms-3 mb-4"><?= Html::encode($this->title) ?></h1>
+
+<div class="px-3" id="buyer-reference-container">
+ <ul class="nav nav-tabs" id="buyerRefTabs" role="tablist">
+ <li class="nav-item" role="presentation">
+ <button class="nav-link active" id="tab-suppliers-btn" data-bs-toggle="tab"
+ data-bs-target="#tab-suppliers" type="button" role="tab"
+ aria-controls="tab-suppliers" aria-selected="true">
+ <i class="fas fa-building me-1"></i>Поставщики
+ </button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button class="nav-link" id="tab-producers-btn" data-bs-toggle="tab"
+ data-bs-target="#tab-producers" type="button" role="tab"
+ aria-controls="tab-producers" aria-selected="false">
+ <i class="fas fa-seedling me-1"></i>Производители
+ </button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button class="nav-link" id="tab-markings-btn" data-bs-toggle="tab"
+ data-bs-target="#tab-markings" type="button" role="tab"
+ aria-controls="tab-markings" aria-selected="false">
+ <i class="fas fa-barcode me-1"></i>Маркировка
+ </button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button class="nav-link" id="tab-mappings-btn" data-bs-toggle="tab"
+ data-bs-target="#tab-mappings" type="button" role="tab"
+ aria-controls="tab-mappings" aria-selected="false">
+ <i class="fas fa-link me-1"></i>Маппинг
+ </button>
+ </li>
+ </ul>
+
+ <div class="tab-content mt-3" id="buyerRefTabContent">
+ <div class="tab-pane fade show active" id="tab-suppliers" role="tabpanel" aria-labelledby="tab-suppliers-btn">
+ <div class="text-muted p-4 text-center">
+ <i class="fas fa-spinner fa-spin me-2"></i>Загрузка справочника поставщиков...
+ </div>
+ </div>
+ <div class="tab-pane fade" id="tab-producers" role="tabpanel" aria-labelledby="tab-producers-btn">
+ <div class="text-muted p-4 text-center">
+ <i class="fas fa-spinner fa-spin me-2"></i>Загрузка производителей и плантаций...
+ </div>
+ </div>
+ <div class="tab-pane fade" id="tab-markings" role="tabpanel" aria-labelledby="tab-markings-btn">
+ <div class="text-muted p-4 text-center">
+ <i class="fas fa-spinner fa-spin me-2"></i>Загрузка маркировок...
+ </div>
+ </div>
+ <div class="tab-pane fade" id="tab-mappings" role="tabpanel" aria-labelledby="tab-mappings-btn">
+ <div class="text-muted p-4 text-center">
+ <i class="fas fa-spinner fa-spin me-2"></i>Загрузка маппинга товаров...
+ </div>
+ </div>
+ </div>
+</div>
+
+<?php
+use yii\helpers\Url;
+
+$supplierIndexUrl = Url::to(['/supplier/index']);
+
+$js = <<<JS
+(function() {
+ function loadTab(tabId, url) {
+ var \$pane = $('#' + tabId);
+ if (\$pane.data('loaded')) return;
+ $.get(url, function(html) {
+ \$pane.html(html);
+ \$pane.data('loaded', true);
+ });
+ }
+
+ // Загрузка вкладки поставщиков сразу (она active по умолчанию)
+ loadTab('tab-suppliers', '{$supplierIndexUrl}');
+
+ // Загрузка при переключении вкладок
+ $('#tab-suppliers-btn').on('shown.bs.tab', function() {
+ loadTab('tab-suppliers', '{$supplierIndexUrl}');
+ });
+})();
+JS;
+
+$this->registerJs($js);
+?>
--- /dev/null
+<?php
+
+use yii\helpers\Html;
+use yii_app\records\Supplier;
+
+/** @var yii_app\records\Supplier $model */
+?>
+
+<form id="supplier-form">
+ <input type="hidden" name="<?= Yii::$app->request->csrfParam ?>" value="<?= Yii::$app->request->csrfToken ?>">
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Название *</label>
+ <input type="text" class="form-control form-control-sm" name="Supplier[name]"
+ value="<?= Html::encode($model->name) ?>" maxlength="200"
+ placeholder="Flower Group" required>
+ </div>
+
+ <div class="d-flex gap-2 mb-2">
+ <div class="flex-fill">
+ <label class="form-label fw-semibold" style="font-size:11px;">Тип *</label>
+ <?= Html::activeDropDownList($model, 'type', Supplier::getTypeOptions(), [
+ 'class' => 'form-select form-select-sm',
+ 'name' => 'Supplier[type]',
+ ]) ?>
+ </div>
+ <div class="flex-fill">
+ <label class="form-label fw-semibold" style="font-size:11px;">Валюта *</label>
+ <?= Html::activeDropDownList($model, 'currency', Supplier::getCurrencyOptions(), [
+ 'class' => 'form-select form-select-sm',
+ 'name' => 'Supplier[currency]',
+ ]) ?>
+ </div>
+ <div style="width:80px;">
+ <label class="form-label fw-semibold" style="font-size:11px;">Lead time *</label>
+ <input type="number" class="form-control form-control-sm" name="Supplier[lead_time_days]"
+ value="<?= (int)$model->lead_time_days ?>" min="0" required>
+ </div>
+ </div>
+
+ <div class="d-flex justify-content-end gap-2 pt-2 border-top mt-3">
+ <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
+ <button type="button" class="btn btn-primary btn-sm" id="btn-supplier-save">
+ <i class="fas fa-save me-1"></i>Сохранить
+ </button>
+ </div>
+</form>
--- /dev/null
+<?php
+
+use yii\grid\GridView;
+use yii\helpers\Html;
+use yii\helpers\Url;
+use yii\widgets\Pjax;
+use yii_app\records\Supplier;
+
+/** @var yii\web\View $this */
+/** @var yii_app\records\SupplierSearch $searchModel */
+/** @var yii\data\ActiveDataProvider $dataProvider */
+?>
+
+<style>
+ .badge-type-local { background:#d1e7dd; color:#0f5132; font-size:10px; padding:1px 6px; border-radius:3px; display:inline-block; }
+ .badge-type-international { background:#cfe2ff; color:#084298; font-size:10px; padding:1px 6px; border-radius:3px; display:inline-block; }
+ .badge-status-active { background:#d1e7dd; color:#0f5132; font-size:10px; padding:1px 6px; border-radius:3px; display:inline-block; }
+ .badge-status-inactive { background:#e2e3e5; color:#41464b; font-size:10px; padding:1px 6px; border-radius:3px; display:inline-block; }
+</style>
+
+<div class="d-flex justify-content-between align-items-center mb-3">
+ <div>
+ <strong class="fs-5" style="color:#1e3a5f;">
+ <i class="fas fa-building me-1"></i>Справочник поставщиков
+ </strong>
+ <br><span class="text-muted" style="font-size:12px;">Локальные и международные</span>
+ </div>
+ <button class="btn btn-primary btn-sm" id="btn-supplier-create">
+ <i class="fas fa-plus me-1"></i>Добавить
+ </button>
+</div>
+
+<?php Pjax::begin(['id' => 'supplier-pjax', 'timeout' => 5000]); ?>
+
+<?= GridView::widget([
+ 'dataProvider' => $dataProvider,
+ 'tableOptions' => ['class' => 'table table-bordered table-hover table-sm'],
+ 'layout' => '{items}',
+ 'rowOptions' => function ($model) {
+ return $model->is_active ? [] : ['style' => 'opacity:0.5;'];
+ },
+ 'columns' => [
+ [
+ 'attribute' => 'name',
+ 'format' => 'raw',
+ 'value' => function ($model) {
+ return '<strong>' . Html::encode($model->name) . '</strong>';
+ },
+ ],
+ [
+ 'attribute' => 'type',
+ 'format' => 'raw',
+ 'contentOptions' => ['style' => 'text-align:center;'],
+ 'headerOptions' => ['style' => 'text-align:center;'],
+ 'value' => function ($model) {
+ return $model->getTypeBadge();
+ },
+ 'filter' => Supplier::getTypeOptions(),
+ ],
+ [
+ 'attribute' => 'lead_time_days',
+ 'label' => 'Lead time',
+ 'contentOptions' => ['style' => 'text-align:center;'],
+ 'headerOptions' => ['style' => 'text-align:center;'],
+ 'value' => function ($model) {
+ return $model->lead_time_days . ' дн';
+ },
+ ],
+ [
+ 'attribute' => 'currency',
+ 'contentOptions' => ['style' => 'text-align:center;'],
+ 'headerOptions' => ['style' => 'text-align:center;'],
+ 'filter' => Supplier::getCurrencyOptions(),
+ ],
+ [
+ 'attribute' => 'is_active',
+ 'label' => 'Статус',
+ 'format' => 'raw',
+ 'contentOptions' => ['style' => 'text-align:center;'],
+ 'headerOptions' => ['style' => 'text-align:center;'],
+ 'value' => function ($model) {
+ return $model->getStatusBadge();
+ },
+ 'filter' => [1 => 'Активен', 0 => 'Неактивен'],
+ ],
+ [
+ 'class' => 'yii\grid\ActionColumn',
+ 'template' => '{update} {delete}',
+ 'contentOptions' => ['style' => 'text-align:center;white-space:nowrap;'],
+ 'buttons' => [
+ 'update' => function ($url, $model) {
+ return Html::a(
+ '<i class="fas fa-pen text-secondary"></i>',
+ '#',
+ [
+ 'class' => 'btn-supplier-edit',
+ 'data-id' => $model->id,
+ 'title' => 'Редактировать',
+ ]
+ );
+ },
+ 'delete' => function ($url, $model) {
+ if (!$model->is_active) {
+ return ' <i class="fas fa-trash text-secondary" style="opacity:0.3;cursor:default;"></i>';
+ }
+ return ' ' . Html::a(
+ '<i class="fas fa-trash text-danger"></i>',
+ '#',
+ [
+ 'class' => 'btn-supplier-delete',
+ 'data-id' => $model->id,
+ 'data-name' => $model->name,
+ 'title' => 'Деактивировать',
+ ]
+ );
+ },
+ ],
+ ],
+ ],
+]); ?>
+
+<?php Pjax::end(); ?>
+
+<!-- Модальное окно -->
+<div class="modal fade" id="supplier-modal" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header" style="background:#1e3a5f;color:#fff;">
+ <h5 class="modal-title">
+ <i class="fas fa-building me-1"></i>
+ <span id="supplier-modal-title">Добавить поставщика</span>
+ </h5>
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
+ </div>
+ <div class="modal-body" id="supplier-modal-body"></div>
+ </div>
+ </div>
+</div>
+
+<?php
+$createFormUrl = Url::to(['/supplier/create-form']);
+$updateFormUrl = Url::to(['/supplier/update-form']);
+$createUrl = Url::to(['/supplier/create']);
+$updateUrl = Url::to(['/supplier/update']);
+$deleteUrl = Url::to(['/supplier/delete']);
+
+$js = <<<JS
+(function() {
+ var supplierModal = new bootstrap.Modal(document.getElementById('supplier-modal'));
+ var editingId = null;
+
+ // Создание
+ $('#btn-supplier-create').on('click', function() {
+ editingId = null;
+ $('#supplier-modal-title').text('Добавить поставщика');
+ $.get('{$createFormUrl}', function(html) {
+ $('#supplier-modal-body').html(html);
+ supplierModal.show();
+ });
+ });
+
+ // Редактирование
+ $(document).on('click', '.btn-supplier-edit', function(e) {
+ e.preventDefault();
+ editingId = $(this).data('id');
+ $('#supplier-modal-title').text('Редактировать поставщика');
+ $.get('{$updateFormUrl}', {id: editingId}, function(html) {
+ $('#supplier-modal-body').html(html);
+ supplierModal.show();
+ });
+ });
+
+ // Сохранение
+ $(document).on('click', '#btn-supplier-save', function() {
+ var \$form = $('#supplier-form');
+ var url = editingId ? '{$updateUrl}?id=' + editingId : '{$createUrl}';
+
+ // Сбросить ошибки
+ \$form.find('.is-invalid').removeClass('is-invalid');
+ \$form.find('.invalid-feedback').remove();
+
+ $.ajax({
+ url: url,
+ type: 'POST',
+ data: \$form.serialize(),
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ supplierModal.hide();
+ $.pjax.reload({container: '#supplier-pjax'});
+ } else if (resp.errors) {
+ $.each(resp.errors, function(field, messages) {
+ var \$input = \$form.find('[name="Supplier[' + field + ']"]');
+ \$input.addClass('is-invalid');
+ \$input.after('<div class="invalid-feedback">' + messages[0] + '</div>');
+ });
+ }
+ },
+ error: function() {
+ alert('Ошибка сервера');
+ }
+ });
+ });
+
+ // Деактивация
+ $(document).on('click', '.btn-supplier-delete', function(e) {
+ e.preventDefault();
+ var id = $(this).data('id');
+ var name = $(this).data('name');
+
+ if (!confirm('Деактивировать поставщика "' + name + '"?\\nСвязанные маркировки и маппинги также будут деактивированы.')) {
+ return;
+ }
+
+ $.ajax({
+ url: '{$deleteUrl}?id=' + id,
+ type: 'POST',
+ data: {_csrf: yii.getCsrfToken()},
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ $.pjax.reload({container: '#supplier-pjax'});
+ } else {
+ alert(resp.message || 'Ошибка деактивации');
+ }
+ },
+ error: function() {
+ alert('Ошибка сервера');
+ }
+ });
+ });
+
+ // Очистка ошибок при вводе
+ $(document).on('input change', '#supplier-form input, #supplier-form select', function() {
+ $(this).removeClass('is-invalid');
+ $(this).next('.invalid-feedback').remove();
+ });
+})();
+JS;
+
+$this->registerJs($js);
+?>