Corp/backend/procurement/services.py
2026-09-07 19:43:48 +03:00

784 lines
24 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.

"""
Сервисы для модуля закупок и инвентаря.
"""
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.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.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