Игорь Надеин 3cd161d83c 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)
2025-12-09 10:42:07 +03:00

722 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# backend/procurement/models.py
"""
Модели для модуля закупок и инвентаря.
Основные сущности:
- ProcurementRequest: заявка на закупку
- ProcurementItem: позиция в заявке
- Approval: запись о согласовании
- Equipment: единица оборудования/инвентаря
- EquipmentCategory: категория оборудования
- MaintenanceRecord: запись об обслуживании
- Budget: бюджет отдела
- Supplier: поставщик
"""
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator
from django.db import models
from django.utils import timezone
from employees.models import Department
from .constants import (
APPROVAL_THRESHOLD_HIGH,
APPROVAL_THRESHOLD_LOW,
ApprovalRole,
ApprovalStatus,
EquipmentStatus,
MaintenanceType,
ProcurementStatus,
UrgencyLevel,
)
User = get_user_model()
class ProcurementRequest(models.Model):
"""Заявка на закупку.
Статусы: DRAFT → PENDING → APPROVED → IN_PROGRESS → COMPLETED
Альтернативные переходы: → REJECTED, → CANCELLED
"""
title = models.CharField(
'Название',
max_length=255,
help_text='Краткое название заявки'
)
description = models.TextField(
'Описание',
help_text='Подробное описание и обоснование необходимости закупки'
)
department = models.ForeignKey(
Department,
on_delete=models.PROTECT,
related_name='procurement_requests',
verbose_name='Отдел'
)
requestor = models.ForeignKey(
User,
on_delete=models.PROTECT,
related_name='procurement_requests',
verbose_name='Заявитель'
)
status = models.CharField(
'Статус',
max_length=20,
choices=ProcurementStatus.choices,
default=ProcurementStatus.DRAFT
)
urgency = models.CharField(
'Срочность',
max_length=20,
choices=UrgencyLevel.choices,
default=UrgencyLevel.MEDIUM
)
estimated_cost = models.DecimalField(
'Предполагаемая стоимость',
max_digits=12,
decimal_places=2,
default=Decimal('0.00'),
validators=[MinValueValidator(Decimal('0.01'))]
)
actual_cost = models.DecimalField(
'Фактическая стоимость',
max_digits=12,
decimal_places=2,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0.01'))]
)
# Даты
created_at = models.DateTimeField(
'Создано',
auto_now_add=True
)
updated_at = models.DateTimeField(
'Обновлено',
auto_now=True
)
submitted_at = models.DateTimeField(
'Отправлено на согласование',
null=True,
blank=True
)
completed_at = models.DateTimeField(
'Завершено',
null=True,
blank=True
)
class Meta:
verbose_name = 'Заявка на закупку'
verbose_name_plural = 'Заявки на закупку'
ordering = ['-created_at']
permissions = [
('approve_procurementrequest', 'Can approve procurement requests'),
('view_all_requests', 'Can view all department requests'),
('execute_procurement', 'Can execute approved requests'),
]
def __str__(self):
return f"#{self.pk} {self.title}"
def get_required_approvals(self):
"""Возвращает список необходимых ролей для согласования.
Логика:
- < 10,000₽: только руководитель отдела
- 10,000 - 100,000₽: руководитель + финансовый менеджер
- > 100,000₽: руководитель + финансы + директор
"""
cost = self.estimated_cost or Decimal('0')
if cost < APPROVAL_THRESHOLD_LOW:
return [ApprovalRole.DEPARTMENT_HEAD]
elif cost < APPROVAL_THRESHOLD_HIGH:
return [
ApprovalRole.DEPARTMENT_HEAD,
ApprovalRole.FINANCE_MANAGER
]
else:
return [
ApprovalRole.DEPARTMENT_HEAD,
ApprovalRole.FINANCE_MANAGER,
ApprovalRole.DIRECTOR
]
def check_budget_available(self):
"""Проверяет наличие бюджета в отделе.
Returns:
tuple: (bool, Decimal) - (доступно ли, остаток бюджета)
"""
# Определяем текущий квартал
now = timezone.now()
quarter = (now.month - 1) // 3 + 1
try:
budget = Budget.objects.get(
department=self.department,
year=now.year,
quarter=quarter
)
remaining = budget.remaining_amount
return (remaining >= self.estimated_cost, remaining)
except Budget.DoesNotExist:
return (False, Decimal('0'))
@property
def items_count(self):
"""Количество позиций в заявке."""
return self.items.count()
@property
def is_editable(self):
"""Можно ли редактировать заявку."""
return self.status == ProcurementStatus.DRAFT
class ProcurementItem(models.Model):
"""Позиция в заявке на закупку."""
request = models.ForeignKey(
ProcurementRequest,
on_delete=models.CASCADE,
related_name='items',
verbose_name='Заявка'
)
name = models.CharField(
'Название',
max_length=255,
help_text='Название товара или услуги'
)
description = models.TextField(
'Описание',
blank=True,
help_text='Подробное описание, технические характеристики'
)
quantity = models.PositiveIntegerField(
'Количество',
default=1,
validators=[MinValueValidator(1)]
)
unit = models.CharField(
'Единица измерения',
max_length=50,
default='шт',
help_text='Например: шт, кг, л, упак, м'
)
estimated_unit_price = models.DecimalField(
'Цена за единицу',
max_digits=12,
decimal_places=2,
validators=[MinValueValidator(Decimal('0.01'))]
)
supplier_info = models.TextField(
'Информация о поставщике',
blank=True,
help_text='Ссылки на товар, контакты поставщика'
)
equipment = models.OneToOneField(
'Equipment',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='procurement_item',
verbose_name='Оборудование',
help_text='Связь с оборудованием после закупки'
)
created_at = models.DateTimeField('Создано', auto_now_add=True)
class Meta:
verbose_name = 'Позиция заявки'
verbose_name_plural = 'Позиции заявок'
ordering = ['id']
def __str__(self):
return f"{self.name} ({self.quantity} {self.unit})"
@property
def total_price(self):
"""Общая стоимость позиции."""
return self.estimated_unit_price * self.quantity
class Approval(models.Model):
"""Запись о согласовании заявки."""
request = models.ForeignKey(
ProcurementRequest,
on_delete=models.CASCADE,
related_name='approvals',
verbose_name='Заявка'
)
approver = models.ForeignKey(
User,
on_delete=models.PROTECT,
related_name='procurement_approvals',
verbose_name='Согласующий'
)
role = models.CharField(
'Роль',
max_length=20,
choices=ApprovalRole.choices,
help_text='Роль согласующего в процессе'
)
status = models.CharField(
'Статус',
max_length=20,
choices=ApprovalStatus.choices,
default=ApprovalStatus.PENDING
)
comment = models.TextField(
'Комментарий',
blank=True
)
created_at = models.DateTimeField('Создано', auto_now_add=True)
updated_at = models.DateTimeField('Обновлено', auto_now=True)
class Meta:
verbose_name = 'Согласование'
verbose_name_plural = 'Согласования'
ordering = ['created_at']
unique_together = [('request', 'role')]
def __str__(self):
return (
f"{self.get_role_display()} - "
f"{self.get_status_display()}"
)
class EquipmentCategory(models.Model):
"""Категория оборудования (иерархическая)."""
name = models.CharField(
'Название',
max_length=100,
unique=True
)
parent = models.ForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='children',
verbose_name='Родительская категория'
)
description = models.TextField(
'Описание',
blank=True
)
icon = models.CharField(
'Иконка Bootstrap',
max_length=50,
blank=True,
help_text='Например: bi-laptop, bi-printer'
)
created_at = models.DateTimeField('Создано', auto_now_add=True)
class Meta:
verbose_name = 'Категория оборудования'
verbose_name_plural = 'Категории оборудования'
ordering = ['name']
def __str__(self):
if self.parent:
return f"{self.parent.name} → {self.name}"
return self.name
@property
def full_path(self):
"""Полный путь категории."""
if self.parent:
return f"{self.parent.full_path} → {self.name}"
return self.name
class Equipment(models.Model):
"""Единица оборудования/инвентаря."""
name = models.CharField(
'Название',
max_length=255
)
inventory_number = models.CharField(
'Инвентарный номер',
max_length=50,
unique=True,
help_text='Например: INV-2024-0001'
)
serial_number = models.CharField(
'Серийный номер',
max_length=100,
blank=True,
help_text='Серийный номер производителя'
)
category = models.ForeignKey(
EquipmentCategory,
on_delete=models.PROTECT,
related_name='equipment',
verbose_name='Категория'
)
status = models.CharField(
'Статус',
max_length=20,
choices=EquipmentStatus.choices,
default=EquipmentStatus.AVAILABLE
)
department = models.ForeignKey(
Department,
on_delete=models.PROTECT,
related_name='equipment',
verbose_name='Отдел'
)
responsible_person = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='responsible_equipment',
verbose_name='Ответственный'
)
location = models.CharField(
'Расположение',
max_length=255,
blank=True,
help_text='Например: Офис 3.14, Стеллаж А-5'
)
purchase_date = models.DateField(
'Дата покупки'
)
warranty_until = models.DateField(
'Гарантия до',
null=True,
blank=True
)
purchase_cost = models.DecimalField(
'Стоимость покупки',
max_digits=12,
decimal_places=2,
validators=[MinValueValidator(Decimal('0.01'))]
)
notes = models.TextField(
'Примечания',
blank=True
)
created_at = models.DateTimeField('Создано', auto_now_add=True)
updated_at = models.DateTimeField('Обновлено', auto_now=True)
class Meta:
verbose_name = 'Оборудование'
verbose_name_plural = 'Оборудование'
ordering = ['-created_at']
def __str__(self):
return f"{self.inventory_number} - {self.name}"
@property
def is_under_warranty(self):
"""Находится ли под гарантией."""
if not self.warranty_until:
return False
return timezone.now().date() <= self.warranty_until
class MaintenanceRecord(models.Model):
"""Запись о техническом обслуживании оборудования."""
equipment = models.ForeignKey(
Equipment,
on_delete=models.CASCADE,
related_name='maintenance_history',
verbose_name='Оборудование'
)
date = models.DateField(
'Дата обслуживания'
)
type = models.CharField(
'Тип обслуживания',
max_length=20,
choices=MaintenanceType.choices
)
description = models.TextField(
'Описание',
help_text='Что было сделано'
)
cost = models.DecimalField(
'Стоимость',
max_digits=10,
decimal_places=2,
default=Decimal('0.00'),
validators=[MinValueValidator(Decimal('0.00'))]
)
performed_by = models.ForeignKey(
User,
on_delete=models.PROTECT,
related_name='performed_maintenance',
verbose_name='Выполнил'
)
next_maintenance_date = models.DateField(
'Следующее ТО',
null=True,
blank=True
)
created_at = models.DateTimeField('Создано', auto_now_add=True)
class Meta:
verbose_name = 'Запись обслуживания'
verbose_name_plural = 'Записи обслуживания'
ordering = ['-date']
def __str__(self):
return (
f"{self.equipment.inventory_number} - "
f"{self.get_type_display()} ({self.date})"
)
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):
"""Бюджет отдела на квартал."""
department = models.ForeignKey(
Department,
on_delete=models.CASCADE,
related_name='budgets',
verbose_name='Отдел'
)
year = models.PositiveIntegerField(
'Год',
help_text='Например: 2025'
)
quarter = models.PositiveIntegerField(
'Квартал',
choices=[(1, '1 квартал'), (2, '2 квартал'),
(3, '3 квартал'), (4, '4 квартал')]
)
allocated_amount = models.DecimalField(
'Выделено',
max_digits=14,
decimal_places=2,
validators=[MinValueValidator(Decimal('0.00'))]
)
spent_amount = models.DecimalField(
'Потрачено',
max_digits=14,
decimal_places=2,
default=Decimal('0.00'),
validators=[MinValueValidator(Decimal('0.00'))]
)
created_at = models.DateTimeField('Создано', auto_now_add=True)
updated_at = models.DateTimeField('Обновлено', auto_now=True)
class Meta:
verbose_name = 'Бюджет'
verbose_name_plural = 'Бюджеты'
ordering = ['-year', '-quarter']
unique_together = [('department', 'year', 'quarter')]
def __str__(self):
return (
f"{self.department.name} - "
f"{self.year} Q{self.quarter}"
)
@property
def remaining_amount(self):
"""Остаток бюджета."""
return self.allocated_amount - self.spent_amount
@property
def reserved_amount(self):
"""Сумма зарезервированная pending заявками."""
from django.db.models import Sum
pending_sum = self.department.procurement_requests.filter(
status=ProcurementStatus.PENDING,
created_at__year=self.year,
created_at__month__in=self._quarter_months()
).aggregate(total=Sum('estimated_cost'))['total']
return pending_sum or Decimal('0.00')
@property
def available_amount(self):
"""Доступный бюджет (с учётом резерва)."""
return self.remaining_amount - self.reserved_amount
def _quarter_months(self):
"""Получить месяцы квартала."""
return {
1: [1, 2, 3],
2: [4, 5, 6],
3: [7, 8, 9],
4: [10, 11, 12],
}[self.quarter]
@property
def utilization_percentage(self):
"""Процент использования бюджета."""
if self.allocated_amount == 0:
return Decimal('0.00')
return (
self.spent_amount / self.allocated_amount * 100
).quantize(Decimal('0.01'))
def can_spend(self, amount):
"""Проверка возможности потратить сумму."""
return self.remaining_amount >= amount
class Supplier(models.Model):
"""Поставщик товаров/услуг."""
name = models.CharField(
'Название',
max_length=255
)
contact_person = models.CharField(
'Контактное лицо',
max_length=255,
blank=True
)
phone = models.CharField(
'Телефон',
max_length=50,
blank=True
)
email = models.EmailField(
'Email',
blank=True
)
address = models.TextField(
'Адрес',
blank=True
)
website = models.URLField(
'Веб-сайт',
blank=True
)
inn = models.CharField(
'ИНН',
max_length=12,
blank=True,
help_text='ИНН организации (10 или 12 цифр)'
)
rating = models.DecimalField(
'Рейтинг',
max_digits=3,
decimal_places=2,
default=Decimal('0.00'),
validators=[MinValueValidator(Decimal('0.00'))]
)
is_active = models.BooleanField(
'Активен',
default=True
)
notes = models.TextField(
'Примечания',
blank=True
)
created_at = models.DateTimeField('Создано', auto_now_add=True)
updated_at = models.DateTimeField('Обновлено', auto_now=True)
class Meta:
verbose_name = 'Поставщик'
verbose_name_plural = 'Поставщики'
ordering = ['name']
def __str__(self):
return self.name