소스 검색

Добавлены улучшения модуля запчастей: фильтры, справочник расценок, автозаполнение

- Добавлены фильтры в таблицы запчастей (каталог, заказы) через partials/table.blade.php
- Создан справочник расценок (PricingCode) с поддержкой двух типов: tsn_number и pricing_code
- Добавлены tooltips для № по ТСН и шифра расценки в таблицах
- Реализовано автозаполнение расшифровок в форме редактирования запчастей
- Добавлен экспорт справочника расценок на отдельной вкладке Excel
- Создан импорт каталога запчастей и справочника расценок
- Добавлена миграция для обновления таблицы pricing_codes (поле type)
- Обновлены модели SparePart и PricingCode с новыми методами и аксессорами

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Alexander Musikhin 4 일 전
부모
커밋
cd1dec2c7a
44개의 변경된 파일3416개의 추가작업 그리고 16개의 파일을 삭제
  1. 127 0
      SPARE_PARTS_ENHANCEMENTS.md
  2. 298 0
      SPARE_PARTS_MODULE.md
  3. 2 0
      app/Http/Controllers/FilterController.php
  4. 108 0
      app/Http/Controllers/PricingCodeController.php
  5. 41 13
      app/Http/Controllers/ReclamationController.php
  6. 231 0
      app/Http/Controllers/SparePartController.php
  7. 28 0
      app/Http/Controllers/SparePartInventoryController.php
  8. 163 0
      app/Http/Controllers/SparePartOrderController.php
  9. 29 0
      app/Http/Requests/ShipSparePartOrderRequest.php
  10. 6 2
      app/Http/Requests/StoreReclamationDetailsRequest.php
  11. 35 0
      app/Http/Requests/StoreSparePartOrderRequest.php
  12. 63 0
      app/Http/Requests/StoreSparePartRequest.php
  13. 34 0
      app/Jobs/Export/ExportSparePartsJob.php
  14. 76 0
      app/Jobs/Import/ImportSparePartsJob.php
  15. 7 0
      app/Models/Import.php
  16. 56 0
      app/Models/PricingCode.php
  17. 7 0
      app/Models/Reclamation.php
  18. 180 0
      app/Models/SparePart.php
  19. 134 0
      app/Models/SparePartOrder.php
  20. 38 0
      app/Models/SparePartOrderShipment.php
  21. 21 0
      app/Models/SparePartOrdersView.php
  22. 118 0
      app/Services/Export/ExportSparePartsService.php
  23. 201 0
      app/Services/Import/ImportSparePartsService.php
  24. 88 0
      app/Services/SparePartInventoryService.php
  25. 30 0
      database/migrations/2026_01_24_065458_add_missing_columns_to_imports_table.php
  26. 29 0
      database/migrations/2026_01_24_065742_make_filename_nullable_in_imports_table.php
  27. 47 0
      database/migrations/2026_01_24_100000_create_spare_parts_table.php
  28. 47 0
      database/migrations/2026_01_24_100001_create_spare_part_orders_table.php
  29. 36 0
      database/migrations/2026_01_24_100002_create_spare_part_order_shipments_table.php
  30. 31 0
      database/migrations/2026_01_24_100003_create_pricing_codes_table.php
  31. 33 0
      database/migrations/2026_01_24_100004_create_reclamation_spare_part_table.php
  32. 33 0
      database/migrations/2026_01_24_100006_create_spare_part_orders_view_table.php
  33. 29 0
      database/migrations/2026_01_24_100007_alter_spare_part_orders_remaining_quantity.php
  34. 33 0
      database/migrations/2026_01_24_120000_update_pricing_codes_table_add_type_field.php
  35. 4 0
      resources/views/layouts/menu.blade.php
  36. 10 1
      resources/views/partials/newFilterElement.blade.php
  37. 29 0
      resources/views/partials/table.blade.php
  38. 184 0
      resources/views/pricing_codes/index.blade.php
  39. 212 0
      resources/views/spare_part_orders/edit.blade.php
  40. 265 0
      resources/views/spare_parts/edit.blade.php
  41. 231 0
      resources/views/spare_parts/index.blade.php
  42. 42 0
      routes/web.php
  43. BIN
      templates/SparePartOrders.xlsx
  44. BIN
      templates/SpareParts.xlsx

+ 127 - 0
SPARE_PARTS_ENHANCEMENTS.md

@@ -0,0 +1,127 @@
+# Улучшения модуля запчастей
+
+## Реализованные изменения
+
+### 1. Фильтры в таблицах запчастей
+- ✅ Все таблицы в разделе запчастей (каталог, заказы, инвентаризация) теперь используют `partials/table.blade.php`
+- ✅ Добавлены полноценные фильтры по колонкам
+- ✅ Добавлен поиск по тексту
+- ✅ Добавлены настройки отображения колонок
+- ✅ Добавлена сортировка
+
+### 2. Справочник расценок
+- ✅ Создана миграция `2026_01_24_120000_update_pricing_codes_table_add_type_field.php` для добавления поля `type`
+- ✅ Обновлена модель `PricingCode` с методами:
+  - `getTsnDescription()` - получение расшифровки № по ТСН
+  - `getPricingCodeDescription()` - получение расшифровки шифра расценки
+  - `createOrUpdate()` - создание или обновление записи справочника
+- ✅ Добавлены аксессоры в модель `SparePart`:
+  - `tsn_number_description` - расшифровка № по ТСН
+  - `pricing_code_description` - расшифровка шифра расценки
+
+### 3. Tooltips для расценок
+- ✅ При наведении на поля `tsn_number` и `pricing_code` в таблице показывается расшифровка
+- ✅ Используется Bootstrap tooltips
+- ✅ Подсветка полей с расшифровкой (dotted underline)
+
+### 4. Автозаполнение в форме запчастей
+- ✅ При вводе № по ТСН автоматически подтягивается расшифровка из справочника
+- ✅ При вводе шифра расценки автоматически подтягивается расшифровка
+- ✅ Если расшифровка не найдена, появляется поле для ввода новой расшифровки
+- ✅ Новая расшифровка сохраняется в справочник при сохранении запчасти
+- ✅ Визуальная индикация (иконки и цвета):
+  - Зелёная иконка - расшифровка найдена
+  - Жёлтая иконка - расшифровка не найдена
+- ✅ API endpoint `/pricing-codes/get-description` для получения расшифровок
+
+### 5. Экспорт и импорт справочника расценок
+- ✅ Обновлён `ExportSparePartsService` для экспорта справочника на отдельной вкладке "Справочник расценок"
+- ✅ Создан `ImportSparePartsService` для импорта каталога и справочника
+- ✅ Создан `ImportSparePartsJob` для асинхронного импорта
+- ✅ Добавлен метод `import()` в `SparePartController`
+- ✅ Добавлен роут `/spare-parts/import`
+- ✅ Добавлена кнопка "Импорт" и модальное окно в интерфейсе
+
+## Структура файла экспорта
+
+### Вкладка 1: Каталог запчастей
+Колонки:
+- A: ID
+- B: Артикул
+- C: Где используется
+- D: Кол-во без док
+- E: Кол-во с док
+- F: Кол-во общее
+- G: Примечание
+- H: Цена закупки
+- I: Цена для заказчика
+- J: Цена экспертизы
+- K: № по ТСН
+- L: Шифр расценки
+- M: Минимальный остаток
+
+### Вкладка 2: Справочник расценок
+Колонки:
+- A: ID
+- B: Тип (№ по ТСН / Шифр расценки)
+- C: Код
+- D: Расшифровка
+
+## Файлы с изменениями
+
+### Модели
+- `app/Models/PricingCode.php` - обновлён
+- `app/Models/SparePart.php` - добавлены аксессоры для расшифровок
+
+### Миграции
+- `database/migrations/2026_01_24_120000_update_pricing_codes_table_add_type_field.php` - новая
+
+### Контроллеры
+- `app/Http/Controllers/SparePartController.php` - добавлен метод import()
+- `app/Http/Controllers/SparePartOrderController.php` - добавлен routeName
+- `app/Http/Controllers/PricingCodeController.php` - добавлен метод getDescription()
+
+### Requests
+- `app/Http/Requests/StoreSparePartRequest.php` - добавлена обработка расшифровок
+
+### Services
+- `app/Services/Export/ExportSparePartsService.php` - добавлен экспорт справочника
+- `app/Services/Import/ImportSparePartsService.php` - новый
+
+### Jobs
+- `app/Jobs/Import/ImportSparePartsJob.php` - новый
+
+### Views
+- `resources/views/spare_parts/index.blade.php` - использование partials/table, добавлена кнопка импорта
+- `resources/views/spare_parts/edit.blade.php` - добавлено автозаполнение расшифровок
+- `resources/views/partials/table.blade.php` - добавлены tooltips для tsn_number и pricing_code
+
+### Routes
+- `routes/web.php` - добавлен роут для импорта и API для получения расшифровок
+
+## Применение изменений
+
+1. Запустить миграцию:
+```bash
+php artisan migrate
+```
+
+2. Перезапустить queue worker:
+```bash
+php artisan queue:restart
+```
+
+3. Очистить кэш (опционально):
+```bash
+php artisan cache:clear
+php artisan config:clear
+php artisan view:clear
+```
+
+## Примечания
+
+- Справочник расценок теперь поддерживает два типа записей: `tsn_number` и `pricing_code`
+- Уникальность записей гарантируется комбинацией `type` + `code`
+- Импорт обновляет существующие записи и создаёт новые
+- Экспорт включает все записи справочника на отдельной вкладке
+- Все операции импорта/экспорта выполняются асинхронно через очереди

+ 298 - 0
SPARE_PARTS_MODULE.md

@@ -0,0 +1,298 @@
+# Модуль управления запчастями
+
+## Обзор
+
+Модуль управления запчастями добавляет полнофункциональную систему для:
+- Ведения каталога запчастей
+- Управления заказами деталей
+- Автоматического списания при использовании в рекламациях
+- Контроля наличия на складе
+
+## Основные возможности
+
+### 1. Каталог запчастей
+
+**ВАЖНО:** Каталог запчастей является **общим для всех лет**. Одна запчасть (по артикулу) существует во всех годах, но количества рассчитываются только для текущего выбранного года.
+
+**Доступ:** Меню → Запчасти → вкладка "Каталог"
+
+**Функции:**
+- Просмотр всех запчастей с вычисляемыми остатками
+- Добавление/редактирование запчастей (только admin)
+- Загрузка изображений запчастей
+- Экспорт/импорт каталога в Excel
+- Управление ценами (закупка, заказчик, экспертиза)
+- Установка минимального остатка
+
+**Вычисляемые поля:**
+- **Кол-во без док** - количество на складе без документов (для текущего года)
+- **Кол-во с док** - количество на складе с документами (для текущего года)
+- **Кол-во общее** - сумма двух предыдущих
+
+**Кликабельные ячейки:**
+Клик по ячейке с количеством открывает вкладку "Заказы деталей" с автоматическим фильтром по:
+- Артикулу
+- Наличию документов (если кликнули на "без док" или "с док")
+- Статусу "На складе"
+- Остатку > 0
+
+### 2. Заказы деталей
+
+**ВАЖНО:** Заказы **разделены по годам**. Каждый заказ принадлежит конкретному году.
+
+**Доступ:** Меню → Запчасти → вкладка "Заказы деталей"
+
+**Функции:**
+- Создание заказа детали (admin, manager)
+- Изменение статуса (Заказано → На складе → Отгружено)
+- Отгрузка детали (списание) с указанием куда/для чего
+- Просмотр истории списаний
+- Автоматическое изменение статуса на "Отгружено" при полном списании
+
+**Статусы заказа:**
+- **Заказано** - деталь в процессе заказа
+- **На складе** - деталь поступила и доступна для использования
+- **Отгружено** - деталь полностью списана
+
+**История списаний:**
+- Дата и время списания
+- Количество
+- Примечание (куда/для чего)
+- Пользователь
+- Флаг "автоматическое" (если списано через рекламацию)
+- Ссылка на рекламацию (если применимо)
+
+### 3. Контроль наличия
+
+**Доступ:** Меню → Запчасти → вкладка "Контроль наличия"
+
+**Две таблицы:**
+
+**Критический недостаток (красные строки):**
+- Запчасти с **отрицательным количеством**
+- Означает, что была попытка списать больше, чем есть на складе
+- Автоматически создаётся виртуальный заказ с отрицательным остатком
+
+**Ниже минимального остатка (желтые строки):**
+- Запчасти, у которых общее количество < минимальный остаток
+- Предупреждение о необходимости пополнения
+
+### 4. Интеграция с рекламациями
+
+**Автоматическое списание:**
+При добавлении детали в рекламацию система:
+1. Проверяет, есть ли запчасть с таким артикулом в каталоге
+2. Если есть - автоматически списывает из подходящего заказа (FIFO)
+3. Если нет достаточного количества - создаёт виртуальный заказ с недостачей
+4. Сохраняет связь запчасть-рекламация
+
+**Чекбокс "С док.":**
+- В форме деталей рекламации добавлен чекбокс "С док."
+- Определяет, нужна ли запчасть с документами или без
+- Влияет на выбор заказа для списания
+
+### 5. Справочник расценок
+
+**Доступ:** Меню → Запчасти → (отдельный раздел, только для admin)
+
+**Функции:**
+- Ведение справочника кодов расценок
+- Добавление/редактирование/удаление кодов
+- Расшифровка кодов
+- Tooltip при наведении на код в каталоге (планируется)
+
+## Мультигодовая архитектура
+
+**КРИТИЧНО ВАЖНО:**
+
+### Каталог запчастей (БЕЗ year):
+- Таблица `spare_parts` **НЕ имеет** поле `year`
+- Модель `SparePart` **НЕ использует** `YearScope`
+- Одна запчасть видна во всех годах
+
+### Заказы деталей (С year):
+- Таблица `spare_part_orders` **имеет** поле `year`
+- Модель `SparePartOrder` **использует** `YearScope`
+- Заказы разделены по годам
+
+### Логика вычисления количеств:
+- Вычисляемые поля (`quantity_without_docs`, `quantity_with_docs`) в модели `SparePart`
+- Учитывают **только заказы текущего года** из сессии `year()`
+- При смене года количества пересчитываются автоматически
+
+**Пример:**
+```php
+// Запчасть "BOLT-001" существует одна
+$sparePart = SparePart::where('article', 'BOLT-001')->first();
+
+// Год 2024: есть заказ на 100 шт
+session(['year' => 2024]);
+echo $sparePart->total_quantity; // 100
+
+// Год 2025: есть заказ на 50 шт
+session(['year' => 2025]);
+echo $sparePart->total_quantity; // 50
+
+// Год 2026: нет заказов
+session(['year' => 2026]);
+echo $sparePart->total_quantity; // 0
+```
+
+## Структура базы данных
+
+### Таблицы:
+1. `spare_parts` - каталог запчастей (БЕЗ year)
+2. `spare_part_orders` - заказы деталей (С year)
+3. `spare_part_order_shipments` - история списаний (БЕЗ year, привязана к заказу)
+4. `pricing_codes` - справочник расценок
+5. `reclamation_spare_part` - pivot таблица запчасти-рекламации
+
+### View:
+- `spare_part_orders_view` - представление для заказов с join'ами
+
+## Экспорт/Импорт
+
+### Экспорт каталога:
+- Кнопка "Экспорт" (только admin)
+- Выполняется через очередь (`ExportSparePartsJob`)
+- Шаблон: `/templates/SpareParts.xlsx`
+- **ВАЖНО:** Вычисляемые поля (количества) экспортируются для текущего года!
+
+### Формат Excel (13 колонок, БЕЗ года):
+| ID | Артикул | Где используется | Кол-во без док | Кол-во с док | Кол-во общее | Примечание | Цена закупки | Цена для заказчика | Цена экспертизы | № по ТСН | Шифр расценки | Минимальный остаток |
+
+## Изображения
+
+**Расположение:** `/public/images/spare_parts/`
+
+**Формат имени:** `{article}.jpg`
+
+**Пример:** `TEST-001.jpg`
+
+**Загрузка:**
+- Форма редактирования запчасти → кнопка "Загрузить изображение" (только admin)
+- Автоматическое отображение в каталоге и формах
+
+## Роли и права доступа
+
+### Admin:
+- Полный доступ ко всем функциям
+- Создание/редактирование/удаление запчастей
+- Экспорт/импорт
+- Управление заказами и списание
+- Доступ к ценам закупки
+
+### Manager:
+- Просмотр каталога
+- Создание/редактирование заказов деталей
+- Списание деталей
+- Просмотр контроля наличия
+- **НЕТ доступа** к ценам закупки
+
+### Другие роли:
+- Только просмотр
+
+## Технические детали
+
+### Сервисы:
+- `SparePartInventoryService` - логика автосписания и контроля
+- `Export/ExportSparePartsService` - экспорт в Excel
+
+### Jobs:
+- `Export/ExportSparePartsJob` - фоновая задача экспорта
+
+### Form Requests:
+- `StoreSparePartRequest` - валидация запчасти
+- `StoreSparePartOrderRequest` - валидация заказа
+- `ShipSparePartOrderRequest` - валидация отгрузки
+- `StoreReclamationDetailsRequest` - обновлён для поддержки `with_documents`
+
+### Контроллеры:
+- `SparePartController` - каталог
+- `SparePartOrderController` - заказы
+- `SparePartInventoryController` - контроль наличия
+- `PricingCodeController` - справочник расценок
+- `ReclamationController` - обновлён для интеграции
+
+## Маршруты
+
+```php
+// Каталог
+GET    /spare-parts
+POST   /spare-parts (admin)
+GET    /spare-parts/create (admin)
+GET    /spare-parts/{id}
+PUT    /spare-parts/{id} (admin)
+DELETE /spare-parts/{id} (admin)
+POST   /spare-parts/export (admin)
+POST   /spare-parts/{id}/upload-image (admin)
+
+// Заказы
+GET    /spare-part-orders
+POST   /spare-part-orders (admin, manager)
+GET    /spare-part-orders/create (admin, manager)
+GET    /spare-part-orders/{id}
+PUT    /spare-part-orders/{id} (admin, manager)
+DELETE /spare-part-orders/{id} (admin)
+POST   /spare-part-orders/{id}/ship (admin, manager)
+POST   /spare-part-orders/{id}/set-in-stock (admin, manager)
+
+// Контроль
+GET    /spare-part-inventory
+
+// Справочник
+GET    /pricing-codes (admin)
+POST   /pricing-codes (admin)
+PUT    /pricing-codes/{id} (admin)
+DELETE /pricing-codes/{id} (admin)
+
+// API
+GET    /api/pricing-codes/{code}
+```
+
+## Тестирование
+
+### Создание тестовых данных:
+```bash
+docker exec dkr-app-1 php artisan tinker
+```
+
+```php
+// Создать запчасть
+$sp = SparePart::create([
+    'article' => 'TEST-001',
+    'used_in_maf' => 'Качели',
+    'customer_price' => 150.00,
+    'min_stock' => 10
+]);
+
+// Создать заказ
+$order = SparePartOrder::create([
+    'spare_part_id' => $sp->id,
+    'source_text' => 'Поставщик А',
+    'status' => 'in_stock',
+    'ordered_quantity' => 100,
+    'with_documents' => true,
+    'user_id' => 1
+]);
+
+// Проверить количество
+echo $sp->quantity_with_docs; // 100
+```
+
+### Тест автосписания:
+```php
+$service = new SparePartInventoryService();
+$service->deductForReclamation('TEST-001', 10, true, $reclamationId);
+
+// Проверить
+$sp->refresh();
+echo $sp->quantity_with_docs; // 90
+```
+
+## Известные особенности
+
+1. **Отрицательные остатки** - разрешены и означают недостачу
+2. **Виртуальные заказы** - автоматически создаются при недостаче со статусом "На складе" и отрицательным остатком
+3. **FIFO** - при списании выбирается самый ранний заказ с подходящими параметрами
+4. **Пересчёт количеств** - происходит динамически при каждом обращении к атрибутам модели

+ 2 - 0
app/Http/Controllers/FilterController.php

@@ -18,6 +18,8 @@ class FilterController extends Controller
         'responsibles'  => 'responsibles',
         'users'         => 'users',
         'contracts'     => 'contracts',
+        'spare_parts'   => 'spare_parts',
+        'spare_part_orders' => 'spare_part_orders_view',
     ];
     public function getFilters(FilterRequest $request)
     {

+ 108 - 0
app/Http/Controllers/PricingCodeController.php

@@ -0,0 +1,108 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\PricingCode;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+
+class PricingCodeController extends Controller
+{
+    protected array $data = [
+        'active' => 'spare_parts',
+        'title' => 'Справочник расшифровок',
+        'id' => 'pricing_codes',
+        'header' => [
+            'id' => 'ID',
+            'code' => 'Код',
+            'description' => 'Расшифровка',
+        ],
+    ];
+
+    public function index(Request $request)
+    {
+        $q = PricingCode::query();
+
+        // Поиск
+        if ($request->has('search')) {
+            $search = $request->get('search');
+            $q->where(function ($query) use ($search) {
+                $query->where('code', 'LIKE', '%' . $search . '%')
+                    ->orWhere('description', 'LIKE', '%' . $search . '%');
+            });
+        }
+
+        $q->orderBy('code');
+
+        $this->data['pricing_codes'] = $q->paginate(session('per_page', config('pagination.per_page')))->withQueryString();
+        $this->data['search'] = $request->get('search');
+
+        return view('pricing_codes.index', $this->data);
+    }
+
+    public function store(Request $request): RedirectResponse
+    {
+        $request->validate([
+            'type' => 'required|in:tsn_number,pricing_code',
+            'code' => 'required|string',
+            'description' => 'nullable|string',
+        ]);
+
+        // Проверяем уникальность комбинации type + code
+        $exists = PricingCode::where('type', $request->type)
+            ->where('code', $request->code)
+            ->exists();
+
+        if ($exists) {
+            return redirect()->route('pricing_codes.index')
+                ->with(['error' => 'Такой код уже существует для данного типа!']);
+        }
+
+        PricingCode::create($request->only(['type', 'code', 'description']));
+
+        return redirect()->route('pricing_codes.index')
+            ->with(['success' => 'Код расценки успешно добавлен!']);
+    }
+
+    public function update(Request $request, PricingCode $pricingCode): RedirectResponse
+    {
+        $request->validate([
+            'description' => 'nullable|string',
+        ]);
+
+        $pricingCode->update(['description' => $request->get('description')]);
+
+        return redirect()->route('pricing_codes.index')
+            ->with(['success' => 'Расшифровка успешно обновлена!']);
+    }
+
+    public function destroy(PricingCode $pricingCode): RedirectResponse
+    {
+        $pricingCode->delete();
+
+        return redirect()->route('pricing_codes.index')
+            ->with(['success' => 'Код расценки успешно удалён!']);
+    }
+
+    /**
+     * API метод для получения расшифровки кода
+     */
+    public function getDescription(Request $request)
+    {
+        $type = $request->get('type');
+        $code = $request->get('code');
+
+        if (!$type || !$code) {
+            return response()->json(['description' => null]);
+        }
+
+        $description = null;
+        if ($type === 'tsn_number') {
+            $description = PricingCode::getTsnDescription($code);
+        } elseif ($type === 'pricing_code') {
+            $description = PricingCode::getPricingCodeDescription($code);
+        }
+
+        return response()->json(['description' => $description]);
+    }
+}

+ 41 - 13
app/Http/Controllers/ReclamationController.php

@@ -97,6 +97,7 @@ class ReclamationController extends Controller
         $this->data['brigadiers'] = User::query()->where('role', Role::BRIGADIER)->get()->pluck('name', 'id');
         $this->data['reclamation'] = $reclamation;
         $this->data['previous_url'] = $request->get('previous_url');
+        $this->data['spare_parts'] = \App\Models\SparePart::orderBy('article')->get();
         return view('reclamations.edit', $this->data);
     }
 
@@ -207,26 +208,53 @@ class ReclamationController extends Controller
 
     public function updateDetails(StoreReclamationDetailsRequest $request, Reclamation $reclamation)
     {
-        $names      = $request->validated('name');
-        $quantity   = $request->validated('quantity');
+        $names = $request->validated('name');
+        $quantity = $request->validated('quantity');
+        $withDocuments = $request->validated('with_documents');
+
+        $inventoryService = app(\App\Services\SparePartInventoryService::class);
+
         foreach ($names as $key => $name) {
-            if(!$name) continue;
-            if ((int)$quantity[$key] > 1) {
-                ReclamationDetail::query()
-                    ->updateOrCreate([
-                        'reclamation_id' => $reclamation->id,
-                        'name' => $name,
-                    ],
-                    [
-                        'quantity' => $quantity[$key],
+            if (!$name) continue;
+
+            if ((int)$quantity[$key] >= 1) {
+                // Проверяем, является ли это запчастью
+                $sparePart = \App\Models\SparePart::where('article', $name)->first();
+
+                if ($sparePart) {
+                    // Автоматическое списание
+                    $withDocs = isset($withDocuments[$key]) && $withDocuments[$key];
+
+                    $inventoryService->deductForReclamation(
+                        $name,
+                        (int)$quantity[$key],
+                        $withDocs,
+                        $reclamation->id
+                    );
+
+                    // Сохраняем в pivot
+                    $reclamation->spareParts()->syncWithoutDetaching([
+                        $sparePart->id => [
+                            'quantity' => $quantity[$key],
+                            'with_documents' => $withDocs,
+                        ]
                     ]);
+                } else {
+                    // Обычная деталь
+                    ReclamationDetail::query()->updateOrCreate(
+                        ['reclamation_id' => $reclamation->id, 'name' => $name],
+                        ['quantity' => $quantity[$key]]
+                    );
+                }
             } else {
+                // Удаление
                 ReclamationDetail::query()
-                    ->where('reclamation_details.reclamation_id', $reclamation->id)
-                    ->where('reclamation_details.name', $name)
+                    ->where('reclamation_id', $reclamation->id)
+                    ->where('name', $name)
                     ->delete();
             }
         }
+
         return redirect()->route('reclamations.show', ['reclamation' => $reclamation, 'previous_url' => $request->get('previous_url')]);
     }
 

+ 231 - 0
app/Http/Controllers/SparePartController.php

@@ -0,0 +1,231 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\StoreSparePartRequest;
+use App\Jobs\Export\ExportSparePartsJob;
+use App\Jobs\Import\ImportSparePartsJob;
+use App\Models\Import;
+use App\Models\SparePart;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+
+class SparePartController extends Controller
+{
+    protected array $data = [
+        'active' => 'spare_parts',
+        'title' => 'Каталог запчастей',
+        'id' => 'spare_parts',
+        'header' => [
+            'image' => 'Картинка',
+            'id' => 'ID',
+            'article' => 'Артикул',
+            'used_in_maf' => 'Где используется',
+            'quantity_without_docs' => 'Кол-во без док',
+            'quantity_with_docs' => 'Кол-во с док',
+            'total_quantity' => 'Кол-во общее',
+            'note' => 'Примечание',
+            'customer_price_txt' => 'Цена для заказчика',
+            'expertise_price_txt' => 'Цена экспертизы',
+            'tsn_number' => '№ по ТСН',
+            'pricing_code' => 'Шифр расценки',
+            'min_stock' => 'Минимальный остаток',
+        ],
+        'searchFields' => [
+            'article',
+            'used_in_maf',
+            'note',
+            'tsn_number',
+            'pricing_code',
+        ],
+        'routeName' => 'spare_parts.show',
+    ];
+
+    public function index(Request $request)
+    {
+        session(['gp_spare_parts' => $request->query()]);
+        $model = new SparePart();
+
+        // Для админа добавляем колонку цены закупки
+        if (hasRole('admin')) {
+            $this->data['header'] = array_merge(
+                array_slice($this->data['header'], 0, 8, true),
+                ['purchase_price_txt' => 'Цена закупки'],
+                array_slice($this->data['header'], 8, null, true)
+            );
+        }
+
+        // Фильтры
+        $this->createFilters($model, 'used_in_maf');
+
+        // Для range фильтров нужно использовать реальные поля БД (без _txt)
+        // но заголовки брать из header с _txt
+        $this->createRangeFiltersForPrices($model, 'customer_price', 'expertise_price', 'min_stock');
+        if (hasRole('admin')) {
+            $this->createRangeFiltersForPrices($model, 'purchase_price');
+        }
+
+        // Запрос
+        $q = $model::query();
+
+        $this->acceptFilters($q, $request);
+        $this->acceptSearch($q, $request);
+
+        $this->setSortAndOrderBy($model, $request);
+        $q->orderBy($this->data['sortBy'], $this->data['orderBy']);
+
+        $this->data['spare_parts'] = $q->paginate(session('per_page', config('pagination.per_page')))->withQueryString();
+        $this->data['strings'] = $this->data['spare_parts'];
+        $this->data['tab'] = 'catalog';
+
+        return view('spare_parts.index', $this->data);
+    }
+
+    public function show(Request $request, SparePart $sparePart)
+    {
+        $this->data['previous_url'] = $request->get('previous_url');
+        $this->data['spare_part'] = $sparePart;
+        return view('spare_parts.edit', $this->data);
+    }
+
+    public function create()
+    {
+        $this->data['spare_part'] = null;
+        return view('spare_parts.edit', $this->data);
+    }
+
+    public function store(StoreSparePartRequest $request): RedirectResponse
+    {
+        SparePart::create($request->validated());
+        $previous_url = $request->get('previous_url') ?? route('spare_parts.index', session('gp_spare_parts'));
+        return redirect()->to($previous_url)->with(['success' => 'Запчасть успешно создана!']);
+    }
+
+    public function update(StoreSparePartRequest $request, SparePart $sparePart): RedirectResponse
+    {
+        $sparePart->update($request->validated());
+        $previous_url = $request->get('previous_url') ?? route('spare_parts.index', session('gp_spare_parts'));
+        return redirect()->to($previous_url)->with(['success' => 'Запчасть успешно обновлена!']);
+    }
+
+    public function destroy(SparePart $sparePart): RedirectResponse
+    {
+        // Проверка на наличие заказов
+        if ($sparePart->orders()->count() > 0) {
+            return redirect()->route('spare_parts.index', session('gp_spare_parts'))
+                ->with(['error' => 'Невозможно удалить запчасть, т.к. для неё есть заказы!']);
+        }
+
+        $sparePart->delete();
+        return redirect()->route('spare_parts.index', session('gp_spare_parts'))
+            ->with(['success' => 'Запчасть успешно удалена!']);
+    }
+
+    public function export(Request $request): RedirectResponse
+    {
+        // Запускаем Job для экспорта
+        ExportSparePartsJob::dispatch(auth()->id());
+        Log::info('ExportSparePartsJob created!');
+
+        return redirect()->route('spare_parts.index', session('gp_spare_parts'))
+            ->with(['success' => 'Задача экспорта успешно создана!']);
+    }
+
+    public function import(Request $request): RedirectResponse
+    {
+        $request->validate([
+            'file' => 'required|mimes:xlsx,xls|max:10240',
+        ]);
+
+        try {
+            // Сохраняем временный файл
+            $file = $request->file('file');
+            $tempPath = $file->storeAs('temp/imports', 'spare_parts_import_' . time() . '.xlsx');
+            $fullPath = Storage::path($tempPath);
+
+            // Создаём запись импорта
+            $import = Import::create([
+                'user_id' => auth()->id(),
+                'type' => 'spare_parts',
+                'status' => Import::STATUS_PENDING,
+                'file_path' => $tempPath,
+                'original_filename' => $file->getClientOriginalName(),
+            ]);
+
+            // Запускаем Job для импорта
+            ImportSparePartsJob::dispatch($fullPath, auth()->id(), $import->id);
+            Log::info('ImportSparePartsJob created!', ['import_id' => $import->id]);
+
+            return redirect()->route('spare_parts.index', session('gp_spare_parts'))
+                ->with(['success' => 'Задача импорта успешно создана! Следите за статусом в разделе импорта.']);
+        } catch (\Exception $e) {
+            Log::error('Ошибка создания задачи импорта: ' . $e->getMessage());
+
+            return redirect()->route('spare_parts.index', session('gp_spare_parts'))
+                ->with(['error' => 'Ошибка импорта: ' . $e->getMessage()]);
+        }
+    }
+
+    public function uploadImage(Request $request, SparePart $sparePart): RedirectResponse
+    {
+        $request->validate([
+            'image' => 'required|image|mimes:jpeg,jpg,png|max:2048',
+        ]);
+
+        if ($request->hasFile('image')) {
+            $image = $request->file('image');
+            $filename = $sparePart->article . '.jpg';
+
+            // Создаём директорию если её нет
+            $directory = public_path('images/spare_parts');
+            if (!file_exists($directory)) {
+                mkdir($directory, 0755, true);
+            }
+
+            // Сохраняем изображение
+            $image->move($directory, $filename);
+
+            return redirect()->route('spare_parts.show', $sparePart)
+                ->with(['success' => 'Изображение успешно загружено!']);
+        }
+
+        return redirect()->route('spare_parts.show', $sparePart)
+            ->with(['error' => 'Ошибка загрузки изображения!']);
+    }
+
+    /**
+     * Создание range фильтров для полей с ценами
+     * Использует правильные заголовки из header (_txt версии)
+     */
+    protected function createRangeFiltersForPrices(SparePart $model, string ...$columnNames): void
+    {
+        foreach ($columnNames as $columnName) {
+            // Определяем ключ заголовка
+            $headerKey = str_ends_with($columnName, '_price') ? $columnName . '_txt' : $columnName;
+
+            // Проверяем, есть ли заголовок
+            if (!isset($this->data['header'][$headerKey])) {
+                continue;
+            }
+
+            if (str_ends_with($columnName, '_price')) {
+                $min = $model::query()->min($columnName);
+                $max = $model::query()->max($columnName);
+
+                $this->data['ranges'][$columnName] = [
+                    'title' => $this->data['header'][$headerKey],
+                    'min' => $min ? $min / 100 : 0,
+                    'max' => $max ? $max / 100 : 0,
+                ];
+            } else {
+                $this->data['ranges'][$columnName] = [
+                    'title' => $this->data['header'][$headerKey],
+                    'min' => $model::query()->min($columnName) ?? 0,
+                    'max' => $model::query()->max($columnName) ?? 0,
+                ];
+            }
+        }
+    }
+}

+ 28 - 0
app/Http/Controllers/SparePartInventoryController.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Services\SparePartInventoryService;
+use Illuminate\Http\Request;
+
+class SparePartInventoryController extends Controller
+{
+    protected array $data = [
+        'active' => 'spare_parts',
+        'title' => 'Контроль наличия запчастей',
+        'id' => 'spare_part_inventory',
+    ];
+
+    public function __construct(protected SparePartInventoryService $inventoryService)
+    {
+    }
+
+    public function index(Request $request)
+    {
+        $this->data['critical_shortages'] = $this->inventoryService->getCriticalShortages();
+        $this->data['below_min_stock'] = $this->inventoryService->getBelowMinStock();
+        $this->data['tab'] = 'inventory';
+
+        return view('spare_parts.index', $this->data);
+    }
+}

+ 163 - 0
app/Http/Controllers/SparePartOrderController.php

@@ -0,0 +1,163 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\ShipSparePartOrderRequest;
+use App\Http\Requests\StoreSparePartOrderRequest;
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use App\Models\SparePartOrdersView;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+
+class SparePartOrderController extends Controller
+{
+    protected array $data = [
+        'active' => 'spare_parts',
+        'title' => 'Заказы деталей',
+        'id' => 'spare_part_orders',
+        'header' => [
+            'id' => 'ID',
+            'year' => 'Год',
+            'article' => 'Артикул',
+            'source_text' => 'Источник заказа',
+            'status' => 'Статус',
+            'ordered_quantity' => 'Заказано',
+            'remaining_quantity' => 'Остаток',
+            'with_documents' => 'С документами',
+            'note' => 'Примечание',
+            'user_name' => 'Менеджер',
+            'created_at' => 'Дата создания',
+        ],
+        'searchFields' => [
+            'article',
+            'source_text',
+            'note',
+            'user_name',
+        ],
+        'routeName' => 'spare_part_orders.show',
+    ];
+
+    public function index(Request $request)
+    {
+        session(['gp_spare_part_orders' => $request->query()]);
+        $model = new SparePartOrdersView();
+
+        // Фильтры
+        $this->createFilters($model, 'year', 'article', 'status', 'with_documents');
+        $this->createRangeFilters($model, 'ordered_quantity', 'remaining_quantity');
+        $this->createDateFilters($model, 'created_at');
+
+        // Запрос
+        $q = $model::query();
+
+        // Специальная обработка фильтров из клика по каталогу
+        if ($request->has('spare_part_id')) {
+            $sparePart = SparePart::find($request->get('spare_part_id'));
+            if ($sparePart) {
+                $q->where('spare_part_id', $sparePart->id);
+                $this->data['filter_spare_part'] = $sparePart;
+            }
+        }
+
+        if ($request->has('with_documents')) {
+            $withDocs = filter_var($request->get('with_documents'), FILTER_VALIDATE_BOOLEAN);
+            $q->where('with_documents', $withDocs);
+        }
+
+        if ($request->has('status')) {
+            $q->where('status', $request->get('status'));
+        }
+
+        if ($request->has('remaining_quantity_min')) {
+            $q->where('remaining_quantity', '>=', $request->get('remaining_quantity_min'));
+        }
+
+        $this->acceptFilters($q, $request);
+        $this->acceptSearch($q, $request);
+
+        $this->setSortAndOrderBy($model, $request);
+        $q->orderBy($this->data['sortBy'], $this->data['orderBy']);
+
+        $this->data['spare_part_orders'] = $q->paginate(session('per_page', config('pagination.per_page')))->withQueryString();
+        $this->data['strings'] = $this->data['spare_part_orders'];
+        $this->data['tab'] = 'orders';
+
+        return view('spare_parts.index', $this->data);
+    }
+
+    public function show(Request $request, SparePartOrder $sparePartOrder)
+    {
+        $this->data['previous_url'] = $request->get('previous_url');
+        $this->data['spare_part_order'] = $sparePartOrder->load(['sparePart', 'shipments.user', 'shipments.reclamation']);
+        $this->data['spare_parts'] = SparePart::orderBy('article')->get();
+        return view('spare_part_orders.edit', $this->data);
+    }
+
+    public function create()
+    {
+        $this->data['spare_part_order'] = null;
+        $this->data['spare_parts'] = SparePart::orderBy('article')->get();
+        return view('spare_part_orders.edit', $this->data);
+    }
+
+    public function store(StoreSparePartOrderRequest $request): RedirectResponse
+    {
+        $data = $request->validated();
+        $data['user_id'] = auth()->id();
+
+        SparePartOrder::create($data);
+
+        $previous_url = $request->get('previous_url') ?? route('spare_part_orders.index', session('gp_spare_part_orders'));
+        return redirect()->to($previous_url)->with(['success' => 'Заказ детали успешно создан!']);
+    }
+
+    public function update(StoreSparePartOrderRequest $request, SparePartOrder $sparePartOrder): RedirectResponse
+    {
+        $sparePartOrder->update($request->validated());
+
+        $previous_url = $request->get('previous_url') ?? route('spare_part_orders.index', session('gp_spare_part_orders'));
+        return redirect()->to($previous_url)->with(['success' => 'Заказ детали успешно обновлён!']);
+    }
+
+    public function destroy(SparePartOrder $sparePartOrder): RedirectResponse
+    {
+        $sparePartOrder->delete();
+
+        return redirect()->route('spare_part_orders.index', session('gp_spare_part_orders'))
+            ->with(['success' => 'Заказ детали успешно удалён!']);
+    }
+
+    public function ship(ShipSparePartOrderRequest $request, SparePartOrder $sparePartOrder): RedirectResponse
+    {
+        $validated = $request->validated();
+
+        if ($validated['quantity'] > $sparePartOrder->remaining_quantity) {
+            return redirect()->route('spare_part_orders.show', $sparePartOrder)
+                ->with(['error' => 'Количество отгрузки превышает остаток!']);
+        }
+
+        $success = $sparePartOrder->shipQuantity(
+            $validated['quantity'],
+            $validated['note'],
+            null,
+            auth()->id()
+        );
+
+        if ($success) {
+            return redirect()->route('spare_part_orders.show', $sparePartOrder)
+                ->with(['success' => 'Отгрузка успешно выполнена!']);
+        } else {
+            return redirect()->route('spare_part_orders.show', $sparePartOrder)
+                ->with(['error' => 'Ошибка отгрузки!']);
+        }
+    }
+
+    public function setInStock(SparePartOrder $sparePartOrder): RedirectResponse
+    {
+        $sparePartOrder->update(['status' => SparePartOrder::STATUS_IN_STOCK]);
+
+        return redirect()->route('spare_part_orders.show', $sparePartOrder)
+            ->with(['success' => 'Статус изменён на "На складе"!']);
+    }
+}

+ 29 - 0
app/Http/Requests/ShipSparePartOrderRequest.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class ShipSparePartOrderRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     */
+    public function authorize(): bool
+    {
+        return hasRole('admin,manager');
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
+     */
+    public function rules(): array
+    {
+        return [
+            'quantity' => 'required|integer|min:1',
+            'note' => 'required|string',
+        ];
+    }
+}

+ 6 - 2
app/Http/Requests/StoreReclamationDetailsRequest.php

@@ -22,8 +22,12 @@ class StoreReclamationDetailsRequest extends FormRequest
     public function rules(): array
     {
         return [
-            'name.*'        => 'nullable|string',
-            'quantity.*'    => 'nullable|string',
+            'name' => 'nullable|array',
+            'name.*' => 'nullable|string',
+            'quantity' => 'nullable|array',
+            'quantity.*' => 'nullable|string',
+            'with_documents' => 'nullable|array',
+            'with_documents.*' => 'nullable|boolean',
         ];
     }
 }

+ 35 - 0
app/Http/Requests/StoreSparePartOrderRequest.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreSparePartOrderRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     */
+    public function authorize(): bool
+    {
+        return hasRole('admin,manager');
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
+     */
+    public function rules(): array
+    {
+        return [
+            'spare_part_id' => 'required|exists:spare_parts,id',
+            'source_text' => 'nullable|string|max:255',
+            'sourceable_id' => 'nullable|integer',
+            'sourceable_type' => 'nullable|string',
+            'status' => 'required|in:ordered,in_stock,shipped',
+            'ordered_quantity' => 'required|integer|min:1',
+            'with_documents' => 'boolean',
+            'note' => 'nullable|string',
+        ];
+    }
+}

+ 63 - 0
app/Http/Requests/StoreSparePartRequest.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreSparePartRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     */
+    public function authorize(): bool
+    {
+        return hasRole('admin');
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
+     */
+    public function rules(): array
+    {
+        return [
+            'article' => 'required|string|max:255',
+            'used_in_maf' => 'nullable|string|max:255',
+            'product_id' => 'nullable|exists:products,id',
+            'note' => 'nullable|string',
+            'purchase_price' => 'nullable|numeric|min:0',
+            'customer_price' => 'nullable|numeric|min:0',
+            'expertise_price' => 'nullable|numeric|min:0',
+            'tsn_number' => 'nullable|string|max:255',
+            'tsn_number_description' => 'nullable|string',
+            'pricing_code' => 'nullable|string',
+            'pricing_code_description' => 'nullable|string',
+            'min_stock' => 'nullable|integer|min:0',
+        ];
+    }
+
+    /**
+     * Обработка данных после валидации
+     */
+    protected function passedValidation()
+    {
+        // Сохраняем расшифровку № по ТСН в справочник, если указана
+        if ($this->filled('tsn_number') && $this->filled('tsn_number_description')) {
+            \App\Models\PricingCode::createOrUpdate(
+                \App\Models\PricingCode::TYPE_TSN_NUMBER,
+                $this->input('tsn_number'),
+                $this->input('tsn_number_description')
+            );
+        }
+
+        // Сохраняем расшифровку шифра расценки в справочник, если указана
+        if ($this->filled('pricing_code') && $this->filled('pricing_code_description')) {
+            \App\Models\PricingCode::createOrUpdate(
+                \App\Models\PricingCode::TYPE_PRICING_CODE,
+                $this->input('pricing_code'),
+                $this->input('pricing_code_description')
+            );
+        }
+    }
+}

+ 34 - 0
app/Jobs/Export/ExportSparePartsJob.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Jobs\Export;
+
+use App\Events\SendWebSocketMessageEvent;
+use App\Services\Export\ExportSparePartsService;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+
+class ExportSparePartsJob implements ShouldQueue
+{
+    use Queueable;
+
+    /**
+     * Create a new job instance.
+     */
+    public function __construct(private readonly int $userId)
+    {
+    }
+
+    /**
+     * Execute the job.
+     */
+    public function handle(ExportSparePartsService $service): void
+    {
+        $link = $service->handle($this->userId);
+
+        event(new SendWebSocketMessageEvent(
+            'Экспорт каталога запчастей завершён!',
+            $this->userId,
+            ['link' => $link]
+        ));
+    }
+}

+ 76 - 0
app/Jobs/Import/ImportSparePartsJob.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Jobs\Import;
+
+use App\Models\Import;
+use App\Services\Import\ImportSparePartsService;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+
+class ImportSparePartsJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public function __construct(
+        private readonly string $filePath,
+        private readonly int $userId,
+        private readonly ?int $importId = null,
+    ) {}
+
+    public function handle(): void
+    {
+        Log::info("ImportSparePartsJob started", [
+            'file_path' => $this->filePath,
+            'user_id' => $this->userId,
+        ]);
+
+        $import = null;
+        if ($this->importId) {
+            $import = Import::find($this->importId);
+        }
+
+        try {
+            $service = new ImportSparePartsService($this->filePath, $this->userId);
+            $result = $service->handle();
+
+            if ($import) {
+                if ($result['success']) {
+                    $import->update([
+                        'status' => Import::STATUS_COMPLETED,
+                        'result' => implode("\n", $result['logs']),
+                    ]);
+                } else {
+                    $import->update([
+                        'status' => Import::STATUS_FAILED,
+                        'result' => implode("\n", $result['logs']) . "\n\nОШИБКА: " . ($result['error'] ?? 'Неизвестная ошибка'),
+                    ]);
+                }
+            }
+
+            Log::info("ImportSparePartsJob finished successfully");
+        } catch (\Exception $e) {
+            Log::error("ImportSparePartsJob failed: " . $e->getMessage(), [
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            if ($import) {
+                $import->update([
+                    'status' => Import::STATUS_FAILED,
+                    'result' => "ОШИБКА: " . $e->getMessage() . "\n\n" . $e->getTraceAsString(),
+                ]);
+            }
+
+            throw $e;
+        } finally {
+            // Удаляем временный файл
+            if (file_exists($this->filePath)) {
+                unlink($this->filePath);
+            }
+        }
+    }
+}

+ 7 - 0
app/Models/Import.php

@@ -8,12 +8,19 @@ class Import extends Model
 {
     const DEFAULT_SORT_BY = 'created_at';
 
+    const STATUS_PENDING = 'pending';
+    const STATUS_COMPLETED = 'completed';
+    const STATUS_FAILED = 'failed';
+
     protected $fillable = [
         'type',
         'year',
         'filename',
         'status',
         'result',
+        'user_id',
+        'file_path',
+        'original_filename',
     ];
 
     public function log(string $message, string $level = 'INFO'): string

+ 56 - 0
app/Models/PricingCode.php

@@ -0,0 +1,56 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class PricingCode extends Model
+{
+    const TYPE_TSN_NUMBER = 'tsn_number';
+    const TYPE_PRICING_CODE = 'pricing_code';
+
+    protected $fillable = [
+        'type',
+        'code',
+        'description',
+    ];
+
+    /**
+     * Получить расшифровку для № по ТСН
+     */
+    public static function getTsnDescription(?string $code): ?string
+    {
+        if (!$code) {
+            return null;
+        }
+
+        return self::where('type', self::TYPE_TSN_NUMBER)
+            ->where('code', $code)
+            ->value('description');
+    }
+
+    /**
+     * Получить расшифровку для шифра расценки
+     */
+    public static function getPricingCodeDescription(?string $code): ?string
+    {
+        if (!$code) {
+            return null;
+        }
+
+        return self::where('type', self::TYPE_PRICING_CODE)
+            ->where('code', $code)
+            ->value('description');
+    }
+
+    /**
+     * Создать или обновить запись справочника
+     */
+    public static function createOrUpdate(string $type, string $code, string $description): void
+    {
+        self::updateOrCreate(
+            ['type' => $type, 'code' => $code],
+            ['description' => $description]
+        );
+    }
+}

+ 7 - 0
app/Models/Reclamation.php

@@ -106,4 +106,11 @@ class Reclamation extends Model
         return $this->hasMany(ReclamationDetail::class);
     }
 
+    public function spareParts(): BelongsToMany
+    {
+        return $this->belongsToMany(SparePart::class, 'reclamation_spare_part')
+            ->withPivot('quantity', 'with_documents')
+            ->withTimestamps();
+    }
+
 }

+ 180 - 0
app/Models/SparePart.php

@@ -0,0 +1,180 @@
+<?php
+
+namespace App\Models;
+
+use App\Helpers\Price;
+use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class SparePart extends Model
+{
+    use SoftDeletes;
+
+    const DEFAULT_SORT_BY = 'article';
+
+    protected $fillable = [
+        // БЕЗ year! Каталог общий для всех лет
+        'article',
+        'used_in_maf',
+        'product_id',
+        'note',
+        'purchase_price',
+        'customer_price',
+        'expertise_price',
+        'tsn_number',
+        'pricing_code',
+        'min_stock',
+    ];
+
+    protected $appends = [
+        'purchase_price',
+        'customer_price',
+        'expertise_price',
+        'purchase_price_txt',
+        'customer_price_txt',
+        'expertise_price_txt',
+        'image',
+        'quantity_without_docs',
+        'quantity_with_docs',
+        'total_quantity',
+        'tsn_number_description',
+        'pricing_code_description',
+    ];
+
+    // Аксессоры для цен (копейки -> рубли)
+    protected function purchasePrice(): Attribute
+    {
+        return Attribute::make(
+            get: fn($value) => $value ? $value / 100 : null,
+            set: fn($value) => $value ? round($value * 100) : null,
+        );
+    }
+
+    protected function customerPrice(): Attribute
+    {
+        return Attribute::make(
+            get: fn($value) => $value ? $value / 100 : null,
+            set: fn($value) => $value ? round($value * 100) : null,
+        );
+    }
+
+    protected function expertisePrice(): Attribute
+    {
+        return Attribute::make(
+            get: fn($value) => $value ? $value / 100 : null,
+            set: fn($value) => $value ? round($value * 100) : null,
+        );
+    }
+
+    // Текстовое представление цен
+    protected function purchasePriceTxt(): Attribute
+    {
+        return Attribute::make(
+            get: fn() => isset($this->attributes['purchase_price']) && $this->attributes['purchase_price'] !== null
+                ? Price::format($this->attributes['purchase_price'] / 100)
+                : '-',
+        );
+    }
+
+    protected function customerPriceTxt(): Attribute
+    {
+        return Attribute::make(
+            get: fn() => isset($this->attributes['customer_price']) && $this->attributes['customer_price'] !== null
+                ? Price::format($this->attributes['customer_price'] / 100)
+                : '-',
+        );
+    }
+
+    protected function expertisePriceTxt(): Attribute
+    {
+        return Attribute::make(
+            get: fn() => isset($this->attributes['expertise_price']) && $this->attributes['expertise_price'] !== null
+                ? Price::format($this->attributes['expertise_price'] / 100)
+                : '-',
+        );
+    }
+
+    // Атрибут для картинки
+    public function image(): Attribute
+    {
+        $path = '';
+        if (file_exists(public_path() . '/images/spare_parts/' . $this->article . '.jpg')) {
+            $path = url('/images/spare_parts/' . $this->article . '.jpg');
+        }
+        return Attribute::make(get: fn() => $path);
+    }
+
+    // Отношения
+    public function product(): BelongsTo
+    {
+        return $this->belongsTo(Product::class);
+    }
+
+    public function orders(): HasMany
+    {
+        return $this->hasMany(SparePartOrder::class);
+    }
+
+    public function reclamations(): BelongsToMany
+    {
+        return $this->belongsToMany(Reclamation::class, 'reclamation_spare_part')
+            ->withPivot('quantity', 'with_documents')
+            ->withTimestamps();
+    }
+
+    // ВЫЧИСЛЯЕМЫЕ ПОЛЯ (с учетом текущего года!)
+    public function getQuantityWithoutDocsAttribute(): int
+    {
+        // Убираем фильтр по положительному остатку, чтобы учитывать недостачу
+        return $this->orders()
+            ->where('year', year())  // КРИТИЧНО! Учитываем текущий год из сессии
+            ->where('status', SparePartOrder::STATUS_IN_STOCK)
+            ->where('with_documents', false)
+            ->sum('remaining_quantity') ?? 0;
+    }
+
+    public function getQuantityWithDocsAttribute(): int
+    {
+        // Убираем фильтр по положительному остатку, чтобы учитывать недостачу
+        return $this->orders()
+            ->where('year', year())  // КРИТИЧНО! Учитываем текущий год из сессии
+            ->where('status', SparePartOrder::STATUS_IN_STOCK)
+            ->where('with_documents', true)
+            ->sum('remaining_quantity') ?? 0;
+    }
+
+    public function getTotalQuantityAttribute(): int
+    {
+        return $this->quantity_without_docs + $this->quantity_with_docs;
+    }
+
+    // Методы проверки
+    public function hasCriticalShortage(): bool
+    {
+        return $this->quantity_without_docs < 0 || $this->quantity_with_docs < 0;
+    }
+
+    public function isBelowMinStock(): bool
+    {
+        return $this->total_quantity < $this->min_stock && $this->total_quantity >= 0;
+    }
+
+    // Расшифровки из справочника
+    protected function tsnNumberDescription(): Attribute
+    {
+        return Attribute::make(
+            get: fn() => PricingCode::getTsnDescription($this->tsn_number)
+        );
+    }
+
+    protected function pricingCodeDescription(): Attribute
+    {
+        return Attribute::make(
+            get: fn() => PricingCode::getPricingCodeDescription($this->pricing_code)
+        );
+    }
+}

+ 134 - 0
app/Models/SparePartOrder.php

@@ -0,0 +1,134 @@
+<?php
+
+namespace App\Models;
+
+use App\Models\Scopes\YearScope;
+use Illuminate\Database\Eloquent\Attributes\ScopedBy;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+#[ScopedBy([YearScope::class])]
+class SparePartOrder extends Model
+{
+    use SoftDeletes;
+
+    const STATUS_ORDERED = 'ordered';
+    const STATUS_IN_STOCK = 'in_stock';
+    const STATUS_SHIPPED = 'shipped';
+
+    const STATUS_NAMES = [
+        self::STATUS_ORDERED => 'Заказано',
+        self::STATUS_IN_STOCK => 'На складе',
+        self::STATUS_SHIPPED => 'Отгружено',
+    ];
+
+    const DEFAULT_SORT_BY = 'created_at';
+
+    protected $fillable = [
+        'year',
+        'spare_part_id',
+        'source_text',
+        'sourceable_id',
+        'sourceable_type',
+        'status',
+        'ordered_quantity',
+        'remaining_quantity',
+        'with_documents',
+        'note',
+        'user_id',
+    ];
+
+    protected $casts = [
+        'with_documents' => 'boolean',
+        'ordered_quantity' => 'integer',
+        'remaining_quantity' => 'integer',
+        'year' => 'integer',
+    ];
+
+    protected static function boot(): void
+    {
+        parent::boot();
+
+        static::creating(function ($model) {
+            if (!isset($model->year)) {
+                $model->year = year();
+            }
+            if (!isset($model->remaining_quantity)) {
+                $model->remaining_quantity = $model->ordered_quantity;
+            }
+        });
+
+        // Автосмена статуса
+        static::updating(function ($model) {
+            if ($model->remaining_quantity === 0 && $model->status !== self::STATUS_SHIPPED) {
+                $model->status = self::STATUS_SHIPPED;
+            }
+        });
+    }
+
+    // Отношения
+    public function sparePart(): BelongsTo
+    {
+        return $this->belongsTo(SparePart::class);
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function sourceable(): MorphTo
+    {
+        return $this->morphTo();
+    }
+
+    public function shipments(): HasMany
+    {
+        return $this->hasMany(SparePartOrderShipment::class);
+    }
+
+    // Scopes
+    public function scopeInStock($query)
+    {
+        return $query->where('status', self::STATUS_IN_STOCK)
+            ->where('remaining_quantity', '>', 0);
+    }
+
+    public function scopeWithDocuments($query, bool $withDocs = true)
+    {
+        return $query->where('with_documents', $withDocs);
+    }
+
+    // Метод списания
+    public function shipQuantity(int $quantity, string $note, ?int $reclamationId = null, ?int $userId = null): bool
+    {
+        if ($quantity > $this->remaining_quantity) {
+            return false;
+        }
+
+        // Уменьшаем остаток
+        $this->remaining_quantity -= $quantity;
+        $this->save();
+
+        // Создаем запись в истории
+        SparePartOrderShipment::create([
+            'spare_part_order_id' => $this->id,
+            'quantity' => $quantity,
+            'note' => $note,
+            'reclamation_id' => $reclamationId,
+            'user_id' => $userId ?? auth()->id(),
+            'is_automatic' => $reclamationId !== null,
+        ]);
+
+        return true;
+    }
+
+    // Получить имя статуса
+    public function getStatusNameAttribute(): string
+    {
+        return self::STATUS_NAMES[$this->status] ?? $this->status;
+    }
+}

+ 38 - 0
app/Models/SparePartOrderShipment.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class SparePartOrderShipment extends Model
+{
+    protected $fillable = [
+        'spare_part_order_id',
+        'quantity',
+        'note',
+        'reclamation_id',
+        'user_id',
+        'is_automatic',
+    ];
+
+    protected $casts = [
+        'is_automatic' => 'boolean',
+        'quantity' => 'integer',
+    ];
+
+    public function sparePartOrder(): BelongsTo
+    {
+        return $this->belongsTo(SparePartOrder::class);
+    }
+
+    public function reclamation(): BelongsTo
+    {
+        return $this->belongsTo(Reclamation::class);
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+}

+ 21 - 0
app/Models/SparePartOrdersView.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class SparePartOrdersView extends Model
+{
+    protected $table = 'spare_part_orders_view';
+
+    public $timestamps = false;
+
+    const DEFAULT_SORT_BY = 'created_at';
+
+    protected $casts = [
+        'with_documents' => 'boolean',
+        'ordered_quantity' => 'integer',
+        'remaining_quantity' => 'integer',
+        'year' => 'integer',
+    ];
+}

+ 118 - 0
app/Services/Export/ExportSparePartsService.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Services\Export;
+
+use App\Models\File;
+use App\Models\PricingCode;
+use App\Models\SparePart;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\Color;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+class ExportSparePartsService
+{
+    public function handle(int $userId): string
+    {
+        $inputFileType = 'Xlsx';
+        $inputFileName = './templates/SpareParts.xlsx';
+
+        $reader = IOFactory::createReader($inputFileType);
+        $spreadsheet = $reader->load($inputFileName);
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // Получаем данные из модели (НЕ view!)
+        // ВАЖНО: Вычисляемые поля учитывают текущий год из сессии
+        $spareParts = SparePart::orderBy('article')->get();
+
+        $i = 2;
+        foreach ($spareParts as $sp) {
+            $sheet->setCellValue('A' . $i, $sp->id);
+            $sheet->setCellValue('B' . $i, $sp->article);
+            $sheet->setCellValue('C' . $i, $sp->used_in_maf);
+            $sheet->setCellValue('D' . $i, $sp->quantity_without_docs);
+            $sheet->setCellValue('E' . $i, $sp->quantity_with_docs);
+            $sheet->setCellValue('F' . $i, $sp->total_quantity);
+            $sheet->setCellValue('G' . $i, $sp->note);
+            $sheet->setCellValue('H' . $i, $sp->purchase_price);
+            $sheet->setCellValue('I' . $i, $sp->customer_price);
+            $sheet->setCellValue('J' . $i, $sp->expertise_price);
+            $sheet->setCellValue('K' . $i, $sp->tsn_number);
+            $sheet->setCellValue('L' . $i, $sp->pricing_code);
+            $sheet->setCellValue('M' . $i, $sp->min_stock);
+            $i++;
+        }
+
+        $sheet->getStyle('A1:M' . ($i - 1))
+            ->getBorders()
+            ->getAllBorders()
+            ->setBorderStyle(Border::BORDER_THIN)
+            ->setColor(new Color('777777'));
+
+        // Создаём вторую вкладку для справочника расшифровок
+        $pricingCodesSheet = $spreadsheet->createSheet(1);
+        $pricingCodesSheet->setTitle('Справочник расшифровок');
+
+        // Заголовки
+        $pricingCodesSheet->setCellValue('A1', 'ID');
+        $pricingCodesSheet->setCellValue('B1', 'Тип');
+        $pricingCodesSheet->setCellValue('C1', 'Код');
+        $pricingCodesSheet->setCellValue('D1', 'Расшифровка');
+
+        // Стиль заголовков
+        $pricingCodesSheet->getStyle('A1:D1')->getFont()->setBold(true);
+        $pricingCodesSheet->getStyle('A1:D1')->getFill()
+            ->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
+            ->getStartColor()->setARGB('DDDDDD');
+
+        // Данные справочника
+        $pricingCodes = PricingCode::orderBy('type')->orderBy('code')->get();
+        $j = 2;
+        foreach ($pricingCodes as $pc) {
+            $pricingCodesSheet->setCellValue('A' . $j, $pc->id);
+            $pricingCodesSheet->setCellValue('B' . $j, $pc->type === PricingCode::TYPE_TSN_NUMBER ? '№ по ТСН' : 'Шифр расценки');
+            $pricingCodesSheet->setCellValue('C' . $j, $pc->code);
+            $pricingCodesSheet->setCellValue('D' . $j, $pc->description);
+            $j++;
+        }
+
+        // Границы для справочника
+        if ($j > 2) {
+            $pricingCodesSheet->getStyle('A1:D' . ($j - 1))
+                ->getBorders()
+                ->getAllBorders()
+                ->setBorderStyle(Border::BORDER_THIN)
+                ->setColor(new Color('777777'));
+        }
+
+        // Автоширина колонок
+        foreach (range('A', 'D') as $col) {
+            $pricingCodesSheet->getColumnDimension($col)->setAutoSize(true);
+        }
+
+        // Возвращаемся на первую вкладку
+        $spreadsheet->setActiveSheetIndex(0);
+
+        $fileName = 'export_spare_parts_' . date('Y-m-d_H-i-s') . '.xlsx';
+        $writer = new Xlsx($spreadsheet);
+        $fd = 'export/spare_parts';
+        Storage::disk('public')->makeDirectory($fd);
+        $fp = storage_path('app/public/' . $fd . '/') . $fileName;
+        Storage::disk('public')->delete($fd . '/' . $fileName);
+        $writer->save($fp);
+
+        $link = url('/storage/' . $fd . '/' . $fileName);
+
+        // Создаём запись в таблице files
+        File::query()->create([
+            'link' => $link,
+            'path' => $fp,
+            'user_id' => $userId,
+            'original_name' => $fileName,
+            'mime_type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+        ]);
+
+        return $link;
+    }
+}

+ 201 - 0
app/Services/Import/ImportSparePartsService.php

@@ -0,0 +1,201 @@
+<?php
+
+namespace App\Services\Import;
+
+use App\Models\PricingCode;
+use App\Models\SparePart;
+use Exception;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+
+class ImportSparePartsService
+{
+    private array $logs = [];
+
+    public function __construct(
+        private readonly string $filePath,
+        private readonly int $userId,
+    ) {}
+
+    public function handle(): array
+    {
+        try {
+            $this->log("Начало импорта каталога запчастей и справочника расшифровок");
+
+            DB::beginTransaction();
+
+            try {
+                // Загружаем файл Excel
+                $spreadsheet = IOFactory::load($this->filePath);
+
+                // Импорт каталога запчастей (первая вкладка)
+                $this->importSpareParts($spreadsheet->getSheet(0));
+
+                // Импорт справочника расшифровок (вторая вкладка, если есть)
+                if ($spreadsheet->getSheetCount() > 1) {
+                    $this->importPricingCodes($spreadsheet->getSheet(1));
+                }
+
+                DB::commit();
+                $this->log("Импорт успешно завершён");
+
+                return [
+                    'success' => true,
+                    'logs' => $this->logs,
+                ];
+            } catch (Exception $e) {
+                DB::rollBack();
+                throw $e;
+            }
+        } catch (Exception $e) {
+            $this->log("ОШИБКА: " . $e->getMessage());
+            Log::error("Ошибка импорта запчастей: " . $e->getMessage(), [
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => $e->getMessage(),
+                'logs' => $this->logs,
+            ];
+        }
+    }
+
+    private function importSpareParts($sheet): void
+    {
+        $this->log("Импорт каталога запчастей...");
+
+        $highestRow = $sheet->getHighestRow();
+        $imported = 0;
+        $updated = 0;
+        $skipped = 0;
+
+        // Начинаем со 2-й строки (пропускаем заголовок)
+        for ($row = 2; $row <= $highestRow; $row++) {
+            $article = trim($sheet->getCell('B' . $row)->getValue());
+
+            // Пропускаем пустые строки
+            if (empty($article)) {
+                $skipped++;
+                continue;
+            }
+
+            $data = [
+                'article' => $article,
+                'used_in_maf' => $sheet->getCell('C' . $row)->getValue(),
+                'note' => $sheet->getCell('G' . $row)->getValue(),
+                'purchase_price' => $this->parsePrice($sheet->getCell('H' . $row)->getValue()),
+                'customer_price' => $this->parsePrice($sheet->getCell('I' . $row)->getValue()),
+                'expertise_price' => $this->parsePrice($sheet->getCell('J' . $row)->getValue()),
+                'tsn_number' => $sheet->getCell('K' . $row)->getValue(),
+                'pricing_code' => $sheet->getCell('L' . $row)->getValue(),
+                'min_stock' => (int)$sheet->getCell('M' . $row)->getValue() ?: 0,
+            ];
+
+            // Создаём или обновляем запчасть (включая удалённые)
+            $sparePart = SparePart::withTrashed()->where('article', $article)->first();
+
+            if ($sparePart) {
+                // Восстанавливаем, если была удалена
+                if ($sparePart->trashed()) {
+                    $sparePart->restore();
+                }
+                $sparePart->update($data);
+                $updated++;
+            } else {
+                SparePart::create($data);
+                $imported++;
+            }
+        }
+
+        $this->log("Каталог запчастей: импортировано {$imported}, обновлено {$updated}, пропущено {$skipped}");
+    }
+
+    private function importPricingCodes($sheet): void
+    {
+        $this->log("Импорт справочника расшифровок...");
+
+        $highestRow = $sheet->getHighestRow();
+        $imported = 0;
+        $updated = 0;
+        $skipped = 0;
+
+        // Начинаем со 2-й строки (пропускаем заголовок)
+        for ($row = 2; $row <= $highestRow; $row++) {
+            $typeText = trim($sheet->getCell('B' . $row)->getValue());
+            $code = trim($sheet->getCell('C' . $row)->getValue());
+            $description = trim($sheet->getCell('D' . $row)->getValue());
+
+            // Пропускаем пустые строки
+            if (empty($code)) {
+                $skipped++;
+                continue;
+            }
+
+            // Определяем тип
+            $type = null;
+            if (strpos($typeText, 'ТСН') !== false) {
+                $type = PricingCode::TYPE_TSN_NUMBER;
+            } elseif (strpos($typeText, 'Шифр') !== false || strpos($typeText, 'расценк') !== false) {
+                $type = PricingCode::TYPE_PRICING_CODE;
+            }
+
+            if (!$type) {
+                $this->log("Предупреждение: неизвестный тип '{$typeText}' для кода '{$code}' в строке {$row}");
+                $skipped++;
+                continue;
+            }
+
+            // Создаём или обновляем запись
+            $pricingCode = PricingCode::where('type', $type)->where('code', $code)->first();
+
+            if ($pricingCode) {
+                $pricingCode->update(['description' => $description]);
+                $updated++;
+            } else {
+                PricingCode::create([
+                    'type' => $type,
+                    'code' => $code,
+                    'description' => $description,
+                ]);
+                $imported++;
+            }
+        }
+
+        $this->log("Справочник расшифровок: импортировано {$imported}, обновлено {$updated}, пропущено {$skipped}");
+    }
+
+    /**
+     * Парсинг цены
+     * Если цена уже в копейках (целое число > 1000), оставляем как есть
+     * Если цена в рублях (дробное или < 1000), конвертируем в копейки
+     */
+    private function parsePrice($value): ?int
+    {
+        if (empty($value)) {
+            return null;
+        }
+
+        $price = (float)$value;
+
+        // Если число целое и большое (скорее всего уже в копейках)
+        if ($price == (int)$price && $price >= 1000) {
+            return (int)$price;
+        }
+
+        // Иначе конвертируем из рублей в копейки
+        return round($price * 100);
+    }
+
+    private function log(string $message): void
+    {
+        $this->logs[] = '[' . date('H:i:s') . '] ' . $message;
+        Log::info($message);
+    }
+
+    public function getLogs(): array
+    {
+        return $this->logs;
+    }
+}

+ 88 - 0
app/Services/SparePartInventoryService.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use Illuminate\Support\Collection;
+
+class SparePartInventoryService
+{
+    /**
+     * Автоматическое списание запчасти для рекламации
+     */
+    public function deductForReclamation(
+        string $article,
+        int $quantity,
+        bool $withDocuments,
+        int $reclamationId
+    ): bool {
+        $sparePart = SparePart::where('article', $article)->first();
+        if (!$sparePart) {
+            return false;
+        }
+
+        // Ищем заказ для списания (FIFO - первый созданный)
+        $order = SparePartOrder::where('spare_part_id', $sparePart->id)
+            ->where('status', SparePartOrder::STATUS_IN_STOCK)
+            ->where('with_documents', $withDocuments)
+            ->where('remaining_quantity', '>=', $quantity)
+            ->orderBy('created_at', 'asc')
+            ->first();
+
+        if ($order) {
+            // Списываем
+            $note = "Автоматическое списание для рекламации: #{$reclamationId}";
+            return $order->shipQuantity($quantity, $note, $reclamationId, null);
+        } else {
+            // Создаём виртуальный заказ с недостачей
+            // Статус IN_STOCK чтобы учитывалось в вычисляемых полях!
+            SparePartOrder::create([
+                'spare_part_id' => $sparePart->id,
+                'source_text' => "Автоспискание для рекламации #{$reclamationId}",
+                'status' => SparePartOrder::STATUS_IN_STOCK,
+                'ordered_quantity' => 0,
+                'remaining_quantity' => -$quantity,
+                'with_documents' => $withDocuments,
+                'note' => "Недостача, созданная автоматически",
+                'user_id' => auth()->id() ?? 1,
+            ]);
+
+            return false;
+        }
+    }
+
+    /**
+     * Получить список запчастей с критическим недостатком (отрицательное количество)
+     */
+    public function getCriticalShortages(): Collection
+    {
+        return SparePart::all()->filter(function ($sparePart) {
+            return $sparePart->hasCriticalShortage();
+        });
+    }
+
+    /**
+     * Получить список запчастей ниже минимального остатка
+     */
+    public function getBelowMinStock(): Collection
+    {
+        return SparePart::all()->filter(function ($sparePart) {
+            return $sparePart->isBelowMinStock();
+        });
+    }
+
+    /**
+     * Рассчитать сколько нужно заказать для компенсации недостачи
+     */
+    public function calculateOrderQuantity(SparePart $sparePart, bool $withDocuments): int
+    {
+        $quantity = $withDocuments ? $sparePart->quantity_with_docs : $sparePart->quantity_without_docs;
+
+        if ($quantity >= 0) {
+            return 0;
+        }
+
+        return abs($quantity);
+    }
+}

+ 30 - 0
database/migrations/2026_01_24_065458_add_missing_columns_to_imports_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('imports', function (Blueprint $table) {
+            $table->unsignedBigInteger('user_id')->nullable()->after('type');
+            $table->string('file_path')->nullable()->after('status');
+            $table->string('original_filename')->nullable()->after('file_path');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('imports', function (Blueprint $table) {
+            $table->dropColumn(['user_id', 'file_path', 'original_filename']);
+        });
+    }
+};

+ 29 - 0
database/migrations/2026_01_24_065742_make_filename_nullable_in_imports_table.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('imports', function (Blueprint $table) {
+            $table->string('filename')->nullable()->change();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('imports', function (Blueprint $table) {
+            $table->string('filename')->nullable(false)->change();
+        });
+    }
+};

+ 47 - 0
database/migrations/2026_01_24_100000_create_spare_parts_table.php

@@ -0,0 +1,47 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('spare_parts', function (Blueprint $table) {
+            $table->id();
+            // БЕЗ поля year! Каталог общий для всех лет
+            $table->string('article')->unique();
+            $table->string('used_in_maf')->nullable();
+            $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete();
+            $table->text('note')->nullable();
+
+            // Цены в копейках (как в Product)
+            $table->unsignedBigInteger('purchase_price')->nullable();
+            $table->unsignedBigInteger('customer_price')->nullable();
+            $table->unsignedBigInteger('expertise_price')->nullable();
+
+            $table->string('tsn_number')->nullable();
+            $table->text('pricing_code')->nullable();
+            $table->integer('min_stock')->default(0);
+
+            $table->softDeletes();
+            $table->timestamps();
+
+            // Индексы без year
+            $table->index('article');
+            $table->index('used_in_maf');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('spare_parts');
+    }
+};

+ 47 - 0
database/migrations/2026_01_24_100001_create_spare_part_orders_table.php

@@ -0,0 +1,47 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('spare_part_orders', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedInteger('year');
+            $table->foreignId('spare_part_id')->constrained('spare_parts')->restrictOnDelete();
+
+            // Источник заказа
+            $table->string('source_text')->nullable();
+            $table->nullableMorphs('sourceable'); // polymorphic для orders/reclamations
+
+            $table->enum('status', ['ordered', 'in_stock', 'shipped'])->default('ordered');
+            $table->unsignedInteger('ordered_quantity');
+            $table->unsignedInteger('remaining_quantity');
+            $table->boolean('with_documents')->default(false);
+            $table->text('note')->nullable();
+
+            $table->foreignId('user_id')->constrained('users');
+
+            $table->softDeletes();
+            $table->timestamps();
+
+            $table->index(['year', 'spare_part_id', 'status']);
+            $table->index(['spare_part_id', 'status', 'remaining_quantity']);
+            $table->index(['with_documents', 'status']);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('spare_part_orders');
+    }
+};

+ 36 - 0
database/migrations/2026_01_24_100002_create_spare_part_order_shipments_table.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('spare_part_order_shipments', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('spare_part_order_id')->constrained('spare_part_orders')->cascadeOnDelete();
+            $table->unsignedInteger('quantity');
+            $table->text('note')->nullable();
+            $table->foreignId('reclamation_id')->nullable()->constrained('reclamations')->nullOnDelete();
+            $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
+            $table->boolean('is_automatic')->default(false);
+            $table->timestamps();
+
+            $table->index('spare_part_order_id');
+            $table->index('reclamation_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('spare_part_order_shipments');
+    }
+};

+ 31 - 0
database/migrations/2026_01_24_100003_create_pricing_codes_table.php

@@ -0,0 +1,31 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('pricing_codes', function (Blueprint $table) {
+            $table->id();
+            $table->string('code')->unique();
+            $table->text('description')->nullable();
+            $table->timestamps();
+
+            $table->index('code');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('pricing_codes');
+    }
+};

+ 33 - 0
database/migrations/2026_01_24_100004_create_reclamation_spare_part_table.php

@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('reclamation_spare_part', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('reclamation_id')->constrained('reclamations')->cascadeOnDelete();
+            $table->foreignId('spare_part_id')->constrained('spare_parts')->cascadeOnDelete();
+            $table->unsignedInteger('quantity');
+            $table->boolean('with_documents')->default(false);
+            $table->timestamps();
+
+            $table->index(['reclamation_id', 'spare_part_id']);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('reclamation_spare_part');
+    }
+};

+ 33 - 0
database/migrations/2026_01_24_100006_create_spare_part_orders_view_table.php

@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        DB::statement("
+            CREATE OR REPLACE VIEW spare_part_orders_view AS
+            SELECT
+                spo.*,
+                sp.article,
+                u.name as user_name
+            FROM spare_part_orders spo
+            LEFT JOIN spare_parts sp ON sp.id = spo.spare_part_id
+            LEFT JOIN users u ON u.id = spo.user_id
+            WHERE spo.deleted_at IS NULL
+        ");
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        DB::statement("DROP VIEW IF EXISTS spare_part_orders_view");
+    }
+};

+ 29 - 0
database/migrations/2026_01_24_100007_alter_spare_part_orders_remaining_quantity.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('spare_part_orders', function (Blueprint $table) {
+            // Меняем тип с unsignedInteger на integer для поддержки отрицательных значений
+            $table->integer('remaining_quantity')->change();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('spare_part_orders', function (Blueprint $table) {
+            $table->unsignedInteger('remaining_quantity')->change();
+        });
+    }
+};

+ 33 - 0
database/migrations/2026_01_24_120000_update_pricing_codes_table_add_type_field.php

@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('pricing_codes', function (Blueprint $table) {
+            // Добавляем поле type для различения tsn_number и pricing_code
+            $table->enum('type', ['tsn_number', 'pricing_code'])->after('id');
+
+            // Создаём уникальный индекс по комбинации type + code
+            $table->unique(['type', 'code']);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('pricing_codes', function (Blueprint $table) {
+            $table->dropUnique(['type', 'code']);
+            $table->dropColumn('type');
+        });
+    }
+};

+ 4 - 0
resources/views/layouts/menu.blade.php

@@ -13,6 +13,10 @@
     <li class="nav-item"><a class="nav-link @if($active == 'reclamations') active @endif"
                             href="{{ route('reclamations.index', session('gp_reclamations')) }}">Рекламации</a></li>
 
+    @if(hasRole('admin,manager'))
+        <li class="nav-item"><a class="nav-link @if($active == 'spare_parts') active @endif"
+                                href="{{ route('spare_parts.index') }}">Запчасти</a></li>
+    @endif
 
     <li class="nav-item"><a class="nav-link @if($active == 'schedule') active @endif"
                             href="{{ route('schedule.index', session('gp_schedule')) }}">График монтажей</a></li>

+ 10 - 1
resources/views/partials/newFilterElement.blade.php

@@ -29,7 +29,16 @@
                 .replace(/>/g, "&gt;");
         }
 
-        $(document).ready(async function () {
+        // Ждём загрузки jQuery через Vite
+        function waitForJQuery(callback) {
+            if (typeof window.$ !== 'undefined') {
+                callback();
+            } else {
+                setTimeout(() => waitForJQuery(callback), 50);
+            }
+        }
+
+        waitForJQuery(async function () {
             const $container = $("#filter-list-{{$id}}");
             const $searchInput = $("#search_{{$id}}");
             const urlParams = new URL(document.location.href);

+ 29 - 0
resources/views/partials/table.blade.php

@@ -134,6 +134,20 @@
                                     <option value="{{ $statusId }}" @selected($statusName == $string->$headerName)>{{ $statusName }}</option>
                                 @endforeach
                             </select>
+                        @elseif($headerName === 'tsn_number' && $string->$headerName)
+                            <span data-bs-toggle="tooltip"
+                                  data-bs-placement="top"
+                                  title="{{ $string->tsn_number_description ?? 'Нет расшифровки' }}"
+                                  style="cursor: help; text-decoration: underline dotted;">
+                                {{ $string->$headerName }}
+                            </span>
+                        @elseif($headerName === 'pricing_code' && $string->$headerName)
+                            <span data-bs-toggle="tooltip"
+                                  data-bs-placement="top"
+                                  title="{{ $string->pricing_code_description ?? 'Нет расшифровки' }}"
+                                  style="cursor: help; text-decoration: underline dotted;">
+                                {{ $string->$headerName }}
+                            </span>
                         @else
                             <p title="{!! $string->$headerName !!}">
                                 {!! \Illuminate\Support\Str::words($string->$headerName, config('app.words_in_table_cell_limit'), ' ...') !!}
@@ -291,6 +305,16 @@
 
 @push('scripts')
     <script type="module">
+        // Ждём загрузки jQuery через Vite
+        function waitForJQuery(callback) {
+            if (typeof window.$ !== 'undefined') {
+                callback();
+            } else {
+                setTimeout(() => waitForJQuery(callback), 50);
+            }
+        }
+
+        waitForJQuery(function () {
         // on page load set column visible
         let tbl = $('#tbl');
         let tableName = tbl.attr('data-table-name');
@@ -423,6 +447,11 @@
             let height2 = $('.catalog').innerHeight();
             let totalHeight = (Number(height1) || 0) + (Number(height2) || 0);
             $(".table-responsive").css('maxHeight', $(window).height() - totalHeight - 50);
+
+            // Инициализация tooltips для полей tsn_number и pricing_code
+            const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
+            const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
         });
+        }); // end waitForJQuery
     </script>
 @endpush

+ 184 - 0
resources/views/pricing_codes/index.blade.php

@@ -0,0 +1,184 @@
+@extends('layouts.app')
+
+@section('content')
+<div class="container-fluid">
+    <div class="row">
+        <div class="col-12">
+            <h2>{{ $title }}</h2>
+
+            {{-- Навигация --}}
+            <ul class="nav nav-tabs mb-3">
+                <li class="nav-item">
+                    <a class="nav-link" href="{{ route('spare_parts.index') }}">
+                        Каталог
+                    </a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link" href="{{ route('spare_part_orders.index') }}">
+                        Заказы деталей
+                    </a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link" href="{{ route('spare_part_inventory.index') }}">
+                        Контроль наличия
+                    </a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link active" href="{{ route('pricing_codes.index') }}">
+                        Справочник расшифровок
+                    </a>
+                </li>
+            </ul>
+
+            {{-- Кнопки управления --}}
+            <div class="mb-3">
+                <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addCodeModal">
+                    Добавить код
+                </button>
+            </div>
+
+            {{-- Поиск --}}
+            <form method="GET" action="{{ route('pricing_codes.index') }}" class="mb-3">
+                <div class="input-group">
+                    <input type="text" name="search" class="form-control" placeholder="Поиск по коду или расшифровке..." value="{{ $search ?? '' }}">
+                    <button type="submit" class="btn btn-outline-secondary">Найти</button>
+                    @if($search ?? false)
+                        <a href="{{ route('pricing_codes.index') }}" class="btn btn-outline-danger">Сбросить</a>
+                    @endif
+                </div>
+            </form>
+
+            {{-- Таблица --}}
+            @if(isset($pricing_codes))
+                <div class="table-responsive">
+                    <table class="table table-striped table-hover">
+                        <thead>
+                            <tr>
+                                <th>ID</th>
+                                <th>Тип</th>
+                                <th>Код</th>
+                                <th>Расшифровка</th>
+                                <th>Действия</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            @forelse($pricing_codes as $code)
+                                <tr>
+                                    <td>{{ $code->id }}</td>
+                                    <td>
+                                        @if($code->type === 'tsn_number')
+                                            <span class="badge bg-info">№ по ТСН</span>
+                                        @else
+                                            <span class="badge bg-primary">Шифр расценки</span>
+                                        @endif
+                                    </td>
+                                    <td><strong>{{ $code->code }}</strong></td>
+                                    <td>
+                                        <div class="d-flex justify-content-between align-items-center">
+                                            <span class="description-text-{{ $code->id }}">{{ $code->description }}</span>
+                                            <button type="button" class="btn btn-sm btn-link edit-description"
+                                                    data-id="{{ $code->id }}"
+                                                    data-description="{{ $code->description }}">
+                                                <i class="bi bi-pencil"></i>
+                                            </button>
+                                        </div>
+                                        <form action="{{ route('pricing_codes.update', $code) }}" method="POST" class="edit-form-{{ $code->id }}" style="display: none;">
+                                            @csrf
+                                            @method('PUT')
+                                            <div class="input-group input-group-sm">
+                                                <input type="text" name="description" class="form-control" value="{{ $code->description }}">
+                                                <button type="submit" class="btn btn-success">Сохранить</button>
+                                                <button type="button" class="btn btn-secondary cancel-edit" data-id="{{ $code->id }}">Отмена</button>
+                                            </div>
+                                        </form>
+                                    </td>
+                                    <td>
+                                        <form action="{{ route('pricing_codes.destroy', $code) }}" method="POST" class="d-inline"
+                                              onsubmit="return confirm('Удалить код {{ $code->code }}?')">
+                                            @csrf
+                                            @method('DELETE')
+                                            <button type="submit" class="btn btn-sm btn-danger">Удалить</button>
+                                        </form>
+                                    </td>
+                                </tr>
+                            @empty
+                                <tr>
+                                    <td colspan="5" class="text-center text-muted">Нет записей</td>
+                                </tr>
+                            @endforelse
+                        </tbody>
+                    </table>
+                </div>
+
+                {{ $pricing_codes->links() }}
+            @endif
+        </div>
+    </div>
+</div>
+
+{{-- Модальное окно добавления кода --}}
+<div class="modal fade" id="addCodeModal" tabindex="-1">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">Добавить код расценки</h5>
+                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+            </div>
+            <form action="{{ route('pricing_codes.store') }}" method="POST">
+                @csrf
+                <div class="modal-body">
+                    <div class="mb-3">
+                        <label for="type" class="form-label">Тип</label>
+                        <select class="form-select" id="type" name="type" required>
+                            <option value="tsn_number">№ по ТСН</option>
+                            <option value="pricing_code">Шифр расценки</option>
+                        </select>
+                    </div>
+                    <div class="mb-3">
+                        <label for="code" class="form-label">Код</label>
+                        <input type="text" class="form-control" id="code" name="code" required>
+                    </div>
+                    <div class="mb-3">
+                        <label for="description" class="form-label">Расшифровка</label>
+                        <input type="text" class="form-control" id="description" name="description">
+                    </div>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
+                    <button type="submit" class="btn btn-primary">Добавить</button>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+
+@push('scripts')
+<script type="module">
+    function waitForJQuery(callback) {
+        if (typeof window.$ !== 'undefined') {
+            callback();
+        } else {
+            setTimeout(() => waitForJQuery(callback), 50);
+        }
+    }
+
+    waitForJQuery(function() {
+        // Редактирование описания
+        $('.edit-description').on('click', function() {
+            const id = $(this).data('id');
+            $('.description-text-' + id).hide();
+            $(this).hide();
+            $('.edit-form-' + id).show();
+        });
+
+        // Отмена редактирования
+        $('.cancel-edit').on('click', function() {
+            const id = $(this).data('id');
+            $('.edit-form-' + id).hide();
+            $('.description-text-' + id).show();
+            $('.edit-description[data-id="' + id + '"]').show();
+        });
+    });
+</script>
+@endpush
+@endsection

+ 212 - 0
resources/views/spare_part_orders/edit.blade.php

@@ -0,0 +1,212 @@
+@extends('layouts.app')
+
+@section('content')
+<div class="container-fluid">
+    <div class="row">
+        <div class="col-md-6">
+            <h2>{{ $spare_part_order ? 'Заказ детали #' . $spare_part_order->id : 'Новый заказ детали' }}</h2>
+
+            <form action="{{ $spare_part_order ? route('spare_part_orders.update', $spare_part_order) : route('spare_part_orders.store') }}"
+                  method="POST">
+                @csrf
+                @if($spare_part_order)
+                    @method('PUT')
+                @endif
+
+                <input type="hidden" name="previous_url" value="{{ $previous_url ?? url()->previous() }}">
+
+                @if($spare_part_order && $spare_part_order->sparePart && $spare_part_order->sparePart->image)
+                    <div class="mb-3">
+                        <img src="{{ $spare_part_order->sparePart->image }}" alt="{{ $spare_part_order->sparePart->article }}" class="img-fluid" style="max-width: 150px;">
+                    </div>
+                @endif
+
+                <div class="mb-3">
+                    <label for="spare_part_id" class="form-label">Артикул <span class="text-danger">*</span></label>
+                    <select class="form-select" id="spare_part_id" name="spare_part_id" required {{ $spare_part_order ? 'disabled' : '' }}>
+                        <option value="">Выберите...</option>
+                        @foreach($spare_parts as $sp)
+                            <option value="{{ $sp->id }}"
+                                    {{ old('spare_part_id', $spare_part_order->spare_part_id ?? '') == $sp->id ? 'selected' : '' }}>
+                                {{ $sp->article }} - {{ $sp->used_in_maf }}
+                            </option>
+                        @endforeach
+                    </select>
+                    @if($spare_part_order)
+                        <input type="hidden" name="spare_part_id" value="{{ $spare_part_order->spare_part_id }}">
+                    @endif
+                </div>
+
+                <div class="mb-3">
+                    <label for="source_text" class="form-label">Источник заказа</label>
+                    <input type="text" class="form-control" id="source_text" name="source_text"
+                           value="{{ old('source_text', $spare_part_order->source_text ?? '') }}">
+                </div>
+
+                <div class="mb-3">
+                    <label for="status" class="form-label">Статус <span class="text-danger">*</span></label>
+                    <select class="form-select" id="status" name="status" required>
+                        @foreach(\App\Models\SparePartOrder::STATUS_NAMES as $key => $name)
+                            <option value="{{ $key }}"
+                                    {{ old('status', $spare_part_order->status ?? '') == $key ? 'selected' : '' }}>
+                                {{ $name }}
+                            </option>
+                        @endforeach
+                    </select>
+                </div>
+
+                <div class="mb-3">
+                    <label for="ordered_quantity" class="form-label">Заказано <span class="text-danger">*</span></label>
+                    <input type="number" class="form-control" id="ordered_quantity" name="ordered_quantity"
+                           value="{{ old('ordered_quantity', $spare_part_order->ordered_quantity ?? '') }}"
+                           min="1" required>
+                </div>
+
+                @if($spare_part_order)
+                    <div class="mb-3">
+                        <label class="form-label">Остаток</label>
+                        <input type="text" class="form-control" value="{{ $spare_part_order->remaining_quantity }}" readonly>
+                    </div>
+                @endif
+
+                <div class="mb-3">
+                    <div class="form-check">
+                        <input class="form-check-input" type="checkbox" id="with_documents" name="with_documents" value="1"
+                               {{ old('with_documents', $spare_part_order->with_documents ?? false) ? 'checked' : '' }}>
+                        <label class="form-check-label" for="with_documents">
+                            С документами
+                        </label>
+                    </div>
+                </div>
+
+                <div class="mb-3">
+                    <label for="note" class="form-label">Примечание</label>
+                    <textarea class="form-control" id="note" name="note" rows="3">{{ old('note', $spare_part_order->note ?? '') }}</textarea>
+                </div>
+
+                <div class="mb-3">
+                    <button type="submit" class="btn btn-success">Сохранить</button>
+                    <a href="{{ $previous_url ?? route('spare_part_orders.index') }}" class="btn btn-secondary">Назад</a>
+
+                    @if($spare_part_order && $spare_part_order->status === 'ordered' && hasRole('admin,manager'))
+                        <form action="{{ route('spare_part_orders.set_in_stock', $spare_part_order) }}" method="POST" class="d-inline">
+                            @csrf
+                            <button type="submit" class="btn btn-info">Поступило на склад</button>
+                        </form>
+                    @endif
+
+                    @if($spare_part_order && hasRole('admin'))
+                        <button type="button" class="btn btn-danger float-end" data-bs-toggle="modal" data-bs-target="#deleteModal">
+                            Удалить
+                        </button>
+                    @endif
+                </div>
+            </form>
+        </div>
+
+        @if($spare_part_order)
+            <div class="col-md-6">
+                <h3>История списаний</h3>
+
+                @if($spare_part_order->status === 'in_stock' && $spare_part_order->remaining_quantity > 0 && hasRole('admin,manager'))
+                    <button type="button" class="btn btn-warning mb-3" data-bs-toggle="modal" data-bs-target="#shipModal">
+                        Отгрузить
+                    </button>
+                @endif
+
+                @if($spare_part_order->shipments->count() > 0)
+                    <table class="table table-striped">
+                        <thead>
+                            <tr>
+                                <th>Дата</th>
+                                <th>Количество</th>
+                                <th>Примечание</th>
+                                <th>Пользователь</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            @foreach($spare_part_order->shipments as $shipment)
+                                <tr class="{{ $shipment->is_automatic ? 'table-warning' : '' }}">
+                                    <td>{{ $shipment->created_at->format('d.m.Y H:i') }}</td>
+                                    <td>{{ $shipment->quantity }}</td>
+                                    <td>
+                                        {{ $shipment->note }}
+                                        @if($shipment->reclamation)
+                                            <br><small><a href="{{ route('reclamations.show', $shipment->reclamation->id) }}">Рекламация #{{ $shipment->reclamation->id }}</a></small>
+                                        @endif
+                                    </td>
+                                    <td>{{ $shipment->user->name ?? '-' }}</td>
+                                </tr>
+                            @endforeach
+                        </tbody>
+                    </table>
+                @else
+                    <p class="text-muted">Списаний пока нет</p>
+                @endif
+            </div>
+        @endif
+    </div>
+</div>
+
+@if($spare_part_order && hasRole('admin,manager'))
+    {{-- Модальное окно отгрузки --}}
+    <div class="modal fade" id="shipModal" tabindex="-1">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <form action="{{ route('spare_part_orders.ship', $spare_part_order) }}" method="POST">
+                    @csrf
+                    <div class="modal-header">
+                        <h5 class="modal-title">Отгрузка</h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+                    </div>
+                    <div class="modal-body">
+                        <div class="alert alert-info">
+                            Доступно: {{ $spare_part_order->remaining_quantity }} шт.
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="quantity" class="form-label">Количество <span class="text-danger">*</span></label>
+                            <input type="number" class="form-control" id="quantity" name="quantity"
+                                   min="1" max="{{ $spare_part_order->remaining_quantity }}" required>
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="ship_note" class="form-label">Примечание (куда/для чего) <span class="text-danger">*</span></label>
+                            <textarea class="form-control" id="ship_note" name="note" rows="3" required></textarea>
+                        </div>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="submit" class="btn btn-primary">Отгрузить</button>
+                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+@endif
+
+@if($spare_part_order && hasRole('admin'))
+    {{-- Модальное окно удаления --}}
+    <div class="modal fade" id="deleteModal" tabindex="-1">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title">Подтверждение удаления</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+                </div>
+                <div class="modal-body">
+                    Вы действительно хотите удалить заказ #{{ $spare_part_order->id }}?
+                </div>
+                <div class="modal-footer">
+                    <form action="{{ route('spare_part_orders.destroy', $spare_part_order) }}" method="POST">
+                        @csrf
+                        @method('DELETE')
+                        <button type="submit" class="btn btn-danger">Удалить</button>
+                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+@endif
+@endsection

+ 265 - 0
resources/views/spare_parts/edit.blade.php

@@ -0,0 +1,265 @@
+@extends('layouts.app')
+
+@section('content')
+<div class="container-fluid">
+    <div class="row">
+        <div class="col-12">
+            <h2>{{ $spare_part ? 'Редактирование запчасти' : 'Создание запчасти' }}</h2>
+
+            @if($spare_part && hasRole('admin'))
+                <div class="mb-3">
+                    <label class="form-label">Загрузить изображение</label>
+                    <form action="{{ route('spare_parts.upload_image', $spare_part) }}"
+                          method="POST"
+                          enctype="multipart/form-data"
+                          id="imageUploadForm">
+                        @csrf
+                        <input type="file" name="image" class="form-control" accept="image/*">
+                        <button type="submit" class="btn btn-sm btn-primary mt-2">Загрузить</button>
+                    </form>
+                </div>
+            @endif
+
+            <form action="{{ $spare_part ? route('spare_parts.update', $spare_part) : route('spare_parts.store') }}"
+                  method="POST">
+                @csrf
+                @if($spare_part)
+                    @method('PUT')
+                @endif
+
+                <input type="hidden" name="previous_url" value="{{ $previous_url ?? url()->previous() }}">
+
+                <div class="row">
+                    {{-- Левая колонка --}}
+                    <div class="col-md-6">
+                        @if($spare_part && $spare_part->image)
+                            <div class="mb-3">
+                                <img src="{{ $spare_part->image }}" alt="{{ $spare_part->article }}" class="img-fluid" style="max-width: 200px;">
+                            </div>
+                        @endif
+
+                        <div class="mb-3">
+                            <label for="article" class="form-label">Артикул <span class="text-danger">*</span></label>
+                            <input type="text"
+                                   class="form-control"
+                                   id="article"
+                                   name="article"
+                                   value="{{ old('article', $spare_part->article ?? '') }}"
+                                   {{ hasRole('admin') ? '' : 'readonly' }}
+                                   required>
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="used_in_maf" class="form-label">Где используется</label>
+                            <input type="text"
+                                   class="form-control"
+                                   id="used_in_maf"
+                                   name="used_in_maf"
+                                   value="{{ old('used_in_maf', $spare_part->used_in_maf ?? '') }}"
+                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="note" class="form-label">Примечание</label>
+                            <textarea class="form-control"
+                                      id="note"
+                                      name="note"
+                                      rows="3"
+                                      {{ hasRole('admin') ? '' : 'readonly' }}>{{ old('note', $spare_part->note ?? '') }}</textarea>
+                        </div>
+                    </div>
+
+                    {{-- Правая колонка --}}
+                    <div class="col-md-6">
+                        @if(hasRole('admin'))
+                            <div class="mb-3">
+                                <label for="purchase_price" class="form-label">Цена закупки (руб.)</label>
+                                <input type="number"
+                                       class="form-control"
+                                       id="purchase_price"
+                                       name="purchase_price"
+                                       step="0.01"
+                                       value="{{ old('purchase_price', $spare_part->purchase_price ?? '') }}">
+                            </div>
+                        @endif
+
+                        <div class="mb-3">
+                            <label for="customer_price" class="form-label">Цена для заказчика (руб.)</label>
+                            <input type="number"
+                                   class="form-control"
+                                   id="customer_price"
+                                   name="customer_price"
+                                   step="0.01"
+                                   value="{{ old('customer_price', $spare_part->customer_price ?? '') }}"
+                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="expertise_price" class="form-label">Цена экспертизы (руб.)</label>
+                            <input type="number"
+                                   class="form-control"
+                                   id="expertise_price"
+                                   name="expertise_price"
+                                   step="0.01"
+                                   value="{{ old('expertise_price', $spare_part->expertise_price ?? '') }}"
+                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="tsn_number" class="form-label">№ по ТСН</label>
+                            <input type="text"
+                                   class="form-control"
+                                   id="tsn_number"
+                                   name="tsn_number"
+                                   value="{{ old('tsn_number', $spare_part->tsn_number ?? '') }}"
+                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                            <div class="form-text" id="tsn_number_hint"></div>
+                        </div>
+
+                        <div class="mb-3" id="tsn_description_block" style="display: none;">
+                            <label for="tsn_number_description" class="form-label">Расшифровка № по ТСН</label>
+                            <input type="text"
+                                   class="form-control"
+                                   id="tsn_number_description"
+                                   name="tsn_number_description"
+                                   placeholder="Введите расшифровку для нового номера"
+                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                            <div class="form-text text-muted">Расшифровка будет сохранена в справочник при сохранении запчасти</div>
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="pricing_code" class="form-label">Шифр расценки</label>
+                            <input type="text"
+                                   class="form-control"
+                                   id="pricing_code"
+                                   name="pricing_code"
+                                   value="{{ old('pricing_code', $spare_part->pricing_code ?? '') }}"
+                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                            <div class="form-text" id="pricing_code_hint"></div>
+                        </div>
+
+                        <div class="mb-3" id="pricing_code_description_block" style="display: none;">
+                            <label for="pricing_code_description" class="form-label">Расшифровка шифра расценки</label>
+                            <input type="text"
+                                   class="form-control"
+                                   id="pricing_code_description"
+                                   name="pricing_code_description"
+                                   placeholder="Введите расшифровку для нового шифра"
+                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                            <div class="form-text text-muted">Расшифровка будет сохранена в справочник при сохранении запчасти</div>
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="min_stock" class="form-label">Минимальный остаток</label>
+                            <input type="number"
+                                   class="form-control"
+                                   id="min_stock"
+                                   name="min_stock"
+                                   value="{{ old('min_stock', $spare_part->min_stock ?? 0) }}"
+                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="row">
+                    <div class="col-12">
+                        @if(hasRole('admin'))
+                            <button type="submit" class="btn btn-success">Сохранить</button>
+                        @endif
+                        <a href="{{ $previous_url ?? route('spare_parts.index') }}" class="btn btn-secondary">Назад</a>
+
+                        @if($spare_part && hasRole('admin'))
+                            <button type="button" class="btn btn-danger float-end" data-bs-toggle="modal" data-bs-target="#deleteModal">
+                                Удалить
+                            </button>
+                        @endif
+                    </div>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+
+@if($spare_part && hasRole('admin'))
+    {{-- Модальное окно удаления --}}
+    <div class="modal fade" id="deleteModal" tabindex="-1">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title">Подтверждение удаления</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+                </div>
+                <div class="modal-body">
+                    Вы действительно хотите удалить запчасть "{{ $spare_part->article }}"?
+                </div>
+                <div class="modal-footer">
+                    <form action="{{ route('spare_parts.destroy', $spare_part) }}" method="POST">
+                        @csrf
+                        @method('DELETE')
+                        <button type="submit" class="btn btn-danger">Удалить</button>
+                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+@endif
+
+@push('scripts')
+<script>
+$(document).ready(function() {
+    @if(hasRole('admin'))
+        // Автозаполнение для № по ТСН
+        $('#tsn_number').on('input', function() {
+            const code = $(this).val().trim();
+            if (code.length > 0) {
+                $.get('{{ route('pricing_codes.get_description') }}', {
+                    type: 'tsn_number',
+                    code: code
+                }, function(data) {
+                    if (data.description) {
+                        $('#tsn_number_hint').html('<i class="bi bi-info-circle text-success"></i> ' + data.description).removeClass('text-danger').addClass('text-success');
+                        $('#tsn_description_block').hide();
+                        $('#tsn_number_description').val('');
+                    } else {
+                        $('#tsn_number_hint').html('<i class="bi bi-exclamation-triangle text-warning"></i> Расшифровка не найдена').removeClass('text-success').addClass('text-warning');
+                        $('#tsn_description_block').show();
+                    }
+                });
+            } else {
+                $('#tsn_number_hint').text('');
+                $('#tsn_description_block').hide();
+            }
+        });
+
+        // Автозаполнение для шифра расценки
+        $('#pricing_code').on('input', function() {
+            const code = $(this).val().trim();
+            if (code.length > 0) {
+                $.get('{{ route('pricing_codes.get_description') }}', {
+                    type: 'pricing_code',
+                    code: code
+                }, function(data) {
+                    if (data.description) {
+                        $('#pricing_code_hint').html('<i class="bi bi-info-circle text-success"></i> ' + data.description).removeClass('text-danger').addClass('text-success');
+                        $('#pricing_code_description_block').hide();
+                        $('#pricing_code_description').val('');
+                    } else {
+                        $('#pricing_code_hint').html('<i class="bi bi-exclamation-triangle text-warning"></i> Расшифровка не найдена').removeClass('text-success').addClass('text-warning');
+                        $('#pricing_code_description_block').show();
+                    }
+                });
+            } else {
+                $('#pricing_code_hint').text('');
+                $('#pricing_code_description_block').hide();
+            }
+        });
+
+        // Триггер при загрузке страницы
+        $('#tsn_number').trigger('input');
+        $('#pricing_code').trigger('input');
+    @endif
+});
+</script>
+@endpush
+@endsection

+ 231 - 0
resources/views/spare_parts/index.blade.php

@@ -0,0 +1,231 @@
+@extends('layouts.app')
+
+@section('content')
+<div class="container-fluid">
+    <div class="row">
+        <div class="col-12">
+            <h2>{{ $title }}</h2>
+
+            {{-- Вкладки --}}
+            <ul class="nav nav-tabs mb-3">
+                <li class="nav-item">
+                    <a class="nav-link {{ ($tab ?? 'catalog') === 'catalog' ? 'active' : '' }}"
+                       href="{{ route('spare_parts.index') }}">
+                        Каталог
+                    </a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link {{ ($tab ?? '') === 'orders' ? 'active' : '' }}"
+                       href="{{ route('spare_part_orders.index') }}">
+                        Заказы деталей
+                    </a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link {{ ($tab ?? '') === 'inventory' ? 'active' : '' }}"
+                       href="{{ route('spare_part_inventory.index') }}">
+                        Контроль наличия
+                    </a>
+                </li>
+                @if(hasRole('admin'))
+                <li class="nav-item">
+                    <a class="nav-link" href="{{ route('pricing_codes.index') }}">
+                        Справочник расшифровок
+                    </a>
+                </li>
+                @endif
+            </ul>
+
+            @if(($tab ?? 'catalog') === 'catalog')
+                {{-- Кнопки управления --}}
+                <div class="mb-3">
+                    @if(hasRole('admin'))
+                        <a href="{{ route('spare_parts.create') }}" class="btn btn-primary">Добавить запчасть</a>
+                        <form action="{{ route('spare_parts.export') }}" method="POST" class="d-inline">
+                            @csrf
+                            <button type="submit" class="btn btn-success">Экспорт</button>
+                        </form>
+                        <button type="button" class="btn btn-info" data-bs-toggle="modal" data-bs-target="#importModal">
+                            Импорт
+                        </button>
+                    @endif
+                </div>
+
+                {{-- Таблица каталога --}}
+                @if(isset($spare_parts) && isset($strings))
+                    @include('partials.table', [
+                        'id' => $id,
+                        'header' => $header,
+                        'strings' => $strings,
+                        'filters' => $filters ?? [],
+                        'ranges' => $ranges ?? [],
+                        'dates' => $dates ?? [],
+                        'searchFields' => $searchFields ?? [],
+                        'sortBy' => $sortBy ?? 'article',
+                        'orderBy' => $orderBy ?? 'asc',
+                        'routeName' => $routeName ?? null,
+                    ])
+
+                    {{ $spare_parts->links() }}
+                @endif
+
+            @elseif($tab === 'orders')
+                {{-- Таблица заказов --}}
+                @if(isset($spare_part_orders))
+                    <div class="mb-3">
+                        @if(hasRole('admin,manager'))
+                            <a href="{{ route('spare_part_orders.create') }}" class="btn btn-primary">Создать заказ</a>
+                        @endif
+                    </div>
+
+                    @include('partials.table', [
+                        'id' => $id,
+                        'header' => $header,
+                        'strings' => $strings,
+                        'filters' => $filters ?? [],
+                        'ranges' => $ranges ?? [],
+                        'dates' => $dates ?? [],
+                        'searchFields' => $searchFields ?? [],
+                        'sortBy' => $sortBy ?? 'id',
+                        'orderBy' => $orderBy ?? 'desc',
+                        'routeName' => $routeName ?? null,
+                    ])
+
+                    {{ $spare_part_orders->links() }}
+                @endif
+
+            @elseif($tab === 'inventory')
+                {{-- Контроль наличия --}}
+                <h4 class="text-danger">Критический недостаток</h4>
+                @if(isset($critical_shortages) && $critical_shortages->count() > 0)
+                    <div class="table-responsive">
+                        <table class="table table-danger table-striped">
+                            <thead>
+                                <tr>
+                                    <th>Картинка</th>
+                                    <th>Артикул</th>
+                                    <th>Кол-во без док</th>
+                                    <th>Кол-во с док</th>
+                                    <th>Примечание</th>
+                                </tr>
+                            </thead>
+                            <tbody>
+                                @foreach($critical_shortages as $sp)
+                                    <tr>
+                                        <td>
+                                            @if($sp->image)
+                                                <img src="{{ $sp->image }}" alt="{{ $sp->article }}" style="max-width: 50px;">
+                                            @endif
+                                        </td>
+                                        <td><a href="{{ route('spare_parts.show', $sp->id) }}">{{ $sp->article }}</a></td>
+                                        <td>{{ $sp->quantity_without_docs }}</td>
+                                        <td>{{ $sp->quantity_with_docs }}</td>
+                                        <td>Отсутствие детали</td>
+                                    </tr>
+                                @endforeach
+                            </tbody>
+                        </table>
+                    </div>
+                @else
+                    <p class="text-muted">Нет запчастей с критическим недостатком</p>
+                @endif
+
+                <h4 class="text-warning mt-4">Ниже минимального остатка</h4>
+                @if(isset($below_min_stock) && $below_min_stock->count() > 0)
+                    <div class="table-responsive">
+                        <table class="table table-warning table-striped">
+                            <thead>
+                                <tr>
+                                    <th>Картинка</th>
+                                    <th>Артикул</th>
+                                    <th>Текущий остаток</th>
+                                    <th>Минимальный остаток</th>
+                                    <th>Примечание</th>
+                                </tr>
+                            </thead>
+                            <tbody>
+                                @foreach($below_min_stock as $sp)
+                                    <tr>
+                                        <td>
+                                            @if($sp->image)
+                                                <img src="{{ $sp->image }}" alt="{{ $sp->article }}" style="max-width: 50px;">
+                                            @endif
+                                        </td>
+                                        <td><a href="{{ route('spare_parts.show', $sp->id) }}">{{ $sp->article }}</a></td>
+                                        <td>{{ $sp->total_quantity }}</td>
+                                        <td>{{ $sp->min_stock }}</td>
+                                        <td>Достигнут лимит минимального остатка ({{ $sp->min_stock }} шт)</td>
+                                    </tr>
+                                @endforeach
+                            </tbody>
+                        </table>
+                    </div>
+                @else
+                    <p class="text-muted">Нет запчастей ниже минимального остатка</p>
+                @endif
+            @endif
+        </div>
+    </div>
+</div>
+
+{{-- Модальное окно импорта --}}
+@if(hasRole('admin'))
+<div class="modal fade" id="importModal" tabindex="-1">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">Импорт каталога запчастей и справочника расшифровок</h5>
+                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+            </div>
+            <form action="{{ route('spare_parts.import') }}" method="POST" enctype="multipart/form-data">
+                @csrf
+                <div class="modal-body">
+                    <div class="mb-3">
+                        <label for="import_file" class="form-label">Выберите файл Excel</label>
+                        <input type="file" class="form-control" id="import_file" name="file" accept=".xlsx,.xls" required>
+                        <div class="form-text">
+                            Файл должен содержать две вкладки:<br>
+                            1. Каталог запчастей (колонки: ID, Артикул, Где используется, Кол-во без док, Кол-во с док, Кол-во общее, Примечание, Цена закупки, Цена для заказчика, Цена экспертизы, № по ТСН, Шифр расценки, Минимальный остаток)<br>
+                            2. Справочник расшифровок (колонки: ID, Тип, Код, Расшифровка)
+                        </div>
+                    </div>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
+                    <button type="submit" class="btn btn-primary">Импортировать</button>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+@endif
+
+@push('scripts')
+<script type="module">
+    function waitForJQuery(callback) {
+        if (typeof window.$ !== 'undefined') {
+            callback();
+        } else {
+            setTimeout(() => waitForJQuery(callback), 50);
+        }
+    }
+
+    waitForJQuery(function() {
+        $(document).on('click', '.clickable-quantity', function(e) {
+            e.preventDefault();
+            const sparePartId = $(this).data('spare-part-id');
+            const withDocs = $(this).data('with-docs');
+
+            let url = "{{ route('spare_part_orders.index') }}" +
+                      "?spare_part_id=" + sparePartId +
+                      "&status=in_stock&remaining_quantity_min=1";
+
+            if (withDocs !== 'all') {
+                url += "&with_documents=" + withDocs;
+            }
+
+            window.location.href = url;
+        });
+    });
+</script>
+@endpush
+@endsection

+ 42 - 0
routes/web.php

@@ -8,13 +8,18 @@ use App\Http\Controllers\FilterController;
 use App\Http\Controllers\ImportController;
 use App\Http\Controllers\MafOrderController;
 use App\Http\Controllers\OrderController;
+use App\Http\Controllers\PricingCodeController;
 use App\Http\Controllers\ProductController;
 use App\Http\Controllers\ProductSKUController;
 use App\Http\Controllers\ReclamationController;
 use App\Http\Controllers\ReportController;
 use App\Http\Controllers\ResponsibleController;
 use App\Http\Controllers\ScheduleController;
+use App\Http\Controllers\SparePartController;
+use App\Http\Controllers\SparePartInventoryController;
+use App\Http\Controllers\SparePartOrderController;
 use App\Http\Controllers\UserController;
+use App\Models\PricingCode;
 use App\Models\Role;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
@@ -225,6 +230,43 @@ Route::middleware('auth:web')->group(function () {
     // ajax get areas by district
     Route::get('areas/{district_id?}', [AreaController::class, 'ajaxGetAreasByDistrict'])->name('area.ajax-get-areas-by-district');
 
+    // Каталог запчастей
+    Route::prefix('spare-parts')->name('spare_parts.')->group(function () {
+        Route::get('/', [SparePartController::class, 'index'])->name('index');
+        Route::get('/create', [SparePartController::class, 'create'])->name('create')->middleware('role:admin');
+        Route::get('/{sparePart}', [SparePartController::class, 'show'])->name('show');
+        Route::post('/', [SparePartController::class, 'store'])->name('store')->middleware('role:admin');
+        Route::put('/{sparePart}', [SparePartController::class, 'update'])->name('update')->middleware('role:admin');
+        Route::delete('/{sparePart}', [SparePartController::class, 'destroy'])->name('destroy')->middleware('role:admin');
+        Route::post('/export', [SparePartController::class, 'export'])->name('export')->middleware('role:admin');
+        Route::post('/import', [SparePartController::class, 'import'])->name('import')->middleware('role:admin');
+        Route::post('/{sparePart}/upload-image', [SparePartController::class, 'uploadImage'])->name('upload_image')->middleware('role:admin');
+    });
+
+    // Заказы деталей
+    Route::prefix('spare-part-orders')->name('spare_part_orders.')->group(function () {
+        Route::get('/', [SparePartOrderController::class, 'index'])->name('index');
+        Route::get('/create', [SparePartOrderController::class, 'create'])->name('create')->middleware('role:admin,manager');
+        Route::get('/{sparePartOrder}', [SparePartOrderController::class, 'show'])->name('show');
+        Route::post('/', [SparePartOrderController::class, 'store'])->name('store')->middleware('role:admin,manager');
+        Route::put('/{sparePartOrder}', [SparePartOrderController::class, 'update'])->name('update')->middleware('role:admin,manager');
+        Route::delete('/{sparePartOrder}', [SparePartOrderController::class, 'destroy'])->name('destroy')->middleware('role:admin');
+        Route::post('/{sparePartOrder}/ship', [SparePartOrderController::class, 'ship'])->name('ship')->middleware('role:admin,manager');
+        Route::post('/{sparePartOrder}/set-in-stock', [SparePartOrderController::class, 'setInStock'])->name('set_in_stock')->middleware('role:admin,manager');
+    });
+
+    // Контроль наличия
+    Route::get('/spare-part-inventory', [SparePartInventoryController::class, 'index'])->name('spare_part_inventory.index');
+
+    // Справочник расшифровок
+    Route::prefix('pricing-codes')->name('pricing_codes.')->middleware('role:admin')->group(function () {
+        Route::get('/', [PricingCodeController::class, 'index'])->name('index');
+        Route::post('/', [PricingCodeController::class, 'store'])->name('store');
+        Route::put('/{pricingCode}', [PricingCodeController::class, 'update'])->name('update');
+        Route::delete('/{pricingCode}', [PricingCodeController::class, 'destroy'])->name('destroy');
+    });
 
+    // API для получения расшифровки кодов
+    Route::get('/pricing-codes/get-description', [PricingCodeController::class, 'getDescription'])->name('pricing_codes.get_description');
 
 });

BIN
templates/SparePartOrders.xlsx


BIN
templates/SpareParts.xlsx