""" Сервисы для модуля закупок и инвентаря. """ from typing import Optional from django.db import transaction from django.utils import timezone from procurement.constants import ( ApprovalStatus, ProcurementStatus, get_default_approval_step_name, ) 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 ProcurementApprovalResolver: """Определяет согласующих для заявки на закупку. Состав этапов и их порядок берутся из таблицы ApprovalRoute. Для этапа типа department_head согласующий определяется через Department.head. """ @classmethod def user_is_department_participant(cls, user, department_id): """Проверяет рабочее участие пользователя в отделе. Участником считается физический сотрудник отдела, руководитель отдела или сотрудник с активной ролью в этом отделе. """ if not department_id or not user or not user.is_authenticated: return False from employees.models import ( Department, EmployeeDepartment, RoleAssignment, ) return ( EmployeeDepartment.objects.filter( employee_id=user.id, department_id=department_id, is_active=True, ).exists() or Department.objects.filter( id=department_id, head_id=user.id, ).exists() or RoleAssignment.objects.filter( employee_id=user.id, role__department_id=department_id, is_active=True, ).exists() ) @classmethod def get_user_department_participant_ids(cls, user): """Возвращает отделы, где пользователь участник или role-only участник.""" if not user or not user.is_authenticated: return [] from employees.models import ( Department, EmployeeDepartment, RoleAssignment, ) department_ids = set( EmployeeDepartment.objects.filter( employee_id=user.id, is_active=True, ).values_list("department_id", flat=True) ) department_ids.update( Department.objects.filter(head_id=user.id).values_list( "id", flat=True, ) ) department_ids.update( RoleAssignment.objects.filter( employee_id=user.id, is_active=True, ).values_list("role__department_id", flat=True) ) return sorted(department_ids) @classmethod def user_is_processing_department_member(cls, user, procurement_request): return cls.user_is_department_participant( user, procurement_request.processing_department_id, ) @classmethod def get_department_approval_users(cls, procurement_request): """Return users who can approve the department-head approval step.""" if not procurement_request.department_id: return [] from employees.constants import DeptPerm from employees.models import Employee, EmployeeDepartment, RoleAssignment department = procurement_request.department approver_ids = [] seen = set() def add_user_id(user_id): if user_id and user_id not in seen: seen.add(user_id) approver_ids.append(user_id) if department.head_id and department.head.is_active: add_user_id(department.head_id) role_assignment_user_ids = RoleAssignment.objects.filter( role__department_id=department.id, role__scoped_permissions__code=DeptPerm.APPROVE_PROCUREMENT, employee__is_active=True, is_active=True, ).values_list("employee_id", flat=True) for user_id in role_assignment_user_ids: add_user_id(user_id) legacy_role_user_ids = EmployeeDepartment.objects.filter( department_id=department.id, role__scoped_permissions__code=DeptPerm.APPROVE_PROCUREMENT, employee__is_active=True, is_active=True, ).values_list("employee_id", flat=True) for user_id in legacy_role_user_ids: add_user_id(user_id) users_by_id = Employee.objects.in_bulk(approver_ids) return [ users_by_id[user_id] for user_id in approver_ids if user_id in users_by_id ] @classmethod def user_can_approve_department_step(cls, user, procurement_request) -> bool: if not user or not user.is_authenticated: return False return any( approver.id == user.id for approver in cls.get_department_approval_users( procurement_request, ) ) @classmethod def user_can_submit_for_approval(cls, user, procurement_request) -> bool: if not user or not user.is_authenticated: return False if ( procurement_request.status == ProcurementStatus.PENDING or procurement_request.approvals.filter( status=ApprovalStatus.PENDING, ).exists() or procurement_request.approvals.filter( status=ApprovalStatus.APPROVED, ).exists() ): return False if not procurement_request.processing_department_id: return False if procurement_request.status not in { ProcurementStatus.WAITING, ProcurementStatus.IN_PROGRESS, }: return False if procurement_request.requestor_id == user.id: return False return cls.user_is_processing_department_member( user, procurement_request, ) @classmethod def user_can_start_work(cls, user, procurement_request) -> bool: if not user or not user.is_authenticated: return False has_execute_access = ( user.is_superuser or user.is_staff or user.has_perm("procurement.change_procurementrequest") or user.has_perm("procurement.execute_procurement") ) if ( procurement_request.status == ProcurementStatus.IN_PROGRESS and procurement_request.processing_department_id and procurement_request.executor_id ): if procurement_request.executor_id == user.id: return False if has_execute_access: return True return cls.user_is_processing_department_member( user, procurement_request, ) if procurement_request.status not in { ProcurementStatus.APPROVED, ProcurementStatus.WAITING, }: return False if procurement_request.executor_id: return False if has_execute_access: return True if procurement_request.processing_department_id: return cls.user_is_processing_department_member( user, procurement_request, ) return True @classmethod def user_can_process_items(cls, user, procurement_request) -> bool: if not user or not user.is_authenticated: return False if procurement_request.status not in { ProcurementStatus.WAITING, ProcurementStatus.IN_PROGRESS, ProcurementStatus.COMPLETED, }: return False if procurement_request.executor_id == user.id: return True if ( user.is_superuser or user.is_staff or user.has_perm("procurement.change_procurementrequest") or user.has_perm("procurement.execute_procurement") ): return True if procurement_request.processing_department_id: return cls.user_is_processing_department_member( user, procurement_request, ) return False @classmethod def resolve_approval_step(cls, procurement_request, route): from .models import ApprovalRoute if route is None: return None if not isinstance(route, ApprovalRoute): route = ( ApprovalRoute.objects.select_related("employee") .filter( priority=route, ) .first() ) if route is None: return None step_name = route.name or get_default_approval_step_name( route.priority, resolver_type=route.resolver_type, ) if route.resolver_type == ApprovalRoute.ResolverType.DEPARTMENT_HEAD: approvers = cls.get_department_approval_users(procurement_request) if approvers: return route.priority, approvers[0], step_name return None if route.employee and route.employee.is_active: return route.priority, route.employee, step_name return None @classmethod def resolve_approver(cls, procurement_request, priority: int): step = cls.resolve_approval_step(procurement_request, priority) if step is None: return None return step[1] @classmethod def resolve_required_approvers(cls, procurement_request): resolved, missing = [], [] for route in procurement_request.get_required_approval_routes(): step = cls.resolve_approval_step(procurement_request, route) if step: resolved.append(step) else: missing.append(route.priority) resolved.sort(key=lambda item: item[0]) return resolved, missing @classmethod def resolve_required_approvers_detailed(cls, procurement_request): """Возвращает согласующих и подробности по пропущенным этапам.""" from .models import ApprovalRoute resolved = [] missing = [] for route in procurement_request.get_required_approval_routes(): step = cls.resolve_approval_step(procurement_request, route) if step: resolved.append(step) continue step_label = route.name or get_default_approval_step_name( route.priority, resolver_type=route.resolver_type, ) reason = "unknown" if route.resolver_type == ApprovalRoute.ResolverType.DEPARTMENT_HEAD: reason = "department_head_missing" elif route.resolver_type == ApprovalRoute.ResolverType.FIXED_EMPLOYEE: reason = "approver_missing_or_inactive" missing.append( { "priority": route.priority, "step_name": step_label, "resolver_type": route.resolver_type, "reason": reason, } ) resolved.sort(key=lambda item: item[0]) return resolved, missing @classmethod def get_available_approval(cls, user, procurement_request): if not user or not user.is_authenticated: return None if procurement_request.status != ProcurementStatus.PENDING: return None pending = procurement_request.approvals.filter( status=ApprovalStatus.PENDING, ).order_by("priority", "created_at", "id") first_pending = pending.first() if first_pending is None: return None direct_approval = pending.filter( approver=user, priority__gte=first_pending.priority, ).first() if direct_approval is not None: return direct_approval for approval in pending.filter(priority__gte=first_pending.priority): if cls.approval_can_be_taken_by_user(approval, user): return approval return None @classmethod def approval_can_be_taken_by_user(cls, approval, user) -> bool: from .models import ApprovalRoute route = ApprovalRoute.objects.filter( priority=approval.priority, resolver_type=ApprovalRoute.ResolverType.DEPARTMENT_HEAD, ).first() if not route: return False return cls.user_can_approve_department_step(user, approval.request) @classmethod def get_available_higher_route(cls, user, procurement_request): if not user or not user.is_authenticated: return None if procurement_request.status != ProcurementStatus.PENDING: return None first_pending = ( procurement_request.approvals.filter( status=ApprovalStatus.PENDING, ) .order_by("priority", "created_at", "id") .first() ) if first_pending is None: return None from .models import ApprovalRoute existing_priorities = set( procurement_request.approvals.values_list("priority", flat=True) ) routes = ( ApprovalRoute.objects.select_related("employee") .filter(priority__gt=first_pending.priority) .order_by("priority", "id") ) for route in routes: if route.priority in existing_priorities: continue step = cls.resolve_approval_step(procurement_request, route) if step and step[1].id == user.id: return step if ( route.resolver_type == ApprovalRoute.ResolverType.DEPARTMENT_HEAD and cls.user_can_approve_department_step( user, procurement_request, ) ): step_name = ( step[2] if step else route.name or get_default_approval_step_name( route.priority, resolver_type=route.resolver_type, ) ) return route.priority, user, step_name return None @classmethod def get_or_create_available_approval(cls, user, procurement_request): approval = cls.get_available_approval(user, procurement_request) if approval is not None: return approval step = cls.get_available_higher_route(user, procurement_request) if step is None: return None from .models import Approval priority, approver, step_name = step approval, _ = Approval.objects.get_or_create( request=procurement_request, priority=priority, defaults={ "approver": approver, "step_name": step_name, "status": ApprovalStatus.PENDING, }, ) if ( approval.approver_id != user.id or approval.status != ApprovalStatus.PENDING ): return None return approval @classmethod def user_can_approve(cls, user, procurement_request) -> bool: if not user or not user.is_authenticated: return False return ( cls.get_available_approval(user, procurement_request) is not None or cls.get_available_higher_route(user, procurement_request) is not None ) 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