Игорь Надеин 46cb319903 fix: исправить ошибки в админке Budget
- Убрать remaining_amount и utilization_percentage из list_display (это @property)
- Добавить методы get_remaining_amount и get_utilization_percentage
- Защитить от None в allocated_amount
- Исправить prefetch_related в views_frontend
2025-12-10 07:16:00 +03:00

724 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):
"""Остаток бюджета."""
allocated = self.allocated_amount or Decimal('0.00')
return allocated - 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):
"""Процент использования бюджета."""
allocated = self.allocated_amount or Decimal('0.00')
if allocated == 0:
return Decimal('0.00')
return (
self.spent_amount / allocated * 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