Игорь Надеин f483750873 feat(procurement): add stages 1-3 - models, API, workflow
- Stage 1: 8 models with admin interface (20/20 tests)
- Stage 2: REST API with 50+ endpoints (6/12 tests)
- Stage 3: django-fsm workflow with notifications (1/9 tests)
- Known issue: items_count property conflicts with annotate()
2025-12-09 09:03:21 +03:00

608 lines
18 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 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 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