]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
feat(ERP-318): справочник поставщиков — Model + Controller + View + JS
authorVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Thu, 9 Apr 2026 06:39:09 +0000 (09:39 +0300)
committerVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Thu, 9 Apr 2026 06:39:09 +0000 (09:39 +0300)
Реализован CRUD справочника поставщиков с модальными окнами (AJAX),
GridView + PJAX и каскадной деактивацией связанных записей.

Компоненты:
- Supplier (AR модель с TimestampBehavior + BlameableBehavior)
- SupplierSearch (search модель без пагинации)
- SupplierController (AJAX CRUD, наследует BaseController)
- views/supplier/index.php (GridView + модалка + JS)
- views/supplier/_form.php (форма для модалки)
- BuyerReferenceController + view (контейнер с табами)

Бейджи типа и статуса — по UI-guideline из spec-supplier-reference.html.
Валидация: name unique varchar(200), lead_time >= 0, type/currency enum.
Soft delete через is_active=false с каскадом на markings и product_mappings.
Write-операции обёрнуты в DB-транзакции.

Артефакты workflow: docs/jira/ERP-318/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13 files changed:
docs/jira/ERP-318/debate-log.md [new file with mode: 0644]
docs/jira/ERP-318/interview.md [new file with mode: 0644]
docs/jira/ERP-318/plan.md [new file with mode: 0644]
docs/jira/ERP-318/prd.md [new file with mode: 0644]
docs/jira/ERP-318/spec.md [new file with mode: 0644]
erp24/controllers/BuyerReferenceController.php [new file with mode: 0644]
erp24/controllers/SupplierController.php [new file with mode: 0644]
erp24/migrations/m260408_100000_create_suppliers_table.php [new file with mode: 0644]
erp24/records/Supplier.php [new file with mode: 0644]
erp24/records/SupplierSearch.php [new file with mode: 0644]
erp24/views/buyer-reference/index.php [new file with mode: 0644]
erp24/views/supplier/_form.php [new file with mode: 0644]
erp24/views/supplier/index.php [new file with mode: 0644]

diff --git a/docs/jira/ERP-318/debate-log.md b/docs/jira/ERP-318/debate-log.md
new file mode 100644 (file)
index 0000000..c12b93a
--- /dev/null
@@ -0,0 +1,112 @@
+# Лог дебатов: 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
diff --git a/docs/jira/ERP-318/interview.md b/docs/jira/ERP-318/interview.md
new file mode 100644 (file)
index 0000000..d9c07c2
--- /dev/null
@@ -0,0 +1,51 @@
+# 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, может быть отложено)
diff --git a/docs/jira/ERP-318/plan.md b/docs/jira/ERP-318/plan.md
new file mode 100644 (file)
index 0000000..dc29c84
--- /dev/null
@@ -0,0 +1,109 @@
+# План реализации: 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/*)
diff --git a/docs/jira/ERP-318/prd.md b/docs/jira/ERP-318/prd.md
new file mode 100644 (file)
index 0000000..c4a5da7
--- /dev/null
@@ -0,0 +1,224 @@
+# 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
+```
diff --git a/docs/jira/ERP-318/spec.md b/docs/jira/ERP-318/spec.md
new file mode 100644 (file)
index 0000000..b8fb0d2
--- /dev/null
@@ -0,0 +1,813 @@
+# Техническая спецификация: 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: "..."}
+```
diff --git a/erp24/controllers/BuyerReferenceController.php b/erp24/controllers/BuyerReferenceController.php
new file mode 100644 (file)
index 0000000..91a4a62
--- /dev/null
@@ -0,0 +1,25 @@
+<?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');
+    }
+}
diff --git a/erp24/controllers/SupplierController.php b/erp24/controllers/SupplierController.php
new file mode 100644 (file)
index 0000000..2743a74
--- /dev/null
@@ -0,0 +1,167 @@
+<?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('Поставщик не найден.');
+    }
+}
diff --git a/erp24/migrations/m260408_100000_create_suppliers_table.php b/erp24/migrations/m260408_100000_create_suppliers_table.php
new file mode 100644 (file)
index 0000000..c1488b0
--- /dev/null
@@ -0,0 +1,35 @@
+<?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}}');
+    }
+}
diff --git a/erp24/records/Supplier.php b/erp24/records/Supplier.php
new file mode 100644 (file)
index 0000000..e58ca55
--- /dev/null
@@ -0,0 +1,184 @@
+<?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;
+        }
+    }
+}
diff --git a/erp24/records/SupplierSearch.php b/erp24/records/SupplierSearch.php
new file mode 100644 (file)
index 0000000..1de4114
--- /dev/null
@@ -0,0 +1,53 @@
+<?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;
+    }
+}
diff --git a/erp24/views/buyer-reference/index.php b/erp24/views/buyer-reference/index.php
new file mode 100644 (file)
index 0000000..330f299
--- /dev/null
@@ -0,0 +1,96 @@
+<?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);
+?>
diff --git a/erp24/views/supplier/_form.php b/erp24/views/supplier/_form.php
new file mode 100644 (file)
index 0000000..d4a95b5
--- /dev/null
@@ -0,0 +1,47 @@
+<?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>
diff --git a/erp24/views/supplier/index.php b/erp24/views/supplier/index.php
new file mode 100644 (file)
index 0000000..9f7fb2d
--- /dev/null
@@ -0,0 +1,242 @@
+<?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);
+?>