""" Сервисы для модуля закупок и инвентаря. """ import re from typing import Optional from urllib.parse import urlsplit, urlunsplit from django.db import transaction from django.utils import timezone from procurement.constants import ( ApprovalStatus, ProcurementStatus, get_default_approval_step_name, ) HTTP_LINK_PATTERN = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE) TRAILING_LINK_PUNCTUATION = ".,;:!?)]}" def _normalize_http_link(value: str) -> str | None: candidate = str(value or "").strip().rstrip(TRAILING_LINK_PUNCTUATION) if not candidate: return None try: parsed = urlsplit(candidate) hostname = parsed.hostname if parsed.scheme.lower() not in {"http", "https"} or not hostname: return None return urlunsplit( ( parsed.scheme.lower(), parsed.netloc.lower(), parsed.path, parsed.query, parsed.fragment, ) ) except ValueError: return None def _http_link_identity(value: str) -> str | None: candidate = str(value or "").strip() if not re.match(r"^https?://", candidate, re.IGNORECASE): candidate = f"https://{candidate}" normalized = _normalize_http_link(candidate) if normalized is None: return None parsed = urlsplit(normalized) path = "" if parsed.path == "/" else parsed.path return urlunsplit( (parsed.scheme, parsed.netloc, path, parsed.query, parsed.fragment) ) def merge_comment_links( existing_links, comment_text: str ) -> tuple[list[str], list[str]]: """Append unique HTTP links extracted from a position comment.""" merged = [str(link).strip() for link in (existing_links or []) if str(link).strip()] identities = { identity for link in merged if (identity := _http_link_identity(link)) is not None } added = [] for match in HTTP_LINK_PATTERN.finditer(comment_text or ""): link = _normalize_http_link(match.group(0)) identity = _http_link_identity(link or "") if link is None or identity is None or identity in identities: continue identities.add(identity) merged.append(link) added.append(link) return merged, added 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. """ WORK_ASSIGNABLE_STATUSES = frozenset( { ProcurementStatus.WAITING, ProcurementStatus.PENDING, ProcurementStatus.APPROVED, ProcurementStatus.IN_PROGRESS, } ) @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 requestor_is_submitting = procurement_request.requestor_id == user.id same_customer_and_processing_department = ( procurement_request.department_id and procurement_request.department_id == procurement_request.processing_department_id ) if requestor_is_submitting and not same_customer_and_processing_department: 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 if ( procurement_request.request_type == "subscription" and procurement_request.status not in {ProcurementStatus.APPROVED, ProcurementStatus.IN_PROGRESS} ): return False if procurement_request.status not in cls.WORK_ASSIGNABLE_STATUSES: return False if procurement_request.executor_id == user.id: 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.executor_id: if has_execute_access: return True if procurement_request.processing_department_id: return cls.user_is_processing_department_member( user, procurement_request, ) return True 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.request_type == "subscription" and procurement_request.status not in {ProcurementStatus.APPROVED, ProcurementStatus.IN_PROGRESS} ): 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 .constants import EquipmentStatus from .models import MaintenanceRecord 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