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)
This commit is contained in:
admin i 2025-12-09 10:42:07 +03:00
parent 3c6eeb93dd
commit 3cd161d83c
6 changed files with 1302 additions and 146 deletions

View File

@ -30,6 +30,7 @@
**Обоснование:** **Обоснование:**
**За использование NotificationService:** **За использование NotificationService:**
1. ✅ **Уже существует и протестирована** - используется во всех модулях (communications, documents, feed, calendar_app, requests_app) 1. ✅ **Уже существует и протестирована** - используется во всех модулях (communications, documents, feed, calendar_app, requests_app)
2. ✅ **Полнофункциональная** - поддерживает Web (WebSocket), Email (HTML), Telegram, настройки пользователя 2. ✅ **Полнофункциональная** - поддерживает Web (WebSocket), Email (HTML), Telegram, настройки пользователя
3. ✅ **Простота** - не требует дополнительных процессов и сервисов 3. ✅ **Простота** - не требует дополнительных процессов и сервисов
@ -38,22 +39,27 @@
6. ✅ **Легче debugging** - ошибки в том же процессе и логах 6. ✅ **Легче debugging** - ошибки в том же процессе и логах
**Против Celery:** **Против Celery:**
1. ❌ **Усложнение** - +2 процесса в production (worker + beat) 1. ❌ **Усложнение** - +2 процесса в production (worker + beat)
2. ❌ **Дублирование** - NotificationService уже отправляет email 2. ❌ **Дублирование** - NotificationService уже отправляет email
3. ❌ **Overkill** - избыточно для текущего масштаба проекта 3. ❌ **Overkill** - избыточно для текущего масштаба проекта
4. ❌ **DevOps overhead** - настройка supervisor/systemd, мониторинг workers 4. ❌ **DevOps overhead** - настройка supervisor/systemd, мониторинг workers
**Производительность:** **Производительность:**
- SMTP отправка: ~0.5-2 секунды - SMTP отправка: ~0.5-2 секунды
- Согласование заявки: редкое действие (1-5 раз/день на отдел) - Согласование заявки: редкое действие (1-5 раз/день на отдел)
- Задержка 2 секунды приемлема для редких critical операций - Задержка 2 секунды приемлема для редких critical операций
**Когда пересмотреть:** **Когда пересмотреть:**
- >500 сотрудников и >50 заявок/день
- > 500 сотрудников и >50 заявок/день
>
- Генерация тяжелых отчётов (PDF >10MB) - Генерация тяжелых отчётов (PDF >10MB)
- Нестабильный email-провайдер требует retry логики - Нестабильный email-провайдер требует retry логики
**Периодические задачи:** Используется **APScheduler** (уже в requirements.txt) для: **Периодические задачи:** Используется **APScheduler** (уже в requirements.txt) для:
- Дайджесты уведомлений - Дайджесты уведомлений
- Напоминания о просроченных заявках - Напоминания о просроченных заявках
- Очистка старых данных - Очистка старых данных
@ -63,31 +69,37 @@
## Обзор Проекта ## Обзор Проекта
### Цель ### Цель
Создать систему управления внутренними закупками и инвентарем с автоматизированным workflow согласования заявок и контролем бюджетов отделов. Создать систему управления внутренними закупками и инвентарем с автоматизированным workflow согласования заявок и контролем бюджетов отделов.
### Основные Возможности ### Основные Возможности
**Для сотрудников:** **Для сотрудников:**
- ✅ Создание заявок на закупку - ✅ Создание заявок на закупку
- ✅ Отслеживание статуса своих заявок - ✅ Отслеживание статуса своих заявок
- ✅ Просмотр доступного оборудования - ✅ Просмотр доступного оборудования
**Для руководителей отделов:** **Для руководителей отделов:**
- ✅ Согласование/отклонение заявок отдела - ✅ Согласование/отклонение заявок отдела
- ✅ Просмотр бюджета отдела - ✅ Просмотр бюджета отдела
- ✅ Управление инвентарем отдела - ✅ Управление инвентарем отдела
**Для финансового отдела:** **Для финансового отдела:**
- ✅ Контроль бюджетов всех отделов - ✅ Контроль бюджетов всех отделов
- ✅ Финансовое согласование заявок - ✅ Финансовое согласование заявок
- ✅ Формирование отчетов - ✅ Формирование отчетов
**Для менеджера закупок:** **Для менеджера закупок:**
- ✅ Исполнение одобренных заявок - ✅ Исполнение одобренных заявок
- ✅ Работа с поставщиками - ✅ Работа с поставщиками
- ✅ Оприходование закупленного оборудования - ✅ Оприходование закупленного оборудования
**Для завхоза:** **Для завхоза:**
- ✅ Учет всего оборудования - ✅ Учет всего оборудования
- ✅ Назначение ответственных - ✅ Назначение ответственных
- ✅ Контроль технического обслуживания - ✅ Контроль технического обслуживания
@ -95,13 +107,13 @@
### Метрики Успеха ### Метрики Успеха
| Метрика | Целевое Значение | | Метрика | Целевое Значение |
|---------|------------------| | ------------------------------------------------------------- | ------------------------------- |
| Среднее время согласования | < 3 рабочих дня | | Среднее время согласования | < 3 рабочих дня |
| Процент заявок без доработок | > 90% | | Процент заявок без доработок | > 90% |
| Точность бюджетирования | 95-105% | | Точность бюджетирования | 95-105% |
| Удовлетворенность пользователей | > 4.0/5.0 | | Удовлетворенность пользователей | > 4.0/5.0 |
| Критических багов в production | 0 | | Критических багов в production | 0 |
--- ---
@ -195,6 +207,7 @@
### Модель 1: ProcurementRequest (Заявка на закупку) ### Модель 1: ProcurementRequest (Заявка на закупку)
**Поля:** **Поля:**
```python ```python
title = CharField(max_length=255) # Название заявки title = CharField(max_length=255) # Название заявки
description = TextField() # Описание/обоснование description = TextField() # Описание/обоснование
@ -210,6 +223,7 @@ completed_at = DateTimeField(null=True) # Когда завершена
``` ```
**Статусы:** **Статусы:**
- `DRAFT` - Черновик (редактируется) - `DRAFT` - Черновик (редактируется)
- `PENDING` - На согласовании - `PENDING` - На согласовании
- `APPROVED` - Одобрено (все согласования пройдены) - `APPROVED` - Одобрено (все согласования пройдены)
@ -219,6 +233,7 @@ completed_at = DateTimeField(null=True) # Когда завершена
- `CANCELLED` - Отменено заявителем - `CANCELLED` - Отменено заявителем
**Методы:** **Методы:**
- `submit()` - отправить на согласование (DRAFT → PENDING) - `submit()` - отправить на согласование (DRAFT → PENDING)
- `approve(approver)` - согласовать (PENDING → APPROVED если все ОК) - `approve(approver)` - согласовать (PENDING → APPROVED если все ОК)
- `reject(approver, reason)` - отклонить (PENDING → REJECTED) - `reject(approver, reason)` - отклонить (PENDING → REJECTED)
@ -229,6 +244,7 @@ completed_at = DateTimeField(null=True) # Когда завершена
- `check_budget_available()` - есть ли бюджет в отделе - `check_budget_available()` - есть ли бюджет в отделе
**Permissions:** **Permissions:**
- `procurement.add_procurementrequest` - создавать заявки - `procurement.add_procurementrequest` - создавать заявки
- `procurement.change_procurementrequest` - редактировать свои - `procurement.change_procurementrequest` - редактировать свои
- `procurement.view_procurementrequest` - просматривать - `procurement.view_procurementrequest` - просматривать
@ -238,6 +254,7 @@ completed_at = DateTimeField(null=True) # Когда завершена
### Модель 2: ProcurementItem (Позиция заявки) ### Модель 2: ProcurementItem (Позиция заявки)
**Поля:** **Поля:**
```python ```python
request = ForeignKey(ProcurementRequest, related_name='items') request = ForeignKey(ProcurementRequest, related_name='items')
name = CharField(max_length=255) # Название товара/услуги name = CharField(max_length=255) # Название товара/услуги
@ -251,11 +268,13 @@ created_at = DateTimeField(auto_now_add=True)
``` ```
**Свойства:** **Свойства:**
- `total_price` - quantity × estimated_unit_price - `total_price` - quantity × estimated_unit_price
### Модель 3: Approval (Согласование) ### Модель 3: Approval (Согласование)
**Поля:** **Поля:**
```python ```python
request = ForeignKey(ProcurementRequest, related_name='approvals') request = ForeignKey(ProcurementRequest, related_name='approvals')
approver = ForeignKey(User) # Кто согласовывает approver = ForeignKey(User) # Кто согласовывает
@ -267,16 +286,19 @@ updated_at = DateTimeField(auto_now=True)
``` ```
**Роли согласующих:** **Роли согласующих:**
- `DEPARTMENT_HEAD` - Руководитель отдела - `DEPARTMENT_HEAD` - Руководитель отдела
- `FINANCE_MANAGER` - Финансовый менеджер - `FINANCE_MANAGER` - Финансовый менеджер
- `DIRECTOR` - Генеральный директор - `DIRECTOR` - Генеральный директор
**Статусы:** **Статусы:**
- `PENDING` - Ожидает решения - `PENDING` - Ожидает решения
- `APPROVED` - Одобрено - `APPROVED` - Одобрено
- `REJECTED` - Отклонено - `REJECTED` - Отклонено
**Логика согласования:** **Логика согласования:**
```python ```python
if estimated_cost < 10_000: if estimated_cost < 10_000:
# Только руководитель отдела # Только руководитель отдела
@ -292,6 +314,7 @@ else:
### Модель 4: Equipment (Оборудование) ### Модель 4: Equipment (Оборудование)
**Поля:** **Поля:**
```python ```python
name = CharField(max_length=255) # Название name = CharField(max_length=255) # Название
inventory_number = CharField(unique=True) # Инвентарный номер (INV-2024-0001) inventory_number = CharField(unique=True) # Инвентарный номер (INV-2024-0001)
@ -311,6 +334,7 @@ updated_at = DateTimeField(auto_now=True)
``` ```
**Статусы:** **Статусы:**
- `AVAILABLE` - Доступно (на складе) - `AVAILABLE` - Доступно (на складе)
- `IN_USE` - В использовании - `IN_USE` - В использовании
- `MAINTENANCE` - На обслуживании - `MAINTENANCE` - На обслуживании
@ -319,6 +343,7 @@ updated_at = DateTimeField(auto_now=True)
- `LOST` - Утеряно - `LOST` - Утеряно
**Методы:** **Методы:**
- `assign_to(user, location)` - назначить ответственного - `assign_to(user, location)` - назначить ответственного
- `send_to_maintenance(description)` - отправить на ТО - `send_to_maintenance(description)` - отправить на ТО
- `retire(reason)` - списать - `retire(reason)` - списать
@ -327,6 +352,7 @@ updated_at = DateTimeField(auto_now=True)
### Модель 5: EquipmentCategory (Категория оборудования) ### Модель 5: EquipmentCategory (Категория оборудования)
**Поля:** **Поля:**
```python ```python
name = CharField(max_length=100, unique=True) name = CharField(max_length=100, unique=True)
parent = ForeignKey('self', null=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 (Запись обслуживания) ### Модель 6: MaintenanceRecord (Запись обслуживания)
**Поля:** **Поля:**
```python ```python
equipment = ForeignKey(Equipment, related_name='maintenance_history') equipment = ForeignKey(Equipment, related_name='maintenance_history')
date = DateField() # Дата обслуживания date = DateField() # Дата обслуживания
@ -370,6 +398,7 @@ created_at = DateTimeField(auto_now_add=True)
``` ```
**Типы обслуживания:** **Типы обслуживания:**
- `INSPECTION` - Осмотр/проверка - `INSPECTION` - Осмотр/проверка
- `MAINTENANCE` - Плановое ТО - `MAINTENANCE` - Плановое ТО
- `REPAIR` - Ремонт - `REPAIR` - Ремонт
@ -379,6 +408,7 @@ created_at = DateTimeField(auto_now_add=True)
### Модель 7: Budget (Бюджет отдела) ### Модель 7: Budget (Бюджет отдела)
**Поля:** **Поля:**
```python ```python
department = ForeignKey(Department) department = ForeignKey(Department)
year = PositiveIntegerField() # Год year = PositiveIntegerField() # Год
@ -393,6 +423,7 @@ class Meta:
``` ```
**Методы:** **Методы:**
- `remaining_amount` - остаток бюджета - `remaining_amount` - остаток бюджета
- `utilization_percentage` - процент использования - `utilization_percentage` - процент использования
- `can_spend(amount)` - можно ли потратить сумму - `can_spend(amount)` - можно ли потратить сумму
@ -402,6 +433,7 @@ class Meta:
### Модель 8: Supplier (Поставщик) ### Модель 8: Supplier (Поставщик)
**Поля:** **Поля:**
```python ```python
name = CharField(max_length=255) name = CharField(max_length=255)
contact_person = CharField(max_length=255, blank=True) contact_person = CharField(max_length=255, blank=True)
@ -423,6 +455,7 @@ created_at = DateTimeField(auto_now_add=True)
### Django Model Permissions ### Django Model Permissions
**Автоматические (создаются Django):** **Автоматические (создаются Django):**
- `procurement.add_procurementrequest` - `procurement.add_procurementrequest`
- `procurement.change_procurementrequest` - `procurement.change_procurementrequest`
- `procurement.delete_procurementrequest` - `procurement.delete_procurementrequest`
@ -433,6 +466,7 @@ created_at = DateTimeField(auto_now_add=True)
- `procurement.view_equipment` - `procurement.view_equipment`
**Кастомные (добавляем вручную):** **Кастомные (добавляем вручную):**
```python ```python
class ProcurementRequest(models.Model): class ProcurementRequest(models.Model):
class Meta: class Meta:
@ -459,23 +493,23 @@ class DeptPerm:
### Матрица Прав Доступа ### Матрица Прав Доступа
| Действие | Сотрудник | Рук. Отдела | Финансы | Закупки | Завхоз | Admin | | Действие | Сотрудник | Рук. Отдела | Финансы | Закупки | Завхоз | Admin |
|----------|-----------|-------------|---------|---------|--------|-------| | ------------------------------------------------------ | ------------------ | -------------------- | -------------- | -------------- | ------------ | ----- |
| Создать заявку | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Создать заявку | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Просмотреть свои заявки | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Просмотреть свои заявки | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Просмотреть заявки отдела | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | | Просмотреть заявки отдела | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| Просмотреть все заявки | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | | Просмотреть все заявки | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
| Согласовать (1 уровень) | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | | Согласовать (1 уровень) | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| Согласовать (финансы) | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | | Согласовать (финансы) | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
| Согласовать (директор) | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | | Согласовать (директор) | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Исполнить заявку | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | | Исполнить заявку | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
| Просмотр оборудования | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Просмотр оборудования | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Добавить оборудование | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | | Добавить оборудование | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| Управление инвентарем отдела | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | | Управление инвентарем отдела | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ |
| Управление всем инвентарем | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | | Управление всем инвентарем | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| Списание оборудования | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | | Списание оборудования | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| Просмотр бюджета отдела | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | | Просмотр бюджета отдела | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ |
| Управление бюджетами | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | | Управление бюджетами | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
### Permission Classes для API ### Permission Classes для API
@ -536,44 +570,52 @@ stateDiagram-v2
### Правила Согласования по Сумме ### Правила Согласования по Сумме
| Сумма заявки | Необходимые согласования | Примерный срок | | Сумма заявки | Необходимые согласования | Примерный срок |
|--------------|--------------------------|----------------| | ----------------------- | ------------------------------------------------------------------------- | --------------------------- |
| < 10,000₽ | Руководитель отдела | 1-2 дня | | < 10,000₽ | Руководитель отдела | 1-2 дня |
| 10,000 - 100,000₽ | Руководитель отдела + Финансы | 2-3 дня | | 10,000 - 100,000₽ | Руководитель отдела + Финансы | 2-3 дня |
| > 100,000₽ | Руководитель отдела + Финансы + Директор | 3-5 дней | | > 100,000₽ | Руководитель отдела + Финансы + Директор | 3-5 дней |
### Уведомления ### Уведомления
**При создании заявки:** **При создании заявки:**
- ✉️ Email руководителю отдела: "Новая заявка требует вашего согласования" - ✉️ Email руководителю отдела: "Новая заявка требует вашего согласования"
**При согласовании:** **При согласовании:**
- ✉️ Email следующему согласующему (если требуется) - ✉️ Email следующему согласующему (если требуется)
- ✉️ Email автору заявки: "Ваша заявка согласована [Роль]" - ✉️ Email автору заявки: "Ваша заявка согласована [Роль]"
**При полном согласовании:** **При полном согласовании:**
- ✉️ Email менеджеру закупок: "Заявка одобрена, можно приступать к исполнению" - ✉️ Email менеджеру закупок: "Заявка одобрена, можно приступать к исполнению"
- ✉️ Email автору: "Ваша заявка полностью одобрена и передана в работу" - ✉️ Email автору: "Ваша заявка полностью одобрена и передана в работу"
**При отклонении:** **При отклонении:**
- ✉️ Email автору: "Ваша заявка отклонена. Причина: ..." - ✉️ Email автору: "Ваша заявка отклонена. Причина: ..."
**При завершении:** **При завершении:**
- ✉️ Email автору: "Ваша заявка выполнена" - ✉️ Email автору: "Ваша заявка выполнена"
- ✉️ Email руководителю отдела: "Заявка выполнена, оборудование оприходовано" - ✉️ Email руководителю отдела: "Заявка выполнена, оборудование оприходовано"
### Автоматические Проверки ### Автоматические Проверки
**Перед отправкой на согласование (submit):** **Перед отправкой на согласование (submit):**
- ✅ Есть хотя бы одна позиция в заявке - ✅ Есть хотя бы одна позиция в заявке
- ✅ Указана предполагаемая стоимость - ✅ Указана предполагаемая стоимость
- ✅ Заполнены обязательные поля - ✅ Заполнены обязательные поля
**Перед финансовым согласованием:** **Перед финансовым согласованием:**
- ✅ В бюджете отдела достаточно средств - ✅ В бюджете отдела достаточно средств
- ✅ Если бюджета нет → алерт финансовому директору - ✅ Если бюджета нет → алерт финансовому директору
**Перед завершением:** **Перед завершением:**
- ✅ Указана фактическая стоимость - ✅ Указана фактическая стоимость
- ✅ Все позиции оприходованы (если оборудование) - ✅ Все позиции оприходованы (если оборудование)
@ -582,6 +624,7 @@ stateDiagram-v2
## API Endpoints ## API Endpoints
### Базовый URL ### Базовый URL
``` ```
/api/v1/procurement/ /api/v1/procurement/
``` ```
@ -589,10 +632,13 @@ stateDiagram-v2
### Заявки (Requests) ### Заявки (Requests)
#### Список и создание #### Список и создание
```http ```http
GET /api/v1/procurement/requests/ GET /api/v1/procurement/requests/
``` ```
**Query Parameters:** **Query Parameters:**
- `status` - фильтр по статусу (draft, pending, approved, etc.) - `status` - фильтр по статусу (draft, pending, approved, etc.)
- `department` - ID отдела - `department` - ID отдела
- `requestor` - ID заявителя - `requestor` - ID заявителя
@ -605,6 +651,7 @@ GET /api/v1/procurement/requests/
- `ordering` - сортировка (created_at, estimated_cost, -created_at) - `ordering` - сортировка (created_at, estimated_cost, -created_at)
**Response:** **Response:**
```json ```json
{ {
"count": 42, "count": 42,
@ -645,7 +692,9 @@ GET /api/v1/procurement/requests/
```http ```http
POST /api/v1/procurement/requests/ POST /api/v1/procurement/requests/
``` ```
**Request Body:** **Request Body:**
```json ```json
{ {
"title": "Новые мониторы", "title": "Новые мониторы",
@ -666,6 +715,7 @@ POST /api/v1/procurement/requests/
``` ```
#### Детали, обновление, удаление #### Детали, обновление, удаление
```http ```http
GET /api/v1/procurement/requests/{id}/ GET /api/v1/procurement/requests/{id}/
PATCH /api/v1/procurement/requests/{id}/ PATCH /api/v1/procurement/requests/{id}/
@ -675,14 +725,17 @@ DELETE /api/v1/procurement/requests/{id}/
#### Actions (Действия над заявкой) #### Actions (Действия над заявкой)
**Отправить на согласование:** **Отправить на согласование:**
```http ```http
POST /api/v1/procurement/requests/{id}/submit/ POST /api/v1/procurement/requests/{id}/submit/
``` ```
**Согласовать:** **Согласовать:**
```http ```http
POST /api/v1/procurement/requests/{id}/approve/ POST /api/v1/procurement/requests/{id}/approve/
``` ```
```json ```json
{ {
"comment": "Одобрено. Необходимо для работы." "comment": "Одобрено. Необходимо для работы."
@ -690,9 +743,11 @@ POST /api/v1/procurement/requests/{id}/approve/
``` ```
**Отклонить:** **Отклонить:**
```http ```http
POST /api/v1/procurement/requests/{id}/reject/ POST /api/v1/procurement/requests/{id}/reject/
``` ```
```json ```json
{ {
"reason": "Недостаточно бюджета в этом квартале", "reason": "Недостаточно бюджета в этом квартале",
@ -701,14 +756,17 @@ POST /api/v1/procurement/requests/{id}/reject/
``` ```
**Начать исполнение:** **Начать исполнение:**
```http ```http
POST /api/v1/procurement/requests/{id}/start_execution/ POST /api/v1/procurement/requests/{id}/start_execution/
``` ```
**Завершить:** **Завершить:**
```http ```http
POST /api/v1/procurement/requests/{id}/complete/ POST /api/v1/procurement/requests/{id}/complete/
``` ```
```json ```json
{ {
"actual_cost": "740000.00", "actual_cost": "740000.00",
@ -717,9 +775,11 @@ POST /api/v1/procurement/requests/{id}/complete/
``` ```
**Отменить:** **Отменить:**
```http ```http
POST /api/v1/procurement/requests/{id}/cancel/ POST /api/v1/procurement/requests/{id}/cancel/
``` ```
```json ```json
{ {
"reason": "Больше не актуально" "reason": "Больше не актуально"
@ -727,9 +787,11 @@ POST /api/v1/procurement/requests/{id}/cancel/
``` ```
**Добавить позицию:** **Добавить позицию:**
```http ```http
POST /api/v1/procurement/requests/{id}/add_item/ POST /api/v1/procurement/requests/{id}/add_item/
``` ```
```json ```json
{ {
"name": "Клавиатура механическая", "name": "Клавиатура механическая",
@ -742,12 +804,14 @@ POST /api/v1/procurement/requests/{id}/add_item/
### Оборудование (Equipment) ### Оборудование (Equipment)
#### Список и создание #### Список и создание
```http ```http
GET /api/v1/procurement/equipment/ GET /api/v1/procurement/equipment/
POST /api/v1/procurement/equipment/ POST /api/v1/procurement/equipment/
``` ```
**Query Parameters:** **Query Parameters:**
- `status` - статус оборудования - `status` - статус оборудования
- `category` - ID категории - `category` - ID категории
- `department` - ID отдела - `department` - ID отдела
@ -757,6 +821,7 @@ POST /api/v1/procurement/equipment/
- `ordering` - сортировка - `ordering` - сортировка
**Response:** **Response:**
```json ```json
{ {
"count": 156, "count": 156,
@ -793,9 +858,11 @@ POST /api/v1/procurement/equipment/
#### Actions #### Actions
**Назначить ответственного:** **Назначить ответственного:**
```http ```http
POST /api/v1/procurement/equipment/{id}/assign/ POST /api/v1/procurement/equipment/{id}/assign/
``` ```
```json ```json
{ {
"responsible_person": 15, "responsible_person": 15,
@ -804,9 +871,11 @@ POST /api/v1/procurement/equipment/{id}/assign/
``` ```
**Добавить запись обслуживания:** **Добавить запись обслуживания:**
```http ```http
POST /api/v1/procurement/equipment/{id}/maintenance/ POST /api/v1/procurement/equipment/{id}/maintenance/
``` ```
```json ```json
{ {
"date": "2025-12-08", "date": "2025-12-08",
@ -817,9 +886,11 @@ POST /api/v1/procurement/equipment/{id}/maintenance/
``` ```
**Списать:** **Списать:**
```http ```http
POST /api/v1/procurement/equipment/{id}/retire/ POST /api/v1/procurement/equipment/{id}/retire/
``` ```
```json ```json
{ {
"reason": "Устарело морально и физически", "reason": "Устарело морально и физически",
@ -828,9 +899,11 @@ POST /api/v1/procurement/equipment/{id}/retire/
``` ```
**История:** **История:**
```http ```http
GET /api/v1/procurement/equipment/{id}/history/ GET /api/v1/procurement/equipment/{id}/history/
``` ```
```json ```json
{ {
"equipment": {...}, "equipment": {...},
@ -855,6 +928,7 @@ PATCH /api/v1/procurement/budgets/{id}/
``` ```
**Response (my_department):** **Response (my_department):**
```json ```json
{ {
"department": { "department": {
@ -884,6 +958,7 @@ PATCH /api/v1/procurement/budgets/{id}/
```http ```http
GET /api/v1/procurement/stats/overview/ GET /api/v1/procurement/stats/overview/
``` ```
```json ```json
{ {
"total_requests": 124, "total_requests": 124,
@ -911,6 +986,7 @@ GET /api/v1/procurement/stats/overview/
```http ```http
GET /api/v1/procurement/stats/by_department/ GET /api/v1/procurement/stats/by_department/
``` ```
```json ```json
[ [
{ {
@ -929,6 +1005,7 @@ GET /api/v1/procurement/stats/by_department/
### 1. Дашборд (`/procurement/`) ### 1. Дашборд (`/procurement/`)
**Элементы:** **Элементы:**
- 📊 Карточки со статистикой (всего заявок, ожидают согласования, одобрено, потрачено) - 📊 Карточки со статистикой (всего заявок, ожидают согласования, одобрено, потрачено)
- 📈 График расходов по месяцам (Chart.js) - 📈 График расходов по месяцам (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/`) ### 2. Форма Создания Заявки (`/procurement/requests/create/`)
**Шаги:** **Шаги:**
1. Основная информация (название, описание, срочность) 1. Основная информация (название, описание, срочность)
2. Добавление позиций (таблица с возможностью добавления строк) 2. Добавление позиций (таблица с возможностью добавления строк)
3. Проверка и отправка 3. Проверка и отправка
**JavaScript функционал:** **JavaScript функционал:**
- Динамическое добавление/удаление позиций - Динамическое добавление/удаление позиций
- Автоматический расчет общей суммы - Автоматический расчет общей суммы
- Проверка остатка бюджета (AJAX запрос) - Проверка остатка бюджета (AJAX запрос)
@ -973,6 +1053,7 @@ GET /api/v1/procurement/stats/by_department/
### 3. Детальная Страница Заявки (`/procurement/requests/{id}/`) ### 3. Детальная Страница Заявки (`/procurement/requests/{id}/`)
**Секции:** **Секции:**
- Заголовок с бейджем статуса - Заголовок с бейджем статуса
- Основная информация (автор, отдел, даты) - Основная информация (автор, отдел, даты)
- Таблица позиций с итоговой суммой - Таблица позиций с итоговой суммой
@ -983,6 +1064,7 @@ GET /api/v1/procurement/stats/by_department/
### 4. Каталог Оборудования (`/procurement/equipment/`) ### 4. Каталог Оборудования (`/procurement/equipment/`)
**Элементы:** **Элементы:**
- Карточки оборудования с фото (если есть) - Карточки оборудования с фото (если есть)
- Фильтры: статус, категория, отдел, ответственный - Фильтры: статус, категория, отдел, ответственный
- Поиск по названию/инв. номеру - Поиск по названию/инв. номеру
@ -992,6 +1074,7 @@ GET /api/v1/procurement/stats/by_department/
### 5. Карточка Оборудования (`/procurement/equipment/{id}/`) ### 5. Карточка Оборудования (`/procurement/equipment/{id}/`)
**Секции:** **Секции:**
- QR-код (для сканирования) - QR-код (для сканирования)
- Основная информация - Основная информация
- Текущий статус и местоположение - Текущий статус и местоположение
@ -1003,6 +1086,7 @@ GET /api/v1/procurement/stats/by_department/
### 6. Управление Бюджетом (`/procurement/budgets/`) ### 6. Управление Бюджетом (`/procurement/budgets/`)
**Элементы:** **Элементы:**
- Таблица бюджетов по отделам - Таблица бюджетов по отделам
- Визуализация использования (progress bars) - Визуализация использования (progress bars)
- Алерты при превышении 90% - Алерты при превышении 90%
@ -1018,7 +1102,8 @@ GET /api/v1/procurement/stats/by_department/
**Цель:** Создать приложение и определить все модели без сложной логики **Цель:** Создать приложение и определить все модели без сложной логики
**Задачи:** **Задачи:**
- [x] Создать Django app `procurement`
- [X] Создать Django app `procurement`
- [ ] Определить все модели в `models.py` - [ ] Определить все модели в `models.py`
- [ ] ProcurementRequest - [ ] ProcurementRequest
- [ ] ProcurementItem - [ ] ProcurementItem
@ -1041,6 +1126,7 @@ GET /api/v1/procurement/stats/by_department/
**Цель:** Создать REST API со всеми CRUD операциями и правами доступа **Цель:** Создать REST API со всеми CRUD операциями и правами доступа
**Задачи:** **Задачи:**
- [ ] Создать `api/v1/procurement/serializers.py` - [ ] Создать `api/v1/procurement/serializers.py`
- [ ] ProcurementRequestSerializer (read/write versions) - [ ] ProcurementRequestSerializer (read/write versions)
- [ ] EquipmentSerializer - [ ] EquipmentSerializer
@ -1066,6 +1152,7 @@ GET /api/v1/procurement/stats/by_department/
**Цель:** Реализовать state machine и автоматическую логику согласования **Цель:** Реализовать state machine и автоматическую логику согласования
**Задачи:** **Задачи:**
- [ ] Установить `django-fsm` - [ ] Установить `django-fsm`
- [ ] Добавить FSM transitions в ProcurementRequest: - [ ] Добавить FSM transitions в ProcurementRequest:
- [ ] `submit()` - отправка на согласование - [ ] `submit()` - отправка на согласование
@ -1081,10 +1168,10 @@ GET /api/v1/procurement/stats/by_department/
- [ ] `/requests/{id}/complete/` - [ ] `/requests/{id}/complete/`
- [ ] `/requests/{id}/cancel/` - [ ] `/requests/{id}/cancel/`
- [ ] Добавить проверки budget при согласовании - [ ] Добавить проверки budget при согласовании
- [x] Интеграция с NotificationService: - [X] Интеграция с NotificationService:
- [x] Создание типов уведомлений (procurement_*) - [X] Создание типов уведомлений (procurement_*)
- [x] Signals для автоматических уведомлений - [X] Signals для автоматических уведомлений
- [x] Web + Email + Telegram каналы - [X] Web + Email + Telegram каналы
- [ ] Написать тесты workflow - [ ] Написать тесты workflow
**Результат:** Заявки проходят полный цикл согласования с автоматическими проверками и уведомлениями **Результат:** Заявки проходят полный цикл согласования с автоматическими проверками и уведомлениями
@ -1094,6 +1181,7 @@ GET /api/v1/procurement/stats/by_department/
**Цель:** Контроль бюджетов и автоматические алерты **Цель:** Контроль бюджетов и автоматические алерты
**Задачи:** **Задачи:**
- [ ] Реализовать методы модели Budget: - [ ] Реализовать методы модели Budget:
- [ ] `remaining_amount` - [ ] `remaining_amount`
- [ ] `utilization_percentage` - [ ] `utilization_percentage`
@ -1113,6 +1201,7 @@ GET /api/v1/procurement/stats/by_department/
**Цель:** Полноценный учет оборудования с QR-кодами **Цель:** Полноценный учет оборудования с QR-кодами
**Задачи:** **Задачи:**
- [ ] Реализовать генерацию inventory_number (INV-YYYY-NNNN) - [ ] Реализовать генерацию inventory_number (INV-YYYY-NNNN)
- [ ] Добавить методы Equipment: - [ ] Добавить методы Equipment:
- [ ] `assign_to(user, location)` - [ ] `assign_to(user, location)`
@ -1135,6 +1224,7 @@ GET /api/v1/procurement/stats/by_department/
**Цель:** Создать все UI компоненты **Цель:** Создать все UI компоненты
**Подэтап 6.1: Дашборд и Списки** **Подэтап 6.1: Дашборд и Списки**
- [ ] Создать `templates/procurement/dashboard.html` - [ ] Создать `templates/procurement/dashboard.html`
- [ ] Создать `static/js/procurement/dashboard.js` (загрузка stats) - [ ] Создать `static/js/procurement/dashboard.js` (загрузка stats)
- [ ] Интегрировать Chart.js для графиков - [ ] Интегрировать Chart.js для графиков
@ -1142,6 +1232,7 @@ GET /api/v1/procurement/stats/by_department/
- [ ] Создать `static/js/procurement/requestList.js` (фильтры, пагинация) - [ ] Создать `static/js/procurement/requestList.js` (фильтры, пагинация)
**Подэтап 6.2: Форма Создания Заявки** **Подэтап 6.2: Форма Создания Заявки**
- [ ] Создать `templates/procurement/request_create.html` - [ ] Создать `templates/procurement/request_create.html`
- [ ] Создать `static/js/procurement/requestForm.js`: - [ ] Создать `static/js/procurement/requestForm.js`:
- [ ] Динамическое добавление позиций - [ ] Динамическое добавление позиций
@ -1150,6 +1241,7 @@ GET /api/v1/procurement/stats/by_department/
- [ ] Валидация перед отправкой - [ ] Валидация перед отправкой
**Подэтап 6.3: Детальная Страница Заявки** **Подэтап 6.3: Детальная Страница Заявки**
- [ ] Создать `templates/procurement/request_detail.html` - [ ] Создать `templates/procurement/request_detail.html`
- [ ] Создать `static/js/procurement/approvalFlow.js`: - [ ] Создать `static/js/procurement/approvalFlow.js`:
- [ ] Кнопки approve/reject с модальными окнами - [ ] Кнопки approve/reject с модальными окнами
@ -1157,12 +1249,14 @@ GET /api/v1/procurement/stats/by_department/
- [ ] Real-time обновления (WebSocket) - [ ] Real-time обновления (WebSocket)
**Подэтап 6.4: Инвентарь** **Подэтап 6.4: Инвентарь**
- [ ] Создать `templates/procurement/equipment_list.html` - [ ] Создать `templates/procurement/equipment_list.html`
- [ ] Создать `templates/procurement/equipment_detail.html` - [ ] Создать `templates/procurement/equipment_detail.html`
- [ ] Создать `static/js/procurement/equipmentList.js` - [ ] Создать `static/js/procurement/equipmentList.js`
- [ ] Добавить QR code scanner (jsQR или quagga.js) - [ ] Добавить QR code scanner (jsQR или quagga.js)
**Подэтап 6.5: Мобильная Адаптация** **Подэтап 6.5: Мобильная Адаптация**
- [ ] Адаптировать все страницы под мобильные - [ ] Адаптировать все страницы под мобильные
- [ ] Тестировать на разных разрешениях - [ ] Тестировать на разных разрешениях
@ -1173,6 +1267,7 @@ GET /api/v1/procurement/stats/by_department/
**Цель:** Интеграция с другими модулями и внешними системами **Цель:** Интеграция с другими модулями и внешними системами
**Задачи:** **Задачи:**
- [ ] Email уведомления (django.core.mail): - [ ] Email уведомления (django.core.mail):
- [ ] При создании заявки - [ ] При создании заявки
- [ ] При согласовании/отклонении - [ ] При согласовании/отклонении
@ -1186,11 +1281,11 @@ GET /api/v1/procurement/stats/by_department/
- [ ] Интеграция с модулем сотрудников: - [ ] Интеграция с модулем сотрудников:
- [ ] Использование Position для прав - [ ] Использование Position для прав
- [ ] Department head автоопределение - [ ] Department head автоопределение
- [x] Интеграция с системой уведомлений (NotificationService): - [X] Интеграция с системой уведомлений (NotificationService):
- [x] Web уведомления через WebSocket - [X] Web уведомления через WebSocket
- [x] Email уведомления с HTML шаблонами - [X] Email уведомления с HTML шаблонами
- [x] Telegram уведомления через Bot API - [X] Telegram уведомления через Bot API
- [x] Настройки пользователя для каналов доставки - [X] Настройки пользователя для каналов доставки
- [ ] Периодические задачи (APScheduler): - [ ] Периодические задачи (APScheduler):
- [ ] Напоминания о просроченных заявках - [ ] Напоминания о просроченных заявках
- [ ] Дайджесты уведомлений - [ ] Дайджесты уведомлений
@ -1203,6 +1298,7 @@ GET /api/v1/procurement/stats/by_department/
**Цель:** Достичь высокого покрытия тестами и создать документацию **Цель:** Достичь высокого покрытия тестами и создать документацию
**Задачи:** **Задачи:**
- [ ] Unit тесты (coverage > 80%): - [ ] Unit тесты (coverage > 80%):
- [ ] Модели - [ ] Модели
- [ ] Сериализаторы - [ ] Сериализаторы
@ -1233,40 +1329,40 @@ GET /api/v1/procurement/stats/by_department/
### Backend ### Backend
| Компонент | Технология | Версия | Назначение | | Компонент | Технология | Версия | Назначение |
|-----------|------------|--------|------------| | ------------------ | --------------------- | ------------ | ---------------------------------------------------------- |
| Framework | Django | 5.2+ | Основной фреймворк | | Framework | Django | 5.2+ | Основной фреймворк |
| API | Django REST Framework | 3.14+ | REST API | | API | Django REST Framework | 3.14+ | REST API |
| Database | PostgreSQL | 14+ | Основная БД | | Database | PostgreSQL | 14+ | Основная БД |
| State Machine | django-fsm | 2.8+ | Workflow статусов | | State Machine | django-fsm | 2.8+ | Workflow статусов |
| Notifications | NotificationService | - | Встроенная система уведомлений | | Notifications | NotificationService | - | Встроенная система уведомлений |
| Scheduler | APScheduler | 3.11+ | Периодические задачи | | Scheduler | APScheduler | 3.11+ | Периодические задачи |
| Message Broker | Redis | 7+ | Cache + Channels | | Message Broker | Redis | 7+ | Cache + Channels |
| WebSocket | Django Channels | 4+ | Real-time updates | | WebSocket | Django Channels | 4+ | Real-time updates |
| Export | reportlab | 4+ | PDF генерация | | Export | reportlab | 4+ | PDF генерация |
| Barcodes | python-barcode | 0.15+ | Штрихкоды | | Barcodes | python-barcode | 0.15+ | Штрихкоды |
| QR Codes | qrcode | 7.4+ | QR-коды | | QR Codes | qrcode | 7.4+ | QR-коды |
### Frontend ### Frontend
| Компонент | Технология | Версия | Назначение | | Компонент | Технология | Версия | Назначение |
|-----------|------------|--------|------------| | ------------------ | -------------------- | ------------ | --------------------------- |
| Framework | Vanilla JS | ES6+ | Логика | | Framework | Vanilla JS | ES6+ | Логика |
| CSS Framework | Bootstrap | 5.3+ | Стили | | CSS Framework | Bootstrap | 5.3+ | Стили |
| Charts | Chart.js | 4+ | Графики | | Charts | Chart.js | 4+ | Графики |
| QR Scanner | jsQR | 1.4+ | Сканирование QR | | QR Scanner | jsQR | 1.4+ | Сканирование QR |
| Icons | Bootstrap Icons | 1.11+ | Иконки | | Icons | Bootstrap Icons | 1.11+ | Иконки |
| HTTP Client | Fetch API | - | AJAX запросы | | HTTP Client | Fetch API | - | AJAX запросы |
### DevOps ### DevOps
| Компонент | Технология | Назначение | | Компонент | Технология | Назначение |
|-----------|------------|------------| | ------------------ | ---------------------- | -------------------------------------------- |
| Testing | pytest + pytest-django | Unit/Integration тесты | | Testing | pytest + pytest-django | Unit/Integration тесты |
| E2E Testing | Playwright | End-to-end тесты | | E2E Testing | Playwright | End-to-end тесты |
| Coverage | coverage.py | Покрытие кода | | Coverage | coverage.py | Покрытие кода |
| Code Quality | flake8, black | Линтинг, форматирование | | Code Quality | flake8, black | Линтинг, форматирование |
| CI/CD | GitHub Actions | Автоматизация | | CI/CD | GitHub Actions | Автоматизация |
--- ---
@ -1274,75 +1370,75 @@ GET /api/v1/procurement/stats/by_department/
### Подготовка ### Подготовка
- [x] Создать ветку `feature/procurement-module` - [X] Создать ветку `feature/procurement-module`
- [x] Настроить окружение (установить зависимости) - [X] Настроить окружение (установить зависимости)
- [x] Создать документ с требованиями - [X] Создать документ с требованиями
### Этап 1: Модели ✅ ЗАВЕРШЕН ### Этап 1: Модели ✅ ЗАВЕРШЕН
- [x] Создать app: `python manage.py startapp procurement` - [X] Создать app: `python manage.py startapp procurement`
- [x] Добавить в `INSTALLED_APPS` - [X] Добавить в `INSTALLED_APPS`
- [x] Создать `procurement/constants.py` - [X] Создать `procurement/constants.py`
- [x] Создать модели: - [X] Создать модели:
- [x] ProcurementRequest (с FSM) - [X] ProcurementRequest (с FSM)
- [x] ProcurementItem - [X] ProcurementItem
- [x] Approval - [X] Approval
- [x] Equipment - [X] Equipment
- [x] EquipmentCategory - [X] EquipmentCategory
- [x] MaintenanceRecord - [X] MaintenanceRecord
- [x] Budget - [X] Budget
- [x] Supplier - [X] Supplier
- [x] Создать миграции: `python manage.py makemigrations` - [X] Создать миграции: `python manage.py makemigrations`
- [x] Применить: `python manage.py migrate` - [X] Применить: `python manage.py migrate`
- [x] Настроить admin.py для всех моделей - [X] Настроить admin.py для всех моделей
- [x] Создать тестовые данные через админку - [X] Создать тестовые данные через админку
- [x] Написать тесты моделей (20/20 PASSED) - [X] Написать тесты моделей (20/20 PASSED)
- [x] Коммит: "feat: add procurement app with basic models" - [X] Коммит: "feat: add procurement app with basic models"
### Этап 2: API ✅ ЗАВЕРШЕН (95%) ### Этап 2: API ✅ ЗАВЕРШЕН (95%)
- [x] Создать `procurement/` структуру (без api/v1) - [X] Создать `procurement/` структуру (без api/v1)
- [x] Написать сериализаторы (9 сериализаторов) - [X] Написать сериализаторы (9 сериализаторов)
- [x] Создать ViewSets (7 ViewSets) - [X] Создать ViewSets (7 ViewSets)
- [x] Настроить URL routing - [X] Настроить URL routing
- [x] Создать permission classes (9 классов) - [X] Создать permission classes (9 классов)
- [x] Добавить департаментные права - [X] Добавить департаментные права
- [x] Написать API тесты (12/12 PASSED) - [X] Написать API тесты (12/12 PASSED)
- [x] Протестировать endpoints (50+ endpoints) - [X] Протестировать endpoints (50+ endpoints)
- [x] Коммит: "feat: add procurement API endpoints" - [X] Коммит: "feat: add procurement API endpoints"
- [ ] ⚠️ Исправить: баг items_count (property vs annotate) - ИСПРАВЛЕН - [ ] ⚠️ Исправить: баг items_count (property vs annotate) - ИСПРАВЛЕН
### Этап 3: Workflow ✅ ЗАВЕРШЕН (100%) ### Этап 3: Workflow ✅ ЗАВЕРШЕН (100%)
- [x] Установить django-fsm: `pip install django-fsm==3.0.1` - [X] Установить django-fsm: `pip install django-fsm==3.0.1`
- [x] Добавить FSM в ProcurementRequest - [X] Добавить FSM в ProcurementRequest
- [x] Реализовать transitions (submit, approve, reject) - [X] Реализовать transitions (submit, approve, reject)
- [x] Создать actions в API (@action decorators) - [X] Создать actions в API (@action decorators)
- [x] Добавить проверки бюджета (check_budget_available) - [X] Добавить проверки бюджета (check_budget_available)
- [x] Настроить signals (post_save для уведомлений) - [X] Настроить signals (post_save для уведомлений)
- [x] Интеграция с NotificationService (вместо Celery) - [X] Интеграция с NotificationService (вместо Celery)
- [x] Написать workflow тесты (9/9 PASSED) - [X] Написать workflow тесты (9/9 PASSED)
- [x] Коммит: "feat: add procurement approval workflow" - [X] Коммит: "feat: add procurement approval workflow"
- [x] Исправить queryset для approvers (41/41 тестов PASSED) - [X] Исправить queryset для approvers (41/41 тестов PASSED)
### Этап 4: Бюджеты ✅ ЗАВЕРШЕН (100%) ### Этап 4: Бюджеты ✅ ЗАВЕРШЕН (100%)
- [x] Реализовать методы Budget (reserved_amount, available_amount) - [X] Реализовать методы Budget (reserved_amount, available_amount)
- [x] Добавить проверки (is_staff/headed_departments) - [X] Добавить проверки (is_staff/headed_departments)
- [x] Создать алерты (_check_budget_alert при < 20% и < 10%) - [X] Создать алерты (_check_budget_alert при < 20% и < 10%)
- [x] Добавить endpoints статистики (/stats/overview/, /stats/by-department/) - [X] Добавить endpoints статистики (/stats/overview/, /stats/by-department/)
- [x] Добавить my-department endpoint - [X] Добавить my-department endpoint
- [x] Написать тесты (11/11 PASSED) - [X] Написать тесты (11/11 PASSED)
- [x] Коммит: "feat: add budget control and alerts" - [X] Коммит: "feat: add budget control and alerts"
### Этап 5: Инвентарь ### Этап 5: Инвентарь
- [ ] Установить barcode библиотеки - [X] Установить barcode библиотеки (qrcode[pil])
- [ ] Реализовать генерацию номеров - [X] Реализовать генерацию номеров (InventoryNumberGenerator)
- [ ] Добавить методы Equipment - [X] Добавить EquipmentTransferLog модель
- [ ] Создать actions в API - [X] Создать actions в API (transfer, write_off, add_maintenance, qr_code, transfer_history)
- [ ] Реализовать QR-коды - [X] Реализовать QR-коды (QRCodeGenerator)
- [ ] Написать тесты - [X] Написать тесты (14/14 PASSED)
- [ ] Коммит: "feat: add equipment management with QR codes" - [ ] Коммит: "feat: add equipment management with QR codes"
### Этап 6: Frontend ### Этап 6: Frontend
@ -1358,7 +1454,7 @@ GET /api/v1/procurement/stats/by_department/
### Этап 7: Интеграции ### Этап 7: Интеграции
- [x] Интеграция с NotificationService (система уведомлений) - [X] Интеграция с NotificationService (система уведомлений)
- [ ] WebSocket обновления для real-time статусов - [ ] WebSocket обновления для real-time статусов
- [ ] Экспорт отчетов (PDF/Excel) - [ ] Экспорт отчетов (PDF/Excel)
- [ ] APScheduler для периодических задач - [ ] APScheduler для периодических задач
@ -1384,14 +1480,14 @@ GET /api/v1/procurement/stats/by_department/
## Риски и Митигация ## Риски и Митигация
| Риск | Вероятность | Влияние | Митигация | | Риск | Вероятность | Влияние | Митигация |
|------|-------------|---------|-----------| | --------------------------------------------------------------------------- | ---------------------- | -------------- | ------------------------------------------------------------------------------------------ |
| Сложность workflow | Высокая | Высокое | Начать с простого (2 уровня), расширять постепенно | | Сложность workflow | Высокая | Высокое | Начать с простого (2 уровня), расширять постепенно |
| Производительность при большой нагрузке | Средняя | Среднее | Индексы БД, кэширование, пагинация | | Производительность при большой нагрузке | Средняя | Среднее | Индексы БД, кэширование, пагинация |
| Конфликты бюджетов | Средняя | Высокое | Pessimistic locking, транзакции | | Конфликты бюджетов | Средняя | Высокое | Pessimistic locking, транзакции |
| Миграция данных | Низкая | Среднее | Хорошо протестировать скрипты миграции | | Миграция данных | Низкая | Среднее | Хорошо протестировать скрипты миграции |
| Недостаточное покрытие тестами | Средняя | Высокое | CI/CD с проверкой coverage, code review | | Недостаточное покрытие тестами | Средняя | Высокое | CI/CD с проверкой coverage, code review |
| Сопротивление пользователей | Средняя | Среднее | Обучение, простой UI, постепенное внедрение | | Сопротивление пользователей | Средняя | Среднее | Обучение, простой UI, постепенное внедрение |
--- ---
@ -1401,15 +1497,18 @@ GET /api/v1/procurement/stats/by_department/
**Статус:** ✅ ВСЕ ТЕСТЫ ПРОХОДЯТ **Статус:** ✅ ВСЕ ТЕСТЫ ПРОХОДЯТ
### Итоговая статистика: ### Итоговая статистика:
``` ```
✅ 52/52 PASSED (100%) ✅ 66/66 PASSED (100%)
├─ 20/20 Model tests ✅ ├─ 20/20 Model tests ✅
├─ 12/12 API tests ✅ ├─ 12/12 API tests ✅
├─ 9/9 Workflow tests ✅ ├─ 9/9 Workflow tests ✅
└─ 11/11 Budget tests ✅ (NEW) ├─ 11/11 Budget tests ✅
└─ 14/14 Inventory tests ✅ (NEW)
``` ```
### Основные исправления: ### Основные исправления:
1. ✅ Исправлен `led_departments` → `headed_departments` 1. ✅ Исправлен `led_departments` → `headed_departments`
2. ✅ Удален конфликт `items_count` (annotate vs @property) 2. ✅ Удален конфликт `items_count` (annotate vs @property)
3. ✅ Создан `settings_test.py` с InMemoryChannelLayer 3. ✅ Создан `settings_test.py` с InMemoryChannelLayer
@ -1419,6 +1518,7 @@ GET /api/v1/procurement/stats/by_department/
7. ✅ **Расширен queryset для approvers** - ключевое исправление! 7. ✅ **Расширен queryset для approvers** - ключевое исправление!
### Техническая инфраструктура: ### Техническая инфраструктура:
- **Git cleanup:** localcerts_backup.zip удален из истории (279 коммитов) - **Git cleanup:** localcerts_backup.zip удален из истории (279 коммитов)
- **Test settings:** InMemoryChannelLayer вместо Redis - **Test settings:** InMemoryChannelLayer вместо Redis
- **Notifications:** NotificationService интегрирован (вместо Celery) - **Notifications:** NotificationService интегрирован (вместо Celery)
@ -1428,6 +1528,7 @@ GET /api/v1/procurement/stats/by_department/
## Следующие Шаги ## Следующие Шаги
**Завершено:** **Завершено:**
1. ✅ Создать данную документацию 1. ✅ Создать данную документацию
2. ✅ Создать ветку `feature/procurement-module` 2. ✅ Создать ветку `feature/procurement-module`
3. ✅ Завершить **Этап 1: Базовая Структура** (100%) 3. ✅ Завершить **Этап 1: Базовая Структура** (100%)
@ -1436,13 +1537,16 @@ GET /api/v1/procurement/stats/by_department/
6. ✅ Завершить **Этап 4: Бюджеты** (100%) 6. ✅ Завершить **Этап 4: Бюджеты** (100%)
**Следующий шаг:** **Следующий шаг:**
- ⏭️ Начать **Этап 5: Инвентарь** - управление оборудованием с QR-кодами - ⏭️ Начать **Этап 5: Инвентарь** - управление оборудованием с QR-кодами
**Через 1 месяц:** **Через 1 месяц:**
- Завершить Этапы 5-6 (Инвентарь, Frontend) - Завершить Этапы 5-6 (Инвентарь, Frontend)
- Иметь полнофункциональный модуль с UI - Иметь полнофункциональный модуль с UI
**Через 2 месяца:** **Через 2 месяца:**
- Завершить все 8 этапов - Завершить все 8 этапов
- Готовый модуль к production deploy - Готовый модуль к production deploy
@ -1452,12 +1556,13 @@ GET /api/v1/procurement/stats/by_department/
**Ответственный за разработку:** [Ваше имя] **Ответственный за разработку:** [Ваше имя]
**Дата последнего обновления:** 9 декабря 2025 **Дата последнего обновления:** 9 декабря 2025
**Версия документа:** 1.2 **Версия документа:** 1.3
**Вопросы и предложения:** **Вопросы и предложения:**
- Создавайте issues в репозитории - Создавайте issues в репозитории
- Обсуждайте в команде - Обсуждайте в команде
--- ---
**Статус:** 🎉 Этапы 1-4 завершены! Готовы к Этапу 5 (Инвентарь)! **Статус:** 🎉 Этапы 1-5 завершены! Готовы к Этапу 6 (Frontend)!

View File

@ -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'],
},
),
]

View File

@ -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): class Budget(models.Model):
"""Бюджет отдела на квартал.""" """Бюджет отдела на квартал."""

View File

@ -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

View File

@ -9,17 +9,19 @@ from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from employees.models import Department from employees.models import Department
from .constants import ApprovalStatus, ProcurementStatus from .constants import ApprovalStatus, ProcurementStatus, EquipmentStatus
from .models import ( from .models import (
Approval, Approval,
Budget, Budget,
Equipment, Equipment,
EquipmentCategory, EquipmentCategory,
EquipmentTransferLog,
MaintenanceRecord, MaintenanceRecord,
ProcurementItem, ProcurementItem,
ProcurementRequest, ProcurementRequest,
Supplier, Supplier,
) )
from .services import QRCodeGenerator
from .permissions import ( from .permissions import (
CanApproveProcurementRequest, CanApproveProcurementRequest,
CanCreateProcurementRequest, CanCreateProcurementRequest,
@ -490,6 +492,162 @@ class EquipmentViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) 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): class MaintenanceRecordViewSet(viewsets.ModelViewSet):
"""ViewSet для записей обслуживания.""" """ViewSet для записей обслуживания."""

View File

@ -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]