feat: expand document procurement and request workflows

This commit is contained in:
lizerk 2026-08-04 20:19:12 +03:00
parent 1295d24c86
commit 8fd5ae1054
59 changed files with 2697 additions and 518 deletions

View File

@ -9,6 +9,21 @@ from rest_framework.views import View
from ..permissions import AdminOrActionOrModelPerms
def _is_admin(user) -> bool:
return bool(
user
and user.is_authenticated
and (getattr(user, "is_staff", False) or getattr(user, "is_superuser", False))
)
def _request_sets_regulation(request: Request) -> bool:
raw_value = request.data.get("is_regulation")
if isinstance(raw_value, bool):
return raw_value
return str(raw_value or "").strip().lower() in {"1", "true", "yes", "on"}
def _has_any_model_perm(
user,
app_label: str,
@ -34,9 +49,15 @@ def _user_has_document_access(user, obj) -> bool:
if obj.recipients.filter(pk=user.pk, is_active=True).exists():
return True
return obj.departments.filter(
if obj.departments.filter(
employeedepartment__employee=user,
employeedepartment__is_active=True,
).exists():
return True
return obj.departments.filter(
roles__assignments__employee=user,
roles__assignments__is_active=True,
).exists()
@ -68,9 +89,11 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms):
):
return bool(request.user and request.user.is_authenticated)
# CREATE → разрешаем всем аутентифицированным (документы создаются
# неопубликованными)
# Обычные документы могут создавать все аутентифицированные сотрудники,
# регламенты — только администраторы.
if action == "create":
if _request_sets_regulation(request):
return _is_admin(request.user)
return bool(request.user and request.user.is_authenticated)
# update/destroy и related documents - проверяем на уровне объекта
@ -99,6 +122,19 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms):
action = getattr(view, "action", None)
if action in ("update", "partial_update") and (
getattr(obj, "is_regulation", False)
or _request_sets_regulation(request)
):
return _is_admin(user)
if getattr(obj, "is_regulation", False) and action in (
"add_related",
"remove_related",
"revert",
):
return _is_admin(user)
# Полный доступ при наличии ЛЮБОГО модельного права или staff
if user.is_staff or _has_any_model_perm(user, "documents", "document"):
return True

View File

@ -679,6 +679,10 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
departments_links__department_id__in=department_ids,
departments_links__is_active=True,
)
| Q(
role_assignments__role__department_id__in=department_ids,
role_assignments__is_active=True,
)
).values_list("pk", flat=True)
invalid_recipients = acknowledgement_recipient_ids - set(
allowed_recipients

View File

@ -128,6 +128,10 @@ class DocumentViewSet(ModelViewSet):
departments__employeedepartment__employee=user,
departments__employeedepartment__is_active=True,
)
| Q(
departments__roles__assignments__employee=user,
departments__roles__assignments__is_active=True,
)
| Q(uploaded_by=user)
).distinct()
# Если нет НИКАКИХ модельных прав на документы - ограничиваем queryset
@ -149,6 +153,10 @@ class DocumentViewSet(ModelViewSet):
departments__employeedepartment__employee=user,
departments__employeedepartment__is_active=True,
)
| Q(
departments__roles__assignments__employee=user,
departments__roles__assignments__is_active=True,
)
| Q(uploaded_by=user)
).distinct()
@ -227,7 +235,7 @@ class DocumentViewSet(ModelViewSet):
return
from communications.models import Message
from employees.models import EmployeeDepartment
from employees.models import EmployeeDepartment, RoleAssignment
document_ids = [document.id for document in documents]
active_user_ids = set(
@ -253,6 +261,14 @@ class DocumentViewSet(ModelViewSet):
employees_by_department.setdefault(department_id, set()).add(
employee_id
)
for department_id, employee_id in RoleAssignment.objects.filter(
role__department_id__in=department_ids,
is_active=True,
employee__is_active=True,
).values_list("role__department_id", "employee_id"):
employees_by_department.setdefault(department_id, set()).add(
employee_id
)
acknowledged_by_document: dict[int, set[int]] = {
document_id: set() for document_id in document_ids

View File

@ -238,7 +238,19 @@ class CanManageProcurementRequest(permissions.BasePermission):
if action == "complete":
return obj.executor == user
if action in ("mark_all_received", "notify_arrival"):
if action == "mark_all_received":
if obj.requestor == user:
return True
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 == "notify_arrival":
if obj.executor == user:
return True
if obj.processing_department_id:

View File

@ -135,17 +135,6 @@ def _expected_delivery_date_values(item):
return dates
def _validate_item_quantities(attrs, instance=None):
quantity = attrs.get("quantity", getattr(instance, "quantity", None))
for field in ("ordered_quantity", "received_quantity"):
value = attrs.get(field, getattr(instance, field, None))
if value is not None and quantity is not None and value > quantity:
raise serializers.ValidationError(
{field: "Не может быть больше количества позиции"}
)
return attrs
def _validate_processing_department_available(processing_department):
if not processing_department:
return
@ -183,6 +172,10 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
read_only=True,
allow_null=True,
)
last_defective_at = serializers.DateTimeField(
read_only=True,
allow_null=True,
)
next_expected_delivery_date = serializers.SerializerMethodField()
items_total_count = serializers.SerializerMethodField()
items_received_count = serializers.SerializerMethodField()
@ -441,6 +434,7 @@ class ProcurementItemSerializer(serializers.ModelSerializer):
"actual_unit_price",
"execution_status",
"execution_status_display",
"defective_at",
"executor_comment",
"initial_comment",
"comments_count",
@ -454,14 +448,12 @@ class ProcurementItemSerializer(serializers.ModelSerializer):
"created_at",
"total_price",
"execution_status_display",
"defective_at",
"comments_count",
"attachments",
"attachments_count",
]
def validate(self, attrs):
return _validate_item_quantities(attrs, self.instance)
def validate_expected_delivery_dates(self, value):
return _validate_expected_delivery_dates(value)
@ -530,9 +522,6 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer):
]
read_only_fields = ["id"]
def validate(self, attrs):
return _validate_item_quantities(attrs, self.instance)
def validate_expected_delivery_dates(self, value):
return _validate_expected_delivery_dates(value)
@ -743,6 +732,7 @@ class ProcurementRequestListSerializer(
"fulfillment_status_display",
"is_viewed",
"last_arrival_notice_at",
"last_defective_at",
"total_cost",
"items_count",
"next_expected_delivery_date",
@ -782,6 +772,7 @@ class ProcurementRequestListSerializer(
"fulfillment_status_display",
"is_viewed",
"last_arrival_notice_at",
"last_defective_at",
"items_count",
"total_cost",
"next_expected_delivery_date",
@ -996,6 +987,7 @@ class ProcurementRequestDetailSerializer(
"fulfillment_status_display",
"is_viewed",
"last_arrival_notice_at",
"last_defective_at",
"total_cost",
"actual_cost",
"next_expected_delivery_date",
@ -1034,6 +1026,7 @@ class ProcurementRequestDetailSerializer(
"is_editable",
"is_viewed",
"last_arrival_notice_at",
"last_defective_at",
"total_cost",
"next_expected_delivery_date",
"items_total_count",

View File

@ -1,5 +1,6 @@
"""ViewSets для API модуля закупок."""
from datetime import date
from pathlib import Path
from django.contrib.contenttypes.models import ContentType
@ -55,7 +56,7 @@ from procurement.notifications.read_service import (
from communications import comments_helpers
from communications.models import Message
from ..employees.serializers import EmployeeBriefSerializer
from procurement.services import ProcurementApprovalResolver
from procurement.services import ProcurementApprovalResolver, merge_comment_links
from .permissions import (
CanApproveProcurementRequest,
CanManageEquipment,
@ -112,7 +113,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
}
search_fields = ["title", "description"]
ordering_fields = ["created_at", "status"]
ordering = ["-created_at"]
ordering = None
def get_serializer_class(self):
"""Выбрать сериализатор в зависимости от действия."""
@ -237,6 +238,14 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
.order_by("-timestamp")
.values("timestamp")[:1]
)
last_defective_at_sub = (
ProcurementItem.objects.filter(
request_id=OuterRef("pk"),
defective_at__isnull=False,
)
.order_by("-defective_at")
.values("defective_at")[:1]
)
user = self.request.user
viewed_annotation = (
@ -262,6 +271,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
arrival_notice_sub,
output_field=DateTimeField(),
),
last_defective_at=Subquery(
last_defective_at_sub,
output_field=DateTimeField(),
),
).annotate(
default_sort_at=Coalesce(
"last_defective_at",
"created_at",
output_field=DateTimeField(),
),
)
scope = self.request.query_params.get("scope", None)
period = self.request.query_params.get("period", None)
@ -290,18 +309,9 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(executor=user)
elif scope == "available":
queryset = queryset.filter(
Q(
status__in=[
ProcurementStatus.WAITING,
ProcurementStatus.APPROVED,
],
executor__isnull=True,
)
| Q(
status=ProcurementStatus.IN_PROGRESS,
processing_department__isnull=False,
executor__isnull=False,
)
status__in=ProcurementApprovalResolver.WORK_ASSIGNABLE_STATUSES,
).exclude(
executor=user,
)
if not (
user.is_superuser
@ -311,25 +321,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
):
queryset = queryset.filter(
Q(
status=ProcurementStatus.WAITING,
processing_department_id__in=participant_department_ids,
)
| Q(
status=ProcurementStatus.APPROVED,
processing_department__isnull=True,
)
| (
Q(
status=ProcurementStatus.IN_PROGRESS,
processing_department_id__in=participant_department_ids,
)
& ~Q(executor=user)
)
)
else:
queryset = queryset.exclude(
status=ProcurementStatus.IN_PROGRESS,
executor=user,
)
elif scope == "all":
# Все заявки - применяем стандартную логику прав
@ -389,6 +385,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
if parsed_date_to:
queryset = queryset.filter(created_at__date__lte=parsed_date_to)
if not self.request.query_params.get("ordering"):
queryset = queryset.order_by("-default_sort_at", "-id")
return queryset.distinct()
def _attach_linked_task_payloads(
@ -1020,19 +1018,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
"""Получить заявки, доступные для взятия в работу."""
queryset = self.filter_queryset(
self.get_queryset().filter(
Q(
status__in=[
ProcurementStatus.WAITING,
ProcurementStatus.APPROVED,
],
executor__isnull=True,
)
| Q(
status=ProcurementStatus.IN_PROGRESS,
processing_department__isnull=False,
executor__isnull=False,
)
)
status__in=ProcurementApprovalResolver.WORK_ASSIGNABLE_STATUSES,
).exclude(executor=request.user)
)
if not (
@ -1048,25 +1035,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
)
queryset = queryset.filter(
Q(
status=ProcurementStatus.WAITING,
processing_department_id__in=participant_department_ids,
)
| Q(
status=ProcurementStatus.APPROVED,
processing_department__isnull=True,
)
| (
Q(
status=ProcurementStatus.IN_PROGRESS,
processing_department_id__in=participant_department_ids,
)
& ~Q(executor=request.user)
)
)
else:
queryset = queryset.exclude(
status=ProcurementStatus.IN_PROGRESS,
executor=request.user,
)
page = self.paginate_queryset(queryset)
@ -1079,38 +1052,39 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"])
def start_work(self, request, pk=None):
"""Начать работу над заявкой (перевод в статус IN_PROGRESS).
"""Назначить себя исполнителем заявки.
Общий пул может взять любой авторизованный пользователь.
Адресную заявку может взять сотрудник отдела-исполнителя.
Адресную заявку в работе может забрать другой сотрудник отдела.
Другой доступный сотрудник может забрать уже назначенную заявку.
Во время согласования статус PENDING сохраняется.
"""
procurement_request = self.get_object()
self.check_object_permissions(request, procurement_request)
is_reassignment = (
procurement_request.status == ProcurementStatus.IN_PROGRESS
and procurement_request.processing_department_id
and procurement_request.executor_id
if procurement_request.executor_id == request.user.id:
return Response(
{"error": "Вы уже являетесь исполнителем заявки"},
status=status.HTTP_400_BAD_REQUEST,
)
can_start_work = ProcurementApprovalResolver.user_can_start_work(
request.user,
procurement_request,
)
if not can_start_work:
message = (
"Вы не можете забрать эту заявку в работу"
if procurement_request.executor_id
else "Вы не можете взять эту заявку в работу"
)
return Response(
{"error": message},
status=status.HTTP_403_FORBIDDEN,
)
if is_reassignment:
if procurement_request.executor_id:
previous_executor = procurement_request.executor
if previous_executor == request.user:
return Response(
{"error": "Вы уже являетесь исполнителем заявки"},
status=status.HTTP_400_BAD_REQUEST,
)
if not ProcurementApprovalResolver.user_can_start_work(
request.user,
procurement_request,
):
return Response(
{"error": "Вы не можете забрать эту заявку в работу"},
status=status.HTTP_403_FORBIDDEN,
)
procurement_request.executor = request.user
procurement_request._notification_actor = request.user
update_fields = ["executor", "updated_at"]
@ -1140,49 +1114,28 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
if procurement_request.status not in {
ProcurementStatus.APPROVED,
ProcurementStatus.WAITING,
}:
return Response(
{
"error": (
"Только одобренные или ожидающие заявки "
"можно взять в работу"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
# Проверяем, что заявка ещё не взята кем-то
if procurement_request.executor:
return Response(
{
"error": (
f"Заявка уже взята в работу пользователем "
f"{procurement_request.executor.get_full_name()}"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
if not ProcurementApprovalResolver.user_can_start_work(
request.user,
procurement_request,
):
return Response(
{"error": "Вы не можете взять эту заявку в работу"},
status=status.HTTP_403_FORBIDDEN,
)
# Назначаем исполнителя и меняем статус
# Сигнал post_save(ProcurementRequest) с IN_PROGRESS отправит
# уведомления
previous_status = procurement_request.status
procurement_request.executor = request.user
procurement_request.started_at = timezone.now()
procurement_request.status = ProcurementStatus.IN_PROGRESS
update_fields = ["executor", "started_at", "updated_at"]
if previous_status != ProcurementStatus.PENDING:
procurement_request.status = ProcurementStatus.IN_PROGRESS
update_fields.append("status")
procurement_request._notification_actor = request.user
procurement_request.save()
procurement_request.save(update_fields=update_fields)
if previous_status == ProcurementStatus.PENDING:
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.STARTED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
metadata={
"old_status": previous_status,
"new_status": procurement_request.status,
},
)
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@ -1242,15 +1195,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
if (
item.execution_status
!= ProcurementItemExecutionStatus.RECEIVED
or item.ordered_quantity != item.quantity
or item.received_quantity != item.quantity
or item.ordered_quantity != item.fulfillment_target_quantity
or item.received_quantity != item.fulfillment_target_quantity
)
]
for item in items_to_update:
fulfillment_target_quantity = item.fulfillment_target_quantity
item.execution_status = ProcurementItemExecutionStatus.RECEIVED
item.ordered_quantity = item.quantity
item.received_quantity = item.quantity
item.ordered_quantity = fulfillment_target_quantity
item.received_quantity = fulfillment_target_quantity
ProcurementItem.objects.bulk_update(
items_to_update,
["execution_status", "ordered_quantity", "received_quantity"],
@ -1556,6 +1510,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"comments",
"delete_comment",
"report_issue",
"confirm_ordered",
"confirm_received",
"cancel_received",
"cancel_issue",
@ -1711,7 +1666,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
if received_quantity > 0:
return (
ProcurementItemExecutionStatus.RECEIVED
if received_quantity >= item.quantity
if received_quantity >= max(item.quantity, ordered_quantity)
else ProcurementItemExecutionStatus.ORDERED
)
if ordered_quantity > 0:
@ -1731,6 +1686,16 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
},
)
def _reset_request_view_states(self, procurement_request):
ProcurementRequestView.objects.filter(
request_id=procurement_request.pk,
is_viewed=True,
).update(
is_viewed=False,
viewed_at=None,
updated_at=timezone.now(),
)
def _reopen_completed_request(self, procurement_request, user):
if procurement_request.status != ProcurementStatus.COMPLETED:
return
@ -1899,6 +1864,17 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
actor=self.request.user,
changed_fields=changed_fields,
)
if (
before["execution_status"]
!= ProcurementItemExecutionStatus.DEFECTIVE
and after["execution_status"]
== ProcurementItemExecutionStatus.DEFECTIVE
):
self._reset_request_view_states(procurement_request)
notify_item_issue_reported(
updated_item,
actor=self.request.user,
)
self._record_item_update(
updated_item,
self.request.user,
@ -1921,6 +1897,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
item.execution_status = ProcurementItemExecutionStatus.DEFECTIVE
item.save(update_fields=["execution_status"])
self._reset_request_view_states(procurement_request)
self._reopen_completed_request(procurement_request, user)
@ -1945,8 +1922,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
changed_fields=["execution_status"],
)
self._record_item_update(item, user, ["execution_status"])
if procurement_request.requestor_id == user.id:
notify_item_issue_reported(item, actor=user)
notify_item_issue_reported(item, actor=user)
item.refresh_from_db()
serializer = self.get_serializer(item)
@ -2003,7 +1979,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
effective_ordered_quantity = item.effective_ordered_quantity
item.received_quantity = target_received_quantity
if item.ordered_quantity is None and effective_ordered_quantity > 0:
item.ordered_quantity = min(effective_ordered_quantity, item.quantity)
item.ordered_quantity = effective_ordered_quantity
item.execution_status = self._workflow_status_from_quantities(item)
item.save(
update_fields=[
@ -2065,6 +2041,77 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(item)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def confirm_ordered(self, request, pk=None):
"""Подтвердить заказ дополнительного количества позиции."""
item = self.get_object()
procurement_request = item.request
user = request.user
error_response = self._validate_item_quick_action(
user,
procurement_request,
)
if error_response is not None:
return error_response
current_ordered_quantity = item.effective_ordered_quantity
raw_ordered_quantity = request.data.get("ordered_quantity")
try:
target_ordered_quantity = int(raw_ordered_quantity)
except (TypeError, ValueError):
return Response(
{"ordered_quantity": "Укажите целое число заказанных единиц"},
status=status.HTTP_400_BAD_REQUEST,
)
if target_ordered_quantity <= current_ordered_quantity:
return Response(
{
"ordered_quantity": (
"Должно быть больше текущего заказанного количества"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
expected_delivery_dates = list(item.expected_delivery_dates or [])
delivery_date_added = False
raw_delivery_date = request.data.get("expected_delivery_date")
if raw_delivery_date not in (None, ""):
try:
delivery_date = date.fromisoformat(str(raw_delivery_date))
except ValueError:
return Response(
{"expected_delivery_date": "Укажите корректную дату"},
status=status.HTTP_400_BAD_REQUEST,
)
delivery_date_value = delivery_date.isoformat()
if delivery_date_value not in expected_delivery_dates:
expected_delivery_dates.append(delivery_date_value)
delivery_date_added = True
item.ordered_quantity = target_ordered_quantity
item.expected_delivery_dates = expected_delivery_dates
item.execution_status = self._workflow_status_from_quantities(item)
changed_fields = ["execution_status", "ordered_quantity"]
update_fields = ["execution_status", "ordered_quantity"]
if delivery_date_added:
changed_fields.append("expected_delivery_dates")
update_fields.append("expected_delivery_dates")
item.save(update_fields=update_fields)
self._reopen_completed_request(procurement_request, user)
notify_item_updated(
item,
actor=user,
changed_fields=changed_fields,
)
self._record_item_update(item, user, changed_fields)
item.refresh_from_db()
serializer = self.get_serializer(item)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def confirm_received(self, request, pk=None):
"""Подтвердить получение позиции."""
@ -2080,7 +2127,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
return error_response
current_received_quantity = item.effective_received_quantity
target_received_quantity = item.quantity
target_received_quantity = item.fulfillment_target_quantity
if "received_quantity" in request.data:
raw_received_quantity = request.data.get("received_quantity")
@ -2097,15 +2144,6 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
{"received_quantity": "Количество должно быть больше 0"},
status=status.HTTP_400_BAD_REQUEST,
)
if target_received_quantity > item.quantity:
return Response(
{
"received_quantity": (
"Не может быть больше количества позиции"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
if target_received_quantity <= current_received_quantity:
return Response(
{
@ -2117,9 +2155,9 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
)
item.received_quantity = target_received_quantity
item.ordered_quantity = min(
item.quantity,
max(item.effective_ordered_quantity, target_received_quantity),
item.ordered_quantity = max(
item.effective_ordered_quantity,
target_received_quantity,
)
item.execution_status = self._workflow_status_from_quantities(item)
item.save(
@ -2196,6 +2234,10 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
author=request.user,
content=text,
)
item_links, added_links = merge_comment_links(item.links, text)
if added_links:
item.links = item_links
item.save(update_fields=["links"])
notify_item_comment(
item,
message,
@ -2211,6 +2253,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"item_id": item.pk,
"item_name": item.name,
"comment_text": message.content,
"added_links": added_links,
},
)
author_ser = EmployeeBriefSerializer(message.author)
@ -2222,6 +2265,8 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"author": author_ser.data,
"text": message.content,
"created_at": message.created_at,
"item_links": item_links,
"added_links": added_links,
},
status=status.HTTP_201_CREATED,
)

View File

@ -97,6 +97,9 @@ class RequestReadSerializer(serializers.ModelSerializer):
# Используем аннотированное поле из queryset
comments_count = serializers.IntegerField(read_only=True, allow_null=True)
linked_tasks = serializers.SerializerMethodField()
starts_at = serializers.DateTimeField(read_only=True)
ends_at = serializers.DateTimeField(read_only=True)
duration_minutes = serializers.IntegerField(read_only=True)
class Meta:
model = Request
@ -110,6 +113,11 @@ class RequestReadSerializer(serializers.ModelSerializer):
"title",
"date_from",
"date_to",
"time_from",
"time_to",
"starts_at",
"ends_at",
"duration_minutes",
"comment",
"status",
"attachment",
@ -313,6 +321,8 @@ class RequestWriteSerializer(serializers.ModelSerializer):
"title",
"date_from",
"date_to",
"time_from",
"time_to",
"comment",
"status",
"attachment",
@ -395,6 +405,8 @@ class RequestWriteSerializer(serializers.ModelSerializer):
type_ = attrs.get("type", getattr(self.instance, "type", ""))
date_from = attrs.get("date_from", getattr(self.instance, "date_from", None))
date_to = attrs.get("date_to", getattr(self.instance, "date_to", None))
time_from = attrs.get("time_from", getattr(self.instance, "time_from", None))
time_to = attrs.get("time_to", getattr(self.instance, "time_to", None))
recipient_ids = attrs.get("recipient_ids")
if recipient_ids is None:
@ -441,7 +453,25 @@ class RequestWriteSerializer(serializers.ModelSerializer):
{"recipient_ids": "Автор не может быть получателем"}
)
if type_ in (
if type_ == RequestType.SHORT_DAY_OFF:
if not (date_from and time_from and time_to):
raise serializers.ValidationError(
{
"date_from": "Укажите дату короткого отгула.",
"time_from": "Укажите время начала.",
"time_to": "Укажите время окончания.",
}
)
if date_to and date_to != date_from:
raise serializers.ValidationError(
{"date_to": "Короткий отгул должен быть в пределах одного дня."}
)
if time_to <= time_from:
raise serializers.ValidationError(
{"time_to": "Время окончания должно быть позже времени начала."}
)
attrs["date_to"] = date_from
elif type_ in (
RequestType.VACATION,
RequestType.SICK_LEAVE,
RequestType.DAY_OFF,
@ -458,11 +488,24 @@ class RequestWriteSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
{"date_to": "Дата окончания раньше даты начала."}
)
attrs["time_from"] = None
attrs["time_to"] = None
elif type_ in (RequestType.TRANSFER, RequestType.DISMISSAL):
if not date_from:
raise serializers.ValidationError(
{"date_from": "Требуется дата начала."}
)
attrs["time_from"] = None
attrs["time_to"] = None
else:
if bool(time_from) != bool(time_to):
raise serializers.ValidationError(
{"time_to": "Укажите оба значения времени или оставьте их пустыми."}
)
if date_from == date_to and time_from and time_to and time_to <= time_from:
raise serializers.ValidationError(
{"time_to": "Время окончания должно быть позже времени начала."}
)
return attrs

View File

@ -5,7 +5,7 @@ from typing import Any, Mapping
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
# VACATION/SICK_LEAVE/DAY_OFF/MATERNITY/TRANSFER/DISMISSAL/OTHER
# SHORT_DAY_OFF/VACATION/SICK_LEAVE/DAY_OFF/MATERNITY/TRANSFER/DISMISSAL/OTHER
from requests_app.enums import RequestType
from requests_app.enums import RequestStatus
@ -57,6 +57,11 @@ class RequestDatesByTypeValidator:
if not (touches_dates or touches_type):
return
# Точный внутридневной интервал проверяет сериализатор: здесь старая
# валидация диапазона дат к короткому отгулу неприменима.
if type_ == RequestType.SHORT_DAY_OFF:
return
if type_ in (
RequestType.VACATION,
RequestType.SICK_LEAVE,

View File

@ -10,7 +10,7 @@ from django.db.models import Count, IntegerField, OuterRef, Q, QuerySet, Subquer
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404 # раскомментируйте импорт
from django.utils import timezone
from requests_app.enums import RequestStatus, RequestType
from requests_app.enums import DAY_OFF_REQUEST_TYPES, RequestStatus, RequestType
from requests_app.models import Request as EmployeeRequest
from communications import comments_helpers
from drf_spectacular.utils import OpenApiParameter, extend_schema
@ -182,12 +182,13 @@ def _build_employee_statistics(
sick_leave_days = 0
day_off_days = 0
short_day_off_minutes = 0
maternity_days = 0
paid_vacation_days = 0
unpaid_vacation_days = 0
for req in approved_qs.only(
"type", "date_from", "date_to", "title", "comment"
"type", "date_from", "date_to", "time_from", "time_to", "title", "comment"
):
duration = _request_duration_days_in_window(
req,
@ -196,6 +197,13 @@ def _build_employee_statistics(
)
if req.type == RequestType.SICK_LEAVE:
sick_leave_days += duration
elif req.type == RequestType.SHORT_DAY_OFF:
if (
req.date_from
and (start_date is None or start_date <= req.date_from)
and (end_date is None or req.date_from <= end_date)
):
short_day_off_minutes += req.duration_minutes or 0
elif req.type == RequestType.DAY_OFF:
day_off_days += duration
elif req.type == RequestType.MATERNITY:
@ -217,13 +225,17 @@ def _build_employee_statistics(
type=RequestType.SICK_LEAVE
).count(),
"day_off_requests_count": base_qs.filter(
type=RequestType.DAY_OFF
type__in=DAY_OFF_REQUEST_TYPES
).count(),
"short_day_off_requests_count": base_qs.filter(
type=RequestType.SHORT_DAY_OFF
).count(),
"maternity_requests_count": base_qs.filter(
type=RequestType.MATERNITY
).count(),
"sick_leave_days": sick_leave_days,
"day_off_days": day_off_days,
"short_day_off_minutes": short_day_off_minutes,
"maternity_days": maternity_days,
"paid_vacation_days": paid_vacation_days,
"unpaid_vacation_days": unpaid_vacation_days,

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import logging
from io import BytesIO
from datetime import date, timedelta
from datetime import date, time, timedelta
from calendar import monthrange
from typing import Any
@ -34,6 +34,7 @@ from employees.models import Employee
from employees.services.personnel_state import (
EmployeePersonnelState,
PERSONNEL_STATUS_NORMAL,
approved_absence_minutes_within_schedule,
resolve_employee_personnel_state,
)
@ -156,7 +157,9 @@ def save_logstorm_attendance_result(
records = []
personnel_actions = list(
employee.actions.filter(date__date__lte=period_end).order_by("date", "id")
employee.actions.filter(date__date__lte=period_end)
.select_related("source_request")
.order_by("date", "id")
)
with transaction.atomic():
@ -187,6 +190,7 @@ def save_logstorm_attendance_result(
record_defaults = build_attendance_record_defaults(
raw_record,
personnel_state,
schedule_payload=schedule_payload,
)
existing_record = AttendanceRecord.objects.filter(
employee=employee,
@ -772,6 +776,8 @@ def resolve_employee_day_state(
def build_attendance_record_defaults(
raw_record: dict[str, Any],
personnel_state: EmployeePersonnelState,
*,
schedule_payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
is_remote_personnel_state = personnel_state.status == ACTION_REMOTE
work_hours = _nullable_float(raw_record.get("work_hours"))
@ -799,6 +805,55 @@ def build_attendance_record_defaults(
statuses = _list_value(raw_record.get("statuses"))
employee_issues = _list_value(raw_record.get("employee_issues"))
technical_issues = _list_value(raw_record.get("technical_issues"))
approved_absence_minutes = approved_absence_minutes_within_schedule(
personnel_state,
schedule_payload,
)
if approved_absence_minutes:
approved_hours = approved_absence_minutes / 60
statuses = _append_unique(statuses, "approved_short_day_off")
excused_late_minutes, excused_early_minutes = (
_approved_boundary_absence_minutes(
personnel_state,
schedule_payload,
)
)
if late_minutes is not None and excused_late_minutes:
late_minutes = max(late_minutes - excused_late_minutes, 0)
if late_minutes == 0:
is_late = False
statuses = _remove_issue_markers(statuses, ("late", "опозд"))
employee_issues = _remove_issue_markers(
employee_issues,
("late", "опозд"),
)
if early_leave_minutes is not None and excused_early_minutes:
early_leave_minutes = max(
early_leave_minutes - excused_early_minutes,
0,
)
if early_leave_minutes == 0:
is_early_leave = False
statuses = _remove_issue_markers(
statuses,
("early", "ранн"),
)
employee_issues = _remove_issue_markers(
employee_issues,
("early", "ранн"),
)
if underwork_hours is not None:
underwork_hours = max(underwork_hours - approved_hours, 0)
if underwork_hours == 0:
is_underwork = False
statuses = _remove_issue_markers(
statuses,
("underwork", "недоработ"),
)
employee_issues = _remove_issue_markers(
employee_issues,
("underwork", "недоработ"),
)
non_working_reason = _non_working_reason(
is_workday=is_workday,
effective_is_workday=effective_is_workday,
@ -889,7 +944,18 @@ def build_attendance_record_defaults(
if isinstance(raw_record.get("manual_edit_payload"), dict)
else {}
),
"raw_data": {**raw_record, "non_working_reason": non_working_reason},
"raw_data": {
**raw_record,
"non_working_reason": non_working_reason,
"approved_absence_minutes": approved_absence_minutes,
"approved_absence_intervals": [
{
"time_from": start.isoformat(timespec="minutes"),
"time_to": end.isoformat(timespec="minutes"),
}
for start, end in personnel_state.approved_absence_intervals
],
},
}
@ -978,6 +1044,44 @@ def _has_absence_issue(values: list[Any]) -> bool:
)
def _remove_issue_markers(
values: list[Any],
markers: tuple[str, ...],
) -> list[Any]:
return [
item
for item in values
if not any(marker in str(item).lower() for marker in markers)
]
def _approved_boundary_absence_minutes(
state: EmployeePersonnelState,
schedule: dict[str, Any] | None,
) -> tuple[int, int]:
try:
schedule_start = time.fromisoformat(str((schedule or {}).get("start_time")))
schedule_end = time.fromisoformat(str((schedule or {}).get("end_time")))
except ValueError:
return 0, 0
def minutes(value: time) -> int:
return value.hour * 60 + value.minute
start_minutes = minutes(schedule_start)
end_minutes = minutes(schedule_end)
excused_late = 0
excused_early = 0
for interval_start, interval_end in state.approved_absence_intervals:
interval_start_minutes = minutes(interval_start)
interval_end_minutes = minutes(interval_end)
if interval_start_minutes <= start_minutes < interval_end_minutes:
excused_late += min(interval_end_minutes, end_minutes) - start_minutes
if interval_start_minutes < end_minutes <= interval_end_minutes:
excused_early += end_minutes - max(interval_start_minutes, start_minutes)
return max(excused_late, 0), max(excused_early, 0)
def _parse_record_date(value: Any) -> date | None:
if isinstance(value, date):
return value

View File

@ -287,17 +287,9 @@ class DocumentAdmin(admin.ModelAdmin):
def get_recipients_qs(self, obj):
"""Возвращает список всех получателей документа."""
if obj.sent_to_all:
return list(User.objects.filter(is_active=True))
from documents.audience import document_access_audience
# Собираем получателей из recipients и departments
recipients_set = set(obj.recipients.all())
# Добавляем сотрудников из отделов
for department in obj.departments.all():
recipients_set.update(department.active_employees)
return list(recipients_set)
return list(document_access_audience(obj))
def get_acknowledgement_recipients_qs(self, obj):
"""Возвращает сотрудников, обязанных ознакомиться."""

View File

@ -21,6 +21,10 @@ def document_access_audience(document: Document) -> QuerySet:
departments_links__department__in=document.departments.all(),
departments_links__is_active=True,
)
| Q(
role_assignments__role__department__in=document.departments.all(),
role_assignments__is_active=True,
)
).distinct()
@ -40,6 +44,12 @@ def document_acknowledgement_audience(document: Document) -> QuerySet:
),
departments_links__is_active=True,
)
| Q(
role_assignments__role__department__in=(
document.acknowledgement_departments.all()
),
role_assignments__is_active=True,
)
)
return selected.filter(pk__in=access_audience.values("pk")).distinct()

View File

@ -13,6 +13,7 @@
import logging
from django.contrib.auth import get_user_model
from django.db.models import Q
from notifications.signals import notify
from .config import (
@ -276,27 +277,21 @@ def notify_department_employees(document, department_ids):
logger.warning("[handlers] No departments provided")
return
# Получаем всех активных сотрудников выбранных отделов
from employees.models import Department
all_employees = set()
for dept_id in department_ids:
try:
department = Department.objects.get(id=dept_id)
dept_employees = department.active_employees
logger.info(
f"[handlers] Department '{department.name}' "
f"has {len(dept_employees)} active employees: "
f"{[e.id for e in dept_employees]}"
# Роль отдела эквивалентна физическому членству в отделе.
all_employees = set(
Employee.objects.filter(is_active=True)
.filter(
Q(
departments_links__department_id__in=department_ids,
departments_links__is_active=True,
)
all_employees.update(dept_employees)
logger.info(
f"[handlers] After update, all_employees has: "
f"{[e.id for e in all_employees]}"
| Q(
role_assignments__role__department_id__in=department_ids,
role_assignments__is_active=True,
)
except Department.DoesNotExist:
logger.warning(f"[handlers] Department {dept_id} not found")
continue
)
.distinct()
)
# Исключаем загрузившего
if document.uploaded_by and document.uploaded_by in all_employees:

View File

@ -53,6 +53,12 @@ def has_document_access(user, document):
).exists():
return True
if document.departments.filter(
roles__assignments__employee=user,
roles__assignments__is_active=True,
).exists():
return True
# Прямой доступ через recipients
if document.recipients.filter(id=user.id).exists():
return True

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from datetime import date, datetime, time
from typing import Iterable
from django.utils import timezone
@ -35,11 +35,19 @@ class EmployeePersonnelState:
date_from: date | None = None
date_to: date | None = None
expects_attendance: bool = True
approved_absence_intervals: tuple[tuple[time, time], ...] = ()
@property
def is_non_working(self) -> bool:
return not self.expects_attendance
@property
def approved_absence_minutes(self) -> int:
return sum(
_time_to_minutes(end) - _time_to_minutes(start)
for start, end in self.approved_absence_intervals
)
def resolve_employee_personnel_state(
employee: Employee,
@ -60,6 +68,14 @@ def resolve_employee_personnel_state(
if not relevant_actions:
return EmployeePersonnelState()
partial_day_off_intervals = _short_day_off_intervals(
employee_actions,
target_date,
)
partial_kwargs = {
"approved_absence_intervals": partial_day_off_intervals,
}
permanent_action = _resolve_permanent_action(relevant_actions)
if permanent_action and permanent_action.action == ACTION_DISMISSED:
return EmployeePersonnelState(
@ -68,6 +84,7 @@ def resolve_employee_personnel_state(
action_id=permanent_action.id,
date_from=_action_date(permanent_action),
expects_attendance=False,
**partial_kwargs,
)
active_temporary = _active_temporary_actions(employee_actions, target_date)
@ -80,6 +97,7 @@ def resolve_employee_personnel_state(
date_from=_action_date(action),
date_to=date_to,
expects_attendance=False,
**partial_kwargs,
)
marker_action = _active_marker_action(relevant_actions, target_date)
@ -90,6 +108,7 @@ def resolve_employee_personnel_state(
action_id=marker_action.id,
date_from=_action_date(marker_action),
expects_attendance=True,
**partial_kwargs,
)
if permanent_action:
@ -98,11 +117,13 @@ def resolve_employee_personnel_state(
label=permanent_action.get_action_display(),
action_id=permanent_action.id,
date_from=_action_date(permanent_action),
**partial_kwargs,
)
return EmployeePersonnelState(
status=ACTION_WORKING,
label=ACTION_LABELS[ACTION_WORKING],
**partial_kwargs,
)
@ -222,6 +243,8 @@ def _active_temporary_actions(
for action in actions:
if action.action not in TEMPORARY_START_ACTIONS or not action.date:
continue
if _is_short_day_off_action(action):
continue
date_from = _action_date(action)
if date_from is None:
@ -253,6 +276,90 @@ def _resolve_temporary_action_date_to(action: EmployeeAction) -> date | None:
return None
def _is_short_day_off_action(action: EmployeeAction) -> bool:
source_request = getattr(action, "source_request", None)
if source_request is not None:
return getattr(source_request, "type", None) == "short_day_off"
return bool(isinstance(action.extra, dict) and action.extra.get("partial_day"))
def _short_day_off_intervals(
actions: list[EmployeeAction],
target_date: date,
) -> tuple[tuple[time, time], ...]:
intervals: list[tuple[time, time]] = []
for action in actions:
if not _is_short_day_off_action(action) or _action_date(action) != target_date:
continue
source_request = getattr(action, "source_request", None)
if source_request is not None:
if getattr(source_request, "status", None) != "approved":
continue
start = getattr(source_request, "time_from", None)
end = getattr(source_request, "time_to", None)
else:
extra = action.extra if isinstance(action.extra, dict) else {}
try:
start = time.fromisoformat(str(extra.get("starts_at", "")).split("T")[-1])
end = time.fromisoformat(str(extra.get("ends_at", "")).split("T")[-1])
except ValueError:
continue
if start:
start = start.replace(tzinfo=None)
if end:
end = end.replace(tzinfo=None)
if start and end and end > start:
intervals.append((start, end))
return _merge_time_intervals(intervals)
def _merge_time_intervals(
intervals: list[tuple[time, time]],
) -> tuple[tuple[time, time], ...]:
if not intervals:
return ()
ordered = sorted(intervals, key=lambda item: _time_to_minutes(item[0]))
merged: list[tuple[time, time]] = [ordered[0]]
for start, end in ordered[1:]:
previous_start, previous_end = merged[-1]
if _time_to_minutes(start) <= _time_to_minutes(previous_end):
if end > previous_end:
merged[-1] = (previous_start, end)
else:
merged.append((start, end))
return tuple(merged)
def _time_to_minutes(value: time) -> int:
return value.hour * 60 + value.minute
def approved_absence_minutes_within_schedule(
state: EmployeePersonnelState,
schedule: dict | None,
) -> int:
"""Return approved partial absence clipped to the employee work schedule."""
if not state.approved_absence_intervals:
return 0
try:
schedule_start = time.fromisoformat(str((schedule or {}).get("start_time")))
schedule_end = time.fromisoformat(str((schedule or {}).get("end_time")))
except ValueError:
return state.approved_absence_minutes
start_minutes = _time_to_minutes(schedule_start)
end_minutes = _time_to_minutes(schedule_end)
if end_minutes <= start_minutes:
return state.approved_absence_minutes
return sum(
max(
min(_time_to_minutes(end), end_minutes)
- max(_time_to_minutes(start), start_minutes),
0,
)
for start, end in state.approved_absence_intervals
)
def is_temporary_start_action(action: str) -> bool:
return action in TEMPORARY_START_ACTIONS

View File

@ -41,6 +41,7 @@ IMMEDIATE_ACTION_MAPPING = {
# Интервальные события (создаются сразу при одобрении с датами заявки)
SCHEDULED_ACTION_MAPPING = {
"short_day_off": ACTION_ON_DAY_OFF, # Короткий отгул → частичный отгул
"vacation": ACTION_ON_LEAVE, # Отпуск → В отпуске
"sick_leave": ACTION_ON_SICK_LEAVE, # Больничный → На больничном
"day_off": ACTION_ON_DAY_OFF, # Отгул → В отгуле
@ -86,7 +87,7 @@ def create_action_on_request_approval(sender, instance, created, **kwargs):
Две стратегии:
1. НЕМЕДЛЕННО (transfer, dismissal) - сразу при одобрении
2. ИНТЕРВАЛЬНО (vacation, sick_leave, day_off) - сразу с date_to
2. ИНТЕРВАЛЬНО (отпуск, больничный, отгулы, декрет) - сразу с date_to
"""
# Пропускаем если это новая заявка
if created:
@ -206,16 +207,28 @@ def _create_interval_action(request):
return
try:
is_partial_day_off = request.type == "short_day_off"
extra = {
"approved_by": (request.approver.id if request.approver else None),
"interval": True,
}
if is_partial_day_off:
extra.update(
{
"partial_day": True,
"starts_at": request.starts_at.isoformat(),
"ends_at": request.ends_at.isoformat(),
"duration_minutes": request.duration_minutes,
}
)
action, created = create_request_action(
request=request,
action_type=action_type,
action_date=request.date_from,
action_date=request.starts_at if is_partial_day_off else request.date_from,
date_to=request.date_to,
comment=build_request_action_comment(request),
extra={
"approved_by": (request.approver.id if request.approver else None),
"interval": True,
},
extra=extra,
)
if not created:
logger.warning(f"EmployeeAction already exists for Request #{request.id}")

View File

@ -479,9 +479,11 @@ SPECTACULAR_SETTINGS = {
("lost", "Утеряно"),
],
"RequestTypeEnum": [
("short_day_off", "Короткий отгул"),
("vacation", "Отпуск"),
("sick_leave", "Больничный"),
("day_off", "Отгул"),
("maternity", "Декрет"),
("transfer", "Перевод"),
("dismissal", "Увольнение"),
("other", "Другое"),

View File

@ -669,6 +669,17 @@ def build_payroll_employee_point_breakdown(
if personnel_request.date_to
else None
),
"time_from": (
personnel_request.time_from.isoformat(timespec="minutes")
if personnel_request.time_from
else None
),
"time_to": (
personnel_request.time_to.isoformat(timespec="minutes")
if personnel_request.time_to
else None
),
"duration_minutes": personnel_request.duration_minutes,
"comment": personnel_request.comment,
}
)

View File

@ -12,6 +12,9 @@ from attendance.services import (
)
from employees.constants import ACTIVATING_MARKER_ACTIONS
from employees.services.personnel_state import resolve_employee_personnel_state
from employees.services.personnel_state import (
approved_absence_minutes_within_schedule,
)
from finance.models import PayrollPeriod, PayrollWorkSettings
@ -125,22 +128,24 @@ def calculate_period_personnel_points(
workdates = period_workdates(period, schedule)
today = timezone.localdate()
completed_workdates = [workdate for workdate in workdates if workdate < today]
attended_dates = [
workdate
for workdate in completed_workdates
if (first_employment_date is None or workdate >= first_employment_date)
and resolve_employee_personnel_state(
attendance_factors = []
for workdate in completed_workdates:
if first_employment_date is not None and workdate < first_employment_date:
continue
state = resolve_employee_personnel_state(
employee,
workdate,
actions=personnel_actions,
).expects_attendance
]
)
factor = _personnel_day_factor(state, schedule)
if factor > 0:
attendance_factors.append(factor)
points = (
period_target_points * Decimal(len(attended_dates)) / Decimal(len(workdates))
period_target_points * sum(attendance_factors, Decimal("0")) / Decimal(len(workdates))
if workdates
else Decimal("0")
).quantize(QUANTUM)
return points, len(attended_dates)
return points, len(attendance_factors)
def calculate_period_personnel_daily_points(
@ -179,14 +184,34 @@ def calculate_period_personnel_daily_points(
if workdate >= today:
result[workdate] = None
continue
expects_attendance = (
first_employment_date is None or workdate >= first_employment_date
) and resolve_employee_personnel_state(
state = resolve_employee_personnel_state(
employee,
workdate,
actions=personnel_actions,
).expects_attendance
result[workdate] = (
daily_point_value if expects_attendance else Decimal("0")
).quantize(QUANTUM)
)
factor = (
_personnel_day_factor(state, schedule)
if first_employment_date is None or workdate >= first_employment_date
else Decimal("0")
)
result[workdate] = (daily_point_value * factor).quantize(QUANTUM)
return result
def _personnel_day_factor(state, schedule: dict) -> Decimal:
if not state.expects_attendance:
return Decimal("0")
try:
expected_minutes = Decimal(str(schedule.get("expected_hours", 0))) * Decimal("60")
except (TypeError, ValueError):
expected_minutes = Decimal("0")
if expected_minutes <= 0:
return Decimal("1")
absence_minutes = Decimal(
approved_absence_minutes_within_schedule(state, schedule)
)
return max(
(expected_minutes - min(absence_minutes, expected_minutes))
/ expected_minutes,
Decimal("0"),
)

View File

@ -7,7 +7,7 @@
*/
// Версия Service Worker (для обновления кэша при изменениях)
const SW_VERSION = '1.0.0';
const SW_VERSION = '1.0.1';
// Настройки по умолчанию для уведомлений (без project-specific путей!)
// Все пути к ресурсам (icon, badge, image) должны приходить с сервера
@ -151,21 +151,44 @@ self.addEventListener('notificationclick', (event) => {
// Открываем или фокусируем окно
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Ищем уже открытое окно с нашим сайтом
for (const client of clientList) {
const clientUrl = new URL(client.url);
const targetUrl = new URL(urlToOpen, self.location.origin);
// Если найдено окно с тем же origin - фокусируем и переходим
if (clientUrl.origin === targetUrl.origin && 'focus' in client) {
return client.navigate(urlToOpen).then(() => client.focus());
}
.then(async (clientList) => {
const targetUrl = new URL(urlToOpen, self.location.origin).toString();
const exactClient = clientList.find(
(client) => client.url === targetUrl && 'focus' in client,
);
if (exactClient) {
return exactClient.focus();
}
const sameOriginClients = clientList.filter(
(client) => new URL(client.url).origin === self.location.origin,
);
const sameOriginClient =
sameOriginClients.find((client) => client.focused) ||
sameOriginClients.find((client) => client.visibilityState === 'visible') ||
sameOriginClients[0];
if (sameOriginClient) {
if ('navigate' in sameOriginClient) {
try {
const navigatedClient = await sameOriginClient.navigate(targetUrl);
return (navigatedClient || sameOriginClient).focus();
} catch (error) {
console.warn('[SW] Failed to navigate existing client:', error);
}
}
sameOriginClient.postMessage({
type: 'NOTIFICATION_CLICK',
url: targetUrl,
notificationId: notificationData.notification_id,
});
return sameOriginClient.focus();
}
// Если нет открытого окна - открываем новое
if (self.clients.openWindow) {
return self.clients.openWindow(urlToOpen);
return self.clients.openWindow(targetUrl);
}
})
);

View File

@ -0,0 +1,39 @@
# Generated by Django 5.2.4 on 2026-08-04 15:31
from django.db import migrations, models
def backfill_defective_at(apps, schema_editor):
ProcurementItem = apps.get_model('procurement', 'ProcurementItem')
pending = []
queryset = ProcurementItem.objects.filter(
execution_status='defective',
defective_at__isnull=True,
).select_related('request')
for item in queryset.iterator(chunk_size=500):
item.defective_at = item.request.updated_at
pending.append(item)
if len(pending) >= 500:
ProcurementItem.objects.bulk_update(pending, ['defective_at'])
pending.clear()
if pending:
ProcurementItem.objects.bulk_update(pending, ['defective_at'])
class Migration(migrations.Migration):
dependencies = [
('procurement', '0016_procurementrequestactivity'),
]
operations = [
migrations.AddField(
model_name='procurementitem',
name='defective_at',
field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Отмечено как брак'),
),
migrations.RunPython(
backfill_defective_at,
migrations.RunPython.noop,
),
]

View File

@ -267,6 +267,9 @@ class ProcurementRequest(models.Model):
new_status = ProcurementFulfillmentStatus.ISSUES
else:
total_quantity = sum(item.quantity for item in items)
fulfillment_target_quantity = sum(
item.fulfillment_target_quantity for item in items
)
ordered_quantity = sum(
item.effective_ordered_quantity for item in items
)
@ -274,7 +277,7 @@ class ProcurementRequest(models.Model):
item.effective_received_quantity for item in items
)
if received_quantity >= total_quantity:
if received_quantity >= fulfillment_target_quantity:
new_status = ProcurementFulfillmentStatus.COMPLETED
elif received_quantity > 0:
new_status = ProcurementFulfillmentStatus.PARTIALLY_RECEIVED
@ -495,6 +498,12 @@ class ProcurementItem(models.Model):
choices=ProcurementItemExecutionStatus.choices,
default=ProcurementItemExecutionStatus.PENDING,
)
defective_at = models.DateTimeField(
"Отмечено как брак",
null=True,
blank=True,
db_index=True,
)
executor_comment = models.TextField(
"Комментарий исполнителя",
blank=True,
@ -537,6 +546,10 @@ class ProcurementItem(models.Model):
return self.quantity
return 0
@property
def fulfillment_target_quantity(self):
return max(self.quantity, self.effective_ordered_quantity)
@property
def execution_status_display(self):
problem_statuses = {
@ -548,7 +561,7 @@ class ProcurementItem(models.Model):
if self.execution_status in problem_statuses:
return self.get_execution_status_display()
if self.effective_received_quantity > 0:
if self.effective_received_quantity < self.quantity:
if self.effective_received_quantity < self.fulfillment_target_quantity:
return "Получено частично"
return ProcurementItemExecutionStatus.RECEIVED.label
if self.effective_ordered_quantity > 0:
@ -567,24 +580,6 @@ class ProcurementItem(models.Model):
return Decimal("0.00")
return unit_price * self.quantity
def clean(self):
super().clean()
if (
self.ordered_quantity is not None
and self.ordered_quantity > self.quantity
):
raise ValidationError(
{"ordered_quantity": "Не может быть больше количества позиции"}
)
if (
self.received_quantity is not None
and self.received_quantity > self.quantity
):
raise ValidationError(
{"received_quantity": "Не может быть больше количества позиции"}
)
@staticmethod
def _mark_update_field(update_fields, field):
if update_fields is not None:
@ -603,11 +598,11 @@ class ProcurementItem(models.Model):
received_quantity = self.received_quantity or 0
if received_quantity > 0 and self.ordered_quantity is None:
self.ordered_quantity = min(received_quantity, self.quantity)
self.ordered_quantity = received_quantity
self._mark_update_field(update_fields, "ordered_quantity")
ordered_quantity = self.ordered_quantity
elif received_quantity > 0 and ordered_quantity < received_quantity:
self.ordered_quantity = min(received_quantity, self.quantity)
self.ordered_quantity = received_quantity
self._mark_update_field(update_fields, "ordered_quantity")
ordered_quantity = self.ordered_quantity
@ -615,7 +610,7 @@ class ProcurementItem(models.Model):
if received_quantity > 0:
new_status = (
ProcurementItemExecutionStatus.RECEIVED
if received_quantity >= self.quantity
if received_quantity >= max(self.quantity, ordered_quantity)
else ProcurementItemExecutionStatus.ORDERED
)
elif ordered_quantity > 0:
@ -649,6 +644,13 @@ class ProcurementItem(models.Model):
if self.received_quantity is None:
self.received_quantity = self.quantity
self._mark_update_field(update_fields, "received_quantity")
if self.execution_status == ProcurementItemExecutionStatus.DEFECTIVE:
if self.defective_at is None:
self.defective_at = timezone.now()
self._mark_update_field(update_fields, "defective_at")
elif self.defective_at is not None:
self.defective_at = None
self._mark_update_field(update_fields, "defective_at")
self._sync_execution_status_from_quantities(update_fields=update_fields)
super().save(*args, **kwargs)
if self.request_id:

View File

@ -29,6 +29,7 @@ class NotificationVerbs:
# Позиции и комментарии
ITEM_UPDATED = 'procurement_item_updated'
ITEM_ISSUE_REPORTED = 'procurement_item_issue_reported'
REQUEST_COMMENTED = 'procurement_request_commented'
ITEM_COMMENTED = 'procurement_item_commented'

View File

@ -570,7 +570,7 @@ def notify_item_issue_reported(item, actor=None):
recipients.extend(_processing_department_recipients(procurement_request))
_notify_many(
recipients,
verb=NotificationVerbs.ITEM_UPDATED,
verb=NotificationVerbs.ITEM_ISSUE_REPORTED,
procurement_request=procurement_request,
title=notification_title,
description=description,

View File

@ -2,7 +2,9 @@
Сервисы для модуля закупок и инвентаря.
"""
import re
from typing import Optional
from urllib.parse import urlsplit, urlunsplit
from django.db import transaction
from django.utils import timezone
@ -14,6 +16,70 @@ from procurement.constants import (
)
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:
"""Генератор инвентарных номеров."""
@ -94,6 +160,15 @@ class ProcurementApprovalResolver:
через 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):
"""Проверяет рабочее участие пользователя в отделе.
@ -267,6 +342,12 @@ class ProcurementApprovalResolver:
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
@ -274,28 +355,15 @@ class ProcurementApprovalResolver:
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 procurement_request.executor_id:
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 procurement_request.processing_department_id:
return cls.user_is_processing_department_member(
user,
procurement_request,
)
return True
if has_execute_access:
return True

View File

@ -42,7 +42,15 @@ def record_procurement_request_activity(sender, instance, created, **kwargs):
if old_status == instance.status:
return
action = REQUEST_STATUS_ACTIONS.get(instance.status)
if (
old_status == ProcurementStatus.PENDING
and instance.status == ProcurementStatus.IN_PROGRESS
and instance.executor_id
and instance.started_at
):
action = ProcurementActivityAction.APPROVED
else:
action = REQUEST_STATUS_ACTIONS.get(instance.status)
if action is None:
return
create_procurement_activity(

View File

@ -71,6 +71,7 @@ class RequestAdmin(admin.ModelAdmin):
"type",
"title",
("date_from", "date_to"),
("time_from", "time_to"),
"comment",
"attachment",
)

View File

@ -6,6 +6,7 @@ from django.db.models import TextChoices
class RequestType(TextChoices):
"""Перечень типов заявлений (значение хранится в БД)."""
SHORT_DAY_OFF = "short_day_off", "Короткий отгул"
VACATION = "vacation", "Отпуск"
SICK_LEAVE = "sick_leave", "Больничный"
DAY_OFF = "day_off", "Отгул"
@ -15,6 +16,11 @@ class RequestType(TextChoices):
OTHER = "other", "Другое"
DAY_OFF_REQUEST_TYPES = frozenset(
{RequestType.DAY_OFF, RequestType.SHORT_DAY_OFF}
)
class RequestStatus(TextChoices):
"""Статусы жизненного цикла заявления (значение хранится в БД)."""

View File

@ -0,0 +1,35 @@
# Generated by Django 5.2.4 on 2026-08-04 17:03
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('employees', '0054_employee_id_guest_range_guard'),
('requests_app', '0013_alter_request_attachment'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='request',
name='time_from',
field=models.TimeField(blank=True, null=True, verbose_name='Время начала'),
),
migrations.AddField(
model_name='request',
name='time_to',
field=models.TimeField(blank=True, null=True, verbose_name='Время окончания'),
),
migrations.AlterField(
model_name='request',
name='type',
field=models.CharField(choices=[('short_day_off', 'Короткий отгул'), ('vacation', 'Отпуск'), ('sick_leave', 'Больничный'), ('day_off', 'Отгул'), ('maternity', 'Декрет'), ('transfer', 'Перевод'), ('dismissal', 'Увольнение'), ('other', 'Другое')], max_length=32, verbose_name='Тип заявления'),
),
migrations.AddConstraint(
model_name='request',
constraint=models.CheckConstraint(condition=models.Q(('status', 'draft'), models.Q(('type', 'short_day_off'), _negated=True), models.Q(('date_from__isnull', False), ('date_to', models.F('date_from')), ('time_from__isnull', False), ('time_to__gt', models.F('time_from')), ('time_to__isnull', False)), _connector='OR'), name='request_short_day_off_interval_valid'),
),
]

View File

@ -121,6 +121,8 @@ class Request(models.Model):
title = models.CharField(_("Тема"), max_length=200, blank=True)
date_from = models.DateField(_("Дата начала"), null=True, blank=True)
date_to = models.DateField(_("Дата окончания"), null=True, blank=True)
time_from = models.TimeField(_("Время начала"), null=True, blank=True)
time_to = models.TimeField(_("Время окончания"), null=True, blank=True)
comment = models.TextField(_("Комментарий"), blank=True)
status = models.CharField(
@ -173,6 +175,18 @@ class Request(models.Model):
| Q(date_from__isnull=True)
| Q(date_from__lte=F("date_to")),
),
models.CheckConstraint(
name="request_short_day_off_interval_valid",
condition=Q(status=RequestStatus.DRAFT)
| ~Q(type=RequestType.SHORT_DAY_OFF)
| Q(
date_from__isnull=False,
date_to=F("date_from"),
time_from__isnull=False,
time_to__isnull=False,
time_to__gt=F("time_from"),
),
),
models.CheckConstraint(
name="request_approver_required_on_decision",
condition=Q(
@ -200,6 +214,15 @@ class Request(models.Model):
raise ValidationError(
_("Для выбранного типа укажите даты начала и окончания.")
)
if self.type == RequestType.SHORT_DAY_OFF:
if not (self.date_from and self.date_to and self.time_from and self.time_to):
raise ValidationError(
_("Для короткого отгула укажите дату и время начала и окончания.")
)
if self.date_from != self.date_to:
raise ValidationError(_("Короткий отгул должен быть в пределах одного дня."))
if self.time_to <= self.time_from:
raise ValidationError(_("Время окончания должно быть позже времени начала."))
if (
self.type in {RequestType.TRANSFER, RequestType.DISMISSAL}
and not self.date_from
@ -209,6 +232,34 @@ class Request(models.Model):
if self.approver_id and self.approver_id == self.employee_id:
raise ValidationError(_("Согласующий не может совпадать с автором заявки."))
@property
def starts_at(self):
if not self.date_from:
return None
value = timezone.datetime.combine(
self.date_from,
self.time_from or timezone.datetime.min.time(),
)
return timezone.make_aware(value, timezone.get_current_timezone())
@property
def ends_at(self):
if not self.date_to:
return None
value = timezone.datetime.combine(
self.date_to,
self.time_to or timezone.datetime.max.time(),
)
return timezone.make_aware(value, timezone.get_current_timezone())
@property
def duration_minutes(self) -> int | None:
if not (self.time_from and self.time_to):
return None
start = timezone.datetime.combine(timezone.datetime.min.date(), self.time_from)
end = timezone.datetime.combine(timezone.datetime.min.date(), self.time_to)
return max(int((end - start).total_seconds() // 60), 0)
@property
def display_title(self) -> str:
"""Заголовок для списков: берём title или генерим на лету."""

View File

@ -17,7 +17,7 @@ def create_scheduled_action(self, request_id):
"""
Создать кадровое событие для заявки по расписанию.
Вызывается в date_from для vacation/sick_leave/day_off.
Вызывается в date_from для типов кадрового отсутствия.
Args:
request_id: ID заявки
@ -48,6 +48,7 @@ def create_scheduled_action(self, request_id):
# Определяем тип события
action_mapping = {
"short_day_off": ACTION_ON_DAY_OFF,
"vacation": ACTION_ON_LEAVE,
"sick_leave": ACTION_ON_SICK_LEAVE,
"day_off": ACTION_ON_DAY_OFF,
@ -62,16 +63,28 @@ def create_scheduled_action(self, request_id):
)
return
is_partial_day_off = request.type == "short_day_off"
extra = {
"approved_by": (request.approver.id if request.approver else None),
"scheduled": True,
}
if is_partial_day_off:
extra.update(
{
"partial_day": True,
"starts_at": request.starts_at.isoformat(),
"ends_at": request.ends_at.isoformat(),
"duration_minutes": request.duration_minutes,
}
)
action, created = create_request_action(
request=request,
action_type=action_type,
action_date=request.date_from,
action_date=request.starts_at if is_partial_day_off else request.date_from,
date_to=request.date_to,
comment=build_request_action_comment(request),
extra={
"approved_by": (request.approver.id if request.approver else None),
"scheduled": True,
},
extra=extra,
)
if not created:
logger.info(f"Action already exists for Request #{request_id}, skipping")

View File

@ -3,7 +3,7 @@
"""
import pytest
from datetime import date, timedelta
from datetime import date, time, timedelta
from unittest.mock import patch
from django.utils import timezone
@ -134,6 +134,34 @@ class TestRequestSignals:
assert action.date.date() == future_date
assert action.date_to == end_date
def test_short_day_off_creates_partial_interval_action(self, user_factory):
employee = user_factory()
approver = user_factory()
request_date = date.today() + timedelta(days=1)
request = Request.objects.create(
employee=employee,
type="short_day_off",
status=RequestStatus.PENDING,
date_from=request_date,
date_to=request_date,
time_from=time(14, 0),
time_to=time(16, 30),
)
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
action = EmployeeAction.objects.get(
employee=employee,
action="on_day_off",
source_request=request,
)
assert timezone.localtime(action.date).time().replace(tzinfo=None) == time(14, 0)
assert action.date_to == request_date
assert action.extra["partial_day"] is True
assert action.extra["duration_minutes"] == 150
def test_scheduled_action_maternity(self, user_factory):
"""Декрет создаёт интервальное EmployeeAction."""
employee = user_factory()
@ -317,6 +345,7 @@ class TestRequestMappings:
def test_scheduled_mapping_complete(self):
"""Проверка что все отложенные маппинги правильные."""
assert SCHEDULED_ACTION_MAPPING == {
"short_day_off": "on_day_off",
"vacation": "on_leave",
"sick_leave": "on_sick_leave",
"day_off": "on_day_off",

View File

@ -5,8 +5,10 @@ from __future__ import annotations
Тесты CRUD API документов (/api/v1/documents/ + экшен acknowledge) по чек-листу.
Политика доступа (актуальная):
- Создавать — все аутентифицированные пользователи (документы создаются в draft).
- Редактировать/удалять — только создатель, админы или обладатели модельных прав.
- Обычные документы могут создавать все аутентифицированные пользователи.
- Регламенты могут создавать и редактировать только staff/superuser.
- Редактировать/удалять обычные документы могут создатель, админы или
обладатели модельных прав.
- Approve/Publish — только пользователи с правом change_document (django-rules).
- Читать/скачивать — только аутентифицированные, и только документы,
предназначенные им (или с sent_to_all=True). Ознакомливаться можно только
@ -34,7 +36,12 @@ from documents.models import (
DocumentDisplayState,
DocumentTag,
)
from employees.models import Department, EmployeeDepartment
from employees.models import (
Department,
DepartmentRole,
EmployeeDepartment,
RoleAssignment,
)
from filer.models import File as FilerFile, Folder
from rest_framework import status
from rest_framework.test import APIClient
@ -225,6 +232,73 @@ def enable_media_files(settings):
class TestAuthAndPermissions:
"""A. Роли и доступ — статусы для разных ролей и прав."""
def test_department_role_grants_document_access_and_acknowledgement(
self, auth_client, make_user, api_urls
):
author = make_user("role-document-author@example.com", staff=True)
role_user = make_user("role-document-viewer@example.com")
department = Department.objects.create(name="Role document department")
role = DepartmentRole.objects.create(
department=department,
name="Document role",
)
RoleAssignment.objects.create(
employee=role_user,
role=role,
is_active=True,
)
document = make_document(
uploaded_by=author,
sent_to_all=False,
acknowledgement_required=True,
acknowledgement_for_all=False,
is_regulation=True,
)
document.departments.add(department)
document.acknowledgement_departments.add(department)
client = auth_client(role_user)
list_response = client.get(api_urls["list"], {"is_regulation": True})
assert list_response.status_code == status.HTTP_200_OK
result = next(
item
for item in list_response.data["results"]
if item["id"] == document.id
)
assert result["acknowledgement_required_for_user"] is True
assert result["acknowledgement_total"] == 1
acknowledge_response = client.post(api_urls["ack"](document.pk), {})
assert acknowledge_response.status_code == status.HTTP_200_OK
assert DocumentAcknowledgement.objects.filter(
document=document,
user=role_user,
).exists()
def test_inactive_department_role_does_not_grant_document_access(
self, auth_client, make_user, api_urls
):
author = make_user("inactive-role-author@example.com", staff=True)
role_user = make_user("inactive-role-viewer@example.com")
department = Department.objects.create(name="Inactive role department")
role = DepartmentRole.objects.create(
department=department,
name="Inactive document role",
)
RoleAssignment.objects.create(
employee=role_user,
role=role,
is_active=False,
)
document = make_document(uploaded_by=author, sent_to_all=False)
document.departments.add(department)
response = auth_client(role_user).get(api_urls["detail"](document.pk))
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_anonymous_all_401(self, api_client: APIClient, api_urls):
"""Аноним: все запросы возвращают 401."""
list_url = api_urls["list"]
@ -581,6 +655,53 @@ class TestCreate:
detail_r = client.get(api_urls["detail"](doc.pk))
assert detail_r.status_code == 200
@pytest.mark.parametrize("is_regulation", [True, "true", "1"])
def test_regular_user_cannot_create_regulation(
self, auth_client, make_user, api_urls, is_regulation
):
regular_user = make_user("regular-regulation@example.com", staff=False)
client = auth_client(regular_user)
response = client.post(
api_urls["list"],
{
"title": "Unauthorized regulation",
"sent_to_all": True,
"is_regulation": is_regulation,
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert not Document.objects.filter(title="Unauthorized regulation").exists()
@pytest.mark.parametrize(
"staff,superuser",
[(True, False), (False, True)],
)
def test_admin_can_create_regulation(
self, auth_client, make_user, api_urls, staff, superuser
):
admin = make_user(
f"regulation-admin-{staff}-{superuser}@example.com",
staff=staff,
superuser=superuser,
)
client = auth_client(admin)
response = client.post(
api_urls["list"],
{
"title": "Admin regulation",
"sent_to_all": True,
"is_regulation": True,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data["is_regulation"] is True
# -------------------- C. Чтение (GET) --------------------
class TestRead:
@ -741,6 +862,93 @@ class TestRead:
class TestUpdate:
"""D. Обновление документа (PUT/PATCH)."""
def test_non_admin_uploader_cannot_update_regulation(
self, auth_client, make_user, api_urls
):
author = make_user("regulation-author@example.com", staff=False)
regulation = make_document(
uploaded_by=author,
title="Original regulation",
is_regulation=True,
)
response = auth_client(author).patch(
api_urls["detail"](regulation.pk),
{"title": "Changed regulation"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
regulation.refresh_from_db()
assert regulation.title == "Original regulation"
def test_non_admin_with_change_permission_cannot_update_regulation(
self, auth_client, make_user, api_urls
):
author = make_user("regulation-owner@example.com")
editor = make_user("regulation-editor@example.com", staff=False)
grant_perms(editor, "change_document")
regulation = make_document(
uploaded_by=author,
title="Protected regulation",
is_regulation=True,
)
response = auth_client(editor).patch(
api_urls["detail"](regulation.pk),
{"description": "Unauthorized change"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
regulation.refresh_from_db()
assert regulation.description == "desc"
def test_non_admin_cannot_convert_document_to_regulation(
self, auth_client, make_user, api_urls
):
author = make_user("document-author@example.com", staff=False)
document = make_document(uploaded_by=author, is_regulation=False)
response = auth_client(author).patch(
api_urls["detail"](document.pk),
{"is_regulation": True},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
document.refresh_from_db()
assert document.is_regulation is False
@pytest.mark.parametrize(
"staff,superuser",
[(True, False), (False, True)],
)
def test_admin_can_update_regulation(
self, auth_client, make_user, api_urls, staff, superuser
):
owner = make_user("regulation-legacy-owner@example.com")
admin = make_user(
f"regulation-editor-{staff}-{superuser}@example.com",
staff=staff,
superuser=superuser,
)
regulation = make_document(
uploaded_by=owner,
title="Old regulation title",
is_regulation=True,
)
response = auth_client(admin).patch(
api_urls["detail"](regulation.pk),
{"title": "New regulation title"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
regulation.refresh_from_db()
assert regulation.title == "New regulation title"
def test_update_title_description(self, auth_client, make_user, api_urls):
"""PUT/PATCH обновляют title/description."""
author = make_user("author@example.com")
@ -1053,6 +1261,38 @@ class TestFolders:
class TestDocumentNotifications:
"""E3. Уведомления о новых документах и ознакомлении."""
def test_department_notification_includes_role_only_employee(
self, monkeypatch, make_user
):
from documents.notifications.handlers import notify_department_employees
calls = []
monkeypatch.setattr(
"documents.notifications.handlers.notify.send",
lambda **kwargs: calls.append(kwargs),
)
author = make_user("role-notification-author@example.com")
role_user = make_user("role-notification-viewer@example.com")
department = Department.objects.create(name="Role notification department")
role = DepartmentRole.objects.create(
department=department,
name="Notification role",
)
RoleAssignment.objects.create(
employee=role_user,
role=role,
is_active=True,
)
document = make_document(
uploaded_by=author,
sent_to_all=False,
is_regulation=True,
)
notify_department_employees(document, {department.id})
assert [call["recipient"] for call in calls] == [role_user]
def test_required_document_notification_mentions_acknowledgement(
self, monkeypatch, make_user
):

View File

@ -43,6 +43,7 @@ from procurement.models import (
ProcurementItemAttachment,
ProcurementRequest,
ProcurementRequestActivity,
ProcurementRequestView,
ProcurementSettings,
)
from procurement.notifications.handlers import notify_new_request
@ -289,6 +290,58 @@ class TestProcurementRequestList:
assert results[0]['id'] == procurement_request.id
assert results[0]['title'] == "Закупка ноутбуков"
def test_default_order_uses_latest_defect_or_creation_date(
self, api_client, user, department, procurement_request_factory,
procurement_item_factory
):
now = timezone.now()
old_defective_request = procurement_request_factory(
department=department,
requestor=user,
title="Старая заявка с новым браком",
)
middle_request = procurement_request_factory(
department=department,
requestor=user,
title="Вчерашняя заявка",
)
newest_request = procurement_request_factory(
department=department,
requestor=user,
title="Самая новая заявка",
)
defective_item = procurement_item_factory(
request=old_defective_request,
execution_status=ProcurementItemExecutionStatus.DEFECTIVE,
)
ProcurementRequest.objects.filter(pk=old_defective_request.pk).update(
created_at=now - timedelta(days=365),
)
ProcurementRequest.objects.filter(pk=middle_request.pk).update(
created_at=now - timedelta(days=1),
)
ProcurementRequest.objects.filter(pk=newest_request.pk).update(
created_at=now,
)
ProcurementItem.objects.filter(pk=defective_item.pk).update(
defective_at=now - timedelta(minutes=1),
)
api_client.force_authenticate(user=user)
url = reverse('api:v1:procurement:procurementrequest-list')
response = api_client.get(
url,
{'scope': 'mine', 'status': ProcurementStatus.DRAFT},
)
assert response.status_code == status.HTTP_200_OK
assert [item['id'] for item in response.data['results'][:3]] == [
newest_request.id,
old_defective_request.id,
middle_request.id,
]
assert response.data['results'][1]['last_defective_at'] is not None
def test_list_includes_visible_linked_tasks_only(
self,
api_client,
@ -944,6 +997,55 @@ class TestProcurementItemComments:
]
assert comment_activities.first().metadata["item_id"] == procurement_item.id
def test_comment_links_are_added_to_item_without_duplicates(
self, api_client, user, procurement_item
):
procurement_item.links = [
"https://existing.example/path",
"example.org/catalog",
]
procurement_item.save(update_fields=["links"])
api_client.force_authenticate(user=user)
comments_url = reverse(
'api:v1:procurement:procurementitem-comments',
kwargs={'pk': procurement_item.id},
)
response = api_client.post(
comments_url,
{
'text': (
'Существующая (HTTPS://EXISTING.EXAMPLE/path), '
'каталог https://example.org/catalog и новая '
'https://shop.example/item?id=7. Повтор: '
'https://shop.example/item?id=7.'
)
},
format='json',
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data['added_links'] == [
'https://shop.example/item?id=7'
]
assert response.data['item_links'] == [
'https://existing.example/path',
'example.org/catalog',
'https://shop.example/item?id=7',
]
procurement_item.refresh_from_db()
assert procurement_item.links == response.data['item_links']
duplicate_response = api_client.post(
comments_url,
{'text': 'Ещё раз https://shop.example/item?id=7'},
format='json',
)
assert duplicate_response.status_code == status.HTTP_201_CREATED
assert duplicate_response.data['added_links'] == []
assert duplicate_response.data['item_links'] == procurement_item.links
def test_outsider_cannot_comment_item(
self, api_client, procurement_item
):
@ -1824,6 +1926,155 @@ class TestProcessingDepartmentWorkflow:
assert reassigned_activity.actor == supply_replacement_user
assert reassigned_activity.metadata["previous_executor_id"] == supply_user.id
def test_processing_department_user_can_start_pending_request(
self, api_client, department_head, supply_user, processing_request,
procurement_item_factory
):
procurement_item_factory(request=processing_request)
processing_request.status = ProcurementStatus.PENDING
processing_request.save(update_fields=["status", "updated_at"])
Approval.objects.create(
request=processing_request,
approver=department_head,
priority=HEAD_PRIORITY,
status=ApprovalStatus.PENDING,
step_name="Руководитель отдела",
)
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)
detail_response = api_client.get(detail_url)
assert detail_response.status_code == status.HTTP_200_OK
assert detail_response.data["can_current_user_start_work"] is True
response = api_client.post(start_url)
assert response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert processing_request.status == ProcurementStatus.PENDING
assert processing_request.executor == supply_user
assert processing_request.started_at is not None
assert ProcurementRequestActivity.objects.filter(
request=processing_request,
action=ProcurementActivityAction.STARTED,
actor=supply_user,
).count() == 1
api_client.force_authenticate(user=department_head)
approve_url = reverse(
'api:v1:procurement:procurementrequest-approve',
kwargs={'pk': processing_request.id},
)
approve_response = api_client.post(approve_url, {'comment': 'Согласовано'})
assert approve_response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.executor == supply_user
assert ProcurementRequestActivity.objects.filter(
request=processing_request,
action=ProcurementActivityAction.STARTED,
).count() == 1
assert ProcurementRequestActivity.objects.filter(
request=processing_request,
action=ProcurementActivityAction.APPROVED,
).exists()
def test_processing_department_user_can_reassign_pending_request(
self, api_client, supply_user, supply_replacement_user,
processing_request, procurement_item_factory
):
procurement_item_factory(request=processing_request)
previous_started_at = timezone.now() - timedelta(hours=1)
processing_request.status = ProcurementStatus.PENDING
processing_request.executor = supply_user
processing_request.started_at = previous_started_at
processing_request.save(
update_fields=[
"status",
"executor",
"started_at",
"updated_at",
],
)
api_client.force_authenticate(user=supply_replacement_user)
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},
)
detail_response = api_client.get(detail_url)
assert detail_response.status_code == status.HTTP_200_OK
assert detail_response.data["can_current_user_start_work"] is True
response = api_client.post(url)
assert response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert processing_request.status == ProcurementStatus.PENDING
assert processing_request.executor == supply_replacement_user
assert processing_request.started_at == previous_started_at
reassigned_activity = ProcurementRequestActivity.objects.get(
request=processing_request,
action=ProcurementActivityAction.EXECUTOR_REASSIGNED,
)
assert reassigned_activity.metadata["previous_executor_id"] == supply_user.id
def test_available_scope_includes_pending_request_for_processing_department(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
procurement_item_factory(request=processing_request)
processing_request.status = ProcurementStatus.PENDING
processing_request.save(update_fields=["status", "updated_at"])
api_client.force_authenticate(user=supply_user)
url = reverse('api:v1:procurement:procurementrequest-list')
response = api_client.get(url, {'scope': 'available'})
assert response.status_code == status.HTTP_200_OK
assert processing_request.id in {
item['id'] for item in response.data['results']
}
@pytest.mark.parametrize(
"request_status",
[
ProcurementStatus.DRAFT,
ProcurementStatus.COMPLETED,
ProcurementStatus.REJECTED,
ProcurementStatus.CANCELLED,
],
)
def test_processing_department_user_cannot_start_inactive_request(
self, api_client, supply_user, processing_request,
procurement_item_factory, request_status
):
procurement_item_factory(request=processing_request)
processing_request.status = request_status
processing_request.save(update_fields=["status", "updated_at"])
api_client.force_authenticate(user=supply_user)
url = reverse(
'api:v1:procurement:procurementrequest-start-work',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
processing_request.refresh_from_db()
assert processing_request.executor is None
def test_available_scope_includes_reassignable_processing_request(
self, api_client, supply_user, supply_replacement_user,
processing_request, procurement_item_factory
@ -2020,7 +2271,7 @@ class TestProcessingDepartmentWorkflow:
processing_request.refresh_from_db()
assert processing_request.executor == supply_user
def test_non_processing_in_progress_request_cannot_be_reassigned(
def test_non_processing_in_progress_request_can_be_reassigned(
self, api_client, user, staff_user, procurement_request,
procurement_item
):
@ -2043,9 +2294,10 @@ class TestProcessingDepartmentWorkflow:
response = api_client.post(url)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.status_code == status.HTTP_200_OK
procurement_request.refresh_from_db()
assert procurement_request.executor == staff_user
assert procurement_request.executor == user
assert procurement_request.status == ProcurementStatus.IN_PROGRESS
def test_processing_department_role_user_can_manage_items(
self, api_client, supply_role_user, processing_request,
@ -2722,7 +2974,10 @@ class TestProcessingDepartmentWorkflow:
assert cancel_received_response.status_code == status.HTTP_400_BAD_REQUEST
assert mark_all_response.status_code == status.HTTP_400_BAD_REQUEST
assert complete_response.status_code == status.HTTP_403_FORBIDDEN
assert start_response.status_code == status.HTTP_400_BAD_REQUEST
assert start_response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert processing_request.status == ProcurementStatus.PENDING
assert processing_request.executor == supply_user
def test_item_status_recalculates_request_fulfillment_status(
self, api_client, supply_user, processing_request,
@ -2949,7 +3204,7 @@ class TestProcessingDepartmentWorkflow:
item.refresh_from_db()
assert item.expected_delivery_dates == []
def test_item_quantities_cannot_exceed_requested_quantity(
def test_item_quantities_can_exceed_requested_quantity(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
@ -2967,14 +3222,60 @@ class TestProcessingDepartmentWorkflow:
kwargs={'pk': item.id},
)
response = api_client.patch(
ordered_response = api_client.patch(
item_url,
{'ordered_quantity': 4},
{'ordered_quantity': 5},
format='json',
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "ordered_quantity" in response.data
assert ordered_response.status_code == status.HTTP_200_OK
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.ordered_quantity == 5
assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.ORDERED
)
partial_received_response = api_client.patch(
item_url,
{'received_quantity': 4},
format='json',
)
assert partial_received_response.status_code == status.HTTP_200_OK
assert (
partial_received_response.data["execution_status_display"]
== "Получено частично"
)
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.ordered_quantity == 5
assert item.received_quantity == 4
assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.PARTIALLY_RECEIVED
)
assert processing_request.status == ProcurementStatus.IN_PROGRESS
excess_received_response = api_client.patch(
item_url,
{'received_quantity': 6},
format='json',
)
assert excess_received_response.status_code == status.HTTP_200_OK
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.ordered_quantity == 6
assert item.received_quantity == 6
assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.COMPLETED
)
def test_ordered_quantity_moves_pending_item_to_ordered(
self, api_client, supply_user, processing_request,
@ -3006,6 +3307,99 @@ class TestProcessingDepartmentWorkflow:
assert item.ordered_quantity == 2
assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
def test_confirm_ordered_adds_quantity_and_optional_delivery_date(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=3,
ordered_quantity=1,
expected_delivery_dates=["2026-08-20"],
execution_status=ProcurementItemExecutionStatus.ORDERED,
)
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:procurementitem-confirm-ordered',
kwargs={'pk': item.id},
)
response = api_client.post(
url,
{
'ordered_quantity': 5,
'expected_delivery_date': '2026-08-25',
},
format='json',
)
assert response.status_code == status.HTTP_200_OK
assert response.data['ordered_quantity'] == 5
assert response.data['expected_delivery_dates'] == [
'2026-08-20',
'2026-08-25',
]
item.refresh_from_db()
assert item.ordered_quantity == 5
assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
without_date_response = api_client.post(
url,
{'ordered_quantity': 6},
format='json',
)
assert without_date_response.status_code == status.HTTP_200_OK
assert without_date_response.data['ordered_quantity'] == 6
assert without_date_response.data['expected_delivery_dates'] == [
'2026-08-20',
'2026-08-25',
]
def test_confirm_ordered_rejects_non_increasing_quantity_and_bad_date(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=3,
ordered_quantity=2,
execution_status=ProcurementItemExecutionStatus.ORDERED,
)
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:procurementitem-confirm-ordered',
kwargs={'pk': item.id},
)
same_quantity_response = api_client.post(
url,
{'ordered_quantity': 2},
format='json',
)
bad_date_response = api_client.post(
url,
{
'ordered_quantity': 3,
'expected_delivery_date': '31.02.2026',
},
format='json',
)
assert same_quantity_response.status_code == status.HTTP_400_BAD_REQUEST
assert bad_date_response.status_code == status.HTTP_400_BAD_REQUEST
item.refresh_from_db()
assert item.ordered_quantity == 2
assert item.expected_delivery_dates == []
def test_partial_quantities_drive_item_display_and_request_fulfillment(
self, api_client, supply_user, processing_request,
procurement_item_factory
@ -3201,6 +3595,20 @@ class TestProcessingDepartmentWorkflow:
processing_request.status = ProcurementStatus.COMPLETED
processing_request.completed_at = timezone.now()
processing_request.save()
ProcurementRequestView.objects.bulk_create([
ProcurementRequestView(
request=processing_request,
user=user,
is_viewed=True,
viewed_at=timezone.now(),
),
ProcurementRequestView(
request=processing_request,
user=supply_user,
is_viewed=True,
viewed_at=timezone.now(),
),
])
sent.clear()
api_client.force_authenticate(user=user)
@ -3219,12 +3627,21 @@ class TestProcessingDepartmentWorkflow:
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.DEFECTIVE
assert item.defective_at is not None
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.ISSUES
)
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.completed_at is None
assert not ProcurementRequestView.objects.filter(
request=processing_request,
is_viewed=True,
).exists()
assert not ProcurementRequestView.objects.filter(
request=processing_request,
viewed_at__isnull=False,
).exists()
assert [comment.content for comment in get_comments(item)] == [
"Обнаружен брак"
]
@ -3247,10 +3664,61 @@ class TestProcessingDepartmentWorkflow:
issue_recipients = [
notification["recipient"].email
for notification in sent
if notification["verb"] == "procurement_item_updated"
if notification["verb"] == "procurement_item_issue_reported"
]
assert supply_user.email in issue_recipients
def test_processing_member_defect_notifies_assigned_executor(
self, api_client, supply_user, supply_replacement_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()
ProcurementRequestView.objects.bulk_create([
ProcurementRequestView(
request=processing_request,
user=supply_user,
is_viewed=True,
viewed_at=timezone.now(),
),
ProcurementRequestView(
request=processing_request,
user=supply_replacement_user,
is_viewed=True,
viewed_at=timezone.now(),
),
])
api_client.force_authenticate(user=supply_replacement_user)
url = reverse(
'api:v1:procurement:procurementitem-detail',
kwargs={'pk': item.id},
)
response = api_client.patch(
url,
{'execution_status': ProcurementItemExecutionStatus.DEFECTIVE},
format='json',
)
assert response.status_code == status.HTTP_200_OK
notification = Notification.objects.get(
recipient=supply_user,
verb='procurement_item_issue_reported',
target_object_id=str(processing_request.id),
)
assert notification.unread is True
assert notification.data['item_id'] == item.id
assert notification.data['new_status'] == (
ProcurementItemExecutionStatus.DEFECTIVE
)
assert ProcurementRequestView.objects.filter(
request=processing_request,
is_viewed=False,
viewed_at__isnull=True,
).count() == 2
def test_executor_issue_reopens_request_for_requestor_without_status_spam(
self, api_client, user, supply_user, processing_request,
procurement_item_factory, monkeypatch
@ -3325,7 +3793,7 @@ class TestProcessingDepartmentWorkflow:
item = procurement_item_factory(
request=processing_request,
quantity=2,
ordered_quantity=1,
ordered_quantity=3,
received_quantity=0,
execution_status=ProcurementItemExecutionStatus.ORDERED,
)
@ -3345,8 +3813,8 @@ class TestProcessingDepartmentWorkflow:
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert item.ordered_quantity == item.quantity
assert item.received_quantity == item.quantity
assert item.ordered_quantity == 3
assert item.received_quantity == 3
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.COMPLETED
@ -3394,7 +3862,7 @@ class TestProcessingDepartmentWorkflow:
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.completed_at is None
def test_confirm_item_received_rejects_invalid_quantity(
def test_confirm_item_received_rejects_non_increasing_and_accepts_excess(
self, api_client, user, supply_user, processing_request,
procurement_item_factory
):
@ -3427,9 +3895,11 @@ class TestProcessingDepartmentWorkflow:
)
assert too_low_response.status_code == status.HTTP_400_BAD_REQUEST
assert too_high_response.status_code == status.HTTP_400_BAD_REQUEST
assert too_high_response.status_code == status.HTTP_200_OK
item.refresh_from_db()
assert item.received_quantity == 1
assert item.ordered_quantity == 3
assert item.received_quantity == 3
assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
def test_requestor_can_cancel_item_issue(
self, api_client, user, supply_user, processing_request,
@ -3459,6 +3929,7 @@ class TestProcessingDepartmentWorkflow:
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert item.defective_at is None
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.COMPLETED
@ -3909,7 +4380,7 @@ class TestProcessingDepartmentWorkflow:
request=processing_request,
quantity=2,
execution_status=ProcurementItemExecutionStatus.ORDERED,
ordered_quantity=1,
ordered_quantity=3,
)
item_2 = procurement_item_factory(
request=processing_request,
@ -3934,8 +4405,8 @@ class TestProcessingDepartmentWorkflow:
item_2.refresh_from_db()
assert item_1.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert item_2.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert item_1.ordered_quantity == item_1.quantity
assert item_1.received_quantity == item_1.quantity
assert item_1.ordered_quantity == 3
assert item_1.received_quantity == 3
assert item_2.ordered_quantity == item_2.quantity
assert item_2.received_quantity == item_2.quantity
assert (
@ -3957,6 +4428,65 @@ class TestProcessingDepartmentWorkflow:
]
assert completed_recipients == [processing_request.requestor.email]
def test_requestor_can_mark_all_received_from_request_card(
self, api_client, user, supply_user, processing_request,
procurement_item_factory, monkeypatch
):
monkeypatch.setattr(
"procurement.notifications.handlers.notify.send",
lambda **kwargs: None,
)
item = procurement_item_factory(
request=processing_request,
quantity=2,
execution_status=ProcurementItemExecutionStatus.ORDERED,
ordered_quantity=3,
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save(
update_fields=["executor", "status", "updated_at"],
)
api_client.force_authenticate(user=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
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert item.received_quantity == 3
assert processing_request.status == ProcurementStatus.COMPLETED
activity = ProcurementRequestActivity.objects.get(
request=processing_request,
action=ProcurementActivityAction.ALL_ITEMS_RECEIVED,
)
assert activity.actor == user
def test_customer_department_colleague_cannot_mark_all_received(
self, api_client, department_head, supply_user, processing_request,
procurement_item_factory
):
procurement_item_factory(request=processing_request)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save(
update_fields=["executor", "status", "updated_at"],
)
api_client.force_authenticate(user=department_head)
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_403_FORBIDDEN
def test_processing_user_can_notify_requestor_about_arrival_multiple_times(
self, api_client, user, supply_user, processing_request, monkeypatch
):

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import date, datetime
from datetime import date, datetime, time
from typing import Any
import pytest
@ -573,6 +573,99 @@ def test_day_off_request_requires_full_period(
assert "date_to" in resp.json()
def test_short_day_off_requires_valid_single_day_time_interval(
auth_client, regular_user: models.Model, make_user
) -> None:
recipient = make_user(email="short-day-off-recipient@example.com")
base_payload = {
"type": RequestType.SHORT_DAY_OFF,
"date_from": "2026-04-20",
"recipient_ids": [recipient.id],
}
missing_time = auth_client(regular_user).post(
API_BASE,
data=base_payload,
format="json",
)
reversed_time = auth_client(regular_user).post(
API_BASE,
data={**base_payload, "time_from": "15:00", "time_to": "14:00"},
format="json",
)
different_day = auth_client(regular_user).post(
API_BASE,
data={
**base_payload,
"date_to": "2026-04-21",
"time_from": "14:00",
"time_to": "15:00",
},
format="json",
)
assert missing_time.status_code == 400
assert reversed_time.status_code == 400
assert different_day.status_code == 400
def test_short_day_off_normalizes_end_date_and_exposes_datetimes(
auth_client, regular_user: models.Model, make_user
) -> None:
recipient = make_user(email="short-day-off-ok-recipient@example.com")
response = auth_client(regular_user).post(
API_BASE,
data={
"type": RequestType.SHORT_DAY_OFF,
"date_from": "2026-04-20",
"time_from": "14:00",
"time_to": "16:30",
"recipient_ids": [recipient.id],
},
format="json",
)
assert response.status_code == 201
payload = response.json()
assert payload["date_from"] == "2026-04-20"
assert payload["date_to"] == "2026-04-20"
assert payload["time_from"] == "14:00:00"
assert payload["time_to"] == "16:30:00"
assert payload["duration_minutes"] == 150
assert payload["starts_at"].startswith("2026-04-20T14:00:00")
assert payload["ends_at"].startswith("2026-04-20T16:30:00")
def test_statistics_counts_short_day_off_as_day_off_without_full_day(
auth_client,
admin_user: models.Model,
make_user,
) -> None:
employee = make_user(email="short-day-off-stats@example.com")
EmployeeRequest.objects.create(
employee=employee,
approver=admin_user,
type=RequestType.SHORT_DAY_OFF,
status=RequestStatus.APPROVED,
date_from=date(2026, 4, 20),
date_to=date(2026, 4, 20),
time_from=time(14, 0),
time_to=time(16, 30),
)
response = auth_client(admin_user).get(
f"{API_BASE}statistics/?employee_id={employee.id}&period=all"
)
assert response.status_code == 200
payload = response.json()
assert payload["day_off_requests_count"] == 1
assert payload["short_day_off_requests_count"] == 1
assert payload["day_off_days"] == 0
assert payload["short_day_off_minutes"] == 150
def test_create_request_sends_notification_to_cc_users(
auth_client, regular_user: models.Model, make_user
) -> None:

View File

@ -1,4 +1,4 @@
from datetime import date
from datetime import date, time
import pytest
from django.utils import timezone
@ -20,6 +20,8 @@ from employees.services.personnel_state import (
calculate_employee_tenure_days,
resolve_employee_personnel_state,
)
from requests_app.enums import RequestStatus, RequestType
from requests_app.models import Request
pytestmark = pytest.mark.django_db
@ -215,6 +217,35 @@ def test_overlapping_temporary_intervals_use_priority(user_factory):
assert state.action_id == sick.id
def test_short_day_off_keeps_workday_and_exposes_approved_interval(user_factory):
employee = user_factory()
request = Request.objects.create(
employee=employee,
approver=employee,
type=RequestType.SHORT_DAY_OFF,
status=RequestStatus.APPROVED,
date_from=date(2026, 4, 21),
date_to=date(2026, 4, 21),
time_from=time(14, 0),
time_to=time(16, 30),
)
EmployeeAction.objects.create(
employee=employee,
source_request=request,
action=ACTION_ON_DAY_OFF,
date=timezone.make_aware(timezone.datetime(2026, 4, 21, 14, 0)),
date_to=date(2026, 4, 21),
extra={"partial_day": True},
)
state = resolve_employee_personnel_state(employee, date(2026, 4, 21))
assert state.expects_attendance is True
assert state.status != ACTION_ON_DAY_OFF
assert state.approved_absence_intervals == ((time(14, 0), time(16, 30)),)
assert state.approved_absence_minutes == 150
def test_dismissal_overrides_temporary_interval(user_factory):
employee = user_factory()
EmployeeAction.objects.create(

View File

@ -0,0 +1,161 @@
from datetime import date, time
from decimal import Decimal
import pytest
from django.utils import timezone
from attendance.services import build_attendance_record_defaults
from employees.constants import ACTION_HIRED, ACTION_ON_DAY_OFF
from employees.models import EmployeeAction
from employees.services.personnel_state import resolve_employee_personnel_state
from finance.models import PayrollPeriod
from finance.payroll.work_norm import calculate_period_personnel_daily_points
from requests_app.enums import RequestStatus, RequestType
from requests_app.models import Request
pytestmark = pytest.mark.django_db
def _short_day_off(
employee,
approver,
*,
time_from: time = time(9, 0),
time_to: time = time(11, 0),
) -> tuple[Request, EmployeeAction]:
request = Request.objects.create(
employee=employee,
approver=approver,
type=RequestType.SHORT_DAY_OFF,
status=RequestStatus.APPROVED,
date_from=date(2026, 7, 1),
date_to=date(2026, 7, 1),
time_from=time_from,
time_to=time_to,
)
action = EmployeeAction.objects.create(
employee=employee,
source_request=request,
action=ACTION_ON_DAY_OFF,
date=timezone.make_aware(
timezone.datetime.combine(date(2026, 7, 1), time_from)
),
date_to=date(2026, 7, 1),
extra={"partial_day": True},
)
return request, action
def test_short_day_off_reduces_personnel_points_proportionally(user_factory):
employee = user_factory(email="short-day-off-finance@example.com")
approver = user_factory(email="short-day-off-approver@example.com")
employee.actions.all().delete()
hired = EmployeeAction.objects.create(
employee=employee,
action=ACTION_HIRED,
date=timezone.make_aware(timezone.datetime(2026, 6, 1, 12, 0)),
)
_, short_action = _short_day_off(employee, approver)
period = PayrollPeriod.objects.create(
code="2026-07-short-day-off",
name="Короткий отгул",
date_from=date(2026, 7, 1),
date_to=date(2026, 7, 1),
pay_date=date(2026, 7, 5),
currency="RUB",
created_by=approver,
)
result = calculate_period_personnel_daily_points(
period,
employee=employee,
actions=[hired, short_action],
period_target_points=Decimal("9"),
schedule={
"start_time": "08:00",
"end_time": "17:00",
"expected_hours": 9,
"workdays": ["Wednesday"],
"date_overrides": [],
},
)
assert result[date(2026, 7, 1)] == Decimal("7.0000")
def test_short_day_off_keeps_attendance_workday_and_excuses_underwork(user_factory):
employee = user_factory(email="short-day-off-attendance@example.com")
approver = user_factory(email="short-day-off-attendance-approver@example.com")
_, action = _short_day_off(employee, approver)
state = resolve_employee_personnel_state(
employee,
date(2026, 7, 1),
actions=[action],
)
defaults = build_attendance_record_defaults(
{
"date": "2026-07-01",
"is_workday": True,
"effective_is_workday": True,
"work_hours": 6.5,
"expected_hours": 9,
"is_underwork": True,
"underwork_hours": 2.5,
"statuses": ["underwork"],
},
state,
schedule_payload={
"start_time": "08:00",
"end_time": "17:00",
"expected_hours": 9,
},
)
assert defaults["effective_is_workday"] is True
assert defaults["personnel_status"] == "normal"
assert defaults["is_underwork"] is True
assert defaults["underwork_hours"] == 0.5
assert "approved_short_day_off" in defaults["statuses"]
assert defaults["raw_data"]["approved_absence_minutes"] == 120
def test_short_day_off_at_shift_start_excuses_lateness(user_factory):
employee = user_factory(email="short-day-off-late@example.com")
approver = user_factory(email="short-day-off-late-approver@example.com")
_, action = _short_day_off(
employee,
approver,
time_from=time(8, 0),
time_to=time(10, 0),
)
state = resolve_employee_personnel_state(
employee,
date(2026, 7, 1),
actions=[action],
)
defaults = build_attendance_record_defaults(
{
"is_workday": True,
"effective_is_workday": True,
"arrival_time": "10:00",
"work_hours": 7,
"expected_hours": 9,
"is_late": True,
"late_minutes": 120,
"statuses": ["late"],
},
state,
schedule_payload={
"start_time": "08:00",
"end_time": "17:00",
"expected_hours": 9,
},
)
assert defaults["is_late"] is False
assert defaults["late_minutes"] == 0
assert "late" not in defaults["statuses"]
assert "approved_short_day_off" in defaults["statuses"]

View File

@ -6,7 +6,7 @@
* нативные уведомления даже когда страница закрыта.
*/
const SW_VERSION = '2.1.1';
const SW_VERSION = '2.1.2';
const STATIC_CACHE = `eusrr-static-${SW_VERSION}`;
const PRECACHE_URLS = [
'/manifest.webmanifest',
@ -197,8 +197,6 @@ self.addEventListener('notificationclick', (event) => {
self.location.origin,
).toString();
// Для установленного PWA даём браузеру самому решить, открыть app window
// или вкладку. Не фокусируем произвольную already-open same-origin вкладку.
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then(async (windowClients) => {
@ -209,26 +207,34 @@ self.addEventListener('notificationclick', (event) => {
return exactClient.focus();
}
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
const sameOriginClients = windowClients.filter(
(client) => new URL(client.url).origin === self.location.origin,
);
const sameOriginClient =
sameOriginClients.find((client) => client.focused) ||
sameOriginClients.find((client) => client.visibilityState === 'visible') ||
sameOriginClients[0];
if (sameOriginClient) {
if ('navigate' in sameOriginClient) {
try {
const navigatedClient = await sameOriginClient.navigate(urlToOpen);
return (navigatedClient || sameOriginClient).focus();
} catch (error) {
console.warn('[SW] Failed to navigate existing client:', error);
}
}
sameOriginClient.postMessage({
type: 'NOTIFICATION_CLICK',
url: urlToOpen,
notificationId: event.notification.data?.notification_id
});
return sameOriginClient.focus();
}
const sameOriginClient = windowClients.find(
(client) =>
client.url.startsWith(self.location.origin) &&
'focus' in client,
);
if (sameOriginClient) {
return sameOriginClient.focus().then((client) => {
if ('navigate' in client) {
return client.navigate(urlToOpen);
}
return client.postMessage({
type: 'NOTIFICATION_CLICK',
url: urlToOpen,
notificationId: event.notification.data?.notification_id
});
});
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);

View File

@ -46,6 +46,7 @@ import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { Modal } from "@/components/ui/Modal";
import { useDepartmentPage } from "@/hooks/useDepartmentPage";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { regulationMatchesAcknowledgementSource } from "@/lib/feed-regulation-filters";
import { displayUserName, formatDate, loadAllPages, userProfileLink } from "@/lib/shared";
@ -999,6 +1000,10 @@ export default function DepartmentDetailPage() {
const router = useRouter();
const departmentId = Number(params?.id);
const h = useDepartmentPage(departmentId);
const { user } = useUser();
const canManageRegulations = Boolean(
user?.auth?.is_staff || user?.auth?.is_superuser,
);
const [managementMode, setManagementMode] = useState(false);
const [membersExpanded, setMembersExpanded] = useState(false);
const [regulationsExpanded, setRegulationsExpanded] = useState(false);
@ -1458,15 +1463,17 @@ export default function DepartmentDetailPage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => setCreateRegulationOpen(true)}
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} />
<span className="hidden sm:inline">Создать регламент</span>
<span className="sm:hidden">Создать</span>
</button>
{canManageRegulations ? (
<button
type="button"
onClick={() => setCreateRegulationOpen(true)}
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} />
<span className="hidden sm:inline">Создать регламент</span>
<span className="sm:hidden">Создать</span>
</button>
) : null}
{departmentRegulations.length > 0 ? (
<button
type="button"
@ -1624,7 +1631,7 @@ export default function DepartmentDetailPage() {
/>
<Modal
isOpen={createRegulationOpen}
isOpen={createRegulationOpen && canManageRegulations}
onClose={() => setCreateRegulationOpen(false)}
title="Создать регламент отдела"
size="xl"

View File

@ -44,6 +44,7 @@ import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { Modal } from "@/components/ui";
import { canPreviewDocument, getDocumentFileExtension } from "@/lib/document-preview";
import { useNotifications } from "@/contexts/NotificationsContext";
import { useUser } from "@/contexts/UserContext";
import { NAV_NOTIFICATION_CATEGORIES } from "@/lib/navigation-notifications";
import {
regulationMatchesAcknowledgementSource,
@ -153,6 +154,10 @@ function DocumentsPageContent() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { user } = useUser();
const canManageRegulations = Boolean(
user?.auth?.is_staff || user?.auth?.is_superuser,
);
const {
unreadCategoryCounts,
unreadRegulationDepartmentCounts,
@ -451,6 +456,11 @@ function DocumentsPageContent() {
}, [activeSection, documents, search, selectedTags, dateFrom, dateTo, regulationSource, sortBy, sortOrder]);
const selection = useDocumentSelection(filteredDocuments);
const canEditSelectedDocuments = canManageRegulations || !selection.selectedIds.some(
(documentId) => documents.some(
(documentItem) => documentItem.id === documentId && documentItem.is_regulation,
),
);
// Find selected folder and build breadcrumb path
const selectedFolder = useMemo(() => {
@ -683,6 +693,7 @@ function DocumentsPageContent() {
};
const openDocumentMetadataFromMenu = (documentItem: Document) => {
if (documentItem.is_regulation && !canManageRegulations) return;
setMetadataDocument(documentItem);
setShowMetadataEditor(true);
setDocumentMenuOpenId(null);
@ -846,13 +857,15 @@ function DocumentsPageContent() {
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">
{activeSection === "regulations" ? "Регламенты" : "Документы"}
</p>
<button
onClick={() => setShowUploadForm(true)}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} />
{activeSection === "regulations" ? "Создать регламент" : "Загрузить документ"}
</button>
{(activeSection !== "regulations" || canManageRegulations) ? (
<button
onClick={() => setShowUploadForm(true)}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} />
{activeSection === "regulations" ? "Создать регламент" : "Загрузить документ"}
</button>
) : null}
</div>
<div className="mb-4 flex items-center gap-2">
@ -1316,8 +1329,8 @@ function DocumentsPageContent() {
selectedIds={selection.selectedIds}
availableFolders={bulkFolderOptions}
availableTags={bulkTagOptions}
onMove={handleBulkMoveDocuments}
onAddTags={handleBulkAddTags}
onMove={canEditSelectedDocuments ? handleBulkMoveDocuments : undefined}
onAddTags={canEditSelectedDocuments ? handleBulkAddTags : undefined}
onDownload={handleBulkDownloadDocuments}
onDelete={handleBulkDeleteDocuments}
onClearSelection={disableSelectionMode}
@ -1840,25 +1853,29 @@ function DocumentsPageContent() {
Детали
</button>
<button
type="button"
role="menuitem"
onClick={() => openDocumentMetadataFromMenu(doc)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} />
Редактировать
</button>
{(!doc.is_regulation || canManageRegulations) ? (
<>
<button
type="button"
role="menuitem"
onClick={() => openDocumentMetadataFromMenu(doc)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} />
Редактировать
</button>
<button
type="button"
role="menuitem"
onClick={() => openDocumentMetadataFromMenu(doc)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<FolderOpen size={14} />
Переместить
</button>
<button
type="button"
role="menuitem"
onClick={() => openDocumentMetadataFromMenu(doc)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<FolderOpen size={14} />
Переместить
</button>
</>
) : null}
{hasPreview && (
<button
@ -2085,18 +2102,17 @@ function DocumentsPageContent() {
<FileText size={16} />
</button>
<button
type="button"
onClick={() => {
setMetadataDocument(doc);
setShowMetadataEditor(true);
}}
title="Редактировать"
aria-label={`Редактировать документ ${doc.title}`}
className="app-action-secondary inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg"
>
<Pencil size={16} />
</button>
{(!doc.is_regulation || canManageRegulations) ? (
<button
type="button"
onClick={() => openDocumentMetadataFromMenu(doc)}
title="Редактировать"
aria-label={`Редактировать документ ${doc.title}`}
className="app-action-secondary inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg"
>
<Pencil size={16} />
</button>
) : null}
</div>
</div>
</div>
@ -2135,7 +2151,7 @@ function DocumentsPageContent() {
{/* Regulation Modal */}
<Modal
isOpen={showUploadForm && activeSection === "regulations"}
isOpen={showUploadForm && activeSection === "regulations" && canManageRegulations}
onClose={() => setShowUploadForm(false)}
title="Создать регламент"
size="xl"
@ -2162,12 +2178,14 @@ function DocumentsPageContent() {
apiClient.getDocument(selectedDocument.id).then(setSelectedDocument);
}
}}
onEditMetadata={() => {
if (selectedDocument) {
setMetadataDocument(selectedDocument);
setShowMetadataEditor(true);
}
}}
onEditMetadata={selectedDocument?.is_regulation && !canManageRegulations
? undefined
: () => {
if (selectedDocument) {
setMetadataDocument(selectedDocument);
setShowMetadataEditor(true);
}
}}
onViewReport={() => {
if (selectedDocument) {
setShowAcknowledgementsReport({
@ -2373,6 +2391,7 @@ function DocumentsPageContent() {
setMetadataDocument(null);
}}
document={metadataDocument}
canManageRegulations={canManageRegulations}
onUpdate={() => {
loadDocuments();
loadFolders();

View File

@ -191,6 +191,7 @@ function HomePageContent() {
};
const canDeleteAnyComments = Boolean(auth?.is_staff || auth?.is_superuser);
const canManageRegulations = Boolean(auth?.is_staff || auth?.is_superuser);
const canCreateCompanyPost = Boolean(
auth?.is_staff ||
auth?.is_superuser ||
@ -792,6 +793,7 @@ function HomePageContent() {
};
const openCreateRegulationModal = () => {
if (!canManageRegulations) return;
setCreateMenuOpen(false);
setCreateRegulationOpen(true);
};
@ -965,9 +967,9 @@ function HomePageContent() {
isAcknowledging={acknowledgingRegulationId === entry.document.id}
onAcknowledge={(document) => void handleAcknowledgeRegulation(document)}
onDelete={(document) => void deleteRegulation(document)}
onEdit={setMetadataRegulation}
onEdit={canManageRegulations ? setMetadataRegulation : undefined}
onLinkTask={setTaskLinkRegulation}
onMove={setMetadataRegulation}
onMove={canManageRegulations ? setMetadataRegulation : undefined}
onOpen={openRegulation}
onOpenComments={openRegulationComments}
onPreview={openRegulationPreview}
@ -1138,7 +1140,7 @@ function HomePageContent() {
)}
</div>
{user && !createPostOpen && !createRegulationOpen ? (
{user && (canCreatePost || canManageRegulations) && !createPostOpen && !createRegulationOpen ? (
<div
ref={createMenuRef}
className="pointer-events-none fixed bottom-[calc(env(safe-area-inset-bottom)+5.5rem)] right-4 z-30 flex flex-col items-end gap-2 lg:bottom-6 lg:right-[max(2rem,calc((100vw-72rem)/2+21.5rem))]"
@ -1156,15 +1158,17 @@ function HomePageContent() {
Новость
</button>
) : null}
<button
type="button"
role="menuitem"
onClick={openCreateRegulationModal}
className="app-action-primary pointer-events-auto inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm font-medium shadow-[var(--shadow-card)]"
>
<ScrollText size={17} />
Регламент
</button>
{canManageRegulations ? (
<button
type="button"
role="menuitem"
onClick={openCreateRegulationModal}
className="app-action-primary pointer-events-auto inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm font-medium shadow-[var(--shadow-card)]"
>
<ScrollText size={17} />
Регламент
</button>
) : null}
</div>
) : null}
<button
@ -1182,7 +1186,7 @@ function HomePageContent() {
) : null}
<Modal
isOpen={createRegulationOpen}
isOpen={createRegulationOpen && canManageRegulations}
onClose={() => setCreateRegulationOpen(false)}
title="Создать регламент"
size="xl"
@ -1322,9 +1326,11 @@ function HomePageContent() {
void apiClient.getDocument(selectedRegulation.id).then(setSelectedRegulation);
}
}}
onEditMetadata={() => {
if (selectedRegulation) setMetadataRegulation(selectedRegulation);
}}
onEditMetadata={canManageRegulations
? () => {
if (selectedRegulation) setMetadataRegulation(selectedRegulation);
}
: undefined}
onViewReport={() => {
if (!selectedRegulation) return;
setAcknowledgementsReport({
@ -1338,10 +1344,11 @@ function HomePageContent() {
}}
/>
{metadataRegulation ? (
{metadataRegulation && canManageRegulations ? (
<DocumentMetadataEditor
isOpen
document={metadataRegulation}
canManageRegulations
onClose={() => setMetadataRegulation(null)}
onUpdate={() => {
void refreshRegulations();

View File

@ -104,6 +104,17 @@ const getReceivedProgressClass = (received: number, total: number) => {
return received >= total ? "app-feedback-success" : "app-feedback-warning";
};
const canConfirmAllReceived = (request: ProcurementRequest, isAuthor: boolean) => {
const status = String(request.status || "").toLowerCase();
const itemsCount = Number(request.items_total_count ?? request.items_count ?? request.items?.length ?? 0);
return Boolean(
isAuthor &&
itemsCount > 0 &&
request.fulfillment_status !== "completed" &&
["waiting", "in_progress"].includes(status)
);
};
const urgencyMeta: Record<string, { label: string; cls: string }> = {
low: { label: "Низкая", cls: "text-gray-500" },
medium: { label: "Средняя", cls: "text-sky-600" },
@ -198,6 +209,7 @@ function ProcurementRequestActionButtons({
onReject,
onStart,
onComplete,
onMarkAllReceived,
onNotifyArrival,
onCancel,
}: {
@ -218,6 +230,7 @@ function ProcurementRequestActionButtons({
onReject: (id: number) => void | Promise<unknown>;
onStart: (id: number) => void | Promise<unknown>;
onComplete: (id: number) => void | Promise<unknown>;
onMarkAllReceived: (id: number) => void | Promise<unknown>;
onNotifyArrival: (id: number) => void | Promise<unknown>;
onCancel: (id: number) => void | Promise<unknown>;
}) {
@ -230,7 +243,8 @@ function ProcurementRequestActionButtons({
request.can_current_user_process_items &&
(status === "waiting" || status === "in_progress")
);
const startWorkLabel = isInProgress ? "Забрать в работу" : "Взять в работу";
const canConfirmRequestReceived = canConfirmAllReceived(request, isAuthor);
const startWorkLabel = request.executor ? "Забрать в работу" : "Взять в работу";
const canApproveThis = Boolean(request.can_current_user_approve);
const buttonClass = (variantClass: string) =>
`${variantClass} inline-flex h-9 items-center justify-center rounded-lg disabled:opacity-60 ${
@ -312,16 +326,20 @@ function ProcurementRequestActionButtons({
{label(startWorkLabel)}
</button>
) : null}
{isInProgress && isExecutor ? (
{(isInProgress && isExecutor) || canConfirmRequestReceived ? (
<button
type="button"
onClick={() => onComplete(request.id)}
disabled={busyKey === `complete-${request.id}`}
title="Закрыть заявку"
onClick={() => (
canConfirmRequestReceived
? onMarkAllReceived(request.id)
: onComplete(request.id)
)}
disabled={busyKey === `${canConfirmRequestReceived ? "mark-all" : "complete"}-${request.id}`}
title={canConfirmRequestReceived ? "Подтвердить получение всей заявки" : "Закрыть заявку"}
className={buttonClass("app-action-success")}
>
<ClipboardCheck size={14} />
{label("Закрыть заявку")}
{label(canConfirmRequestReceived ? "Подтвердить получение" : "Закрыть заявку")}
</button>
) : null}
{canNotifyArrival ? (
@ -398,6 +416,7 @@ export default function ProcurementPage() {
handleToggleViewed,
handleReportItemIssue,
handleCancelItemIssue,
handleConfirmItemOrdered,
handleConfirmItemReceived,
handleCancelItemReceived,
handleUpdateItem,
@ -1139,11 +1158,13 @@ export default function ProcurementPage() {
const canEditThis = Boolean(isEditableStatus && isAuthor);
const canDeleteThis = Boolean(isEditableStatus && (isAuthor || canManage));
const canCancelThis = Boolean(isAuthor && !isFinal(st) && st !== "draft");
const canConfirmAllReceivedThis = canConfirmAllReceived(resolvedDetail, isAuthor);
const hasInlineRequestActions = Boolean(
canSubmitThis ||
canApproveThis ||
(resolvedDetail.can_current_user_start_work ?? req.can_current_user_start_work) ||
(st === "in_progress" && isExecutor)
(st === "in_progress" && isExecutor) ||
canConfirmAllReceivedThis
);
const expanded = expandedIds.has(req.id);
const comments = commentsMap[req.id] || [];
@ -1153,7 +1174,10 @@ export default function ProcurementPage() {
const hasUnreadNotifications = (unreadProcurementRequestCounts[req.id] || 0) > 0;
const rowExpanded = !isViewed && expanded;
const rowCommentsOpen = !isViewed && commentsOpen;
const showInlineRequestActions = !isViewed && hasInlineRequestActions;
const showInlineRequestActions = (
(!isViewed && hasInlineRequestActions) ||
canConfirmAllReceivedThis
);
const showRequestMenu = true;
const requestMenuOpen = showRequestMenu && procurementMenuOpenId === req.id;
const requestorName = displayUserName(req.requestor, req.requestor_name, req.requestor_email);
@ -1448,6 +1472,7 @@ export default function ProcurementPage() {
onReject={(id) => openRequestActionDialog("reject", id)}
onStart={handleStart}
onComplete={handleComplete}
onMarkAllReceived={handleMarkAllReceived}
onNotifyArrival={handleNotifyArrival}
onCancel={handleCancel}
/>
@ -1509,6 +1534,7 @@ export default function ProcurementPage() {
onUpdateItem={handleUpdateItem}
onReportItemIssue={handleReportItemIssue}
onCancelItemIssue={handleCancelItemIssue}
onConfirmItemOrdered={handleConfirmItemOrdered}
onConfirmItemReceived={handleConfirmItemReceived}
onCancelItemReceived={handleCancelItemReceived}
onMarkAllReceived={handleMarkAllReceived}
@ -1540,6 +1566,7 @@ export default function ProcurementPage() {
onReject={handleReject}
onStart={handleStart}
onComplete={handleComplete}
onMarkAllReceived={handleMarkAllReceived}
onNotifyArrival={handleNotifyArrival}
onCancel={handleCancel}
/>
@ -1590,6 +1617,7 @@ export default function ProcurementPage() {
onReject={(id) => openRequestActionDialog("reject", id)}
onStart={handleStart}
onComplete={handleComplete}
onMarkAllReceived={handleMarkAllReceived}
onNotifyArrival={handleNotifyArrival}
onCancel={(id) => openRequestActionDialog("cancel", id)}
/>
@ -1689,6 +1717,7 @@ export default function ProcurementPage() {
onUpdateItem={handleUpdateItem}
onReportItemIssue={handleReportItemIssue}
onCancelItemIssue={handleCancelItemIssue}
onConfirmItemOrdered={handleConfirmItemOrdered}
onConfirmItemReceived={handleConfirmItemReceived}
onCancelItemReceived={handleCancelItemReceived}
onMarkAllReceived={handleMarkAllReceived}

View File

@ -165,6 +165,7 @@ const attendanceIssueLabelMap: Record<string, string> = {
overtime: "Переработка",
technical: "Техническая проблема",
work_outside_personnel_schedule: "Работа вне графика",
approved_short_day_off: "Согласованный короткий отгул",
};
function formatAttendanceIssueLabel(label: string) {

View File

@ -26,6 +26,7 @@ import {
MessageSquare,
Link2,
ScrollText,
X,
} from "lucide-react";
import { DocumentAcknowledgeButton, DocumentAcknowledgement } from "./DocumentAcknowledgement";
import { DocumentComments } from "./DocumentComments";
@ -154,7 +155,7 @@ export function DocumentDetailModal({
isOpen={isOpen}
onClose={onClose}
size="full"
showCloseButton
noHeader
noPadding
className="relative flex h-[95vh] flex-col sm:h-[90vh]"
>
@ -200,6 +201,15 @@ export function DocumentDetailModal({
</button>
</>
)}
<button
type="button"
onClick={onClose}
className="app-icon-button rounded-full p-1.5"
title="Закрыть"
aria-label="Закрыть"
>
<X size={20} />
</button>
</div>
</div>
@ -556,6 +566,7 @@ export function DocumentDetailModal({
<div className="app-surface rounded-xl p-6">
<DocumentRelated
documentId={document.id}
canEdit={Boolean(onEditMetadata)}
onNavigate={(docId) => onNavigateToRelated?.(docId)}
/>
</div>
@ -831,6 +842,7 @@ export function DocumentDetailModal({
<div className="app-surface rounded-xl p-4 sm:p-6">
<DocumentRelated
documentId={document.id}
canEdit={Boolean(onEditMetadata)}
onNavigate={(docId) => onNavigateToRelated?.(docId)}
/>
</div>

View File

@ -55,6 +55,7 @@ interface DocumentMetadataEditorProps {
isOpen: boolean;
onClose: () => void;
document: Document;
canManageRegulations?: boolean;
onUpdate?: () => void;
}
@ -108,6 +109,7 @@ export function DocumentMetadataEditor({
isOpen,
onClose,
document,
canManageRegulations = false,
onUpdate,
}: DocumentMetadataEditorProps) {
const [title, setTitle] = useState(document.title || '');
@ -556,25 +558,27 @@ export function DocumentMetadataEditor({
<p className="app-text-muted mt-1 text-xs">Выбрано: {selectedTags.length}</p>
</div>
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
<input
type="checkbox"
id="metadataIsRegulation"
checked={isRegulation}
onChange={(event) => setIsRegulation(event.target.checked)}
disabled={isSaving}
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)] disabled:opacity-50"
/>
<div className="flex-1">
<label htmlFor="metadataIsRegulation" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
<ScrollText className="mr-1 inline h-4 w-4" />
Регламент
</label>
<p className="app-text-muted mt-0.5 text-xs">
Документ будет отображаться в отдельном разделе регламентов.
</p>
{canManageRegulations ? (
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
<input
type="checkbox"
id="metadataIsRegulation"
checked={isRegulation}
onChange={(event) => setIsRegulation(event.target.checked)}
disabled={isSaving}
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)] disabled:opacity-50"
/>
<div className="flex-1">
<label htmlFor="metadataIsRegulation" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
<ScrollText className="mr-1 inline h-4 w-4" />
Регламент
</label>
<p className="app-text-muted mt-0.5 text-xs">
Документ будет отображаться в отдельном разделе регламентов.
</p>
</div>
</div>
</div>
) : null}
</section>
<section className="space-y-6">

View File

@ -14,10 +14,11 @@ interface RelatedDocument {
interface DocumentRelatedProps {
documentId: number;
canEdit?: boolean;
onNavigate?: (documentId: number) => void;
}
export function DocumentRelated({ documentId, onNavigate }: DocumentRelatedProps) {
export function DocumentRelated({ documentId, canEdit = true, onNavigate }: DocumentRelatedProps) {
const [relatedDocs, setRelatedDocs] = useState<RelatedDocument[]>([]);
const [loading, setLoading] = useState(true);
const [showAddDialog, setShowAddDialog] = useState(false);
@ -121,13 +122,15 @@ export function DocumentRelated({ documentId, onNavigate }: DocumentRelatedProps
Связанные документы ({relatedDocs.length})
</h3>
</div>
<button
onClick={() => setShowAddDialog(true)}
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium"
>
<Plus size={14} />
Добавить
</button>
{canEdit ? (
<button
onClick={() => setShowAddDialog(true)}
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium"
>
<Plus size={14} />
Добавить
</button>
) : null}
</div>
{/* Add Dialog */}
@ -230,13 +233,15 @@ export function DocumentRelated({ documentId, onNavigate }: DocumentRelatedProps
</span>
</div>
</div>
<button
onClick={() => handleRemove(doc.id)}
className="app-action-danger ml-2 rounded p-1"
title="Удалить связь"
>
<X size={16} />
</button>
{canEdit ? (
<button
onClick={() => handleRemove(doc.id)}
className="app-action-danger ml-2 rounded p-1"
title="Удалить связь"
>
<X size={16} />
</button>
) : null}
</div>
))}
</div>

View File

@ -31,9 +31,9 @@ type FeedRegulationCardProps = {
isAcknowledging?: boolean;
onAcknowledge: (document: Document) => void;
onDelete: (document: Document) => void;
onEdit: (document: Document) => void;
onEdit?: (document: Document) => void;
onLinkTask: (document: Document) => void;
onMove: (document: Document) => void;
onMove?: (document: Document) => void;
onOpen: (document: Document) => void;
onOpenAcknowledgements: (document: Document) => void;
onOpenComments: (document: Document) => void;
@ -169,28 +169,32 @@ export function FeedRegulationCard({
<FileText size={14} />
Детали
</button>
<button
type="button"
onClick={() => {
setMenuOpen(false);
onEdit(document);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} />
Редактировать
</button>
<button
type="button"
onClick={() => {
setMenuOpen(false);
onMove(document);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<FolderOpen size={14} />
Переместить
</button>
{onEdit ? (
<button
type="button"
onClick={() => {
setMenuOpen(false);
onEdit(document);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} />
Редактировать
</button>
) : null}
{onMove ? (
<button
type="button"
onClick={() => {
setMenuOpen(false);
onMove(document);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<FolderOpen size={14} />
Переместить
</button>
) : null}
{hasPreview ? (
<button
type="button"

View File

@ -2,7 +2,7 @@
import { useState } from "react";
import Link from "next/link";
import { AlertTriangle, Check, CheckCircle2, CircleDot, Download, ExternalLink, MessageSquare, Minus, Paperclip, Plus, RotateCcw, Save, SlidersHorizontal, Trash2, X } from "lucide-react";
import { AlertTriangle, Check, CheckCircle2, ChevronRight, CircleDot, Download, ExternalLink, MessageSquare, Minus, Paperclip, Plus, RotateCcw, Save, ShoppingCart, SlidersHorizontal, Trash2, X } from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { ProcurementRequestHistory } from "@/components/procurement/ProcurementRequestHistory";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
@ -28,6 +28,7 @@ interface ProcurementRequestDetailContentProps {
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise<unknown>;
onCancelItemIssue?: (requestId: number, itemId: number) => void | Promise<unknown>;
onConfirmItemOrdered?: (requestId: number, itemId: number, orderedQuantity: number, expectedDeliveryDate?: string) => void | Promise<unknown>;
onConfirmItemReceived?: (requestId: number, itemId: number, receivedQuantity?: number) => void | Promise<unknown>;
onCancelItemReceived?: (requestId: number, itemId: number, cancelQuantity?: number) => void | Promise<unknown>;
onMarkAllReceived?: (requestId: number) => void | Promise<unknown>;
@ -165,6 +166,7 @@ interface ProcurementItemCardProps {
canEditItemProcessing: boolean;
canReportIssue: boolean;
canCancelIssue: boolean;
canConfirmOrder: boolean;
canConfirmReceipt: boolean;
canCancelReceipt: boolean;
currentUserId?: number | null;
@ -173,6 +175,7 @@ interface ProcurementItemCardProps {
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise<unknown>;
onCancelItemIssue?: (requestId: number, itemId: number) => void | Promise<unknown>;
onConfirmItemOrdered?: (requestId: number, itemId: number, orderedQuantity: number, expectedDeliveryDate?: string) => void | Promise<unknown>;
onConfirmItemReceived?: (requestId: number, itemId: number, receivedQuantity?: number) => void | Promise<unknown>;
onCancelItemReceived?: (requestId: number, itemId: number, cancelQuantity?: number) => void | Promise<unknown>;
comments?: ProcurementItemComment[];
@ -194,6 +197,7 @@ function ProcurementItemCard({
canEditItemProcessing,
canReportIssue,
canCancelIssue,
canConfirmOrder,
canConfirmReceipt,
canCancelReceipt,
currentUserId,
@ -202,6 +206,7 @@ function ProcurementItemCard({
onUpdateItem,
onReportItemIssue,
onCancelItemIssue,
onConfirmItemOrdered,
onConfirmItemReceived,
onCancelItemReceived,
comments = [],
@ -216,7 +221,11 @@ function ProcurementItemCard({
onDeleteAttachment,
}: ProcurementItemCardProps) {
const [draft, setDraft] = useState<ItemProcessingDraft>(() => normalizeItemDraft(item));
const [detailsOpen, setDetailsOpen] = useState(false);
const [processingOpen, setProcessingOpen] = useState(false);
const [orderModalOpen, setOrderModalOpen] = useState(false);
const [orderQuantity, setOrderQuantity] = useState("1");
const [orderDeliveryDate, setOrderDeliveryDate] = useState("");
const [receiptModalOpen, setReceiptModalOpen] = useState(false);
const [receiptQuantity, setReceiptQuantity] = useState("1");
const [cancelReceiptModalOpen, setCancelReceiptModalOpen] = useState(false);
@ -228,11 +237,12 @@ function ProcurementItemCard({
const requestedQuantity = Math.max(0, Math.trunc(toNumber(item.quantity)));
const orderedQuantity = effectiveOrderedQuantity(item, requestedQuantity);
const receivedQuantity = effectiveReceivedQuantity(item, requestedQuantity);
const remainingQuantity = Math.max(0, requestedQuantity - receivedQuantity);
const fulfillmentTargetQuantity = Math.max(requestedQuantity, orderedQuantity);
const remainingQuantity = Math.max(0, fulfillmentTargetQuantity - receivedQuantity);
const remainingToOrderQuantity = Math.max(0, requestedQuantity - orderedQuantity);
const itemUnitLabel = getProcurementUnitLabel(item.unit);
const orderedQuantityMarkerClass = quantityProgressMarkerClass(orderedQuantity, requestedQuantity);
const receivedQuantityMarkerClass = quantityProgressMarkerClass(receivedQuantity, requestedQuantity);
const isItemFullyReceived = requestedQuantity > 0 && receivedQuantity >= requestedQuantity;
const isItemFullyReceived = fulfillmentTargetQuantity > 0 && receivedQuantity >= fulfillmentTargetQuantity;
const status = item.execution_status || "pending";
const statusLabel = item.execution_status_display || executionStatusOptions.find((option) => option.value === status)?.label || "Не выполнено";
const canCancelReceived = Boolean(
@ -240,12 +250,18 @@ function ProcurementItemCard({
onCancelItemReceived &&
receivedQuantity > 0
);
const canConfirmOrdered = Boolean(
canConfirmOrder &&
onConfirmItemOrdered &&
requestedQuantity > 0 &&
status !== "rejected" &&
status !== "defective"
);
const canConfirmReceived = Boolean(
canConfirmReceipt &&
onConfirmItemReceived &&
requestedQuantity > 0 &&
(status === "ordered" || receivedQuantity > 0) &&
receivedQuantity < requestedQuantity &&
status !== "rejected" &&
status !== "defective"
);
@ -268,7 +284,7 @@ function ProcurementItemCard({
const adjustDraftQuantity = (field: "ordered_quantity" | "received_quantity", delta: number) => {
setDraft((previous) => {
const current = toOptionalInteger(previous[field]) ?? 0;
const next = Math.min(requestedQuantity, Math.max(0, current + delta));
const next = Math.max(0, current + delta);
return { ...previous, [field]: String(next) };
});
};
@ -276,10 +292,23 @@ function ProcurementItemCard({
const adjustReceiptQuantity = (delta: number) => {
setReceiptQuantity((current) => {
const parsed = toOptionalInteger(current) ?? 1;
return String(Math.min(remainingQuantity, Math.max(1, parsed + delta)));
return String(Math.max(1, parsed + delta));
});
};
const adjustOrderQuantity = (delta: number) => {
setOrderQuantity((current) => {
const parsed = toOptionalInteger(current) ?? 1;
return String(Math.max(1, parsed + delta));
});
};
const openOrderModal = () => {
setOrderQuantity(String(Math.max(1, remainingToOrderQuantity)));
setOrderDeliveryDate("");
setOrderModalOpen(true);
};
const adjustCancelReceiptQuantity = (delta: number) => {
setCancelReceiptQuantity((current) => {
const parsed = toOptionalInteger(current) ?? 1;
@ -288,7 +317,7 @@ function ProcurementItemCard({
};
const openReceiptModal = () => {
setReceiptQuantity(String(remainingQuantity));
setReceiptQuantity(String(Math.max(1, remainingQuantity)));
setReceiptModalOpen(true);
};
@ -298,13 +327,13 @@ function ProcurementItemCard({
};
const confirmReceipt = (quantityReceivedNow: number) => {
const normalizedQuantity = Math.min(remainingQuantity, Math.max(1, Math.trunc(quantityReceivedNow)));
const normalizedQuantity = Math.max(1, Math.trunc(quantityReceivedNow));
const nextReceivedQuantity = receivedQuantity + normalizedQuantity;
return onConfirmItemReceived?.(requestId, item.id, nextReceivedQuantity);
};
const handleConfirmReceiptClick = () => {
if (remainingQuantity > 1) {
if (remainingQuantity !== 1) {
openReceiptModal();
return;
}
@ -328,7 +357,7 @@ function ProcurementItemCard({
const submitReceiptModal = async () => {
const parsedQuantity = toOptionalInteger(receiptQuantity);
if (!parsedQuantity || parsedQuantity < 1 || parsedQuantity > remainingQuantity) return;
if (!parsedQuantity || parsedQuantity < 1) return;
const result = await confirmReceipt(parsedQuantity);
if (result !== false) {
@ -336,6 +365,21 @@ function ProcurementItemCard({
}
};
const submitOrderModal = async () => {
const parsedQuantity = toOptionalInteger(orderQuantity);
if (!parsedQuantity || parsedQuantity < 1) return;
const result = await onConfirmItemOrdered?.(
requestId,
item.id,
orderedQuantity + parsedQuantity,
orderDeliveryDate || undefined,
);
if (result !== false) {
setOrderModalOpen(false);
}
};
const submitCancelReceiptModal = async () => {
const parsedQuantity = toOptionalInteger(cancelReceiptQuantity);
if (!parsedQuantity || parsedQuantity < 1 || parsedQuantity > receivedQuantity) return;
@ -359,21 +403,55 @@ function ProcurementItemCard({
return (
<div className="app-surface-muted rounded-lg px-3 py-3 text-xs">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-2">
<button
type="button"
onClick={() => setDetailsOpen((open) => !open)}
aria-expanded={detailsOpen}
aria-controls={`procurement-item-${item.id}-details`}
className="app-icon-button inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
title={detailsOpen ? "Свернуть позицию" : "Раскрыть позицию"}
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${detailsOpen ? "rotate-90" : ""}`}
/>
</button>
<div className="min-w-0 flex-1">
<p className="app-text-wrap font-semibold text-[var(--foreground)]">{item.name}</p>
{item.description ? (
<button
type="button"
onClick={() => setDetailsOpen((open) => !open)}
className="block w-full text-left"
>
<span className="app-text-wrap block font-semibold text-[var(--foreground)]">{item.name}</span>
</button>
{!detailsOpen ? (
<div className="app-text-muted mt-1 flex min-w-0 items-center gap-1.5 text-[11px]">
<span className="shrink-0">{item.quantity} {itemUnitLabel} ·</span>
{orderedQuantityMarkerClass ? (
<span aria-hidden="true" className={`h-2 w-2 shrink-0 rounded-full ${orderedQuantityMarkerClass}`} />
) : null}
<span className="min-w-0 truncate">
Заказано {orderedQuantity}/{requestedQuantity} · Получено {receivedQuantity}/{requestedQuantity}
</span>
</div>
) : null}
{detailsOpen && item.description ? (
<p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">{item.description}</p>
) : null}
{item.supplier_info ? (
{detailsOpen && item.supplier_info ? (
<p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">
Поставщик: {item.supplier_info}
</p>
) : null}
</div>
<span className={`app-status-pill ${executionStatusBadgeClass(status)}`}>{statusLabel}</span>
<span className={`app-status-pill shrink-0 ${executionStatusBadgeClass(status)}`}>{statusLabel}</span>
</div>
<div
id={`procurement-item-${item.id}-details`}
className={detailsOpen ? "" : "hidden"}
>
<div className="mt-3 grid gap-x-6 gap-y-3 lg:grid-cols-[minmax(0,1fr)_minmax(190px,0.55fr)]">
<div className="min-w-0">
<p className="app-text-muted mb-1 text-[11px] font-semibold uppercase tracking-wide">Исполнение</p>
@ -409,11 +487,8 @@ function ProcurementItemCard({
</div>
<div className="min-w-0">
<p className="app-text-muted text-[11px]">Получено</p>
<p className="app-text-wrap mt-0.5 inline-flex items-center gap-1.5 font-medium text-[var(--foreground)]">
{receivedQuantityMarkerClass ? (
<span aria-hidden="true" className={`h-2 w-2 shrink-0 rounded-full ${receivedQuantityMarkerClass}`} />
) : null}
<span>{receivedQuantity}/{requestedQuantity} {itemUnitLabel}</span>
<p className="app-text-wrap mt-0.5 font-medium text-[var(--foreground)]">
{receivedQuantity}/{requestedQuantity} {itemUnitLabel}
</p>
</div>
</div>
@ -550,6 +625,18 @@ function ProcurementItemCard({
</button>
) : null}
{canConfirmOrdered ? (
<button
type="button"
onClick={openOrderModal}
disabled={busyKey === `item-confirm-ordered-${item.id}`}
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
>
<ShoppingCart size={13} className="text-sky-500" />
<span className="text-sky-500">Подтвердить заказ</span>
</button>
) : null}
{canCancelReceived ? (
<button
type="button"
@ -717,15 +804,14 @@ function ProcurementItemCard({
<input
type="number"
min={0}
max={requestedQuantity}
value={draft.ordered_quantity}
onChange={(event) => {
const parsed = toOptionalInteger(event.target.value);
updateDraft({
ordered_quantity: parsed === null ? "" : String(Math.min(requestedQuantity, parsed)),
ordered_quantity: parsed === null ? "" : String(parsed),
});
}}
placeholder={`0-${requestedQuantity}`}
placeholder="0"
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-xs"
/>
<button
@ -750,15 +836,14 @@ function ProcurementItemCard({
<input
type="number"
min={0}
max={requestedQuantity}
value={draft.received_quantity}
onChange={(event) => {
const parsed = toOptionalInteger(event.target.value);
updateDraft({
received_quantity: parsed === null ? "" : String(Math.min(requestedQuantity, parsed)),
received_quantity: parsed === null ? "" : String(parsed),
});
}}
placeholder={`0-${requestedQuantity}`}
placeholder="0"
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-xs"
/>
<button
@ -820,6 +905,91 @@ function ProcurementItemCard({
</div>
</div>
) : null}
</div>
{orderModalOpen ? (
<Modal
isOpen
onClose={() => setOrderModalOpen(false)}
title="Подтвердить заказ"
size="sm"
footer={
<div className="flex gap-3">
<button
type="button"
onClick={() => setOrderModalOpen(false)}
disabled={busyKey === `item-confirm-ordered-${item.id}`}
className="app-action-secondary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={() => void submitOrderModal()}
disabled={
busyKey === `item-confirm-ordered-${item.id}` ||
!toOptionalInteger(orderQuantity) ||
Number(orderQuantity) < 1
}
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
Подтвердить
</button>
</div>
}
>
<div className="space-y-4">
<div>
<p className="app-text-wrap text-sm font-semibold text-[var(--foreground)]">{item.name}</p>
<p className="app-text-muted mt-1 text-sm">
Требуется {requestedQuantity} {itemUnitLabel}. Уже заказано {orderedQuantity} {itemUnitLabel}.
</p>
</div>
<label className="block">
<span className="app-text-muted mb-2 block text-sm">Заказано сейчас, {itemUnitLabel}</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => adjustOrderQuantity(-1)}
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
disabled={busyKey === `item-confirm-ordered-${item.id}`}
>
<Minus size={14} />
</button>
<input
type="number"
min={1}
value={orderQuantity}
onChange={(event) => {
const parsed = toOptionalInteger(event.target.value);
setOrderQuantity(parsed === null ? "" : String(parsed));
}}
className="app-input min-w-0 flex-1 rounded-lg px-4 py-2.5 text-sm"
autoFocus
/>
<button
type="button"
onClick={() => adjustOrderQuantity(1)}
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
disabled={busyKey === `item-confirm-ordered-${item.id}`}
>
<Plus size={14} />
</button>
</div>
</label>
<label className="block">
<span className="app-text-muted mb-2 block text-sm">Ожидаемая дата (необязательно)</span>
<input
type="date"
value={orderDeliveryDate}
onChange={(event) => setOrderDeliveryDate(event.target.value)}
disabled={busyKey === `item-confirm-ordered-${item.id}`}
className="app-input w-full rounded-lg px-4 py-2.5 text-sm disabled:opacity-60"
/>
</label>
</div>
</Modal>
) : null}
{receiptModalOpen ? (
<Modal
@ -843,8 +1013,7 @@ function ProcurementItemCard({
disabled={
busyKey === `item-confirm-received-${item.id}` ||
!toOptionalInteger(receiptQuantity) ||
Number(receiptQuantity) < 1 ||
Number(receiptQuantity) > remainingQuantity
Number(receiptQuantity) < 1
}
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
@ -857,7 +1026,8 @@ function ProcurementItemCard({
<div>
<p className="app-text-wrap text-sm font-semibold text-[var(--foreground)]">{item.name}</p>
<p className="app-text-muted mt-1 text-sm">
Получено {receivedQuantity}/{requestedQuantity} {itemUnitLabel}. Осталось {remainingQuantity} {itemUnitLabel}.
Заказано {orderedQuantity} {itemUnitLabel}. Получено {receivedQuantity} {itemUnitLabel}.
{remainingQuantity > 0 ? ` Осталось по текущему заказу: ${remainingQuantity} ${itemUnitLabel}.` : ""}
</p>
</div>
<label className="block">
@ -874,11 +1044,10 @@ function ProcurementItemCard({
<input
type="number"
min={1}
max={remainingQuantity}
value={receiptQuantity}
onChange={(event) => {
const parsed = toOptionalInteger(event.target.value);
setReceiptQuantity(parsed === null ? "" : String(Math.min(remainingQuantity, parsed)));
setReceiptQuantity(parsed === null ? "" : String(parsed));
}}
className="app-input min-w-0 flex-1 rounded-lg px-4 py-2.5 text-sm"
autoFocus
@ -986,6 +1155,7 @@ export function ProcurementRequestDetailContent({
onUpdateItem,
onReportItemIssue,
onCancelItemIssue,
onConfirmItemOrdered,
onConfirmItemReceived,
onCancelItemReceived,
onMarkAllReceived,
@ -1002,8 +1172,14 @@ export function ProcurementRequestDetailContent({
footer,
}: ProcurementRequestDetailContentProps) {
const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem);
const isRequestPending = String(request.status || "").toLowerCase() === "pending";
const canMarkAllReceived = canEditItemProcessing && ["waiting", "in_progress"].includes(String(request.status || "").toLowerCase());
const requestStatus = String(request.status || "").toLowerCase();
const isRequestPending = requestStatus === "pending";
const canMarkAllReceived = canEditItemProcessing && ["waiting", "in_progress"].includes(requestStatus);
const canConfirmOrder = Boolean(
canEditItemProcessing &&
onConfirmItemOrdered &&
["approved", "waiting", "in_progress", "completed"].includes(requestStatus)
);
const displayItems = request.items ? [...request.items].reverse() : [];
const requestorId = typeof request.requestor === "number" ? request.requestor : request.requestor?.id ?? null;
const executorId = typeof request.executor === "number" ? request.executor : request.executor?.id ?? null;
@ -1100,6 +1276,7 @@ export function ProcurementRequestDetailContent({
canEditItemProcessing={canEditItemProcessing}
canReportIssue={canReportIssues}
canCancelIssue={canCancelIssues}
canConfirmOrder={canConfirmOrder}
canConfirmReceipt={canConfirmReceipt}
canCancelReceipt={canCancelReceipt}
currentUserId={currentUserId}
@ -1108,6 +1285,7 @@ export function ProcurementRequestDetailContent({
onUpdateItem={onUpdateItem}
onReportItemIssue={onReportItemIssue}
onCancelItemIssue={onCancelItemIssue}
onConfirmItemOrdered={onConfirmItemOrdered}
onConfirmItemReceived={onConfirmItemReceived}
onCancelItemReceived={onCancelItemReceived}
comments={itemCommentsMap[item.id] || []}

View File

@ -160,13 +160,15 @@ export function RequestComposeModal({
type: typeof value === "string" ? value : "",
date_from: "",
date_to: "",
time_from: "",
time_to: "",
}))}
/>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="app-text-muted block text-xs font-medium">
{dateMode === "range" ? "Период" : "Дата"}
{dateMode === "range" ? "Период" : dateMode === "timed" ? "Дата и время" : "Дата"}
{dateMode !== "optional" ? <span className="app-accent-text"> *</span> : null}
</span>
{dateMode === "optional" && !showOptionalDates && (
@ -174,7 +176,42 @@ export function RequestComposeModal({
)}
</div>
{dateMode === "range" ? (
{dateMode === "timed" ? (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_8rem_8rem]">
<label className="space-y-1">
<span className="app-text-muted block text-[11px]">Дата</span>
<input
type="date"
value={form.date_from}
onChange={(event) => setForm((prev) => ({
...prev,
date_from: event.target.value,
date_to: event.target.value,
}))}
className="app-input w-full rounded-lg px-4 py-3 text-sm"
/>
</label>
<label className="space-y-1">
<span className="app-text-muted block text-[11px]">С</span>
<input
type="time"
value={form.time_from}
onChange={(event) => setForm((prev) => ({ ...prev, time_from: event.target.value }))}
className="app-input w-full rounded-lg px-4 py-3 text-sm"
/>
</label>
<label className="space-y-1">
<span className="app-text-muted block text-[11px]">До</span>
<input
type="time"
value={form.time_to}
min={form.time_from || undefined}
onChange={(event) => setForm((prev) => ({ ...prev, time_to: event.target.value }))}
className="app-input w-full rounded-lg px-4 py-3 text-sm"
/>
</label>
</div>
) : dateMode === "range" ? (
<div className="grid gap-2 sm:grid-cols-2">
<input
type="date"

View File

@ -3,6 +3,7 @@
import { Modal } from "@/components/ui";
import {
defaultStatusMeta,
formatRequestPeriod,
requestTypeLabels,
statusMeta,
type RequestAttachmentPreview,
@ -280,10 +281,11 @@ export function RequestDetailModal({
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Даты</p>
<div className="mt-3 space-y-2">
<div>
<p className="app-text-muted text-xs">Период</p>
<p className="app-text-muted text-xs">
{String(request.type || request.request_type) === "short_day_off" ? "Время" : "Период"}
</p>
<p className="mt-1 text-sm text-[var(--foreground)]">
{request.date_from ? formatDate(request.date_from) : "—"}
{request.date_to ? ` — ${formatDate(request.date_to)}` : ""}
{formatRequestPeriod(request)}
</p>
</div>
<div>

View File

@ -1,6 +1,7 @@
import Link from "next/link";
import {
defaultStatusMeta,
formatRequestPeriod,
requestTypeLabels,
statusMeta,
type RequestAttachmentPreview,
@ -263,8 +264,7 @@ export function RequestListItem({
<div className="app-text-muted app-text-wrap mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>
Период: {request.date_from ? formatDate(request.date_from) : "—"}
{request.date_to ? ` — ${formatDate(request.date_to)}` : ""}
{typeKey === "short_day_off" ? "Время" : "Период"}: {formatRequestPeriod(request)}
</span>
</div>

View File

@ -106,9 +106,11 @@ export function RequestStatisticsPanel({
{ label: "Всего заявлений", value: stats.total_submitted_requests },
{ label: "Больничных", value: stats.sick_leave_requests_count },
{ label: "Отгулов", value: stats.day_off_requests_count },
{ label: "Коротких отгулов", value: stats.short_day_off_requests_count },
{ label: "Декретных", value: stats.maternity_requests_count },
{ label: "Дней на больничном", value: stats.sick_leave_days },
{ label: "Дней на отгулах", value: stats.day_off_days },
{ label: "Часов коротких отгулов", value: (stats.short_day_off_minutes / 60).toLocaleString("ru-RU", { maximumFractionDigits: 2 }) },
{ label: "Дней в декрете", value: stats.maternity_days },
{ label: "Оплачиваемый отпуск, дней", value: stats.paid_vacation_days },
{ label: "За свой счёт, дней", value: stats.unpaid_vacation_days },

View File

@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { defaultStatusMeta, requestTypeLabels, statusMeta } from "@/hooks/useRequestsPage";
import { defaultStatusMeta, formatRequestPeriod, requestTypeLabels, statusMeta } from "@/hooks/useRequestsPage";
import type { Request, User } from "@/types/api";
import { Check, ChevronUp, Flame, ThumbsDown, ThumbsUp, Undo2 } from "lucide-react";
@ -220,7 +220,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
{/* period */}
<div className="app-text-muted mb-3 flex flex-wrap items-center gap-3 text-xs">
<span>Период: {fmt(req.date_from)} — {fmt(req.date_to)}</span>
<span>{typeKey === "short_day_off" ? "Время" : "Период"}: {formatRequestPeriod(req)}</span>
<span>Создано: {fmt(req.created_at)}</span>
</div>

View File

@ -35,6 +35,28 @@ export function PwaProvider({ children }: { children: ReactNode }) {
});
}, []);
useEffect(() => {
if (!("serviceWorker" in navigator)) {
return;
}
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (event.data?.type !== "NOTIFICATION_CLICK" || typeof event.data.url !== "string") {
return;
}
const targetUrl = new URL(event.data.url, window.location.origin);
if (targetUrl.origin === window.location.origin) {
window.location.assign(targetUrl.toString());
}
};
navigator.serviceWorker.addEventListener("message", handleServiceWorkerMessage);
return () => {
navigator.serviceWorker.removeEventListener("message", handleServiceWorkerMessage);
};
}, []);
useEffect(() => {
const handleBeforeInstallPrompt = (event: Event) => {
const installEvent = event as BeforeInstallPromptEvent;

View File

@ -484,6 +484,9 @@ export function useProcurementPage(user: User | null) {
high: 3,
critical: 4,
};
const defaultSortTime = (request: ProcurementRequest) => (
new Date(request.last_defective_at || request.created_at).getTime() || 0
);
return [...requests].sort((left, right) => {
switch (ordering) {
@ -497,7 +500,7 @@ export function useProcurementPage(user: User | null) {
return (urgencyRank[right.urgency || "medium"] || 0) - (urgencyRank[left.urgency || "medium"] || 0);
case "-created_at":
default:
return (new Date(right.created_at).getTime() || 0) - (new Date(left.created_at).getTime() || 0);
return defaultSortTime(right) - defaultSortTime(left);
}
});
}, [ordering, requests]);
@ -980,6 +983,7 @@ export function useProcurementPage(user: User | null) {
updateCachedItem(requestId, itemId, (item) => ({
...item,
comments_count: (item.comments_count || 0) + 1,
links: created.item_links ?? item.links,
}));
await markRequestNotificationsRead(requestId);
} catch {
@ -1173,6 +1177,28 @@ export function useProcurementPage(user: User | null) {
}
}, [markRequestNotificationsRead, refreshAfterMutation]);
const handleConfirmItemOrdered = useCallback(async (
requestId: number,
itemId: number,
orderedQuantity: number,
expectedDeliveryDate?: string,
) => {
try {
setBusyKey(`item-confirm-ordered-${itemId}`);
setActionError(null);
await apiClient.confirmProcurementItemOrdered(itemId, orderedQuantity, expectedDeliveryDate);
await markRequestNotificationsRead(requestId);
toast.success("Заказ позиции подтверждён.");
await refreshAfterMutation(requestId);
return true;
} catch (confirmError) {
setActionError(getReadableError(confirmError, "Не удалось подтвердить заказ"));
return false;
} finally {
setBusyKey(null);
}
}, [markRequestNotificationsRead, refreshAfterMutation]);
const handleConfirmItemReceived = useCallback(async (requestId: number, itemId: number, receivedQuantity?: number) => {
try {
setBusyKey(`item-confirm-received-${itemId}`);
@ -1406,6 +1432,7 @@ export function useProcurementPage(user: User | null) {
handleUpdateItem,
handleReportItemIssue,
handleCancelItemIssue,
handleConfirmItemOrdered,
handleConfirmItemReceived,
handleCancelItemReceived,
handleCancel,

View File

@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "@/lib/api";
import type { Request, RequestComment, User, Department } from "@/types/api";
import { extractNextPage, loadAllPages, displayUserName } from "@/lib/shared";
import { extractNextPage, loadAllPages, displayUserName, formatDate } from "@/lib/shared";
/* ── constants ── */
export type RequestFormState = {
@ -11,6 +11,8 @@ export type RequestFormState = {
title: string;
date_from: string;
date_to: string;
time_from: string;
time_to: string;
comment: string;
recipient_ids: number[];
cc_user_ids: number[];
@ -27,6 +29,8 @@ export const createEmptyForm = (): RequestFormState => ({
title: "",
date_from: "",
date_to: "",
time_from: "",
time_to: "",
comment: "",
recipient_ids: [],
cc_user_ids: [],
@ -46,6 +50,7 @@ export const statusMeta: Record<string, { label: string; className: string }> =
export const defaultStatusMeta = { label: "Неизвестный статус", className: "app-badge" };
export const requestTypeLabels: Record<string, string> = {
short_day_off: "Короткий отгул",
vacation: "Отпуск",
sick_leave: "Больничный",
day_off: "Отгул",
@ -63,8 +68,10 @@ export const orderingOptions = [
{ value: "-date_from", label: "По периоду ↓" },
];
export function getRequestDateMode(type: string): "range" | "single" | "optional" {
export function getRequestDateMode(type: string): "range" | "single" | "timed" | "optional" {
switch (String(type || "").toLowerCase()) {
case "short_day_off":
return "timed";
case "vacation":
case "sick_leave":
case "day_off":
@ -78,6 +85,20 @@ export function getRequestDateMode(type: string): "range" | "single" | "optional
}
}
export function formatRequestPeriod(request: Pick<Request, "type" | "request_type" | "date_from" | "date_to" | "time_from" | "time_to">): string {
const type = String(request.type || request.request_type || "").toLowerCase();
if (type === "short_day_off") {
const date = request.date_from ? formatDate(request.date_from) : "—";
const timeFrom = request.time_from?.slice(0, 5) || "—";
const timeTo = request.time_to?.slice(0, 5) || "—";
return `${date}, ${timeFrom}–${timeTo}`;
}
const dateFrom = request.date_from ? formatDate(request.date_from) : "—";
return request.date_to
? `${dateFrom} — ${formatDate(request.date_to)}`
: dateFrom;
}
function getRequestSubmitError(form: RequestFormState): string | null {
if (form.recipient_ids.length === 0) {
return "Укажите хотя бы одного получателя.";
@ -97,6 +118,15 @@ function getRequestSubmitError(form: RequestFormState): string | null {
}
}
if (dateMode === "timed") {
if (!form.date_from || !form.time_from || !form.time_to) {
return "Укажите дату и время короткого отгула.";
}
if (form.time_to <= form.time_from) {
return "Время окончания должно быть позже времени начала.";
}
}
if (dateMode === "single" && !form.date_from) {
return "Укажите дату начала.";
}
@ -294,6 +324,8 @@ export function useRequestsPage(_userId: number | null | undefined) {
title: req.title || "",
date_from: req.date_from || "",
date_to: req.date_to || "",
time_from: req.time_from?.slice(0, 5) || "",
time_to: req.time_to?.slice(0, 5) || "",
comment: req.comment || req.description || "",
recipient_ids: (req.recipients || []).map((u: User) => u.id).filter(Boolean),
cc_user_ids: (req.cc_users || []).map((u: User) => u.id).filter(Boolean),
@ -350,6 +382,8 @@ export function useRequestsPage(_userId: number | null | undefined) {
title: form.title,
date_from: form.date_from || null,
date_to: form.date_to || null,
time_from: form.time_from || null,
time_to: form.time_to || null,
comment: form.comment,
};
if (form.type) payload.type = form.type;

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ProcurementApprovalOptions, ProcurementApprovalStepSelection, ProcurementCreateOptions, ProcurementItemAttachment, ProcurementRequestActivity } from '@/types/api';
import type { ProcurementApprovalOptions, ProcurementApprovalStepSelection, ProcurementCreateOptions, ProcurementItemAttachment, ProcurementItemComment, ProcurementRequestActivity } from '@/types/api';
import { buildQuery, type RawRequestFn, type RequestFn } from './utils';
function parseDownloadFilename(response: Response, fallback: string): string {
@ -56,6 +56,15 @@ export function createProcurementApi(request: RequestFn, requestRaw: RawRequestF
reportProcurementItemIssue: (id: number, text?: string) =>
request(`/api/v1/procurement/items/${id}/report_issue/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text || '' }) }),
cancelProcurementItemIssue: (id: number) => request(`/api/v1/procurement/items/${id}/cancel_issue/`, { method: 'POST' }),
confirmProcurementItemOrdered: (id: number, orderedQuantity: number, expectedDeliveryDate?: string) =>
request(`/api/v1/procurement/items/${id}/confirm_ordered/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ordered_quantity: orderedQuantity,
expected_delivery_date: expectedDeliveryDate || undefined,
}),
}),
confirmProcurementItemReceived: (id: number, receivedQuantity?: number) =>
request(`/api/v1/procurement/items/${id}/confirm_received/`, {
method: 'POST',
@ -70,7 +79,7 @@ export function createProcurementApi(request: RequestFn, requestRaw: RawRequestF
}),
deleteProcurementItem: (id: number): Promise<void> => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }),
getProcurementItemComments: (itemId: number) => request(`/api/v1/procurement/items/${itemId}/comments/`),
addProcurementItemComment: (itemId: number, text: string) =>
addProcurementItemComment: (itemId: number, text: string): Promise<ProcurementItemComment> =>
request(`/api/v1/procurement/items/${itemId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }),
deleteProcurementItemComment: (itemId: number, commentId: number): Promise<void> =>
request(`/api/v1/procurement/items/${itemId}/comments/${commentId}/`, { method: 'DELETE' }),

View File

@ -84,6 +84,7 @@ export const VERB_CATEGORIES: Record<string, string> = {
procurement_completed: 'Закупки',
procurement_cancelled: 'Закупки',
procurement_item_updated: 'Закупки',
procurement_item_issue_reported: 'Закупки',
procurement_request_commented: 'Закупки',
procurement_item_commented: 'Закупки',
equipment_transferred: 'Закупки',
@ -209,6 +210,7 @@ export const VERB_NAMES: Record<string, string> = {
procurement_completed: 'Заявка на закупку завершена',
procurement_cancelled: 'Заявка на закупку отменена',
procurement_item_updated: 'Позиция закупки изменена',
procurement_item_issue_reported: 'Обнаружен брак в закупке',
procurement_request_commented: 'Комментарий к закупке',
procurement_item_commented: 'Комментарий к позиции закупки',
equipment_transferred: 'Оборудование передано',

View File

@ -910,7 +910,7 @@ export interface RelatedDocument {
export interface Request {
id: number;
title: string;
type?: 'vacation' | 'sick_leave' | 'day_off' | 'maternity' | 'transfer' | 'dismissal' | 'other';
type?: 'short_day_off' | 'vacation' | 'sick_leave' | 'day_off' | 'maternity' | 'transfer' | 'dismissal' | 'other';
request_type?: string;
display_title?: string;
description?: string;
@ -936,6 +936,11 @@ export interface Request {
attachment_url?: string | null;
date_from?: string | null;
date_to?: string | null;
time_from?: string | null;
time_to?: string | null;
starts_at?: string | null;
ends_at?: string | null;
duration_minutes?: number | null;
decided_at?: string | null;
created_at: string;
updated_at: string;
@ -958,9 +963,11 @@ export interface RequestEmployeeStatistics {
total_submitted_requests: number;
sick_leave_requests_count: number;
day_off_requests_count: number;
short_day_off_requests_count: number;
maternity_requests_count: number;
sick_leave_days: number;
day_off_days: number;
short_day_off_minutes: number;
maternity_days: number;
paid_vacation_days: number;
unpaid_vacation_days: number;
@ -1160,6 +1167,7 @@ export interface ProcurementRequest {
total_estimated_cost?: string | number;
items_count?: number;
last_arrival_notice_at?: string | null;
last_defective_at?: string | null;
next_expected_delivery_date?: string | null;
items_total_count?: number;
items_received_count?: number;
@ -1204,6 +1212,8 @@ export interface ProcurementComment {
export interface ProcurementItemComment extends ProcurementComment {
item: number;
item_links?: string[];
added_links?: string[];
}
export interface ProcurementSupplier {