feat: expand document procurement and request workflows
This commit is contained in:
parent
1295d24c86
commit
8fd5ae1054
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
"""Возвращает сотрудников, обязанных ознакомиться."""
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -479,9 +479,11 @@ SPECTACULAR_SETTINGS = {
|
||||
("lost", "Утеряно"),
|
||||
],
|
||||
"RequestTypeEnum": [
|
||||
("short_day_off", "Короткий отгул"),
|
||||
("vacation", "Отпуск"),
|
||||
("sick_leave", "Больничный"),
|
||||
("day_off", "Отгул"),
|
||||
("maternity", "Декрет"),
|
||||
("transfer", "Перевод"),
|
||||
("dismissal", "Увольнение"),
|
||||
("other", "Другое"),
|
||||
|
||||
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
39
backend/procurement/migrations/0017_add_item_defective_at.py
Normal file
39
backend/procurement/migrations/0017_add_item_defective_at.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@ -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:
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -71,6 +71,7 @@ class RequestAdmin(admin.ModelAdmin):
|
||||
"type",
|
||||
"title",
|
||||
("date_from", "date_to"),
|
||||
("time_from", "time_to"),
|
||||
"comment",
|
||||
"attachment",
|
||||
)
|
||||
|
||||
@ -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):
|
||||
"""Статусы жизненного цикла заявления (значение хранится в БД)."""
|
||||
|
||||
|
||||
35
backend/requests_app/migrations/0014_add_short_day_off.py
Normal file
35
backend/requests_app/migrations/0014_add_short_day_off.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@ -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 или генерим на лету."""
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
161
backend/tests/finance/test_payroll_short_day_off.py
Normal file
161
backend/tests/finance/test_payroll_short_day_off.py
Normal 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"]
|
||||
@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -165,6 +165,7 @@ const attendanceIssueLabelMap: Record<string, string> = {
|
||||
overtime: "Переработка",
|
||||
technical: "Техническая проблема",
|
||||
work_outside_personnel_schedule: "Работа вне графика",
|
||||
approved_short_day_off: "Согласованный короткий отгул",
|
||||
};
|
||||
|
||||
function formatAttendanceIssueLabel(label: string) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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] || []}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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: 'Оборудование передано',
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user