procurement: add processing department workflow

This commit is contained in:
lizerk 2026-05-22 13:46:53 +03:00
parent 3725975604
commit fce272d352
9 changed files with 782 additions and 42 deletions

View File

@ -153,6 +153,17 @@ class CanManageProcurementRequest(permissions.BasePermission):
"""Проверяет, является ли пользователь начальником отдела."""
return Department.objects.filter(id=dept_id, head_id=user.id).exists()
def _is_dept_member(self, user, dept_id: int) -> bool:
"""Проверяет активную принадлежность пользователя к отделу."""
return (
EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept_id,
is_active=True,
).exists()
or self._is_dept_head(user, dept_id)
)
def has_permission(self, request, view) -> bool:
"""Проверка доступа на уровне запроса."""
user = request.user
@ -235,14 +246,29 @@ class CanManageProcurementRequest(permissions.BasePermission):
if action == "delete_comment":
return True
# Start_work (взять в работу) - любой авторизованный
# Start_work: общий пул может взять любой, адресную заявку - отдел.
if action == "start_work":
if obj.processing_department_id:
return self._is_dept_member(
user,
obj.processing_department_id,
)
return True
# Complete (завершить) - только исполнитель
if action == "complete":
return obj.executor == user
if action == "mark_all_received":
if obj.executor == user:
return True
if obj.processing_department_id:
return self._is_dept_member(
user,
obj.processing_department_id,
)
return False
# Удаление
if action == "destroy":
# Автор может удалить свою заявку в DRAFT
@ -255,6 +281,12 @@ class CanManageProcurementRequest(permissions.BasePermission):
# Изменение
if action in ("update", "partial_update"):
if obj.processing_department_id and self._is_dept_member(
user,
obj.processing_department_id,
):
return True
# Заявка должна быть редактируемой (DRAFT)
if not obj.is_editable:
return False

View File

@ -5,7 +5,10 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from procurement.constants import get_default_approval_step_name
from procurement.constants import (
ProcurementStatus,
get_default_approval_step_name,
)
from procurement.services import ProcurementApprovalResolver
from procurement.models import (
Approval,
@ -26,6 +29,9 @@ class ProcurementItemSerializer(serializers.ModelSerializer):
total_price = serializers.DecimalField(
max_digits=12, decimal_places=2, read_only=True
)
execution_status_display = serializers.CharField(
source="get_execution_status_display", read_only=True
)
class Meta:
model = ProcurementItem
@ -39,10 +45,21 @@ class ProcurementItemSerializer(serializers.ModelSerializer):
"estimated_unit_price",
"total_price",
"supplier_info",
"links",
"expected_delivery_date",
"actual_unit_price",
"execution_status",
"execution_status_display",
"executor_comment",
"equipment",
"created_at",
]
read_only_fields = ["id", "created_at", "total_price"]
read_only_fields = [
"id",
"created_at",
"total_price",
"execution_status_display",
]
class ProcurementItemCreateSerializer(serializers.ModelSerializer):
@ -57,6 +74,11 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer):
"unit",
"estimated_unit_price",
"supplier_info",
"links",
"expected_delivery_date",
"actual_unit_price",
"execution_status",
"executor_comment",
]
@ -109,6 +131,9 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer):
department_name = serializers.CharField(
source="department.name", read_only=True
)
processing_department_name = serializers.CharField(
source="processing_department.name", read_only=True, allow_null=True
)
requestor_name = serializers.CharField(
source="requestor.get_full_name", read_only=True
)
@ -118,6 +143,9 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer):
urgency_display = serializers.CharField(
source="get_urgency_display", read_only=True
)
fulfillment_status_display = serializers.CharField(
source="get_fulfillment_status_display", read_only=True
)
items_count = serializers.IntegerField(read_only=True)
total_cost = serializers.DecimalField(
max_digits=12, decimal_places=2, read_only=True
@ -141,6 +169,8 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer):
"title",
"department",
"department_name",
"processing_department",
"processing_department_name",
"requestor",
"requestor_name",
"executor",
@ -149,6 +179,8 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer):
"status_display",
"urgency",
"urgency_display",
"fulfillment_status",
"fulfillment_status_display",
"total_cost",
"items_count",
"comments_count",
@ -163,10 +195,13 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer):
"submitted_at",
"started_at",
"department_name",
"processing_department_name",
"requestor_name",
"executor_name",
"status_display",
"urgency_display",
"fulfillment_status",
"fulfillment_status_display",
"items_count",
"total_cost",
"comments_count",
@ -191,6 +226,9 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
department_name = serializers.CharField(
source="department.name", read_only=True
)
processing_department_name = serializers.CharField(
source="processing_department.name", read_only=True, allow_null=True
)
requestor_name = serializers.CharField(
source="requestor.get_full_name", read_only=True
)
@ -206,6 +244,9 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
urgency_display = serializers.CharField(
source="get_urgency_display", read_only=True
)
fulfillment_status_display = serializers.CharField(
source="get_fulfillment_status_display", read_only=True
)
total_cost = serializers.DecimalField(
max_digits=12, decimal_places=2, read_only=True
)
@ -232,6 +273,8 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
"description",
"department",
"department_name",
"processing_department",
"processing_department_name",
"requestor",
"requestor_name",
"requestor_email",
@ -241,6 +284,8 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
"status_display",
"urgency",
"urgency_display",
"fulfillment_status",
"fulfillment_status_display",
"total_cost",
"actual_cost",
"items",
@ -265,6 +310,9 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
"is_editable",
"total_cost",
"executor_name",
"fulfillment_status",
"processing_department_name",
"fulfillment_status_display",
"comments_count",
"can_current_user_approve",
]
@ -282,6 +330,7 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
"title",
"description",
"department",
"processing_department",
"requestor",
"urgency",
"items",
@ -295,7 +344,9 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
items_data = validated_data.pop("items", [])
request = self.context["request"]
# Создаем заявку
if validated_data.get("processing_department"):
validated_data["status"] = ProcurementStatus.WAITING
procurement_request = ProcurementRequest.objects.create(
requestor=request.user, **validated_data
)

View File

@ -8,11 +8,12 @@ from rest_framework import filters, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from employees.models import Department
from employees.models import Department, EmployeeDepartment
from procurement.constants import (
ApprovalStatus,
ProcurementStatus,
EquipmentStatus,
ProcurementItemExecutionStatus,
)
from procurement.models import (
Approval,
@ -55,14 +56,24 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
"""ViewSet для заявок на закупку."""
queryset = ProcurementRequest.objects.select_related(
"department", "requestor", "executor"
"department",
"processing_department",
"requestor",
"executor",
).prefetch_related("items", "approvals__approver")
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = ["status", "urgency", "department", "executor"]
filterset_fields = [
"status",
"urgency",
"department",
"processing_department",
"executor",
"fulfillment_status",
]
search_fields = ["title", "description"]
ordering_fields = ["created_at", "status"]
ordering = ["-created_at"]
@ -79,6 +90,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
"start_work",
"complete",
"cancel",
"mark_all_received",
]:
return ProcurementRequestDetailSerializer
return ProcurementRequestListSerializer
@ -138,8 +150,29 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
# Заявки, которые я взял в работу (где я исполнитель)
queryset = queryset.filter(executor=user)
elif scope == "available":
# Доступные заявки - со статусом "Согласовано"
queryset = queryset.filter(status="approved")
queryset = queryset.filter(
status__in=[
ProcurementStatus.WAITING,
ProcurementStatus.APPROVED,
],
executor__isnull=True,
)
if not (
user.is_superuser
or user.is_staff
or user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.execute_procurement")
):
queryset = queryset.filter(
Q(
status=ProcurementStatus.WAITING,
processing_department__in=user.departments.all(),
)
| Q(
status=ProcurementStatus.APPROVED,
processing_department__isnull=True,
)
)
elif scope == "all":
# Все заявки - применяем стандартную логику прав
pass
@ -160,7 +193,12 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(
Q(requestor=user)
| Q(department__in=user.departments.all())
| Q(processing_department__in=user.departments.all())
| Q(approvals__approver=user)
| Q(
status=ProcurementStatus.APPROVED,
processing_department__isnull=True,
)
)
# Фильтрация по периоду
@ -282,6 +320,17 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
procurement_request = self.get_object()
if procurement_request.processing_department_id:
return Response(
{
"error": (
"Заявка уже направлена в отдел-исполнитель "
"и не требует согласования"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
# Проверки
if procurement_request.requestor != request.user:
return Response(
@ -509,13 +558,34 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=["get"])
def available(self, request):
"""Получить одобренные заявки, доступные для взятия в работу."""
"""Получить заявки, доступные для взятия в работу."""
queryset = self.filter_queryset(
self.get_queryset().filter(
status=ProcurementStatus.APPROVED, executor__isnull=True
status__in=[
ProcurementStatus.WAITING,
ProcurementStatus.APPROVED,
],
executor__isnull=True,
)
)
if not (
request.user.is_superuser
or request.user.is_staff
or request.user.has_perm("procurement.change_procurementrequest")
or request.user.has_perm("procurement.execute_procurement")
):
queryset = queryset.filter(
Q(
status=ProcurementStatus.WAITING,
processing_department__in=request.user.departments.all(),
)
| Q(
status=ProcurementStatus.APPROVED,
processing_department__isnull=True,
)
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = ProcurementRequestListSerializer(page, many=True)
@ -528,15 +598,24 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
def start_work(self, request, pk=None):
"""Начать работу над заявкой (перевод в статус IN_PROGRESS).
Любой авторизованный пользователь может взять заявку в работу.
TODO: Позже добавить проверку роли "Закупщик".
Общий пул может взять любой авторизованный пользователь.
Адресную заявку может взять сотрудник отдела-исполнителя.
"""
procurement_request = self.get_object()
self.check_object_permissions(request, procurement_request)
# Проверяем текущий статус
if procurement_request.status != ProcurementStatus.APPROVED:
if procurement_request.status not in [
ProcurementStatus.APPROVED,
ProcurementStatus.WAITING,
]:
return Response(
{"error": "Только одобренные заявки можно взять в работу"},
{
"error": (
"Только одобренные или ожидающие заявки "
"можно взять в работу"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
@ -592,6 +671,35 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def mark_all_received(self, request, pk=None):
"""Отметить все позиции заявки как полученные."""
procurement_request = self.get_object()
self.check_object_permissions(request, procurement_request)
if procurement_request.status not in [
ProcurementStatus.WAITING,
ProcurementStatus.IN_PROGRESS,
]:
return Response(
{
"error": (
"Отмечать позиции можно только в ожидающей "
"или взятой в работу заявке"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
procurement_request.items.update(
execution_status=ProcurementItemExecutionStatus.RECEIVED,
)
procurement_request.recalculate_fulfillment_status(save=True)
procurement_request.refresh_from_db()
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def cancel(self, request, pk=None):
"""Отменить заявку."""
@ -631,12 +739,41 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
class ProcurementItemViewSet(viewsets.ModelViewSet):
"""ViewSet для позиций заявок."""
queryset = ProcurementItem.objects.select_related("request")
queryset = ProcurementItem.objects.select_related(
"request",
"request__department",
"request__processing_department",
)
serializer_class = ProcurementItemSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ["request"]
search_fields = ["name", "description"]
def _is_processing_department_member(self, user, procurement_request):
if not procurement_request.processing_department_id:
return False
return (
EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=procurement_request.processing_department_id,
is_active=True,
).exists()
or procurement_request.processing_department.head_id == user.id
)
def _can_process_request_items(self, user, procurement_request):
if user.is_superuser or user.is_staff:
return True
if (
user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.execute_procurement")
):
return True
return self._is_processing_department_member(
user,
procurement_request,
)
def get_permissions(self):
"""Только создатель заявки может редактировать позиции."""
if self.action in [
@ -666,31 +803,30 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
procurement_request = serializer.validated_data.get("request")
user = self.request.user
# Проверяем, что заявка в DRAFT
if not procurement_request.is_editable:
if procurement_request.status in [
ProcurementStatus.CANCELLED,
ProcurementStatus.REJECTED,
]:
raise PermissionDenied(
"Нельзя добавлять позиции в заявку со статусом '{}'".format(
procurement_request.get_status_display()
)
)
# Админы могут редактировать
if user.is_superuser or user.is_staff:
serializer.save()
return
# Модельные права
if user.has_perm("procurement.change_procurementrequest"):
if self._can_process_request_items(user, procurement_request):
serializer.save()
return
# Автор заявки
if procurement_request.requestor == user:
if procurement_request.requestor == user and procurement_request.is_editable:
serializer.save()
return
# Начальник отдела
if procurement_request.department.head == user:
if (
procurement_request.department.head == user
and procurement_request.is_editable
):
serializer.save()
return
@ -707,28 +843,28 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
user = self.request.user
procurement_request = item.request
# Проверяем, что заявка в DRAFT
if not procurement_request.is_editable:
if procurement_request.status in [
ProcurementStatus.CANCELLED,
ProcurementStatus.REJECTED,
]:
raise PermissionDenied(
"Нельзя изменять позиции в заявке со статусом '{}'".format(
procurement_request.get_status_display()
)
)
# Админы могут редактировать
if user.is_superuser or user.is_staff:
return True
# Модельные права
if user.has_perm("procurement.change_procurementrequest"):
if self._can_process_request_items(user, procurement_request):
return True
# Автор заявки
if procurement_request.requestor == user:
if procurement_request.requestor == user and procurement_request.is_editable:
return True
# Начальник отдела
if procurement_request.department.head == user:
if (
procurement_request.department.head == user
and procurement_request.is_editable
):
return True
# Нет прав

View File

@ -459,6 +459,7 @@ SPECTACULAR_SETTINGS = {
"ENUM_NAME_OVERRIDES": {
"ProcurementStatusEnum": [
("draft", "Черновик"),
("waiting", "Ожидает"),
("pending", "На согласовании"),
("approved", "Одобрено"),
("rejected", "Отклонено"),

View File

@ -26,6 +26,9 @@ class ProcurementItemInline(admin.TabularInline):
"quantity",
"unit",
"estimated_unit_price",
"actual_unit_price",
"execution_status",
"expected_delivery_date",
"supplier_info",
]
@ -35,7 +38,14 @@ class ApprovalInline(admin.TabularInline):
model = Approval
extra = 0
fields = ["approver", "role", "status", "comment", "created_at"]
fields = [
"approver",
"priority",
"step_name",
"status",
"comment",
"created_at",
]
readonly_fields = ["created_at"]
can_delete = False
@ -48,13 +58,22 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
"id",
"title",
"department",
"processing_department",
"requestor",
"status_badge",
"fulfillment_status",
"urgency_badge",
"get_total_cost",
"created_at",
]
list_filter = ["status", "urgency", "department", "created_at"]
list_filter = [
"status",
"fulfillment_status",
"urgency",
"department",
"processing_department",
"created_at",
]
search_fields = ["title", "description", "requestor__username"]
readonly_fields = [
"created_at",
@ -69,13 +88,22 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
fieldsets = (
(
"Основная информация",
{"fields": ("title", "description", "department", "requestor")},
{
"fields": (
"title",
"description",
"department",
"processing_department",
"requestor",
)
},
),
(
"Параметры",
{
"fields": (
"status",
"fulfillment_status",
"urgency",
"get_total_cost",
"actual_cost",
@ -100,6 +128,7 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
"""Цветной бадж статуса."""
colors = {
"draft": "#6c757d",
"waiting": "#ffc107",
"pending": "#0dcaf0",
"approved": "#198754",
"rejected": "#dc3545",
@ -153,9 +182,12 @@ class ProcurementItemAdmin(admin.ModelAdmin):
"quantity",
"unit",
"estimated_unit_price",
"actual_unit_price",
"execution_status",
"expected_delivery_date",
"total_price",
]
list_filter = ["unit", "created_at"]
list_filter = ["unit", "execution_status", "created_at"]
search_fields = ["name", "description", "request__title"]
readonly_fields = ["created_at", "total_price"]

View File

@ -9,6 +9,7 @@ from django.db import models
class ProcurementStatus(models.TextChoices):
"""Статусы заявки на закупку."""
DRAFT = 'draft', 'Черновик'
WAITING = 'waiting', 'Ожидает'
PENDING = 'pending', 'На согласовании'
APPROVED = 'approved', 'Одобрено'
REJECTED = 'rejected', 'Отклонено'
@ -25,6 +26,26 @@ class UrgencyLevel(models.TextChoices):
CRITICAL = 'critical', 'Критическая'
class ProcurementFulfillmentStatus(models.TextChoices):
"""Рабочий статус исполнения заявки по позициям."""
PENDING = 'pending', 'Ожидает обработки'
PARTIALLY_ORDERED = 'partially_ordered', 'Заказано частично'
ORDERED = 'ordered', 'Заказано'
PARTIALLY_RECEIVED = 'partially_received', 'Прибыл частично'
COMPLETED = 'completed', 'Выполнено'
ISSUES = 'issues', 'Выполнено с проблемами'
class ProcurementItemExecutionStatus(models.TextChoices):
"""Рабочий статус отдельной позиции заявки."""
PENDING = 'pending', 'Не выполнено'
ORDERED = 'ordered', 'Заказано'
REJECTED = 'rejected', 'Отказано'
RECEIVED = 'received', 'Получено'
COMPLETED_WITH_ISSUE = 'completed_with_issue', 'Выполнено с замечанием'
EDITED = 'edited', 'Отредактировано'
def get_default_approval_step_name(priority: int,
resolver_type: str | None = None) -> str:
if resolver_type == 'department_head':

View File

@ -0,0 +1,124 @@
# Generated by Codex on 2026-05-22
import django.core.validators
from decimal import Decimal
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("employees", "0052_employee_attendance_aliases"),
("procurement", "0009_approvalroute_min_amount"),
]
operations = [
migrations.AlterField(
model_name="procurementrequest",
name="status",
field=models.CharField(
choices=[
("draft", "Черновик"),
("waiting", "Ожидает"),
("pending", "На согласовании"),
("approved", "Одобрено"),
("rejected", "Отклонено"),
("in_progress", "В работе"),
("completed", "Завершено"),
("cancelled", "Отменено"),
],
default="draft",
max_length=20,
verbose_name="Статус",
),
),
migrations.AddField(
model_name="procurementrequest",
name="fulfillment_status",
field=models.CharField(
choices=[
("pending", "Ожидает обработки"),
("partially_ordered", "Заказано частично"),
("ordered", "Заказано"),
("partially_received", "Прибыл частично"),
("completed", "Выполнено"),
("issues", "Выполнено с проблемами"),
],
default="pending",
max_length=30,
verbose_name="Статус исполнения",
),
),
migrations.AddField(
model_name="procurementrequest",
name="processing_department",
field=models.ForeignKey(
blank=True,
help_text="Отдел, который должен обработать заявку",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="processing_procurement_requests",
to="employees.department",
verbose_name="Отдел-исполнитель",
),
),
migrations.AddField(
model_name="procurementitem",
name="actual_unit_price",
field=models.DecimalField(
blank=True,
decimal_places=2,
max_digits=12,
null=True,
validators=[
django.core.validators.MinValueValidator(Decimal("0.01"))
],
verbose_name="Фактическая цена за единицу",
),
),
migrations.AddField(
model_name="procurementitem",
name="execution_status",
field=models.CharField(
choices=[
("pending", "Не выполнено"),
("ordered", "Заказано"),
("rejected", "Отказано"),
("received", "Получено"),
("completed_with_issue", "Выполнено с замечанием"),
("edited", "Отредактировано"),
],
default="pending",
max_length=30,
verbose_name="Статус выполнения",
),
),
migrations.AddField(
model_name="procurementitem",
name="expected_delivery_date",
field=models.DateField(
blank=True,
null=True,
verbose_name="Ожидаемая дата поступления",
),
),
migrations.AddField(
model_name="procurementitem",
name="executor_comment",
field=models.TextField(
blank=True,
verbose_name="Комментарий исполнителя",
),
),
migrations.AddField(
model_name="procurementitem",
name="links",
field=models.JSONField(
blank=True,
default=list,
help_text="Список ссылок на позицию",
verbose_name="Ссылки",
),
),
]

View File

@ -27,6 +27,8 @@ from .constants import (
ApprovalStatus,
EquipmentStatus,
MaintenanceType,
ProcurementFulfillmentStatus,
ProcurementItemExecutionStatus,
ProcurementStatus,
UrgencyLevel,
get_default_approval_step_name,
@ -55,6 +57,15 @@ class ProcurementRequest(models.Model):
related_name="procurement_requests",
verbose_name="Отдел",
)
processing_department = models.ForeignKey(
Department,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="processing_procurement_requests",
verbose_name="Отдел-исполнитель",
help_text="Отдел, который должен обработать заявку",
)
requestor = models.ForeignKey(
User,
on_delete=models.PROTECT,
@ -82,6 +93,12 @@ class ProcurementRequest(models.Model):
choices=UrgencyLevel.choices,
default=UrgencyLevel.MEDIUM,
)
fulfillment_status = models.CharField(
"Статус исполнения",
max_length=30,
choices=ProcurementFulfillmentStatus.choices,
default=ProcurementFulfillmentStatus.PENDING,
)
actual_cost = models.DecimalField(
"Фактическая стоимость",
max_digits=12,
@ -144,7 +161,67 @@ class ProcurementRequest(models.Model):
@property
def is_editable(self):
"""Можно ли редактировать заявку."""
return self.status == ProcurementStatus.DRAFT
return self.status in [
ProcurementStatus.DRAFT,
ProcurementStatus.WAITING,
] and self.executor_id is None
def recalculate_fulfillment_status(self, save=True):
"""Пересчитать рабочий статус заявки по статусам позиций."""
item_statuses = list(
self.items.values_list("execution_status", flat=True)
)
if not item_statuses:
new_status = ProcurementFulfillmentStatus.PENDING
elif any(
item_status
in {
ProcurementItemExecutionStatus.REJECTED,
ProcurementItemExecutionStatus.COMPLETED_WITH_ISSUE,
ProcurementItemExecutionStatus.EDITED,
}
for item_status in item_statuses
):
new_status = ProcurementFulfillmentStatus.ISSUES
elif all(
item_status == ProcurementItemExecutionStatus.RECEIVED
for item_status in item_statuses
):
new_status = ProcurementFulfillmentStatus.COMPLETED
elif any(
item_status == ProcurementItemExecutionStatus.RECEIVED
for item_status in item_statuses
):
new_status = ProcurementFulfillmentStatus.PARTIALLY_RECEIVED
elif all(
item_status == ProcurementItemExecutionStatus.ORDERED
for item_status in item_statuses
):
new_status = ProcurementFulfillmentStatus.ORDERED
elif any(
item_status == ProcurementItemExecutionStatus.ORDERED
for item_status in item_statuses
):
new_status = ProcurementFulfillmentStatus.PARTIALLY_ORDERED
else:
new_status = ProcurementFulfillmentStatus.PENDING
if self.fulfillment_status == new_status:
return new_status
self.fulfillment_status = new_status
if save:
update_fields = ["fulfillment_status", "updated_at"]
if (
new_status == ProcurementFulfillmentStatus.COMPLETED
and self.status
in [ProcurementStatus.WAITING, ProcurementStatus.IN_PROGRESS]
):
self.status = ProcurementStatus.COMPLETED
self.completed_at = timezone.now()
update_fields.extend(["status", "completed_at"])
self.save(update_fields=update_fields)
return new_status
class ProcurementItem(models.Model):
@ -184,6 +261,35 @@ class ProcurementItem(models.Model):
blank=True,
help_text="Ссылки на товар, контакты поставщика",
)
links = models.JSONField(
"Ссылки",
default=list,
blank=True,
help_text="Список ссылок на позицию",
)
expected_delivery_date = models.DateField(
"Ожидаемая дата поступления",
null=True,
blank=True,
)
actual_unit_price = models.DecimalField(
"Фактическая цена за единицу",
max_digits=12,
decimal_places=2,
null=True,
blank=True,
validators=[MinValueValidator(Decimal("0.01"))],
)
execution_status = models.CharField(
"Статус выполнения",
max_length=30,
choices=ProcurementItemExecutionStatus.choices,
default=ProcurementItemExecutionStatus.PENDING,
)
executor_comment = models.TextField(
"Комментарий исполнителя",
blank=True,
)
equipment = models.OneToOneField(
"Equipment",
on_delete=models.SET_NULL,
@ -208,6 +314,17 @@ class ProcurementItem(models.Model):
"""Общая стоимость позиции."""
return self.estimated_unit_price * self.quantity
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.request_id:
self.request.recalculate_fulfillment_status(save=True)
def delete(self, *args, **kwargs):
request = self.request
result = super().delete(*args, **kwargs)
request.recalculate_fulfillment_status(save=True)
return result
class ApprovalRoute(models.Model):
"""Правило получения согласующего для этапа с заданным приоритетом."""

View File

@ -13,6 +13,8 @@ from rest_framework.test import APIClient
from employees.models import Department, Employee, EmployeeDepartment
from procurement.constants import (
ApprovalStatus,
ProcurementFulfillmentStatus,
ProcurementItemExecutionStatus,
ProcurementStatus,
UrgencyLevel,
)
@ -332,6 +334,46 @@ class TestProcurementRequestCreate:
status.HTTP_403_FORBIDDEN
]
def test_create_processing_department_request_skips_approvals(
self, api_client, user, department, db
):
"""Заявка в отдел-исполнитель сразу попадает в очередь."""
supply_department = Department.objects.create(
name="Снабжение",
description="Отдел снабжения",
)
api_client.force_authenticate(user=user)
url = reverse('api:v1:procurement:procurementrequest-list')
response = api_client.post(
url,
{
'title': 'Расходники для производства',
'description': 'Нужны материалы для заказа',
'department': department.id,
'processing_department': supply_department.id,
'urgency': UrgencyLevel.MEDIUM,
'items': [
{
'name': 'Перчатки',
'quantity': 10,
'unit': 'упак',
'estimated_unit_price': '500.00',
'links': ['https://example.com/gloves'],
},
],
},
format='json',
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data['status'] == ProcurementStatus.WAITING
request_id = response.data['id']
procurement_request = ProcurementRequest.objects.get(id=request_id)
assert procurement_request.processing_department == supply_department
assert procurement_request.approvals.count() == 0
# ==============================================================================
# ТЕСТЫ ДЕТАЛЬНОЙ ИНФОРМАЦИИ
@ -499,6 +541,190 @@ class TestProcurementRequestUpdate:
assert response.status_code == status.HTTP_403_FORBIDDEN
# ==============================================================================
# ТЕСТЫ АДРЕСНЫХ ЗАЯВОК В ОТДЕЛ-ИСПОЛНИТЕЛЬ
# ==============================================================================
class TestProcessingDepartmentWorkflow:
"""Workflow для заявок, направленных в отдел-исполнитель."""
@pytest.fixture
def supply_department(self, db):
return Department.objects.create(
name="Снабжение",
description="Отдел снабжения",
)
@pytest.fixture
def supply_user(self, db, supply_department):
employee = Employee.objects.create_user(
email="supply@example.com",
password="testpass123",
phone_number="+79998888888",
first_name="Снабжение",
last_name="Исполнитель",
is_active=True,
email_verified=True,
send_activation_email=False,
)
EmployeeDepartment.objects.create(
employee=employee,
department=supply_department,
is_active=True,
)
return employee
@pytest.fixture
def outsider(self, db):
return Employee.objects.create_user(
email="outsider@example.com",
password="testpass123",
phone_number="+79998888889",
first_name="Чужой",
last_name="Сотрудник",
is_active=True,
email_verified=True,
send_activation_email=False,
)
@pytest.fixture
def processing_request(
self, department, user, supply_department
):
return ProcurementRequest.objects.create(
title="Заявка на расходники",
description="Нужны расходные материалы",
department=department,
processing_department=supply_department,
requestor=user,
status=ProcurementStatus.WAITING,
urgency=UrgencyLevel.MEDIUM,
)
def test_available_shows_processing_department_requests(
self, api_client, supply_user, processing_request, procurement_item_factory
):
procurement_item_factory(request=processing_request)
api_client.force_authenticate(user=supply_user)
url = reverse('api:v1:procurement:procurementrequest-available')
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
ids = [item['id'] for item in response.data['results']]
assert processing_request.id in ids
def test_outsider_cannot_start_processing_department_request(
self, api_client, outsider, processing_request, procurement_item_factory
):
procurement_item_factory(request=processing_request)
api_client.force_authenticate(user=outsider)
url = reverse(
'api:v1:procurement:procurementrequest-start-work',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
processing_request.refresh_from_db()
assert processing_request.executor is None
assert processing_request.status == ProcurementStatus.WAITING
def test_processing_department_user_can_start_work_and_blocks_author_edit(
self, api_client, user, supply_user, processing_request,
procurement_item_factory
):
procurement_item_factory(request=processing_request)
start_url = reverse(
'api:v1:procurement:procurementrequest-start-work',
kwargs={'pk': processing_request.id},
)
detail_url = reverse(
'api:v1:procurement:procurementrequest-detail',
kwargs={'pk': processing_request.id},
)
api_client.force_authenticate(user=supply_user)
start_response = api_client.post(start_url)
assert start_response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert processing_request.executor == supply_user
assert processing_request.status == ProcurementStatus.IN_PROGRESS
api_client.force_authenticate(user=user)
edit_response = api_client.patch(
detail_url,
{'title': 'Нельзя менять'},
format='json',
)
assert edit_response.status_code == status.HTTP_403_FORBIDDEN
def test_item_status_recalculates_request_fulfillment_status(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(request=processing_request)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save()
api_client.force_authenticate(user=supply_user)
item_url = reverse(
'api:v1:procurement:procurementitem-detail',
kwargs={'pk': item.id},
)
response = api_client.patch(
item_url,
{'execution_status': ProcurementItemExecutionStatus.RECEIVED},
format='json',
)
assert response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.COMPLETED
)
assert processing_request.status == ProcurementStatus.COMPLETED
def test_mark_all_received_completes_processing_request(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
item_1 = procurement_item_factory(request=processing_request)
item_2 = procurement_item_factory(
request=processing_request,
name="Лампы",
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save()
api_client.force_authenticate(user=supply_user)
url = reverse(
'api:v1:procurement:procurementrequest-mark-all-received',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
item_1.refresh_from_db()
item_2.refresh_from_db()
assert item_1.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert item_2.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.COMPLETED
)
assert processing_request.status == ProcurementStatus.COMPLETED
# ==============================================================================
# ТЕСТЫ WORKFLOW ДЕЙСТВИЙ
# ==============================================================================