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:
parent
3c6eeb93dd
commit
3cd161d83c
@ -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)!
|
||||||
|
|||||||
39
backend/procurement/migrations/0002_equipmenttransferlog.py
Normal file
39
backend/procurement/migrations/0002_equipmenttransferlog.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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):
|
||||||
"""Бюджет отдела на квартал."""
|
"""Бюджет отдела на квартал."""
|
||||||
|
|
||||||
|
|||||||
311
backend/procurement/services.py
Normal file
311
backend/procurement/services.py
Normal 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
|
||||||
@ -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 для записей обслуживания."""
|
||||||
|
|||||||
454
backend/tests/procurement/test_inventory.py
Normal file
454
backend/tests/procurement/test_inventory.py
Normal 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]
|
||||||
Loading…
x
Reference in New Issue
Block a user