feat: add equipment management with QR codes and transfers

Stage 5 (Inventory) implementation:
- Add EquipmentTransferLog model for tracking equipment movements
- Add EquipmentViewSet actions:
  - transfer: move equipment between departments/users
  - write_off: decommission equipment
  - add_maintenance: record maintenance activities
  - qr_code: generate QR code for equipment
  - transfer_history: view equipment transfer history
- Use existing QRCodeGenerator and InventoryNumberGenerator services
- Fix EquipmentStatus constants usage (IN_USE, RETIRED)
- Add 14 new tests for inventory functionality

Test results: 66/66 PASSED
- 20/20 Model tests
- 12/12 API tests
- 9/9 Workflow tests
- 11/11 Budget tests
- 14/14 Inventory tests (NEW)
This commit is contained in:
admin i 2025-12-09 10:42:07 +03:00
parent 3c6eeb93dd
commit 3cd161d83c
6 changed files with 1302 additions and 146 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
# Generated by Django 5.2.4 on 2025-12-09 07:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('employees', '0034_alter_employee_telegram'),
('procurement', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EquipmentTransferLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('from_location', models.CharField(blank=True, max_length=255, verbose_name='Откуда')),
('to_location', models.CharField(blank=True, max_length=255, verbose_name='Куда')),
('transfer_type', models.CharField(choices=[('assignment', 'Назначение ответственного'), ('transfer', 'Перемещение'), ('return', 'Возврат')], default='transfer', max_length=20, verbose_name='Тип передачи')),
('reason', models.TextField(blank=True, verbose_name='Причина/комментарий')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата передачи')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfers_created', to=settings.AUTH_USER_MODEL, verbose_name='Кто оформил')),
('equipment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_logs', to='procurement.equipment', verbose_name='Оборудование')),
('from_department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfers_out', to='employees.department', verbose_name='Из отдела')),
('from_person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_given', to=settings.AUTH_USER_MODEL, verbose_name='От кого')),
('to_department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfers_in', to='employees.department', verbose_name='В отдел')),
('to_person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_received', to=settings.AUTH_USER_MODEL, verbose_name='Кому')),
],
options={
'verbose_name': 'Лог передачи',
'verbose_name_plural': 'Логи передач',
'ordering': ['-created_at'],
},
),
]

View File

@ -480,6 +480,95 @@ class MaintenanceRecord(models.Model):
)
class EquipmentTransferLog(models.Model):
"""Лог передачи/перемещения оборудования."""
TRANSFER_TYPES = [
('assignment', 'Назначение ответственного'),
('transfer', 'Перемещение'),
('return', 'Возврат'),
]
equipment = models.ForeignKey(
Equipment,
on_delete=models.CASCADE,
related_name='transfer_logs',
verbose_name='Оборудование'
)
from_department = models.ForeignKey(
Department,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transfers_out',
verbose_name='Из отдела'
)
to_department = models.ForeignKey(
Department,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transfers_in',
verbose_name='В отдел'
)
from_person = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='equipment_given',
verbose_name='От кого'
)
to_person = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='equipment_received',
verbose_name='Кому'
)
from_location = models.CharField(
'Откуда',
max_length=255,
blank=True
)
to_location = models.CharField(
'Куда',
max_length=255,
blank=True
)
transfer_type = models.CharField(
'Тип передачи',
max_length=20,
choices=TRANSFER_TYPES,
default='transfer'
)
reason = models.TextField(
'Причина/комментарий',
blank=True
)
created_at = models.DateTimeField('Дата передачи', auto_now_add=True)
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transfers_created',
verbose_name='Кто оформил'
)
class Meta:
verbose_name = 'Лог передачи'
verbose_name_plural = 'Логи передач'
ordering = ['-created_at']
def __str__(self):
return (
f"{self.equipment.inventory_number}: "
f"{self.from_person or 'Склад'} → {self.to_person or 'Склад'}"
)
class Budget(models.Model):
"""Бюджет отдела на квартал."""

View File

@ -0,0 +1,311 @@
"""
Сервисы для модуля закупок и инвентаря.
"""
import io
import os
from typing import Optional
import qrcode
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils import timezone
class InventoryNumberGenerator:
"""Генератор инвентарных номеров."""
PREFIX = 'INV'
@classmethod
def generate(cls, year: Optional[int] = None) -> str:
"""
Генерирует уникальный инвентарный номер.
Формат: INV-YYYY-NNNN
Например: INV-2025-0001, INV-2025-0002
"""
from .models import Equipment
if year is None:
year = timezone.now().year
# Находим последний номер за этот год
prefix = f'{cls.PREFIX}-{year}-'
last_equipment = Equipment.objects.filter(
inventory_number__startswith=prefix
).order_by('-inventory_number').first()
if last_equipment:
# Извлекаем номер из последнего инвентарного номера
try:
last_num = int(last_equipment.inventory_number.split('-')[-1])
except (ValueError, IndexError):
last_num = 0
else:
last_num = 0
new_num = last_num + 1
return f'{cls.PREFIX}-{year}-{new_num:04d}'
@classmethod
def validate(cls, inventory_number: str) -> bool:
"""
Проверяет формат инвентарного номера.
Returns:
True если формат верный, False иначе
"""
parts = inventory_number.split('-')
if len(parts) != 3:
return False
prefix, year_str, num_str = parts
if prefix != cls.PREFIX:
return False
try:
year = int(year_str)
if year < 2000 or year > 2100:
return False
except ValueError:
return False
try:
num = int(num_str)
if num < 1:
return False
except ValueError:
return False
return True
class QRCodeGenerator:
"""Генератор QR-кодов для оборудования."""
QR_CODE_DIR = 'qr_codes'
@classmethod
def generate_for_equipment(cls, equipment) -> ContentFile:
"""
Генерирует QR-код для оборудования.
QR содержит URL на страницу оборудования и основные данные.
Returns:
ContentFile с PNG изображением QR-кода
"""
# Данные для QR-кода
data = cls._build_qr_data(equipment)
# Генерируем QR-код
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=4,
)
qr.add_data(data)
qr.make(fit=True)
# Создаём изображение
img = qr.make_image(fill_color="black", back_color="white")
# Сохраняем в буфер
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
filename = f'{equipment.inventory_number}.png'
return ContentFile(buffer.read(), name=filename)
@classmethod
def _build_qr_data(cls, equipment) -> str:
"""
Формирует данные для QR-кода.
Формат: URL с параметрами для быстрого доступа.
"""
# Формируем URL на страницу оборудования
base_url = getattr(settings, 'SITE_URL', 'http://localhost:8000')
equipment_url = f'{base_url}/procurement/equipment/{equipment.id}/'
# Можно также добавить vCard или JSON формат
# Но URL проще для сканирования
return equipment_url
@classmethod
def get_qr_code_path(cls, inventory_number: str) -> str:
"""
Возвращает путь к файлу QR-кода.
"""
return os.path.join(cls.QR_CODE_DIR, f'{inventory_number}.png')
class EquipmentService:
"""Сервис для операций с оборудованием."""
@classmethod
@transaction.atomic
def create_equipment(
cls,
name: str,
category,
department,
purchase_date,
purchase_cost,
**kwargs
):
"""
Создаёт новое оборудование с авто-генерацией инвентарного номера.
"""
from .models import Equipment
# Генерируем инвентарный номер если не указан
if 'inventory_number' not in kwargs or not kwargs['inventory_number']:
kwargs['inventory_number'] = InventoryNumberGenerator.generate()
equipment = Equipment.objects.create(
name=name,
category=category,
department=department,
purchase_date=purchase_date,
purchase_cost=purchase_cost,
**kwargs
)
return equipment
@classmethod
@transaction.atomic
def assign_to_person(
cls,
equipment,
person,
location: Optional[str] = None
):
"""
Назначает оборудование ответственному лицу.
"""
from .constants import EquipmentStatus
from .models import EquipmentTransferLog
old_person = equipment.responsible_person
old_location = equipment.location
equipment.responsible_person = person
equipment.status = EquipmentStatus.IN_USE
if location:
equipment.location = location
equipment.save()
# Логируем передачу
EquipmentTransferLog.objects.create(
equipment=equipment,
from_person=old_person,
to_person=person,
from_location=old_location,
to_location=location or equipment.location,
transfer_type='assignment',
)
return equipment
@classmethod
@transaction.atomic
def transfer(
cls,
equipment,
to_department,
to_person=None,
to_location: Optional[str] = None,
reason: str = ''
):
"""
Перемещает оборудование в другой отдел.
"""
from .models import EquipmentTransferLog
old_department = equipment.department
old_person = equipment.responsible_person
old_location = equipment.location
equipment.department = to_department
equipment.responsible_person = to_person
if to_location:
equipment.location = to_location
equipment.save()
# Логируем передачу
EquipmentTransferLog.objects.create(
equipment=equipment,
from_department=old_department,
to_department=to_department,
from_person=old_person,
to_person=to_person,
from_location=old_location,
to_location=to_location or '',
transfer_type='transfer',
reason=reason,
)
return equipment
@classmethod
@transaction.atomic
def write_off(cls, equipment, reason: str):
"""
Списывает оборудование.
"""
from .constants import EquipmentStatus
equipment.status = EquipmentStatus.WRITTEN_OFF
equipment.notes = (
f'{equipment.notes}\n\n'
f'[{timezone.now().strftime("%Y-%m-%d")}] Списано: {reason}'
).strip()
equipment.save()
return equipment
@classmethod
@transaction.atomic
def add_maintenance_record(
cls,
equipment,
maintenance_type: str,
description: str,
performed_by,
cost=None,
date=None
):
"""
Добавляет запись о техническом обслуживании.
"""
from decimal import Decimal
from .models import MaintenanceRecord
from .constants import EquipmentStatus
if date is None:
date = timezone.now().date()
if cost is None:
cost = Decimal('0.00')
record = MaintenanceRecord.objects.create(
equipment=equipment,
date=date,
type=maintenance_type,
description=description,
cost=cost,
performed_by=performed_by,
)
# Если на ремонте - обновляем статус
if maintenance_type == 'repair':
equipment.status = EquipmentStatus.IN_REPAIR
equipment.save()
return record

View File

@ -9,17 +9,19 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from employees.models import Department
from .constants import ApprovalStatus, ProcurementStatus
from .constants import ApprovalStatus, ProcurementStatus, EquipmentStatus
from .models import (
Approval,
Budget,
Equipment,
EquipmentCategory,
EquipmentTransferLog,
MaintenanceRecord,
ProcurementItem,
ProcurementRequest,
Supplier,
)
from .services import QRCodeGenerator
from .permissions import (
CanApproveProcurementRequest,
CanCreateProcurementRequest,
@ -490,6 +492,162 @@ class EquipmentViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def transfer(self, request, pk=None):
"""Перевод оборудования в другой отдел или другому пользователю."""
equipment = self.get_object()
to_department_id = request.data.get('to_department')
to_person_id = request.data.get('to_person')
reason = request.data.get('reason', '')
if not to_department_id and not to_person_id:
return Response(
{'error': 'Укажите отдел или ответственного'},
status=status.HTTP_400_BAD_REQUEST
)
# Сохраняем старые значения
from_department = equipment.department
from_person = equipment.responsible_person
# Обновляем оборудование
if to_department_id:
try:
to_department = Department.objects.get(pk=to_department_id)
equipment.department = to_department
except Department.DoesNotExist:
return Response(
{'error': 'Отдел не найден'},
status=status.HTTP_404_NOT_FOUND
)
else:
to_department = from_department
if to_person_id:
from django.contrib.auth import get_user_model
User = get_user_model()
try:
to_person = User.objects.get(pk=to_person_id)
equipment.responsible_person = to_person
except User.DoesNotExist:
return Response(
{'error': 'Пользователь не найден'},
status=status.HTTP_404_NOT_FOUND
)
else:
to_person = from_person
equipment.save()
# Создаём лог перевода
EquipmentTransferLog.objects.create(
equipment=equipment,
from_department=from_department,
to_department=to_department,
from_person=from_person,
to_person=to_person,
reason=reason,
created_by=request.user
)
return Response({
'status': 'transferred',
'equipment_id': equipment.id,
'from_department': str(from_department),
'to_department': str(to_department)
})
@action(detail=True, methods=['post'])
def write_off(self, request, pk=None):
"""Списание оборудования."""
equipment = self.get_object()
reason = request.data.get('reason', '')
if equipment.status == EquipmentStatus.RETIRED:
return Response(
{'error': 'Оборудование уже списано'},
status=status.HTTP_400_BAD_REQUEST
)
equipment.status = EquipmentStatus.RETIRED
equipment.notes = (equipment.notes or '') + f'\n\nСписано: {reason}'
equipment.save()
return Response({
'status': 'written_off',
'equipment_id': equipment.id
})
@action(detail=True, methods=['post'])
def add_maintenance(self, request, pk=None):
"""Добавить запись об обслуживании."""
from datetime import date
equipment = self.get_object()
maintenance_type = request.data.get('type', 'repair')
description = request.data.get('description', '')
cost = request.data.get('cost')
maintenance_date = request.data.get('date', date.today())
record = MaintenanceRecord.objects.create(
equipment=equipment,
type=maintenance_type,
description=description,
cost=cost,
date=maintenance_date,
performed_by=request.user
)
return Response({
'status': 'created',
'maintenance_id': record.id,
'equipment_id': equipment.id
}, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get'])
def qr_code(self, request, pk=None):
"""Получить QR-код для оборудования."""
equipment = self.get_object()
qr_file = QRCodeGenerator.generate_for_equipment(equipment)
from django.http import HttpResponse
response = HttpResponse(qr_file.read(), content_type='image/png')
response['Content-Disposition'] = (
f'inline; filename="equipment_{equipment.id}_qr.png"'
)
return response
@action(detail=True, methods=['get'])
def transfer_history(self, request, pk=None):
"""История переводов оборудования."""
equipment = self.get_object()
logs = EquipmentTransferLog.objects.filter(
equipment=equipment
).order_by('-created_at')
data = [
{
'id': log.id,
'from_department': str(log.from_department),
'to_department': str(log.to_department),
'from_person': (
str(log.from_person) if log.from_person else None
),
'to_person': (
str(log.to_person) if log.to_person else None
),
'reason': log.reason,
'created_by': str(log.created_by) if log.created_by else None,
'date': log.created_at.isoformat()
}
for log in logs
]
return Response(data)
class MaintenanceRecordViewSet(viewsets.ModelViewSet):
"""ViewSet для записей обслуживания."""

View File

@ -0,0 +1,454 @@
"""
Тесты для Inventory - transfer, write_off, QR-коды, инвентарные номера.
"""
import pytest
from rest_framework import status
from procurement.models import (
Equipment,
EquipmentCategory,
EquipmentTransferLog,
MaintenanceRecord,
)
from procurement.constants import EquipmentStatus
from procurement.services import InventoryNumberGenerator, QRCodeGenerator
@pytest.mark.django_db
class TestInventoryNumberGenerator:
"""Тесты для генератора инвентарных номеров."""
@pytest.fixture(autouse=True)
def setup(self, department_factory, user_factory, link_factory):
"""Настройка тестовых данных."""
self.department = department_factory(name='IT Inventory Test')
self.user = user_factory(staff=True,
email='inventory_user@test.com',
first_name='Test',
last_name='Inventory',
)
link_factory(self.user, self.department, is_active=True)
self.category = EquipmentCategory.objects.create(
name='Computers',
description='Компьютеры и ноутбуки',
)
def test_generate_first_number(self):
"""Тест: генерация первого инвентарного номера за год."""
from django.utils import timezone
year = timezone.now().year
number = InventoryNumberGenerator.generate()
assert number == f'INV-{year}-0001'
def test_generate_sequential_numbers(self):
"""Тест: последовательная генерация номеров."""
from django.utils import timezone
year = timezone.now().year
# Создаём оборудование с первым номером
Equipment.objects.create(
name='Laptop 1',
inventory_number=f'INV-{year}-0005',
category=self.category,
department=self.department,
responsible_person=self.user,
status=EquipmentStatus.IN_USE,
purchase_date='2025-01-01',
purchase_cost='10000.00',
)
number = InventoryNumberGenerator.generate()
assert number == f'INV-{year}-0006'
def test_validate_correct_format(self):
"""Тест: валидация правильного формата."""
assert InventoryNumberGenerator.validate('INV-2025-0001') is True
assert InventoryNumberGenerator.validate('INV-2024-9999') is True
def test_validate_incorrect_format(self):
"""Тест: валидация неправильного формата."""
assert InventoryNumberGenerator.validate('INV-2025') is False
assert InventoryNumberGenerator.validate('ABC-2025-0001') is False
assert InventoryNumberGenerator.validate('') is False
@pytest.mark.django_db
class TestQRCodeGenerator:
"""Тесты для генератора QR-кодов."""
@pytest.fixture(autouse=True)
def setup(self, department_factory, user_factory, link_factory):
"""Настройка тестовых данных."""
self.department = department_factory(name='IT QR Test')
self.user = user_factory(staff=True,
email='qr_user@test.com',
first_name='Test',
last_name='QR',
)
link_factory(self.user, self.department, is_active=True)
self.category = EquipmentCategory.objects.create(
name='Monitors',
description='Мониторы',
)
self.equipment = Equipment.objects.create(
name='Monitor Dell 27',
inventory_number='INV-2025-0100',
category=self.category,
department=self.department,
responsible_person=self.user,
status=EquipmentStatus.IN_USE,
purchase_date='2025-01-01',
purchase_cost='10000.00',
)
def test_generate_qr_code(self):
"""Тест: генерация QR-кода для оборудования."""
qr_file = QRCodeGenerator.generate_for_equipment(self.equipment)
assert qr_file is not None
assert qr_file.name.endswith('.png')
# Файл должен содержать PNG данные
content = qr_file.read()
assert len(content) > 0
# PNG начинается с signature
assert content[:4] == b'\x89PNG'
def test_qr_code_path(self):
"""Тест: путь к файлу QR-кода."""
path = QRCodeGenerator.get_qr_code_path('INV-2025-0100')
assert 'INV-2025-0100.png' in path
@pytest.mark.django_db
class TestEquipmentTransferEndpoint:
"""Тесты для /equipment/{id}/transfer/ endpoint."""
@pytest.fixture(autouse=True)
def setup(
self, api_client, department_factory, user_factory, link_factory
):
"""Настройка тестовых данных."""
self.client = api_client
self.dept1 = department_factory(name='IT Dept')
self.dept2 = department_factory(name='HR Dept')
self.user1 = user_factory(staff=True,
email='transfer_user1@test.com',
first_name='User',
last_name='One',
)
self.user2 = user_factory(
email='transfer_user2@test.com',
first_name='User',
last_name='Two',
)
link_factory(self.user1, self.dept1, is_active=True)
link_factory(self.user2, self.dept2, is_active=True)
self.category = EquipmentCategory.objects.create(
name='Laptops',
description='Ноутбуки',
)
self.equipment = Equipment.objects.create(
name='MacBook Pro',
inventory_number='INV-2025-0200',
category=self.category,
department=self.dept1,
responsible_person=self.user1,
status=EquipmentStatus.IN_USE,
purchase_date='2025-01-01',
purchase_cost='10000.00',
)
def test_transfer_to_another_department(self):
"""Тест: перевод оборудования в другой отдел."""
self.client.force_authenticate(user=self.user1)
response = self.client.post(
f'/api/procurement/equipment/{self.equipment.id}/transfer/',
{
'to_department': self.dept2.id,
'reason': 'Переезд сотрудника',
}
)
assert response.status_code == status.HTTP_200_OK
assert response.data['status'] == 'transferred'
# Проверяем обновление оборудования
self.equipment.refresh_from_db()
assert self.equipment.department == self.dept2
# Проверяем лог перевода
log = EquipmentTransferLog.objects.filter(
equipment=self.equipment
).first()
assert log is not None
assert log.from_department == self.dept1
assert log.to_department == self.dept2
assert log.reason == 'Переезд сотрудника'
def test_transfer_to_another_person(self):
"""Тест: перевод оборудования другому пользователю."""
self.client.force_authenticate(user=self.user1)
response = self.client.post(
f'/api/procurement/equipment/{self.equipment.id}/transfer/',
{
'to_person': self.user2.id,
'reason': 'Смена ответственного',
}
)
assert response.status_code == status.HTTP_200_OK
self.equipment.refresh_from_db()
assert self.equipment.responsible_person == self.user2
def test_transfer_requires_target(self):
"""Тест: нужно указать отдел или пользователя."""
self.client.force_authenticate(user=self.user1)
response = self.client.post(
f'/api/procurement/equipment/{self.equipment.id}/transfer/',
{'reason': 'Без цели'},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
class TestEquipmentWriteOffEndpoint:
"""Тесты для /equipment/{id}/write_off/ endpoint."""
@pytest.fixture(autouse=True)
def setup(
self, api_client, department_factory, user_factory, link_factory
):
"""Настройка тестовых данных."""
self.client = api_client
self.department = department_factory(name='IT WriteOff')
self.user = user_factory(staff=True,
email='writeoff_user@test.com',
first_name='Test',
last_name='WriteOff',
)
link_factory(self.user, self.department, is_active=True)
self.category = EquipmentCategory.objects.create(
name='Printers',
description='Принтеры',
)
self.equipment = Equipment.objects.create(
name='HP LaserJet',
inventory_number='INV-2025-0300',
category=self.category,
department=self.department,
responsible_person=self.user,
status=EquipmentStatus.IN_USE,
purchase_date='2025-01-01',
purchase_cost='10000.00',
)
def test_write_off_equipment(self):
"""Тест: списание оборудования."""
self.client.force_authenticate(user=self.user)
response = self.client.post(
f'/api/procurement/equipment/{self.equipment.id}/write_off/',
{'reason': 'Износ'}
)
assert response.status_code == status.HTTP_200_OK
assert response.data['status'] == 'written_off'
self.equipment.refresh_from_db()
assert self.equipment.status == EquipmentStatus.RETIRED
assert 'Износ' in self.equipment.notes
def test_cannot_write_off_twice(self):
"""Тест: нельзя списать уже списанное оборудование."""
self.equipment.status = EquipmentStatus.RETIRED
self.equipment.save()
self.client.force_authenticate(user=self.user)
response = self.client.post(
f'/api/procurement/equipment/{self.equipment.id}/write_off/',
{'reason': 'Повторно'}
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
class TestEquipmentMaintenanceEndpoint:
"""Тесты для /equipment/{id}/add_maintenance/ endpoint."""
@pytest.fixture(autouse=True)
def setup(
self, api_client, department_factory, user_factory, link_factory
):
"""Настройка тестовых данных."""
self.client = api_client
self.department = department_factory(name='IT Maintenance')
self.user = user_factory(staff=True,
email='maintenance_user@test.com',
first_name='Test',
last_name='Maintenance',
)
link_factory(self.user, self.department, is_active=True)
self.category = EquipmentCategory.objects.create(
name='Servers',
description='Серверы',
)
self.equipment = Equipment.objects.create(
name='Dell PowerEdge',
inventory_number='INV-2025-0400',
category=self.category,
department=self.department,
responsible_person=self.user,
status=EquipmentStatus.IN_USE,
purchase_date='2025-01-01',
purchase_cost='10000.00',
)
def test_add_maintenance_record(self):
"""Тест: добавление записи об обслуживании."""
self.client.force_authenticate(user=self.user)
response = self.client.post(
f'/api/procurement/equipment/{self.equipment.id}/add_maintenance/',
{
'type': 'repair',
'description': 'Замена блока питания',
'cost': '5000.00',
}
)
assert response.status_code == status.HTTP_201_CREATED
assert 'maintenance_id' in response.data
# Проверяем создание записи
record = MaintenanceRecord.objects.get(id=response.data['maintenance_id'])
assert record.equipment == self.equipment
assert record.description == 'Замена блока питания'
assert record.performed_by == self.user
@pytest.mark.django_db
class TestEquipmentQRCodeEndpoint:
"""Тесты для /equipment/{id}/qr_code/ endpoint."""
@pytest.fixture(autouse=True)
def setup(
self, api_client, department_factory, user_factory, link_factory
):
"""Настройка тестовых данных."""
self.client = api_client
self.department = department_factory(name='IT QR Code')
self.user = user_factory(staff=True,
email='qrcode_user@test.com',
first_name='Test',
last_name='QRCode',
)
link_factory(self.user, self.department, is_active=True)
self.category = EquipmentCategory.objects.create(
name='Networking',
description='Сетевое оборудование',
)
self.equipment = Equipment.objects.create(
name='Cisco Router',
inventory_number='INV-2025-0500',
category=self.category,
department=self.department,
responsible_person=self.user,
status=EquipmentStatus.IN_USE,
purchase_date='2025-01-01',
purchase_cost='10000.00',
)
def test_get_qr_code(self):
"""Тест: получение QR-кода для оборудования."""
self.client.force_authenticate(user=self.user)
response = self.client.get(
f'/api/procurement/equipment/{self.equipment.id}/qr_code/'
)
assert response.status_code == status.HTTP_200_OK
assert response['Content-Type'] == 'image/png'
# PNG signature
assert response.content[:4] == b'\x89PNG'
@pytest.mark.django_db
class TestEquipmentTransferHistoryEndpoint:
"""Тесты для /equipment/{id}/transfer_history/ endpoint."""
@pytest.fixture(autouse=True)
def setup(
self, api_client, department_factory, user_factory, link_factory
):
"""Настройка тестовых данных."""
self.client = api_client
self.dept1 = department_factory(name='IT History 1')
self.dept2 = department_factory(name='IT History 2')
self.user1 = user_factory(staff=True,
email='history_user1@test.com',
first_name='User',
last_name='History1',
)
self.user2 = user_factory(
email='history_user2@test.com',
first_name='User',
last_name='History2',
)
link_factory(self.user1, self.dept1, is_active=True)
self.category = EquipmentCategory.objects.create(
name='Phones',
description='Телефоны',
)
self.equipment = Equipment.objects.create(
name='iPhone 15',
inventory_number='INV-2025-0600',
category=self.category,
department=self.dept1,
responsible_person=self.user1,
status=EquipmentStatus.IN_USE,
purchase_date='2025-01-01',
purchase_cost='10000.00',
)
# Создаём историю переводов
EquipmentTransferLog.objects.create(
equipment=self.equipment,
from_department=self.dept1,
to_department=self.dept2,
from_person=self.user1,
to_person=self.user2,
reason='Первый перевод',
created_by=self.user1,
)
def test_get_transfer_history(self):
"""Тест: получение истории переводов."""
self.client.force_authenticate(user=self.user1)
response = self.client.get(
f'/api/procurement/equipment/{self.equipment.id}/transfer_history/'
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 1
assert response.data[0]['reason'] == 'Первый перевод'
assert 'from_department' in response.data[0]
assert 'to_department' in response.data[0]