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):
|
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