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
File diff suppressed because it is too large
Load Diff
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):
|
||||
"""Бюджет отдела на квартал."""
|
||||
|
||||
|
||||
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 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 для записей обслуживания."""
|
||||
|
||||
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