From 3cd161d83c08d86d501bee2dfec5ef8ce523fdde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 9 Dec 2025 10:42:07 +0300 Subject: [PATCH] feat: add equipment management with QR codes and transfers Stage 5 (Inventory) implementation: - Add EquipmentTransferLog model for tracking equipment movements - Add EquipmentViewSet actions: - transfer: move equipment between departments/users - write_off: decommission equipment - add_maintenance: record maintenance activities - qr_code: generate QR code for equipment - transfer_history: view equipment transfer history - Use existing QRCodeGenerator and InventoryNumberGenerator services - Fix EquipmentStatus constants usage (IN_USE, RETIRED) - Add 14 new tests for inventory functionality Test results: 66/66 PASSED - 20/20 Model tests - 12/12 API tests - 9/9 Workflow tests - 11/11 Budget tests - 14/14 Inventory tests (NEW) --- PROCUREMENT_IMPLEMENTATION_PLAN.md | 395 +++++++++------ .../migrations/0002_equipmenttransferlog.py | 39 ++ backend/procurement/models.py | 89 ++++ backend/procurement/services.py | 311 ++++++++++++ backend/procurement/views.py | 160 +++++- backend/tests/procurement/test_inventory.py | 454 ++++++++++++++++++ 6 files changed, 1302 insertions(+), 146 deletions(-) create mode 100644 backend/procurement/migrations/0002_equipmenttransferlog.py create mode 100644 backend/procurement/services.py create mode 100644 backend/tests/procurement/test_inventory.py diff --git a/PROCUREMENT_IMPLEMENTATION_PLAN.md b/PROCUREMENT_IMPLEMENTATION_PLAN.md index 483e6677..1f4f0e84 100644 --- a/PROCUREMENT_IMPLEMENTATION_PLAN.md +++ b/PROCUREMENT_IMPLEMENTATION_PLAN.md @@ -1,7 +1,7 @@ # План Разработки Модуля "Закупки и Инвентарь" (Procurement) -**Дата начала:** 8 декабря 2025 -**Статус:** 🚀 В разработке +**Дата начала:** 8 декабря 2025 +**Статус:** 🚀 В разработке **Версия:** 1.0 --- @@ -30,6 +30,7 @@ **Обоснование:** **За использование NotificationService:** + 1. ✅ **Уже существует и протестирована** - используется во всех модулях (communications, documents, feed, calendar_app, requests_app) 2. ✅ **Полнофункциональная** - поддерживает Web (WebSocket), Email (HTML), Telegram, настройки пользователя 3. ✅ **Простота** - не требует дополнительных процессов и сервисов @@ -38,22 +39,27 @@ 6. ✅ **Легче debugging** - ошибки в том же процессе и логах **Против Celery:** + 1. ❌ **Усложнение** - +2 процесса в production (worker + beat) 2. ❌ **Дублирование** - NotificationService уже отправляет email 3. ❌ **Overkill** - избыточно для текущего масштаба проекта 4. ❌ **DevOps overhead** - настройка supervisor/systemd, мониторинг workers **Производительность:** + - SMTP отправка: ~0.5-2 секунды - Согласование заявки: редкое действие (1-5 раз/день на отдел) - Задержка 2 секунды приемлема для редких critical операций **Когда пересмотреть:** -- >500 сотрудников и >50 заявок/день + +- > 500 сотрудников и >50 заявок/день + > - Генерация тяжелых отчётов (PDF >10MB) - Нестабильный email-провайдер требует retry логики **Периодические задачи:** Используется **APScheduler** (уже в requirements.txt) для: + - Дайджесты уведомлений - Напоминания о просроченных заявках - Очистка старых данных @@ -63,31 +69,37 @@ ## Обзор Проекта ### Цель + Создать систему управления внутренними закупками и инвентарем с автоматизированным workflow согласования заявок и контролем бюджетов отделов. ### Основные Возможности **Для сотрудников:** + - ✅ Создание заявок на закупку - ✅ Отслеживание статуса своих заявок - ✅ Просмотр доступного оборудования **Для руководителей отделов:** + - ✅ Согласование/отклонение заявок отдела - ✅ Просмотр бюджета отдела - ✅ Управление инвентарем отдела **Для финансового отдела:** + - ✅ Контроль бюджетов всех отделов - ✅ Финансовое согласование заявок - ✅ Формирование отчетов **Для менеджера закупок:** + - ✅ Исполнение одобренных заявок - ✅ Работа с поставщиками - ✅ Оприходование закупленного оборудования **Для завхоза:** + - ✅ Учет всего оборудования - ✅ Назначение ответственных - ✅ Контроль технического обслуживания @@ -95,13 +107,13 @@ ### Метрики Успеха -| Метрика | Целевое Значение | -|---------|------------------| -| Среднее время согласования | < 3 рабочих дня | -| Процент заявок без доработок | > 90% | -| Точность бюджетирования | 95-105% | -| Удовлетворенность пользователей | > 4.0/5.0 | -| Критических багов в production | 0 | +| Метрика | Целевое Значение | +| ------------------------------------------------------------- | ------------------------------- | +| Среднее время согласования | < 3 рабочих дня | +| Процент заявок без доработок | > 90% | +| Точность бюджетирования | 95-105% | +| Удовлетворенность пользователей | > 4.0/5.0 | +| Критических багов в production | 0 | --- @@ -195,6 +207,7 @@ ### Модель 1: ProcurementRequest (Заявка на закупку) **Поля:** + ```python title = CharField(max_length=255) # Название заявки description = TextField() # Описание/обоснование @@ -210,6 +223,7 @@ completed_at = DateTimeField(null=True) # Когда завершена ``` **Статусы:** + - `DRAFT` - Черновик (редактируется) - `PENDING` - На согласовании - `APPROVED` - Одобрено (все согласования пройдены) @@ -219,6 +233,7 @@ completed_at = DateTimeField(null=True) # Когда завершена - `CANCELLED` - Отменено заявителем **Методы:** + - `submit()` - отправить на согласование (DRAFT → PENDING) - `approve(approver)` - согласовать (PENDING → APPROVED если все ОК) - `reject(approver, reason)` - отклонить (PENDING → REJECTED) @@ -229,6 +244,7 @@ completed_at = DateTimeField(null=True) # Когда завершена - `check_budget_available()` - есть ли бюджет в отделе **Permissions:** + - `procurement.add_procurementrequest` - создавать заявки - `procurement.change_procurementrequest` - редактировать свои - `procurement.view_procurementrequest` - просматривать @@ -238,6 +254,7 @@ completed_at = DateTimeField(null=True) # Когда завершена ### Модель 2: ProcurementItem (Позиция заявки) **Поля:** + ```python request = ForeignKey(ProcurementRequest, related_name='items') name = CharField(max_length=255) # Название товара/услуги @@ -251,11 +268,13 @@ created_at = DateTimeField(auto_now_add=True) ``` **Свойства:** + - `total_price` - quantity × estimated_unit_price ### Модель 3: Approval (Согласование) **Поля:** + ```python request = ForeignKey(ProcurementRequest, related_name='approvals') approver = ForeignKey(User) # Кто согласовывает @@ -267,16 +286,19 @@ updated_at = DateTimeField(auto_now=True) ``` **Роли согласующих:** + - `DEPARTMENT_HEAD` - Руководитель отдела - `FINANCE_MANAGER` - Финансовый менеджер - `DIRECTOR` - Генеральный директор **Статусы:** + - `PENDING` - Ожидает решения - `APPROVED` - Одобрено - `REJECTED` - Отклонено **Логика согласования:** + ```python if estimated_cost < 10_000: # Только руководитель отдела @@ -292,6 +314,7 @@ else: ### Модель 4: Equipment (Оборудование) **Поля:** + ```python name = CharField(max_length=255) # Название inventory_number = CharField(unique=True) # Инвентарный номер (INV-2024-0001) @@ -311,6 +334,7 @@ updated_at = DateTimeField(auto_now=True) ``` **Статусы:** + - `AVAILABLE` - Доступно (на складе) - `IN_USE` - В использовании - `MAINTENANCE` - На обслуживании @@ -319,6 +343,7 @@ updated_at = DateTimeField(auto_now=True) - `LOST` - Утеряно **Методы:** + - `assign_to(user, location)` - назначить ответственного - `send_to_maintenance(description)` - отправить на ТО - `retire(reason)` - списать @@ -327,6 +352,7 @@ updated_at = DateTimeField(auto_now=True) ### Модель 5: EquipmentCategory (Категория оборудования) **Поля:** + ```python name = CharField(max_length=100, unique=True) parent = ForeignKey('self', null=True) # Родительская категория @@ -336,6 +362,7 @@ created_at = DateTimeField(auto_now_add=True) ``` **Примеры иерархии:** + ``` - Компьютерная техника ├── Ноутбуки @@ -358,6 +385,7 @@ created_at = DateTimeField(auto_now_add=True) ### Модель 6: MaintenanceRecord (Запись обслуживания) **Поля:** + ```python equipment = ForeignKey(Equipment, related_name='maintenance_history') date = DateField() # Дата обслуживания @@ -370,6 +398,7 @@ created_at = DateTimeField(auto_now_add=True) ``` **Типы обслуживания:** + - `INSPECTION` - Осмотр/проверка - `MAINTENANCE` - Плановое ТО - `REPAIR` - Ремонт @@ -379,6 +408,7 @@ created_at = DateTimeField(auto_now_add=True) ### Модель 7: Budget (Бюджет отдела) **Поля:** + ```python department = ForeignKey(Department) year = PositiveIntegerField() # Год @@ -393,6 +423,7 @@ class Meta: ``` **Методы:** + - `remaining_amount` - остаток бюджета - `utilization_percentage` - процент использования - `can_spend(amount)` - можно ли потратить сумму @@ -402,6 +433,7 @@ class Meta: ### Модель 8: Supplier (Поставщик) **Поля:** + ```python name = CharField(max_length=255) contact_person = CharField(max_length=255, blank=True) @@ -423,6 +455,7 @@ created_at = DateTimeField(auto_now_add=True) ### Django Model Permissions **Автоматические (создаются Django):** + - `procurement.add_procurementrequest` - `procurement.change_procurementrequest` - `procurement.delete_procurementrequest` @@ -433,6 +466,7 @@ created_at = DateTimeField(auto_now_add=True) - `procurement.view_equipment` **Кастомные (добавляем вручную):** + ```python class ProcurementRequest(models.Model): class Meta: @@ -449,7 +483,7 @@ class ProcurementRequest(models.Model): ```python class DeptPerm: # ... существующие права ... - + # Новые права для Procurement CREATE_PROCUREMENT = "create_procurement_request" APPROVE_PROCUREMENT = "approve_procurement_request" @@ -459,23 +493,23 @@ class DeptPerm: ### Матрица Прав Доступа -| Действие | Сотрудник | Рук. Отдела | Финансы | Закупки | Завхоз | Admin | -|----------|-----------|-------------|---------|---------|--------|-------| -| Создать заявку | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Просмотреть свои заявки | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Просмотреть заявки отдела | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Просмотреть все заявки | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | -| Согласовать (1 уровень) | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Согласовать (финансы) | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | -| Согласовать (директор) | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | -| Исполнить заявку | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | -| Просмотр оборудования | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Добавить оборудование | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | -| Управление инвентарем отдела | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | -| Управление всем инвентарем | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | -| Списание оборудования | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | -| Просмотр бюджета отдела | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | -| Управление бюджетами | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | +| Действие | Сотрудник | Рук. Отдела | Финансы | Закупки | Завхоз | Admin | +| ------------------------------------------------------ | ------------------ | -------------------- | -------------- | -------------- | ------------ | ----- | +| Создать заявку | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Просмотреть свои заявки | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Просмотреть заявки отдела | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Просмотреть все заявки | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | +| Согласовать (1 уровень) | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Согласовать (финансы) | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | +| Согласовать (директор) | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | +| Исполнить заявку | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | +| Просмотр оборудования | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Добавить оборудование | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | +| Управление инвентарем отдела | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | +| Управление всем инвентарем | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | +| Списание оборудования | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | +| Просмотр бюджета отдела | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | +| Управление бюджетами | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ### Permission Classes для API @@ -497,7 +531,7 @@ class CanApproveProcurement(BasePermission): # Логика проверки роли и суммы required_approvals = obj.get_required_approvals() # ... проверка текущей роли пользователя - + class CanManageEquipment(BasePermission): """Может управлять оборудованием""" def has_permission(self, request, view): @@ -518,17 +552,17 @@ stateDiagram-v2 [*] --> DRAFT DRAFT --> PENDING: submit() DRAFT --> CANCELLED: cancel() - + PENDING --> APPROVED: approve() PENDING --> REJECTED: reject() PENDING --> CANCELLED: cancel() - + APPROVED --> IN_PROGRESS: start_execution() APPROVED --> CANCELLED: cancel() - + IN_PROGRESS --> COMPLETED: complete() IN_PROGRESS --> CANCELLED: cancel() - + COMPLETED --> [*] REJECTED --> [*] CANCELLED --> [*] @@ -536,44 +570,52 @@ stateDiagram-v2 ### Правила Согласования по Сумме -| Сумма заявки | Необходимые согласования | Примерный срок | -|--------------|--------------------------|----------------| -| < 10,000₽ | Руководитель отдела | 1-2 дня | -| 10,000 - 100,000₽ | Руководитель отдела + Финансы | 2-3 дня | -| > 100,000₽ | Руководитель отдела + Финансы + Директор | 3-5 дней | +| Сумма заявки | Необходимые согласования | Примерный срок | +| ----------------------- | ------------------------------------------------------------------------- | --------------------------- | +| < 10,000₽ | Руководитель отдела | 1-2 дня | +| 10,000 - 100,000₽ | Руководитель отдела + Финансы | 2-3 дня | +| > 100,000₽ | Руководитель отдела + Финансы + Директор | 3-5 дней | ### Уведомления **При создании заявки:** + - ✉️ Email руководителю отдела: "Новая заявка требует вашего согласования" **При согласовании:** + - ✉️ Email следующему согласующему (если требуется) - ✉️ Email автору заявки: "Ваша заявка согласована [Роль]" **При полном согласовании:** + - ✉️ Email менеджеру закупок: "Заявка одобрена, можно приступать к исполнению" - ✉️ Email автору: "Ваша заявка полностью одобрена и передана в работу" **При отклонении:** + - ✉️ Email автору: "Ваша заявка отклонена. Причина: ..." **При завершении:** + - ✉️ Email автору: "Ваша заявка выполнена" - ✉️ Email руководителю отдела: "Заявка выполнена, оборудование оприходовано" ### Автоматические Проверки **Перед отправкой на согласование (submit):** + - ✅ Есть хотя бы одна позиция в заявке - ✅ Указана предполагаемая стоимость - ✅ Заполнены обязательные поля **Перед финансовым согласованием:** + - ✅ В бюджете отдела достаточно средств - ✅ Если бюджета нет → алерт финансовому директору **Перед завершением:** + - ✅ Указана фактическая стоимость - ✅ Все позиции оприходованы (если оборудование) @@ -582,6 +624,7 @@ stateDiagram-v2 ## API Endpoints ### Базовый URL + ``` /api/v1/procurement/ ``` @@ -589,10 +632,13 @@ stateDiagram-v2 ### Заявки (Requests) #### Список и создание + ```http GET /api/v1/procurement/requests/ ``` + **Query Parameters:** + - `status` - фильтр по статусу (draft, pending, approved, etc.) - `department` - ID отдела - `requestor` - ID заявителя @@ -605,6 +651,7 @@ GET /api/v1/procurement/requests/ - `ordering` - сортировка (created_at, estimated_cost, -created_at) **Response:** + ```json { "count": 42, @@ -645,7 +692,9 @@ GET /api/v1/procurement/requests/ ```http POST /api/v1/procurement/requests/ ``` + **Request Body:** + ```json { "title": "Новые мониторы", @@ -666,6 +715,7 @@ POST /api/v1/procurement/requests/ ``` #### Детали, обновление, удаление + ```http GET /api/v1/procurement/requests/{id}/ PATCH /api/v1/procurement/requests/{id}/ @@ -675,14 +725,17 @@ DELETE /api/v1/procurement/requests/{id}/ #### Actions (Действия над заявкой) **Отправить на согласование:** + ```http POST /api/v1/procurement/requests/{id}/submit/ ``` **Согласовать:** + ```http POST /api/v1/procurement/requests/{id}/approve/ ``` + ```json { "comment": "Одобрено. Необходимо для работы." @@ -690,9 +743,11 @@ POST /api/v1/procurement/requests/{id}/approve/ ``` **Отклонить:** + ```http POST /api/v1/procurement/requests/{id}/reject/ ``` + ```json { "reason": "Недостаточно бюджета в этом квартале", @@ -701,14 +756,17 @@ POST /api/v1/procurement/requests/{id}/reject/ ``` **Начать исполнение:** + ```http POST /api/v1/procurement/requests/{id}/start_execution/ ``` **Завершить:** + ```http POST /api/v1/procurement/requests/{id}/complete/ ``` + ```json { "actual_cost": "740000.00", @@ -717,9 +775,11 @@ POST /api/v1/procurement/requests/{id}/complete/ ``` **Отменить:** + ```http POST /api/v1/procurement/requests/{id}/cancel/ ``` + ```json { "reason": "Больше не актуально" @@ -727,9 +787,11 @@ POST /api/v1/procurement/requests/{id}/cancel/ ``` **Добавить позицию:** + ```http POST /api/v1/procurement/requests/{id}/add_item/ ``` + ```json { "name": "Клавиатура механическая", @@ -742,12 +804,14 @@ POST /api/v1/procurement/requests/{id}/add_item/ ### Оборудование (Equipment) #### Список и создание + ```http GET /api/v1/procurement/equipment/ POST /api/v1/procurement/equipment/ ``` **Query Parameters:** + - `status` - статус оборудования - `category` - ID категории - `department` - ID отдела @@ -757,6 +821,7 @@ POST /api/v1/procurement/equipment/ - `ordering` - сортировка **Response:** + ```json { "count": 156, @@ -793,9 +858,11 @@ POST /api/v1/procurement/equipment/ #### Actions **Назначить ответственного:** + ```http POST /api/v1/procurement/equipment/{id}/assign/ ``` + ```json { "responsible_person": 15, @@ -804,9 +871,11 @@ POST /api/v1/procurement/equipment/{id}/assign/ ``` **Добавить запись обслуживания:** + ```http POST /api/v1/procurement/equipment/{id}/maintenance/ ``` + ```json { "date": "2025-12-08", @@ -817,9 +886,11 @@ POST /api/v1/procurement/equipment/{id}/maintenance/ ``` **Списать:** + ```http POST /api/v1/procurement/equipment/{id}/retire/ ``` + ```json { "reason": "Устарело морально и физически", @@ -828,9 +899,11 @@ POST /api/v1/procurement/equipment/{id}/retire/ ``` **История:** + ```http GET /api/v1/procurement/equipment/{id}/history/ ``` + ```json { "equipment": {...}, @@ -855,6 +928,7 @@ PATCH /api/v1/procurement/budgets/{id}/ ``` **Response (my_department):** + ```json { "department": { @@ -884,6 +958,7 @@ PATCH /api/v1/procurement/budgets/{id}/ ```http GET /api/v1/procurement/stats/overview/ ``` + ```json { "total_requests": 124, @@ -911,6 +986,7 @@ GET /api/v1/procurement/stats/overview/ ```http GET /api/v1/procurement/stats/by_department/ ``` + ```json [ { @@ -929,6 +1005,7 @@ GET /api/v1/procurement/stats/by_department/ ### 1. Дашборд (`/procurement/`) **Элементы:** + - 📊 Карточки со статистикой (всего заявок, ожидают согласования, одобрено, потрачено) - 📈 График расходов по месяцам (Chart.js) - 🔍 Быстрые фильтры (статус, отдел, период) @@ -936,6 +1013,7 @@ GET /api/v1/procurement/stats/by_department/ - 🎯 Кнопка "Создать заявку" (если есть права) **Макет:** + ``` ┌─────────────────────────────────────────────────────────┐ │ 📊 Дашборд Закупок [+ Создать] │ @@ -960,11 +1038,13 @@ GET /api/v1/procurement/stats/by_department/ ### 2. Форма Создания Заявки (`/procurement/requests/create/`) **Шаги:** + 1. Основная информация (название, описание, срочность) 2. Добавление позиций (таблица с возможностью добавления строк) 3. Проверка и отправка **JavaScript функционал:** + - Динамическое добавление/удаление позиций - Автоматический расчет общей суммы - Проверка остатка бюджета (AJAX запрос) @@ -973,6 +1053,7 @@ GET /api/v1/procurement/stats/by_department/ ### 3. Детальная Страница Заявки (`/procurement/requests/{id}/`) **Секции:** + - Заголовок с бейджем статуса - Основная информация (автор, отдел, даты) - Таблица позиций с итоговой суммой @@ -983,6 +1064,7 @@ GET /api/v1/procurement/stats/by_department/ ### 4. Каталог Оборудования (`/procurement/equipment/`) **Элементы:** + - Карточки оборудования с фото (если есть) - Фильтры: статус, категория, отдел, ответственный - Поиск по названию/инв. номеру @@ -992,6 +1074,7 @@ GET /api/v1/procurement/stats/by_department/ ### 5. Карточка Оборудования (`/procurement/equipment/{id}/`) **Секции:** + - QR-код (для сканирования) - Основная информация - Текущий статус и местоположение @@ -1003,6 +1086,7 @@ GET /api/v1/procurement/stats/by_department/ ### 6. Управление Бюджетом (`/procurement/budgets/`) **Элементы:** + - Таблица бюджетов по отделам - Визуализация использования (progress bars) - Алерты при превышении 90% @@ -1018,7 +1102,8 @@ GET /api/v1/procurement/stats/by_department/ **Цель:** Создать приложение и определить все модели без сложной логики **Задачи:** -- [x] Создать Django app `procurement` + +- [X] Создать Django app `procurement` - [ ] Определить все модели в `models.py` - [ ] ProcurementRequest - [ ] ProcurementItem @@ -1041,6 +1126,7 @@ GET /api/v1/procurement/stats/by_department/ **Цель:** Создать REST API со всеми CRUD операциями и правами доступа **Задачи:** + - [ ] Создать `api/v1/procurement/serializers.py` - [ ] ProcurementRequestSerializer (read/write versions) - [ ] EquipmentSerializer @@ -1066,6 +1152,7 @@ GET /api/v1/procurement/stats/by_department/ **Цель:** Реализовать state machine и автоматическую логику согласования **Задачи:** + - [ ] Установить `django-fsm` - [ ] Добавить FSM transitions в ProcurementRequest: - [ ] `submit()` - отправка на согласование @@ -1081,10 +1168,10 @@ GET /api/v1/procurement/stats/by_department/ - [ ] `/requests/{id}/complete/` - [ ] `/requests/{id}/cancel/` - [ ] Добавить проверки budget при согласовании -- [x] Интеграция с NotificationService: - - [x] Создание типов уведомлений (procurement_*) - - [x] Signals для автоматических уведомлений - - [x] Web + Email + Telegram каналы +- [X] Интеграция с NotificationService: + - [X] Создание типов уведомлений (procurement_*) + - [X] Signals для автоматических уведомлений + - [X] Web + Email + Telegram каналы - [ ] Написать тесты workflow **Результат:** Заявки проходят полный цикл согласования с автоматическими проверками и уведомлениями @@ -1094,6 +1181,7 @@ GET /api/v1/procurement/stats/by_department/ **Цель:** Контроль бюджетов и автоматические алерты **Задачи:** + - [ ] Реализовать методы модели Budget: - [ ] `remaining_amount` - [ ] `utilization_percentage` @@ -1113,6 +1201,7 @@ GET /api/v1/procurement/stats/by_department/ **Цель:** Полноценный учет оборудования с QR-кодами **Задачи:** + - [ ] Реализовать генерацию inventory_number (INV-YYYY-NNNN) - [ ] Добавить методы Equipment: - [ ] `assign_to(user, location)` @@ -1135,6 +1224,7 @@ GET /api/v1/procurement/stats/by_department/ **Цель:** Создать все UI компоненты **Подэтап 6.1: Дашборд и Списки** + - [ ] Создать `templates/procurement/dashboard.html` - [ ] Создать `static/js/procurement/dashboard.js` (загрузка stats) - [ ] Интегрировать Chart.js для графиков @@ -1142,6 +1232,7 @@ GET /api/v1/procurement/stats/by_department/ - [ ] Создать `static/js/procurement/requestList.js` (фильтры, пагинация) **Подэтап 6.2: Форма Создания Заявки** + - [ ] Создать `templates/procurement/request_create.html` - [ ] Создать `static/js/procurement/requestForm.js`: - [ ] Динамическое добавление позиций @@ -1150,6 +1241,7 @@ GET /api/v1/procurement/stats/by_department/ - [ ] Валидация перед отправкой **Подэтап 6.3: Детальная Страница Заявки** + - [ ] Создать `templates/procurement/request_detail.html` - [ ] Создать `static/js/procurement/approvalFlow.js`: - [ ] Кнопки approve/reject с модальными окнами @@ -1157,12 +1249,14 @@ GET /api/v1/procurement/stats/by_department/ - [ ] Real-time обновления (WebSocket) **Подэтап 6.4: Инвентарь** + - [ ] Создать `templates/procurement/equipment_list.html` - [ ] Создать `templates/procurement/equipment_detail.html` - [ ] Создать `static/js/procurement/equipmentList.js` - [ ] Добавить QR code scanner (jsQR или quagga.js) **Подэтап 6.5: Мобильная Адаптация** + - [ ] Адаптировать все страницы под мобильные - [ ] Тестировать на разных разрешениях @@ -1173,6 +1267,7 @@ GET /api/v1/procurement/stats/by_department/ **Цель:** Интеграция с другими модулями и внешними системами **Задачи:** + - [ ] Email уведомления (django.core.mail): - [ ] При создании заявки - [ ] При согласовании/отклонении @@ -1186,11 +1281,11 @@ GET /api/v1/procurement/stats/by_department/ - [ ] Интеграция с модулем сотрудников: - [ ] Использование Position для прав - [ ] Department head автоопределение -- [x] Интеграция с системой уведомлений (NotificationService): - - [x] Web уведомления через WebSocket - - [x] Email уведомления с HTML шаблонами - - [x] Telegram уведомления через Bot API - - [x] Настройки пользователя для каналов доставки +- [X] Интеграция с системой уведомлений (NotificationService): + - [X] Web уведомления через WebSocket + - [X] Email уведомления с HTML шаблонами + - [X] Telegram уведомления через Bot API + - [X] Настройки пользователя для каналов доставки - [ ] Периодические задачи (APScheduler): - [ ] Напоминания о просроченных заявках - [ ] Дайджесты уведомлений @@ -1203,6 +1298,7 @@ GET /api/v1/procurement/stats/by_department/ **Цель:** Достичь высокого покрытия тестами и создать документацию **Задачи:** + - [ ] Unit тесты (coverage > 80%): - [ ] Модели - [ ] Сериализаторы @@ -1233,40 +1329,40 @@ GET /api/v1/procurement/stats/by_department/ ### Backend -| Компонент | Технология | Версия | Назначение | -|-----------|------------|--------|------------| -| Framework | Django | 5.2+ | Основной фреймворк | -| API | Django REST Framework | 3.14+ | REST API | -| Database | PostgreSQL | 14+ | Основная БД | -| State Machine | django-fsm | 2.8+ | Workflow статусов | -| Notifications | NotificationService | - | Встроенная система уведомлений | -| Scheduler | APScheduler | 3.11+ | Периодические задачи | -| Message Broker | Redis | 7+ | Cache + Channels | -| WebSocket | Django Channels | 4+ | Real-time updates | -| Export | reportlab | 4+ | PDF генерация | -| Barcodes | python-barcode | 0.15+ | Штрихкоды | -| QR Codes | qrcode | 7.4+ | QR-коды | +| Компонент | Технология | Версия | Назначение | +| ------------------ | --------------------- | ------------ | ---------------------------------------------------------- | +| Framework | Django | 5.2+ | Основной фреймворк | +| API | Django REST Framework | 3.14+ | REST API | +| Database | PostgreSQL | 14+ | Основная БД | +| State Machine | django-fsm | 2.8+ | Workflow статусов | +| Notifications | NotificationService | - | Встроенная система уведомлений | +| Scheduler | APScheduler | 3.11+ | Периодические задачи | +| Message Broker | Redis | 7+ | Cache + Channels | +| WebSocket | Django Channels | 4+ | Real-time updates | +| Export | reportlab | 4+ | PDF генерация | +| Barcodes | python-barcode | 0.15+ | Штрихкоды | +| QR Codes | qrcode | 7.4+ | QR-коды | ### Frontend -| Компонент | Технология | Версия | Назначение | -|-----------|------------|--------|------------| -| Framework | Vanilla JS | ES6+ | Логика | -| CSS Framework | Bootstrap | 5.3+ | Стили | -| Charts | Chart.js | 4+ | Графики | -| QR Scanner | jsQR | 1.4+ | Сканирование QR | -| Icons | Bootstrap Icons | 1.11+ | Иконки | -| HTTP Client | Fetch API | - | AJAX запросы | +| Компонент | Технология | Версия | Назначение | +| ------------------ | -------------------- | ------------ | --------------------------- | +| Framework | Vanilla JS | ES6+ | Логика | +| CSS Framework | Bootstrap | 5.3+ | Стили | +| Charts | Chart.js | 4+ | Графики | +| QR Scanner | jsQR | 1.4+ | Сканирование QR | +| Icons | Bootstrap Icons | 1.11+ | Иконки | +| HTTP Client | Fetch API | - | AJAX запросы | ### DevOps -| Компонент | Технология | Назначение | -|-----------|------------|------------| -| Testing | pytest + pytest-django | Unit/Integration тесты | -| E2E Testing | Playwright | End-to-end тесты | -| Coverage | coverage.py | Покрытие кода | -| Code Quality | flake8, black | Линтинг, форматирование | -| CI/CD | GitHub Actions | Автоматизация | +| Компонент | Технология | Назначение | +| ------------------ | ---------------------- | -------------------------------------------- | +| Testing | pytest + pytest-django | Unit/Integration тесты | +| E2E Testing | Playwright | End-to-end тесты | +| Coverage | coverage.py | Покрытие кода | +| Code Quality | flake8, black | Линтинг, форматирование | +| CI/CD | GitHub Actions | Автоматизация | --- @@ -1274,75 +1370,75 @@ GET /api/v1/procurement/stats/by_department/ ### Подготовка -- [x] Создать ветку `feature/procurement-module` -- [x] Настроить окружение (установить зависимости) -- [x] Создать документ с требованиями +- [X] Создать ветку `feature/procurement-module` +- [X] Настроить окружение (установить зависимости) +- [X] Создать документ с требованиями ### Этап 1: Модели ✅ ЗАВЕРШЕН -- [x] Создать app: `python manage.py startapp procurement` -- [x] Добавить в `INSTALLED_APPS` -- [x] Создать `procurement/constants.py` -- [x] Создать модели: - - [x] ProcurementRequest (с FSM) - - [x] ProcurementItem - - [x] Approval - - [x] Equipment - - [x] EquipmentCategory - - [x] MaintenanceRecord - - [x] Budget - - [x] Supplier -- [x] Создать миграции: `python manage.py makemigrations` -- [x] Применить: `python manage.py migrate` -- [x] Настроить admin.py для всех моделей -- [x] Создать тестовые данные через админку -- [x] Написать тесты моделей (20/20 PASSED) -- [x] Коммит: "feat: add procurement app with basic models" +- [X] Создать app: `python manage.py startapp procurement` +- [X] Добавить в `INSTALLED_APPS` +- [X] Создать `procurement/constants.py` +- [X] Создать модели: + - [X] ProcurementRequest (с FSM) + - [X] ProcurementItem + - [X] Approval + - [X] Equipment + - [X] EquipmentCategory + - [X] MaintenanceRecord + - [X] Budget + - [X] Supplier +- [X] Создать миграции: `python manage.py makemigrations` +- [X] Применить: `python manage.py migrate` +- [X] Настроить admin.py для всех моделей +- [X] Создать тестовые данные через админку +- [X] Написать тесты моделей (20/20 PASSED) +- [X] Коммит: "feat: add procurement app with basic models" ### Этап 2: API ✅ ЗАВЕРШЕН (95%) -- [x] Создать `procurement/` структуру (без api/v1) -- [x] Написать сериализаторы (9 сериализаторов) -- [x] Создать ViewSets (7 ViewSets) -- [x] Настроить URL routing -- [x] Создать permission classes (9 классов) -- [x] Добавить департаментные права -- [x] Написать API тесты (12/12 PASSED) -- [x] Протестировать endpoints (50+ endpoints) -- [x] Коммит: "feat: add procurement API endpoints" +- [X] Создать `procurement/` структуру (без api/v1) +- [X] Написать сериализаторы (9 сериализаторов) +- [X] Создать ViewSets (7 ViewSets) +- [X] Настроить URL routing +- [X] Создать permission classes (9 классов) +- [X] Добавить департаментные права +- [X] Написать API тесты (12/12 PASSED) +- [X] Протестировать endpoints (50+ endpoints) +- [X] Коммит: "feat: add procurement API endpoints" - [ ] ⚠️ Исправить: баг items_count (property vs annotate) - ИСПРАВЛЕН ### Этап 3: Workflow ✅ ЗАВЕРШЕН (100%) -- [x] Установить django-fsm: `pip install django-fsm==3.0.1` -- [x] Добавить FSM в ProcurementRequest -- [x] Реализовать transitions (submit, approve, reject) -- [x] Создать actions в API (@action decorators) -- [x] Добавить проверки бюджета (check_budget_available) -- [x] Настроить signals (post_save для уведомлений) -- [x] Интеграция с NotificationService (вместо Celery) -- [x] Написать workflow тесты (9/9 PASSED) -- [x] Коммит: "feat: add procurement approval workflow" -- [x] Исправить queryset для approvers (41/41 тестов PASSED) +- [X] Установить django-fsm: `pip install django-fsm==3.0.1` +- [X] Добавить FSM в ProcurementRequest +- [X] Реализовать transitions (submit, approve, reject) +- [X] Создать actions в API (@action decorators) +- [X] Добавить проверки бюджета (check_budget_available) +- [X] Настроить signals (post_save для уведомлений) +- [X] Интеграция с NotificationService (вместо Celery) +- [X] Написать workflow тесты (9/9 PASSED) +- [X] Коммит: "feat: add procurement approval workflow" +- [X] Исправить queryset для approvers (41/41 тестов PASSED) ### Этап 4: Бюджеты ✅ ЗАВЕРШЕН (100%) -- [x] Реализовать методы Budget (reserved_amount, available_amount) -- [x] Добавить проверки (is_staff/headed_departments) -- [x] Создать алерты (_check_budget_alert при < 20% и < 10%) -- [x] Добавить endpoints статистики (/stats/overview/, /stats/by-department/) -- [x] Добавить my-department endpoint -- [x] Написать тесты (11/11 PASSED) -- [x] Коммит: "feat: add budget control and alerts" +- [X] Реализовать методы Budget (reserved_amount, available_amount) +- [X] Добавить проверки (is_staff/headed_departments) +- [X] Создать алерты (_check_budget_alert при < 20% и < 10%) +- [X] Добавить endpoints статистики (/stats/overview/, /stats/by-department/) +- [X] Добавить my-department endpoint +- [X] Написать тесты (11/11 PASSED) +- [X] Коммит: "feat: add budget control and alerts" ### Этап 5: Инвентарь -- [ ] Установить barcode библиотеки -- [ ] Реализовать генерацию номеров -- [ ] Добавить методы Equipment -- [ ] Создать actions в API -- [ ] Реализовать QR-коды -- [ ] Написать тесты +- [X] Установить barcode библиотеки (qrcode[pil]) +- [X] Реализовать генерацию номеров (InventoryNumberGenerator) +- [X] Добавить EquipmentTransferLog модель +- [X] Создать actions в API (transfer, write_off, add_maintenance, qr_code, transfer_history) +- [X] Реализовать QR-коды (QRCodeGenerator) +- [X] Написать тесты (14/14 PASSED) - [ ] Коммит: "feat: add equipment management with QR codes" ### Этап 6: Frontend @@ -1358,7 +1454,7 @@ GET /api/v1/procurement/stats/by_department/ ### Этап 7: Интеграции -- [x] Интеграция с NotificationService (система уведомлений) +- [X] Интеграция с NotificationService (система уведомлений) - [ ] WebSocket обновления для real-time статусов - [ ] Экспорт отчетов (PDF/Excel) - [ ] APScheduler для периодических задач @@ -1384,32 +1480,35 @@ GET /api/v1/procurement/stats/by_department/ ## Риски и Митигация -| Риск | Вероятность | Влияние | Митигация | -|------|-------------|---------|-----------| -| Сложность workflow | Высокая | Высокое | Начать с простого (2 уровня), расширять постепенно | -| Производительность при большой нагрузке | Средняя | Среднее | Индексы БД, кэширование, пагинация | -| Конфликты бюджетов | Средняя | Высокое | Pessimistic locking, транзакции | -| Миграция данных | Низкая | Среднее | Хорошо протестировать скрипты миграции | -| Недостаточное покрытие тестами | Средняя | Высокое | CI/CD с проверкой coverage, code review | -| Сопротивление пользователей | Средняя | Среднее | Обучение, простой UI, постепенное внедрение | +| Риск | Вероятность | Влияние | Митигация | +| --------------------------------------------------------------------------- | ---------------------- | -------------- | ------------------------------------------------------------------------------------------ | +| Сложность workflow | Высокая | Высокое | Начать с простого (2 уровня), расширять постепенно | +| Производительность при большой нагрузке | Средняя | Среднее | Индексы БД, кэширование, пагинация | +| Конфликты бюджетов | Средняя | Высокое | Pessimistic locking, транзакции | +| Миграция данных | Низкая | Среднее | Хорошо протестировать скрипты миграции | +| Недостаточное покрытие тестами | Средняя | Высокое | CI/CD с проверкой coverage, code review | +| Сопротивление пользователей | Средняя | Среднее | Обучение, простой UI, постепенное внедрение | --- ## 🎯 Результаты Тестирования -**Последнее тестирование:** 9 декабря 2025 +**Последнее тестирование:** 9 декабря 2025 **Статус:** ✅ ВСЕ ТЕСТЫ ПРОХОДЯТ ### Итоговая статистика: + ``` -✅ 52/52 PASSED (100%) +✅ 66/66 PASSED (100%) ├─ 20/20 Model tests ✅ ├─ 12/12 API tests ✅ ├─ 9/9 Workflow tests ✅ - └─ 11/11 Budget tests ✅ (NEW) + ├─ 11/11 Budget tests ✅ + └─ 14/14 Inventory tests ✅ (NEW) ``` ### Основные исправления: + 1. ✅ Исправлен `led_departments` → `headed_departments` 2. ✅ Удален конфликт `items_count` (annotate vs @property) 3. ✅ Создан `settings_test.py` с InMemoryChannelLayer @@ -1419,6 +1518,7 @@ GET /api/v1/procurement/stats/by_department/ 7. ✅ **Расширен queryset для approvers** - ключевое исправление! ### Техническая инфраструктура: + - **Git cleanup:** localcerts_backup.zip удален из истории (279 коммитов) - **Test settings:** InMemoryChannelLayer вместо Redis - **Notifications:** NotificationService интегрирован (вместо Celery) @@ -1428,6 +1528,7 @@ GET /api/v1/procurement/stats/by_department/ ## Следующие Шаги **Завершено:** + 1. ✅ Создать данную документацию 2. ✅ Создать ветку `feature/procurement-module` 3. ✅ Завершить **Этап 1: Базовая Структура** (100%) @@ -1436,13 +1537,16 @@ GET /api/v1/procurement/stats/by_department/ 6. ✅ Завершить **Этап 4: Бюджеты** (100%) **Следующий шаг:** + - ⏭️ Начать **Этап 5: Инвентарь** - управление оборудованием с QR-кодами **Через 1 месяц:** + - Завершить Этапы 5-6 (Инвентарь, Frontend) - Иметь полнофункциональный модуль с UI **Через 2 месяца:** + - Завершить все 8 этапов - Готовый модуль к production deploy @@ -1450,14 +1554,15 @@ GET /api/v1/procurement/stats/by_department/ ## Контакты и Вопросы -**Ответственный за разработку:** [Ваше имя] -**Дата последнего обновления:** 9 декабря 2025 -**Версия документа:** 1.2 +**Ответственный за разработку:** [Ваше имя] +**Дата последнего обновления:** 9 декабря 2025 +**Версия документа:** 1.3 **Вопросы и предложения:** + - Создавайте issues в репозитории - Обсуждайте в команде --- -**Статус:** 🎉 Этапы 1-4 завершены! Готовы к Этапу 5 (Инвентарь)! +**Статус:** 🎉 Этапы 1-5 завершены! Готовы к Этапу 6 (Frontend)! diff --git a/backend/procurement/migrations/0002_equipmenttransferlog.py b/backend/procurement/migrations/0002_equipmenttransferlog.py new file mode 100644 index 00000000..79f8d256 --- /dev/null +++ b/backend/procurement/migrations/0002_equipmenttransferlog.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.4 on 2025-12-09 07:33 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0034_alter_employee_telegram'), + ('procurement', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EquipmentTransferLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('from_location', models.CharField(blank=True, max_length=255, verbose_name='Откуда')), + ('to_location', models.CharField(blank=True, max_length=255, verbose_name='Куда')), + ('transfer_type', models.CharField(choices=[('assignment', 'Назначение ответственного'), ('transfer', 'Перемещение'), ('return', 'Возврат')], default='transfer', max_length=20, verbose_name='Тип передачи')), + ('reason', models.TextField(blank=True, verbose_name='Причина/комментарий')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата передачи')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfers_created', to=settings.AUTH_USER_MODEL, verbose_name='Кто оформил')), + ('equipment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_logs', to='procurement.equipment', verbose_name='Оборудование')), + ('from_department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfers_out', to='employees.department', verbose_name='Из отдела')), + ('from_person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_given', to=settings.AUTH_USER_MODEL, verbose_name='От кого')), + ('to_department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfers_in', to='employees.department', verbose_name='В отдел')), + ('to_person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_received', to=settings.AUTH_USER_MODEL, verbose_name='Кому')), + ], + options={ + 'verbose_name': 'Лог передачи', + 'verbose_name_plural': 'Логи передач', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index aa1d3737..f6fdbf04 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -480,6 +480,95 @@ class MaintenanceRecord(models.Model): ) +class EquipmentTransferLog(models.Model): + """Лог передачи/перемещения оборудования.""" + + TRANSFER_TYPES = [ + ('assignment', 'Назначение ответственного'), + ('transfer', 'Перемещение'), + ('return', 'Возврат'), + ] + + equipment = models.ForeignKey( + Equipment, + on_delete=models.CASCADE, + related_name='transfer_logs', + verbose_name='Оборудование' + ) + from_department = models.ForeignKey( + Department, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='transfers_out', + verbose_name='Из отдела' + ) + to_department = models.ForeignKey( + Department, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='transfers_in', + verbose_name='В отдел' + ) + from_person = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='equipment_given', + verbose_name='От кого' + ) + to_person = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='equipment_received', + verbose_name='Кому' + ) + from_location = models.CharField( + 'Откуда', + max_length=255, + blank=True + ) + to_location = models.CharField( + 'Куда', + max_length=255, + blank=True + ) + transfer_type = models.CharField( + 'Тип передачи', + max_length=20, + choices=TRANSFER_TYPES, + default='transfer' + ) + reason = models.TextField( + 'Причина/комментарий', + blank=True + ) + created_at = models.DateTimeField('Дата передачи', auto_now_add=True) + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='transfers_created', + verbose_name='Кто оформил' + ) + + class Meta: + verbose_name = 'Лог передачи' + verbose_name_plural = 'Логи передач' + ordering = ['-created_at'] + + def __str__(self): + return ( + f"{self.equipment.inventory_number}: " + f"{self.from_person or 'Склад'} → {self.to_person or 'Склад'}" + ) + + class Budget(models.Model): """Бюджет отдела на квартал.""" diff --git a/backend/procurement/services.py b/backend/procurement/services.py new file mode 100644 index 00000000..8efa4bae --- /dev/null +++ b/backend/procurement/services.py @@ -0,0 +1,311 @@ +""" +Сервисы для модуля закупок и инвентаря. +""" +import io +import os +from typing import Optional + +import qrcode +from django.conf import settings +from django.core.files.base import ContentFile +from django.db import transaction +from django.utils import timezone + + +class InventoryNumberGenerator: + """Генератор инвентарных номеров.""" + + PREFIX = 'INV' + + @classmethod + def generate(cls, year: Optional[int] = None) -> str: + """ + Генерирует уникальный инвентарный номер. + + Формат: INV-YYYY-NNNN + Например: INV-2025-0001, INV-2025-0002 + """ + from .models import Equipment + + if year is None: + year = timezone.now().year + + # Находим последний номер за этот год + prefix = f'{cls.PREFIX}-{year}-' + last_equipment = Equipment.objects.filter( + inventory_number__startswith=prefix + ).order_by('-inventory_number').first() + + if last_equipment: + # Извлекаем номер из последнего инвентарного номера + try: + last_num = int(last_equipment.inventory_number.split('-')[-1]) + except (ValueError, IndexError): + last_num = 0 + else: + last_num = 0 + + new_num = last_num + 1 + return f'{cls.PREFIX}-{year}-{new_num:04d}' + + @classmethod + def validate(cls, inventory_number: str) -> bool: + """ + Проверяет формат инвентарного номера. + + Returns: + True если формат верный, False иначе + """ + parts = inventory_number.split('-') + if len(parts) != 3: + return False + + prefix, year_str, num_str = parts + + if prefix != cls.PREFIX: + return False + + try: + year = int(year_str) + if year < 2000 or year > 2100: + return False + except ValueError: + return False + + try: + num = int(num_str) + if num < 1: + return False + except ValueError: + return False + + return True + + +class QRCodeGenerator: + """Генератор QR-кодов для оборудования.""" + + QR_CODE_DIR = 'qr_codes' + + @classmethod + def generate_for_equipment(cls, equipment) -> ContentFile: + """ + Генерирует QR-код для оборудования. + + QR содержит URL на страницу оборудования и основные данные. + + Returns: + ContentFile с PNG изображением QR-кода + """ + # Данные для QR-кода + data = cls._build_qr_data(equipment) + + # Генерируем QR-код + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=4, + ) + qr.add_data(data) + qr.make(fit=True) + + # Создаём изображение + img = qr.make_image(fill_color="black", back_color="white") + + # Сохраняем в буфер + buffer = io.BytesIO() + img.save(buffer, format='PNG') + buffer.seek(0) + + filename = f'{equipment.inventory_number}.png' + return ContentFile(buffer.read(), name=filename) + + @classmethod + def _build_qr_data(cls, equipment) -> str: + """ + Формирует данные для QR-кода. + + Формат: URL с параметрами для быстрого доступа. + """ + # Формируем URL на страницу оборудования + base_url = getattr(settings, 'SITE_URL', 'http://localhost:8000') + equipment_url = f'{base_url}/procurement/equipment/{equipment.id}/' + + # Можно также добавить vCard или JSON формат + # Но URL проще для сканирования + return equipment_url + + @classmethod + def get_qr_code_path(cls, inventory_number: str) -> str: + """ + Возвращает путь к файлу QR-кода. + """ + return os.path.join(cls.QR_CODE_DIR, f'{inventory_number}.png') + + +class EquipmentService: + """Сервис для операций с оборудованием.""" + + @classmethod + @transaction.atomic + def create_equipment( + cls, + name: str, + category, + department, + purchase_date, + purchase_cost, + **kwargs + ): + """ + Создаёт новое оборудование с авто-генерацией инвентарного номера. + """ + from .models import Equipment + + # Генерируем инвентарный номер если не указан + if 'inventory_number' not in kwargs or not kwargs['inventory_number']: + kwargs['inventory_number'] = InventoryNumberGenerator.generate() + + equipment = Equipment.objects.create( + name=name, + category=category, + department=department, + purchase_date=purchase_date, + purchase_cost=purchase_cost, + **kwargs + ) + + return equipment + + @classmethod + @transaction.atomic + def assign_to_person( + cls, + equipment, + person, + location: Optional[str] = None + ): + """ + Назначает оборудование ответственному лицу. + """ + from .constants import EquipmentStatus + from .models import EquipmentTransferLog + + old_person = equipment.responsible_person + old_location = equipment.location + + equipment.responsible_person = person + equipment.status = EquipmentStatus.IN_USE + if location: + equipment.location = location + equipment.save() + + # Логируем передачу + EquipmentTransferLog.objects.create( + equipment=equipment, + from_person=old_person, + to_person=person, + from_location=old_location, + to_location=location or equipment.location, + transfer_type='assignment', + ) + + return equipment + + @classmethod + @transaction.atomic + def transfer( + cls, + equipment, + to_department, + to_person=None, + to_location: Optional[str] = None, + reason: str = '' + ): + """ + Перемещает оборудование в другой отдел. + """ + from .models import EquipmentTransferLog + + old_department = equipment.department + old_person = equipment.responsible_person + old_location = equipment.location + + equipment.department = to_department + equipment.responsible_person = to_person + if to_location: + equipment.location = to_location + equipment.save() + + # Логируем передачу + EquipmentTransferLog.objects.create( + equipment=equipment, + from_department=old_department, + to_department=to_department, + from_person=old_person, + to_person=to_person, + from_location=old_location, + to_location=to_location or '', + transfer_type='transfer', + reason=reason, + ) + + return equipment + + @classmethod + @transaction.atomic + def write_off(cls, equipment, reason: str): + """ + Списывает оборудование. + """ + from .constants import EquipmentStatus + + equipment.status = EquipmentStatus.WRITTEN_OFF + equipment.notes = ( + f'{equipment.notes}\n\n' + f'[{timezone.now().strftime("%Y-%m-%d")}] Списано: {reason}' + ).strip() + equipment.save() + + return equipment + + @classmethod + @transaction.atomic + def add_maintenance_record( + cls, + equipment, + maintenance_type: str, + description: str, + performed_by, + cost=None, + date=None + ): + """ + Добавляет запись о техническом обслуживании. + """ + from decimal import Decimal + from .models import MaintenanceRecord + from .constants import EquipmentStatus + + if date is None: + date = timezone.now().date() + + if cost is None: + cost = Decimal('0.00') + + record = MaintenanceRecord.objects.create( + equipment=equipment, + date=date, + type=maintenance_type, + description=description, + cost=cost, + performed_by=performed_by, + ) + + # Если на ремонте - обновляем статус + if maintenance_type == 'repair': + equipment.status = EquipmentStatus.IN_REPAIR + equipment.save() + + return record diff --git a/backend/procurement/views.py b/backend/procurement/views.py index b2907a67..d8ee90c6 100644 --- a/backend/procurement/views.py +++ b/backend/procurement/views.py @@ -9,17 +9,19 @@ from rest_framework.decorators import action from rest_framework.response import Response from employees.models import Department -from .constants import ApprovalStatus, ProcurementStatus +from .constants import ApprovalStatus, ProcurementStatus, EquipmentStatus from .models import ( Approval, Budget, Equipment, EquipmentCategory, + EquipmentTransferLog, MaintenanceRecord, ProcurementItem, ProcurementRequest, Supplier, ) +from .services import QRCodeGenerator from .permissions import ( CanApproveProcurementRequest, CanCreateProcurementRequest, @@ -490,6 +492,162 @@ class EquipmentViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + @action(detail=True, methods=['post']) + def transfer(self, request, pk=None): + """Перевод оборудования в другой отдел или другому пользователю.""" + equipment = self.get_object() + + to_department_id = request.data.get('to_department') + to_person_id = request.data.get('to_person') + reason = request.data.get('reason', '') + + if not to_department_id and not to_person_id: + return Response( + {'error': 'Укажите отдел или ответственного'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Сохраняем старые значения + from_department = equipment.department + from_person = equipment.responsible_person + + # Обновляем оборудование + if to_department_id: + try: + to_department = Department.objects.get(pk=to_department_id) + equipment.department = to_department + except Department.DoesNotExist: + return Response( + {'error': 'Отдел не найден'}, + status=status.HTTP_404_NOT_FOUND + ) + else: + to_department = from_department + + if to_person_id: + from django.contrib.auth import get_user_model + User = get_user_model() + try: + to_person = User.objects.get(pk=to_person_id) + equipment.responsible_person = to_person + except User.DoesNotExist: + return Response( + {'error': 'Пользователь не найден'}, + status=status.HTTP_404_NOT_FOUND + ) + else: + to_person = from_person + + equipment.save() + + # Создаём лог перевода + EquipmentTransferLog.objects.create( + equipment=equipment, + from_department=from_department, + to_department=to_department, + from_person=from_person, + to_person=to_person, + reason=reason, + created_by=request.user + ) + + return Response({ + 'status': 'transferred', + 'equipment_id': equipment.id, + 'from_department': str(from_department), + 'to_department': str(to_department) + }) + + @action(detail=True, methods=['post']) + def write_off(self, request, pk=None): + """Списание оборудования.""" + equipment = self.get_object() + reason = request.data.get('reason', '') + + if equipment.status == EquipmentStatus.RETIRED: + return Response( + {'error': 'Оборудование уже списано'}, + status=status.HTTP_400_BAD_REQUEST + ) + + equipment.status = EquipmentStatus.RETIRED + equipment.notes = (equipment.notes or '') + f'\n\nСписано: {reason}' + equipment.save() + + return Response({ + 'status': 'written_off', + 'equipment_id': equipment.id + }) + + @action(detail=True, methods=['post']) + def add_maintenance(self, request, pk=None): + """Добавить запись об обслуживании.""" + from datetime import date + equipment = self.get_object() + + maintenance_type = request.data.get('type', 'repair') + description = request.data.get('description', '') + cost = request.data.get('cost') + maintenance_date = request.data.get('date', date.today()) + + record = MaintenanceRecord.objects.create( + equipment=equipment, + type=maintenance_type, + description=description, + cost=cost, + date=maintenance_date, + performed_by=request.user + ) + + return Response({ + 'status': 'created', + 'maintenance_id': record.id, + 'equipment_id': equipment.id + }, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['get']) + def qr_code(self, request, pk=None): + """Получить QR-код для оборудования.""" + equipment = self.get_object() + + qr_file = QRCodeGenerator.generate_for_equipment(equipment) + + from django.http import HttpResponse + response = HttpResponse(qr_file.read(), content_type='image/png') + response['Content-Disposition'] = ( + f'inline; filename="equipment_{equipment.id}_qr.png"' + ) + return response + + @action(detail=True, methods=['get']) + def transfer_history(self, request, pk=None): + """История переводов оборудования.""" + equipment = self.get_object() + + logs = EquipmentTransferLog.objects.filter( + equipment=equipment + ).order_by('-created_at') + + data = [ + { + 'id': log.id, + 'from_department': str(log.from_department), + 'to_department': str(log.to_department), + 'from_person': ( + str(log.from_person) if log.from_person else None + ), + 'to_person': ( + str(log.to_person) if log.to_person else None + ), + 'reason': log.reason, + 'created_by': str(log.created_by) if log.created_by else None, + 'date': log.created_at.isoformat() + } + for log in logs + ] + + return Response(data) + class MaintenanceRecordViewSet(viewsets.ModelViewSet): """ViewSet для записей обслуживания.""" diff --git a/backend/tests/procurement/test_inventory.py b/backend/tests/procurement/test_inventory.py new file mode 100644 index 00000000..bcac80f1 --- /dev/null +++ b/backend/tests/procurement/test_inventory.py @@ -0,0 +1,454 @@ +""" +Тесты для Inventory - transfer, write_off, QR-коды, инвентарные номера. +""" +import pytest +from rest_framework import status + +from procurement.models import ( + Equipment, + EquipmentCategory, + EquipmentTransferLog, + MaintenanceRecord, +) +from procurement.constants import EquipmentStatus +from procurement.services import InventoryNumberGenerator, QRCodeGenerator + + +@pytest.mark.django_db +class TestInventoryNumberGenerator: + """Тесты для генератора инвентарных номеров.""" + + @pytest.fixture(autouse=True) + def setup(self, department_factory, user_factory, link_factory): + """Настройка тестовых данных.""" + self.department = department_factory(name='IT Inventory Test') + self.user = user_factory(staff=True, + email='inventory_user@test.com', + first_name='Test', + last_name='Inventory', + ) + link_factory(self.user, self.department, is_active=True) + self.category = EquipmentCategory.objects.create( + name='Computers', + description='Компьютеры и ноутбуки', + ) + + def test_generate_first_number(self): + """Тест: генерация первого инвентарного номера за год.""" + from django.utils import timezone + year = timezone.now().year + + number = InventoryNumberGenerator.generate() + + assert number == f'INV-{year}-0001' + + def test_generate_sequential_numbers(self): + """Тест: последовательная генерация номеров.""" + from django.utils import timezone + year = timezone.now().year + + # Создаём оборудование с первым номером + Equipment.objects.create( + name='Laptop 1', + inventory_number=f'INV-{year}-0005', + category=self.category, + department=self.department, + responsible_person=self.user, + status=EquipmentStatus.IN_USE, + purchase_date='2025-01-01', + purchase_cost='10000.00', + ) + + number = InventoryNumberGenerator.generate() + + assert number == f'INV-{year}-0006' + + def test_validate_correct_format(self): + """Тест: валидация правильного формата.""" + assert InventoryNumberGenerator.validate('INV-2025-0001') is True + assert InventoryNumberGenerator.validate('INV-2024-9999') is True + + def test_validate_incorrect_format(self): + """Тест: валидация неправильного формата.""" + assert InventoryNumberGenerator.validate('INV-2025') is False + assert InventoryNumberGenerator.validate('ABC-2025-0001') is False + assert InventoryNumberGenerator.validate('') is False + + +@pytest.mark.django_db +class TestQRCodeGenerator: + """Тесты для генератора QR-кодов.""" + + @pytest.fixture(autouse=True) + def setup(self, department_factory, user_factory, link_factory): + """Настройка тестовых данных.""" + self.department = department_factory(name='IT QR Test') + self.user = user_factory(staff=True, + email='qr_user@test.com', + first_name='Test', + last_name='QR', + ) + link_factory(self.user, self.department, is_active=True) + self.category = EquipmentCategory.objects.create( + name='Monitors', + description='Мониторы', + ) + self.equipment = Equipment.objects.create( + name='Monitor Dell 27', + inventory_number='INV-2025-0100', + category=self.category, + department=self.department, + responsible_person=self.user, + status=EquipmentStatus.IN_USE, + purchase_date='2025-01-01', + purchase_cost='10000.00', + ) + + def test_generate_qr_code(self): + """Тест: генерация QR-кода для оборудования.""" + qr_file = QRCodeGenerator.generate_for_equipment(self.equipment) + + assert qr_file is not None + assert qr_file.name.endswith('.png') + # Файл должен содержать PNG данные + content = qr_file.read() + assert len(content) > 0 + # PNG начинается с signature + assert content[:4] == b'\x89PNG' + + def test_qr_code_path(self): + """Тест: путь к файлу QR-кода.""" + path = QRCodeGenerator.get_qr_code_path('INV-2025-0100') + + assert 'INV-2025-0100.png' in path + + +@pytest.mark.django_db +class TestEquipmentTransferEndpoint: + """Тесты для /equipment/{id}/transfer/ endpoint.""" + + @pytest.fixture(autouse=True) + def setup( + self, api_client, department_factory, user_factory, link_factory + ): + """Настройка тестовых данных.""" + self.client = api_client + self.dept1 = department_factory(name='IT Dept') + self.dept2 = department_factory(name='HR Dept') + + self.user1 = user_factory(staff=True, + email='transfer_user1@test.com', + first_name='User', + last_name='One', + ) + self.user2 = user_factory( + email='transfer_user2@test.com', + first_name='User', + last_name='Two', + ) + link_factory(self.user1, self.dept1, is_active=True) + link_factory(self.user2, self.dept2, is_active=True) + + self.category = EquipmentCategory.objects.create( + name='Laptops', + description='Ноутбуки', + ) + self.equipment = Equipment.objects.create( + name='MacBook Pro', + inventory_number='INV-2025-0200', + category=self.category, + department=self.dept1, + responsible_person=self.user1, + status=EquipmentStatus.IN_USE, + purchase_date='2025-01-01', + purchase_cost='10000.00', + ) + + def test_transfer_to_another_department(self): + """Тест: перевод оборудования в другой отдел.""" + self.client.force_authenticate(user=self.user1) + + response = self.client.post( + f'/api/procurement/equipment/{self.equipment.id}/transfer/', + { + 'to_department': self.dept2.id, + 'reason': 'Переезд сотрудника', + } + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data['status'] == 'transferred' + + # Проверяем обновление оборудования + self.equipment.refresh_from_db() + assert self.equipment.department == self.dept2 + + # Проверяем лог перевода + log = EquipmentTransferLog.objects.filter( + equipment=self.equipment + ).first() + assert log is not None + assert log.from_department == self.dept1 + assert log.to_department == self.dept2 + assert log.reason == 'Переезд сотрудника' + + def test_transfer_to_another_person(self): + """Тест: перевод оборудования другому пользователю.""" + self.client.force_authenticate(user=self.user1) + + response = self.client.post( + f'/api/procurement/equipment/{self.equipment.id}/transfer/', + { + 'to_person': self.user2.id, + 'reason': 'Смена ответственного', + } + ) + + assert response.status_code == status.HTTP_200_OK + + self.equipment.refresh_from_db() + assert self.equipment.responsible_person == self.user2 + + def test_transfer_requires_target(self): + """Тест: нужно указать отдел или пользователя.""" + self.client.force_authenticate(user=self.user1) + + response = self.client.post( + f'/api/procurement/equipment/{self.equipment.id}/transfer/', + {'reason': 'Без цели'}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +class TestEquipmentWriteOffEndpoint: + """Тесты для /equipment/{id}/write_off/ endpoint.""" + + @pytest.fixture(autouse=True) + def setup( + self, api_client, department_factory, user_factory, link_factory + ): + """Настройка тестовых данных.""" + self.client = api_client + self.department = department_factory(name='IT WriteOff') + self.user = user_factory(staff=True, + email='writeoff_user@test.com', + first_name='Test', + last_name='WriteOff', + ) + link_factory(self.user, self.department, is_active=True) + + self.category = EquipmentCategory.objects.create( + name='Printers', + description='Принтеры', + ) + self.equipment = Equipment.objects.create( + name='HP LaserJet', + inventory_number='INV-2025-0300', + category=self.category, + department=self.department, + responsible_person=self.user, + status=EquipmentStatus.IN_USE, + purchase_date='2025-01-01', + purchase_cost='10000.00', + ) + + def test_write_off_equipment(self): + """Тест: списание оборудования.""" + self.client.force_authenticate(user=self.user) + + response = self.client.post( + f'/api/procurement/equipment/{self.equipment.id}/write_off/', + {'reason': 'Износ'} + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data['status'] == 'written_off' + + self.equipment.refresh_from_db() + assert self.equipment.status == EquipmentStatus.RETIRED + assert 'Износ' in self.equipment.notes + + def test_cannot_write_off_twice(self): + """Тест: нельзя списать уже списанное оборудование.""" + self.equipment.status = EquipmentStatus.RETIRED + self.equipment.save() + + self.client.force_authenticate(user=self.user) + + response = self.client.post( + f'/api/procurement/equipment/{self.equipment.id}/write_off/', + {'reason': 'Повторно'} + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +class TestEquipmentMaintenanceEndpoint: + """Тесты для /equipment/{id}/add_maintenance/ endpoint.""" + + @pytest.fixture(autouse=True) + def setup( + self, api_client, department_factory, user_factory, link_factory + ): + """Настройка тестовых данных.""" + self.client = api_client + self.department = department_factory(name='IT Maintenance') + self.user = user_factory(staff=True, + email='maintenance_user@test.com', + first_name='Test', + last_name='Maintenance', + ) + link_factory(self.user, self.department, is_active=True) + + self.category = EquipmentCategory.objects.create( + name='Servers', + description='Серверы', + ) + self.equipment = Equipment.objects.create( + name='Dell PowerEdge', + inventory_number='INV-2025-0400', + category=self.category, + department=self.department, + responsible_person=self.user, + status=EquipmentStatus.IN_USE, + purchase_date='2025-01-01', + purchase_cost='10000.00', + ) + + def test_add_maintenance_record(self): + """Тест: добавление записи об обслуживании.""" + self.client.force_authenticate(user=self.user) + + response = self.client.post( + f'/api/procurement/equipment/{self.equipment.id}/add_maintenance/', + { + 'type': 'repair', + 'description': 'Замена блока питания', + 'cost': '5000.00', + } + ) + + assert response.status_code == status.HTTP_201_CREATED + assert 'maintenance_id' in response.data + + # Проверяем создание записи + record = MaintenanceRecord.objects.get(id=response.data['maintenance_id']) + assert record.equipment == self.equipment + assert record.description == 'Замена блока питания' + assert record.performed_by == self.user + + +@pytest.mark.django_db +class TestEquipmentQRCodeEndpoint: + """Тесты для /equipment/{id}/qr_code/ endpoint.""" + + @pytest.fixture(autouse=True) + def setup( + self, api_client, department_factory, user_factory, link_factory + ): + """Настройка тестовых данных.""" + self.client = api_client + self.department = department_factory(name='IT QR Code') + self.user = user_factory(staff=True, + email='qrcode_user@test.com', + first_name='Test', + last_name='QRCode', + ) + link_factory(self.user, self.department, is_active=True) + + self.category = EquipmentCategory.objects.create( + name='Networking', + description='Сетевое оборудование', + ) + self.equipment = Equipment.objects.create( + name='Cisco Router', + inventory_number='INV-2025-0500', + category=self.category, + department=self.department, + responsible_person=self.user, + status=EquipmentStatus.IN_USE, + purchase_date='2025-01-01', + purchase_cost='10000.00', + ) + + def test_get_qr_code(self): + """Тест: получение QR-кода для оборудования.""" + self.client.force_authenticate(user=self.user) + + response = self.client.get( + f'/api/procurement/equipment/{self.equipment.id}/qr_code/' + ) + + assert response.status_code == status.HTTP_200_OK + assert response['Content-Type'] == 'image/png' + # PNG signature + assert response.content[:4] == b'\x89PNG' + + +@pytest.mark.django_db +class TestEquipmentTransferHistoryEndpoint: + """Тесты для /equipment/{id}/transfer_history/ endpoint.""" + + @pytest.fixture(autouse=True) + def setup( + self, api_client, department_factory, user_factory, link_factory + ): + """Настройка тестовых данных.""" + self.client = api_client + self.dept1 = department_factory(name='IT History 1') + self.dept2 = department_factory(name='IT History 2') + + self.user1 = user_factory(staff=True, + email='history_user1@test.com', + first_name='User', + last_name='History1', + ) + self.user2 = user_factory( + email='history_user2@test.com', + first_name='User', + last_name='History2', + ) + link_factory(self.user1, self.dept1, is_active=True) + + self.category = EquipmentCategory.objects.create( + name='Phones', + description='Телефоны', + ) + self.equipment = Equipment.objects.create( + name='iPhone 15', + inventory_number='INV-2025-0600', + category=self.category, + department=self.dept1, + responsible_person=self.user1, + status=EquipmentStatus.IN_USE, + purchase_date='2025-01-01', + purchase_cost='10000.00', + ) + + # Создаём историю переводов + EquipmentTransferLog.objects.create( + equipment=self.equipment, + from_department=self.dept1, + to_department=self.dept2, + from_person=self.user1, + to_person=self.user2, + reason='Первый перевод', + created_by=self.user1, + ) + + def test_get_transfer_history(self): + """Тест: получение истории переводов.""" + self.client.force_authenticate(user=self.user1) + + response = self.client.get( + f'/api/procurement/equipment/{self.equipment.id}/transfer_history/' + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]['reason'] == 'Первый перевод' + assert 'from_department' in response.data[0] + assert 'to_department' in response.data[0]