""" Сериализаторы для модуля закупок. """ import re from datetime import date from drf_spectacular.utils import extend_schema_field from django.contrib.auth import get_user_model from django.db import transaction from rest_framework import serializers from communications import comments_helpers from procurement.constants import ( ProcurementItemExecutionStatus, ProcurementStatus, get_default_approval_step_name, ) from procurement.services import ProcurementApprovalResolver from procurement.models import ( Approval, Budget, Equipment, EquipmentCategory, MaintenanceRecord, ApprovalRoute, ProcurementItem, ProcurementItemAttachment, ProcurementRequestActivity, ProcurementRequest, ProcurementRequestView, ProcurementSettings, Supplier, ) from ..employees.serializers import DepartmentBriefSerializer, EmployeeBriefSerializer User = get_user_model() PROBLEM_ITEM_STATUSES = { ProcurementItemExecutionStatus.REJECTED, ProcurementItemExecutionStatus.COMPLETED_WITH_ISSUE, ProcurementItemExecutionStatus.EDITED, ProcurementItemExecutionStatus.DEFECTIVE, } class ProcurementRequestActivitySerializer(serializers.ModelSerializer): actor = EmployeeBriefSerializer(read_only=True) action_display = serializers.CharField( source="get_action_display", read_only=True, ) class Meta: model = ProcurementRequestActivity fields = [ "id", "action", "action_display", "actor", "object_kind", "object_id", "metadata", "created_at", ] read_only_fields = fields DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") UNIT_DISPLAY_LABELS = { "шт": "штуки", "компл": "комплекты", "упак": "упаковки", "короб": "короба", "пачка": "пачки", "кг": "килограммы", "г": "граммы", "л": "литры", "мл": "миллилитры", "м": "метры", "кв. м": "кв. метры", "куб. м": "куб. метры", "рулон": "рулоны", "пара": "пары", "услуга": "услуги", "час": "часы", } def _normalize_unit(value): return re.sub(r"\s+", " ", str(value or "").strip()).lower() def _unit_display_label(value): unit = _normalize_unit(value) if not unit: return "единиц" return UNIT_DISPLAY_LABELS.get(unit, unit) def _validate_expected_delivery_dates(value): if value in (None, ""): return [] if not isinstance(value, list): raise serializers.ValidationError("Ожидаемые даты должны быть списком.") result = [] for raw in value: item = str(raw or "").strip() if not item: continue if not DATE_RE.fullmatch(item): raise serializers.ValidationError( "Каждая ожидаемая дата должна быть в формате YYYY-MM-DD." ) try: parsed = date.fromisoformat(item) except ValueError as exc: raise serializers.ValidationError( "Каждая ожидаемая дата должна быть корректной календарной датой." ) from exc result.append(parsed.isoformat()) return result def _expected_delivery_date_values(item): dates = [] for raw in item.expected_delivery_dates or []: if not isinstance(raw, str) or not DATE_RE.fullmatch(raw): continue try: dates.append(date.fromisoformat(raw)) except ValueError: continue 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 settings = ProcurementSettings.get_solo() available_departments = settings.available_processing_departments.all() if ( available_departments.exists() and not available_departments.filter(pk=processing_department.pk).exists() ): raise serializers.ValidationError( { "processing_department": ( "Выберите отдел-исполнитель из доступных для закупок." ) } ) def _request_items(obj): return list(obj.items.all()) def _effective_ordered_quantity(item): return item.effective_ordered_quantity def _effective_received_quantity(item): return item.effective_received_quantity class ProcurementRequestSummaryMixin(serializers.Serializer): is_viewed = serializers.SerializerMethodField() last_arrival_notice_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() items_problem_count = serializers.SerializerMethodField() items_pending_count = serializers.SerializerMethodField() total_requested_quantity = serializers.SerializerMethodField() total_ordered_quantity = serializers.SerializerMethodField() total_received_quantity = serializers.SerializerMethodField() quantity_unit_label = serializers.SerializerMethodField() linked_tasks = serializers.SerializerMethodField() @extend_schema_field(serializers.BooleanField()) def get_is_viewed(self, obj): annotated_value = getattr(obj, "is_viewed_for_current_user", None) if annotated_value is not None: return bool(annotated_value) request = self.context.get("request") user = getattr(request, "user", None) if not user or not user.is_authenticated: return False return ProcurementRequestView.objects.filter( request=obj, user=user, is_viewed=True, ).exists() @extend_schema_field(serializers.DateField(allow_null=True)) def get_next_expected_delivery_date(self, obj): items = _request_items(obj) all_dates = [] pending_dates = [] all_items_received = all( item.execution_status == ProcurementItemExecutionStatus.RECEIVED for item in items ) for item in items: item_dates = _expected_delivery_date_values(item) if not item_dates: continue all_dates.extend(item_dates) if item.execution_status != ProcurementItemExecutionStatus.RECEIVED: pending_dates.extend(item_dates) if not all_dates: return None if pending_dates: return min(pending_dates).isoformat() if all_items_received: return max(all_dates).isoformat() return None @extend_schema_field(serializers.IntegerField()) def get_items_total_count(self, obj): return len(_request_items(obj)) @extend_schema_field(serializers.IntegerField()) def get_items_received_count(self, obj): return sum( 1 for item in _request_items(obj) if item.execution_status == ProcurementItemExecutionStatus.RECEIVED ) @extend_schema_field(serializers.IntegerField()) def get_items_problem_count(self, obj): return sum( 1 for item in _request_items(obj) if item.execution_status in PROBLEM_ITEM_STATUSES ) @extend_schema_field(serializers.IntegerField()) def get_items_pending_count(self, obj): return sum( 1 for item in _request_items(obj) if item.execution_status == ProcurementItemExecutionStatus.PENDING ) @extend_schema_field(serializers.IntegerField()) def get_total_requested_quantity(self, obj): return sum(item.quantity for item in _request_items(obj)) @extend_schema_field(serializers.IntegerField()) def get_total_ordered_quantity(self, obj): return sum(_effective_ordered_quantity(item) for item in _request_items(obj)) @extend_schema_field(serializers.IntegerField()) def get_total_received_quantity(self, obj): return sum(_effective_received_quantity(item) for item in _request_items(obj)) @extend_schema_field(serializers.CharField()) def get_quantity_unit_label(self, obj): units = { _normalize_unit(item.unit) for item in _request_items(obj) if _normalize_unit(item.unit) } if len(units) == 1: return _unit_display_label(next(iter(units))) return "единиц" @extend_schema_field(serializers.ListField(child=serializers.DictField())) def get_linked_tasks(self, obj: ProcurementRequest) -> list[dict]: prefetched = getattr(obj, "_linked_task_payloads", None) if prefetched is not None: return prefetched request = self.context.get("request") user = getattr(request, "user", None) if not user or not getattr(user, "is_authenticated", False): return [] try: from django.contrib.contenttypes.models import ContentType from tasks.access import task_board_access_q from tasks.models import ( TaskBoard, TaskLinkedObject, TaskLinkedObjectKind, ) except Exception: return [] content_type = ContentType.objects.get_for_model(ProcurementRequest) accessible_boards = TaskBoard.objects.filter( is_archived=False, ).filter(task_board_access_q(user)) links = ( TaskLinkedObject.objects.filter( kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, content_type=content_type, object_id=obj.id, task__board__in=accessible_boards, ) .select_related("task", "task__board", "task__column") .order_by("task__title", "task_id") ) return [ { "link_id": link.id, "id": link.task_id, "title": link.task.title, "board_id": link.task.board_id, "board_name": link.task.board.name, "column_id": link.task.column_id, "column_name": link.task.column.name, "column_color": link.task.column.color, "priority": link.task.priority, "priority_display": link.task.get_priority_display(), } for link in links ] class ProcurementItemAttachmentSerializer(serializers.ModelSerializer): uploaded_by_name = serializers.CharField( source="uploaded_by.get_full_name", read_only=True, ) download_url = serializers.SerializerMethodField() class Meta: model = ProcurementItemAttachment fields = [ "id", "file_name", "file_size", "mime_type", "uploaded_by", "uploaded_by_name", "created_at", "download_url", ] read_only_fields = fields def get_download_url(self, obj): return ( f"/api/v1/procurement/items/{obj.item_id}/" f"attachments/{obj.id}/" ) class ProcurementItemSerializer(serializers.ModelSerializer): """Сериализатор для позиций заявки.""" estimated_unit_price = serializers.DecimalField( max_digits=12, decimal_places=2, required=False, allow_null=True, ) ordered_quantity = serializers.IntegerField( required=False, allow_null=True, min_value=0, ) received_quantity = serializers.IntegerField( required=False, allow_null=True, min_value=0, ) expected_delivery_dates = serializers.ListField( child=serializers.CharField(allow_blank=False, trim_whitespace=True), required=False, allow_empty=True, ) initial_comment = serializers.CharField( write_only=True, required=False, allow_blank=True, ) total_price = serializers.DecimalField( max_digits=12, decimal_places=2, read_only=True ) execution_status_display = serializers.SerializerMethodField() comments_count = serializers.SerializerMethodField() attachments = ProcurementItemAttachmentSerializer(many=True, read_only=True) attachments_count = serializers.IntegerField( source="attachments.count", read_only=True, ) @extend_schema_field(serializers.IntegerField()) def get_comments_count(self, obj): annotated_count = getattr(obj, "comments_count", None) if annotated_count is not None: return annotated_count from communications.comments_helpers import get_comment_count return get_comment_count(obj) @extend_schema_field(serializers.CharField()) def get_execution_status_display(self, obj): return obj.execution_status_display class Meta: model = ProcurementItem fields = [ "id", "request", "name", "description", "quantity", "unit", "estimated_unit_price", "total_price", "ordered_quantity", "received_quantity", "supplier_info", "links", "expected_delivery_dates", "actual_unit_price", "execution_status", "execution_status_display", "executor_comment", "initial_comment", "comments_count", "attachments", "attachments_count", "equipment", "created_at", ] read_only_fields = [ "id", "created_at", "total_price", "execution_status_display", "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) def create(self, validated_data): initial_comment = validated_data.pop("initial_comment", "").strip() item = super().create(validated_data) request = self.context.get("request") author = getattr(request, "user", None) if initial_comment and author and author.is_authenticated: comments_helpers.create_comment( obj=item, author=author, content=initial_comment, ) return item class ProcurementItemCreateSerializer(serializers.ModelSerializer): """Сериализатор для создания позиций (без request).""" estimated_unit_price = serializers.DecimalField( max_digits=12, decimal_places=2, required=False, allow_null=True, ) ordered_quantity = serializers.IntegerField( required=False, allow_null=True, min_value=0, ) received_quantity = serializers.IntegerField( required=False, allow_null=True, min_value=0, ) expected_delivery_dates = serializers.ListField( child=serializers.CharField(allow_blank=False, trim_whitespace=True), required=False, allow_empty=True, ) initial_comment = serializers.CharField( write_only=True, required=False, allow_blank=True, ) class Meta: model = ProcurementItem fields = [ "id", "name", "description", "quantity", "unit", "estimated_unit_price", "ordered_quantity", "received_quantity", "supplier_info", "links", "expected_delivery_dates", "actual_unit_price", "execution_status", "executor_comment", "initial_comment", ] 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) class ProcurementItemEditSerializer(ProcurementItemCreateSerializer): """Сериализатор для редактирования позиций внутри заявки.""" id = serializers.IntegerField(required=False) class Meta: model = ProcurementItem fields = [ "id", "name", "description", "quantity", "unit", "estimated_unit_price", "supplier_info", "links", "initial_comment", ] class ProcurementRequestCreateOptionsSerializer(serializers.Serializer): """Опции формы создания заявки на закупку.""" processing_departments = DepartmentBriefSerializer(many=True) default_processing_department = serializers.IntegerField(allow_null=True) class ProcurementManualApprovalStepSerializer(serializers.Serializer): """Ручной этап согласования при отправке заявки.""" priority = serializers.IntegerField(min_value=1) approver = serializers.PrimaryKeyRelatedField( queryset=User.objects.filter(is_active=True), ) step_name = serializers.CharField( required=False, allow_blank=True, max_length=150, ) def validate_priority(self, value): if not ApprovalRoute.objects.filter(priority=value).exists(): raise serializers.ValidationError( "Выберите настроенный этап согласования." ) return value class ProcurementManualApprovalStepsSerializer(serializers.Serializer): """Список ручных этапов согласования.""" approval_steps = ProcurementManualApprovalStepSerializer( many=True, required=False, allow_empty=True, ) def validate_approval_steps(self, value): priorities = [item["priority"] for item in value] duplicate_priorities = { priority for priority in priorities if priorities.count(priority) > 1 } if duplicate_priorities: raise serializers.ValidationError( "Каждый этап согласования можно выбрать только один раз." ) return value class ApprovalSerializer(serializers.ModelSerializer): """Сериализатор для согласований.""" approver = EmployeeBriefSerializer(read_only=True) approver_name = serializers.CharField( source="approver.get_full_name", read_only=True ) status_display = serializers.CharField( source="get_status_display", read_only=True ) step_label = serializers.SerializerMethodField() @extend_schema_field(serializers.CharField()) def get_step_label(self, obj): return obj.step_name or get_default_approval_step_name(obj.priority) class Meta: model = Approval fields = [ "id", "request", "approver", "approver_name", "priority", "step_name", "step_label", "status", "status_display", "comment", "created_at", "updated_at", ] read_only_fields = [ "id", "created_at", "updated_at", "approver_name", "step_name", "step_label", "status_display", ] class ProcurementRequestListSerializer( ProcurementRequestSummaryMixin, serializers.ModelSerializer, ): """Сериализатор для списка заявок (краткий).""" description = serializers.CharField(required=False, allow_blank=True) items = ProcurementItemEditSerializer( many=True, required=False, write_only=True, ) department_name = serializers.CharField( source="department.name", read_only=True ) processing_department_name = serializers.CharField( source="processing_department.name", read_only=True, allow_null=True ) requestor_name = serializers.CharField( source="requestor.get_full_name", read_only=True ) status_display = serializers.CharField( source="get_status_display", read_only=True ) urgency_display = serializers.CharField( source="get_urgency_display", read_only=True ) fulfillment_status_display = serializers.CharField( source="get_fulfillment_status_display", read_only=True ) items_count = serializers.IntegerField(read_only=True) total_cost = serializers.DecimalField( max_digits=12, decimal_places=2, read_only=True ) executor_name = serializers.CharField( source="executor.get_full_name", read_only=True, allow_null=True ) comments_count = serializers.IntegerField(read_only=True, default=0) can_current_user_approve = serializers.SerializerMethodField() can_current_user_submit_for_approval = serializers.SerializerMethodField() can_current_user_start_work = serializers.SerializerMethodField() can_current_user_process_items = serializers.SerializerMethodField() @extend_schema_field(serializers.BooleanField()) def get_can_current_user_approve(self, obj): request = self.context.get("request") user = getattr(request, "user", None) return ProcurementApprovalResolver.user_can_approve(user, obj) @extend_schema_field(serializers.BooleanField()) def get_can_current_user_submit_for_approval(self, obj): request = self.context.get("request") user = getattr(request, "user", None) return ProcurementApprovalResolver.user_can_submit_for_approval( user, obj, ) @extend_schema_field(serializers.BooleanField()) def get_can_current_user_start_work(self, obj): request = self.context.get("request") user = getattr(request, "user", None) return ProcurementApprovalResolver.user_can_start_work(user, obj) @extend_schema_field(serializers.BooleanField()) def get_can_current_user_process_items(self, obj): request = self.context.get("request") user = getattr(request, "user", None) return ProcurementApprovalResolver.user_can_process_items(user, obj) class Meta: model = ProcurementRequest fields = [ "id", "title", "description", "department", "department_name", "processing_department", "processing_department_name", "requestor", "requestor_name", "executor", "executor_name", "status", "status_display", "urgency", "urgency_display", "fulfillment_status", "fulfillment_status_display", "is_viewed", "last_arrival_notice_at", "total_cost", "items_count", "next_expected_delivery_date", "items_total_count", "items_received_count", "items_problem_count", "items_pending_count", "total_requested_quantity", "total_ordered_quantity", "total_received_quantity", "quantity_unit_label", "comments_count", "linked_tasks", "can_current_user_approve", "can_current_user_submit_for_approval", "can_current_user_start_work", "can_current_user_process_items", "items", "created_at", "submitted_at", "started_at", "completed_at", ] read_only_fields = [ "id", "created_at", "submitted_at", "started_at", "completed_at", "department_name", "processing_department_name", "requestor_name", "executor_name", "status_display", "urgency_display", "fulfillment_status", "fulfillment_status_display", "is_viewed", "last_arrival_notice_at", "items_count", "total_cost", "next_expected_delivery_date", "items_total_count", "items_received_count", "items_problem_count", "items_pending_count", "total_requested_quantity", "total_ordered_quantity", "total_received_quantity", "quantity_unit_label", "comments_count", "linked_tasks", "can_current_user_approve", "can_current_user_submit_for_approval", "can_current_user_start_work", "can_current_user_process_items", ] def update(self, instance, validated_data): """ Обновить заявку и, если передан список, синхронизировать позиции. """ validated_data.pop("requestor", None) validated_data.pop("department", None) items_data = validated_data.pop("items", None) if items_data is None: instance = super().update(instance, validated_data) return instance if not items_data: raise serializers.ValidationError( {"items": "Добавьте хотя бы одну позицию."} ) request = self.context.get("request") author = getattr(request, "user", None) with transaction.atomic(): instance = super().update(instance, validated_data) existing_items = {item.id: item for item in instance.items.all()} kept_item_ids = set() for item_data in items_data: item_id = item_data.pop("id", None) initial_comment = item_data.pop("initial_comment", "").strip() if item_id is not None: item = existing_items.get(item_id) if item is None: raise serializers.ValidationError( {"items": f"Позиция {item_id} не найдена в заявке."} ) for field, value in item_data.items(): setattr(item, field, value) item.save() else: item = ProcurementItem.objects.create( request=instance, **item_data, ) kept_item_ids.add(item.id) if initial_comment and author and author.is_authenticated: comments_helpers.create_comment( obj=item, author=author, content=initial_comment, ) for item_id, item in existing_items.items(): if item_id not in kept_item_ids: item.delete() instance.recalculate_fulfillment_status(save=True) return instance def validate(self, attrs): attrs = super().validate(attrs) if "processing_department" in attrs: _validate_processing_department_available( attrs.get("processing_department") ) items_data = attrs.get("items") if items_data is None: return attrs if not items_data: raise serializers.ValidationError( {"items": "Добавьте хотя бы одну позицию."} ) names = [item.get("name", "").strip() for item in items_data] if not any(names): raise serializers.ValidationError( {"items": "Добавьте хотя бы одну позицию."} ) if self.instance is not None: valid_item_ids = set( self.instance.items.values_list("id", flat=True) ) incoming_item_ids = { item["id"] for item in items_data if item.get("id") is not None } unknown_item_ids = incoming_item_ids - valid_item_ids if unknown_item_ids: raise serializers.ValidationError( { "items": ( "Позиция " f"{min(unknown_item_ids)} не найдена в заявке." ) } ) return attrs class ProcurementRequestDetailSerializer( ProcurementRequestSummaryMixin, serializers.ModelSerializer, ): """Сериализатор для детальной информации о заявке.""" department_name = serializers.CharField( source="department.name", read_only=True ) processing_department_name = serializers.CharField( source="processing_department.name", read_only=True, allow_null=True ) requestor_name = serializers.CharField( source="requestor.get_full_name", read_only=True ) requestor_email = serializers.CharField( source="requestor.email", read_only=True ) executor_name = serializers.CharField( source="executor.get_full_name", read_only=True, allow_null=True ) status_display = serializers.CharField( source="get_status_display", read_only=True ) urgency_display = serializers.CharField( source="get_urgency_display", read_only=True ) fulfillment_status_display = serializers.CharField( source="get_fulfillment_status_display", read_only=True ) total_cost = serializers.DecimalField( max_digits=12, decimal_places=2, read_only=True ) items = ProcurementItemSerializer(many=True, read_only=True) approvals = ApprovalSerializer(many=True, read_only=True) required_approval_priorities = serializers.ListField( source="get_required_approval_priorities", read_only=True ) is_editable = serializers.BooleanField(read_only=True) comments_count = serializers.IntegerField(read_only=True, default=0) activity_count = serializers.SerializerMethodField() can_current_user_approve = serializers.SerializerMethodField() can_current_user_submit_for_approval = serializers.SerializerMethodField() can_current_user_start_work = serializers.SerializerMethodField() can_current_user_process_items = serializers.SerializerMethodField() @extend_schema_field(serializers.BooleanField()) def get_can_current_user_approve(self, obj): request = self.context.get("request") user = getattr(request, "user", None) return ProcurementApprovalResolver.user_can_approve(user, obj) @extend_schema_field(serializers.BooleanField()) def get_can_current_user_submit_for_approval(self, obj): request = self.context.get("request") user = getattr(request, "user", None) return ProcurementApprovalResolver.user_can_submit_for_approval( user, obj, ) @extend_schema_field(serializers.BooleanField()) def get_can_current_user_start_work(self, obj): request = self.context.get("request") user = getattr(request, "user", None) return ProcurementApprovalResolver.user_can_start_work(user, obj) @extend_schema_field(serializers.BooleanField()) def get_can_current_user_process_items(self, obj): request = self.context.get("request") user = getattr(request, "user", None) return ProcurementApprovalResolver.user_can_process_items(user, obj) @extend_schema_field(serializers.IntegerField()) def get_activity_count(self, obj): return obj.activities.count() class Meta: model = ProcurementRequest fields = [ "id", "title", "description", "department", "department_name", "processing_department", "processing_department_name", "requestor", "requestor_name", "requestor_email", "executor", "executor_name", "status", "status_display", "urgency", "urgency_display", "fulfillment_status", "fulfillment_status_display", "is_viewed", "last_arrival_notice_at", "total_cost", "actual_cost", "next_expected_delivery_date", "items_total_count", "items_received_count", "items_problem_count", "items_pending_count", "total_requested_quantity", "total_ordered_quantity", "total_received_quantity", "quantity_unit_label", "items", "approvals", "required_approval_priorities", "is_editable", "comments_count", "activity_count", "linked_tasks", "can_current_user_approve", "can_current_user_submit_for_approval", "can_current_user_start_work", "can_current_user_process_items", "created_at", "updated_at", "submitted_at", "started_at", "completed_at", ] read_only_fields = [ "id", "created_at", "updated_at", "submitted_at", "started_at", "completed_at", "is_editable", "is_viewed", "last_arrival_notice_at", "total_cost", "next_expected_delivery_date", "items_total_count", "items_received_count", "items_problem_count", "items_pending_count", "total_requested_quantity", "total_ordered_quantity", "total_received_quantity", "quantity_unit_label", "executor_name", "fulfillment_status", "processing_department_name", "fulfillment_status_display", "comments_count", "activity_count", "linked_tasks", "can_current_user_approve", "can_current_user_submit_for_approval", "can_current_user_start_work", "can_current_user_process_items", ] class ProcurementRequestCreateSerializer(serializers.ModelSerializer): """Сериализатор для создания заявки с позициями.""" description = serializers.CharField( required=False, allow_blank=True, ) items = ProcurementItemCreateSerializer(many=True, required=False) class Meta: model = ProcurementRequest fields = [ "id", "title", "description", "department", "processing_department", "requestor", "urgency", "items", "status", "created_at", ] read_only_fields = ["id", "requestor", "status", "created_at"] def validate(self, attrs): _validate_processing_department_available( attrs.get("processing_department") ) if ( not attrs.get("processing_department") and not attrs.get("description", "").strip() ): raise serializers.ValidationError( {"description": "Укажите описание и обоснование."} ) return attrs def create(self, validated_data): """Создать заявку с позициями.""" items_data = validated_data.pop("items", []) request = self.context["request"] has_processing_department = bool( validated_data.get("processing_department") ) if has_processing_department: validated_data["status"] = ProcurementStatus.WAITING procurement_request = ProcurementRequest( requestor=request.user, **validated_data, ) procurement_request._notification_actor = request.user procurement_request.save() # Создаем позиции (если они переданы) for item_data in items_data: initial_comment = item_data.pop("initial_comment", "").strip() item = ProcurementItem.objects.create( request=procurement_request, **item_data ) if initial_comment: comments_helpers.create_comment( obj=item, author=request.user, content=initial_comment, ) return procurement_request class EquipmentCategorySerializer(serializers.ModelSerializer): """Сериализатор для категорий оборудования.""" full_path = serializers.CharField(read_only=True) children_count = serializers.SerializerMethodField() class Meta: model = EquipmentCategory fields = [ "id", "name", "parent", "description", "icon", "full_path", "children_count", "created_at", ] read_only_fields = ["id", "created_at", "full_path"] @extend_schema_field(serializers.IntegerField()) def get_children_count(self, obj): """Количество подкатегорий.""" return obj.children.count() class BudgetSerializer(serializers.ModelSerializer): """Сериализатор бюджета отдела.""" department_name = serializers.CharField( source="department.name", read_only=True ) remaining_amount = serializers.DecimalField( max_digits=14, decimal_places=2, read_only=True ) reserved_amount = serializers.DecimalField( max_digits=14, decimal_places=2, read_only=True ) available_amount = serializers.DecimalField( max_digits=14, decimal_places=2, read_only=True ) utilization_percentage = serializers.DecimalField( max_digits=5, decimal_places=2, read_only=True ) class Meta: model = Budget fields = [ "id", "department", "department_name", "year", "quarter", "allocated_amount", "spent_amount", "remaining_amount", "reserved_amount", "available_amount", "utilization_percentage", "created_at", "updated_at", ] read_only_fields = fields class EquipmentListSerializer(serializers.ModelSerializer): """Сериализатор для списка оборудования.""" category_name = serializers.CharField( source="category.name", read_only=True ) category_icon = serializers.CharField( source="category.icon", read_only=True, default="bi-box-seam" ) department_name = serializers.CharField( source="department.name", read_only=True ) responsible_name = serializers.CharField( source="responsible_person.get_full_name", read_only=True ) status_display = serializers.CharField( source="get_status_display", read_only=True ) is_under_warranty = serializers.BooleanField(read_only=True) comments_count = serializers.IntegerField(read_only=True, default=0) class Meta: model = Equipment fields = [ "id", "name", "inventory_number", "serial_number", "category", "category_name", "category_icon", "status", "status_display", "department", "department_name", "responsible_person", "responsible_name", "location", "purchase_date", "purchase_cost", "notes", "is_under_warranty", "comments_count", "created_at", "updated_at", ] read_only_fields = [ "id", "inventory_number", "category_name", "category_icon", "department_name", "responsible_name", "status_display", "is_under_warranty", "comments_count", "created_at", "updated_at", ] def create(self, validated_data): """Автоматическая генерация инвентарного номера при создании.""" import re from datetime import datetime year = datetime.now().year prefix = f"INV-{year}-" # Находим максимальный номер с текущим префиксом last_equipment = ( Equipment.objects.filter(inventory_number__startswith=prefix) .order_by("-inventory_number") .first() ) if last_equipment: match = re.search(r"(\d+)$", last_equipment.inventory_number) if match: next_num = int(match.group(1)) + 1 else: next_num = 1 else: next_num = 1 validated_data["inventory_number"] = f"{prefix}{next_num:04d}" return super().create(validated_data) def _get_user_permission_level(self, user): """Определяет уровень прав пользователя. Возвращает: 'full' - админ/модельные права (свободный выбор отдела/ответственного) 'dept_head' - начальник отдела (свой отдел, выбор ответственного) 'scoped' - скоуп-право (свой отдел, ответственный = начальник) None - нет прав на создание """ from api.v1.permissions import has_dept_perm from employees.constants import DeptPerm from employees.models import Department, EmployeeDepartment if not user or not user.is_authenticated: return None # Админ или модельные права = полный доступ if ( user.is_staff or user.is_superuser or user.has_perm("procurement.add_equipment") ): return "full" # Проверяем, является ли начальником какого-то отдела headed_depts = Department.objects.filter(head_id=user.id) if headed_depts.exists(): return "dept_head" # Проверяем скоуп-право в активных отделах user_dept_links = EmployeeDepartment.objects.filter( employee_id=user.id, is_active=True ).select_related("department", "role") for link in user_dept_links: if has_dept_perm( user, link.department_id, DeptPerm.MANAGE_EQUIPMENT ): return "scoped" return None def _get_user_allowed_departments(self, user, perm_level): """Возвращает отделы, где пользователь может создать оборудование.""" from employees.models import Department, EmployeeDepartment if perm_level == "full": return list(Department.objects.all()) if perm_level == "dept_head": return list(Department.objects.filter(head_id=user.id)) if perm_level == "scoped": from api.v1.permissions import has_dept_perm from employees.constants import DeptPerm result = [] user_dept_links = EmployeeDepartment.objects.filter( employee_id=user.id, is_active=True ).select_related("department") for link in user_dept_links: if has_dept_perm( user, link.department_id, DeptPerm.MANAGE_EQUIPMENT ): result.append(link.department) return result return [] def validate(self, attrs): """Проверка и автозаполнение полей в зависимости от прав. Уровни прав: - full (админ/модельные права): свободный выбор отдела и ответственного - dept_head (начальник): только свой отдел, выбор ответственного из отдела - scoped (скоуп-право): только свой отдел, ответственный = начальник """ request = self.context.get("request") user = request.user if request else None # При update (есть instance) используем облегчённую валидацию if self.instance is not None: return self._validate_update(attrs, user) # Создание — полная проверка прав perm_level = self._get_user_permission_level(user) # Проверяем отдел department = attrs.get("department") if perm_level == "full": # Полный доступ — можно указать любой отдел pass elif perm_level == "dept_head": # Начальник — только свои отделы allowed_depts = self._get_user_allowed_departments(user, perm_level) allowed_ids = [d.id for d in allowed_depts] if department and department.id not in allowed_ids: raise serializers.ValidationError( { "department": "Вы можете создавать оборудование " "только в своём отделе." } ) # Если отдел не указан — берём первый if not department and allowed_depts: attrs["department"] = allowed_depts[0] department = allowed_depts[0] # Ответственный по умолчанию — сам начальник resp = attrs.get("responsible_person") if resp is None: attrs["responsible_person"] = user elif perm_level == "scoped": # Скоуп-право — только свой отдел, ответственный = начальник allowed_depts = self._get_user_allowed_departments(user, perm_level) allowed_ids = [d.id for d in allowed_depts] if department and department.id not in allowed_ids: raise serializers.ValidationError( { "department": "Вы можете создавать оборудование " "только в своём отделе." } ) # Если отдел не указан — берём первый if not department and allowed_depts: attrs["department"] = allowed_depts[0] department = allowed_depts[0] # Ответственный ВСЕГДА = начальник отдела (без выбора) if department and department.head: attrs["responsible_person"] = department.head else: attrs["responsible_person"] = None else: # Нет прав — ошибка raise serializers.ValidationError( {"non_field_errors": "У вас нет прав на создание оборудования."} ) # Валидация ответственного return self._validate_responsible_in_department(attrs) def _validate_update(self, attrs, user): """Валидация при обновлении (облегчённая).""" # При update не проверяем права создания, # только валидацию ответственного если он меняется department = attrs.get("department", self.instance.department) responsible = attrs.get("responsible_person") # Если меняется ответственный — проверяем принадлежность к отделу if responsible is not None: attrs["department"] = department # Для валидации attrs["responsible_person"] = responsible return self._validate_responsible_in_department(attrs) return attrs def _validate_responsible_in_department(self, attrs): """Проверяет, что ответственный состоит в отделе оборудования.""" from employees.models import EmployeeDepartment department = attrs.get("department") responsible = attrs.get("responsible_person") if department and responsible: # Проверяем, что ответственный в этом отделе is_in_dept = ( # Он начальник отдела department.head_id == responsible.id or # Или состоит в отделе EmployeeDepartment.objects.filter( employee_id=responsible.id, department_id=department.id, is_active=True, ).exists() ) if not is_in_dept: raise serializers.ValidationError( { "responsible_person": ( "Ответственный должен состоять в том же отделе, " "что и оборудование." ) } ) return attrs class EquipmentDetailSerializer(serializers.ModelSerializer): """Сериализатор для детальной информации об оборудовании.""" category_name = serializers.CharField( source="category.name", read_only=True ) department_name = serializers.CharField( source="department.name", read_only=True ) responsible_name = serializers.CharField( source="responsible_person.get_full_name", read_only=True ) status_display = serializers.CharField( source="get_status_display", read_only=True ) is_under_warranty = serializers.BooleanField(read_only=True) maintenance_count = serializers.SerializerMethodField() class Meta: model = Equipment fields = [ "id", "name", "inventory_number", "serial_number", "category", "category_name", "status", "status_display", "department", "department_name", "responsible_person", "responsible_name", "location", "purchase_date", "warranty_until", "is_under_warranty", "purchase_cost", "notes", "maintenance_count", "created_at", "updated_at", ] read_only_fields = [ "id", "created_at", "updated_at", "is_under_warranty", "maintenance_count", ] @extend_schema_field(serializers.IntegerField()) def get_maintenance_count(self, obj): """Количество записей обслуживания.""" return obj.maintenance_history.count() class MaintenanceRecordSerializer(serializers.ModelSerializer): """Сериализатор для записей обслуживания.""" equipment_name = serializers.CharField( source="equipment.name", read_only=True ) equipment_inventory = serializers.CharField( source="equipment.inventory_number", read_only=True ) performed_by_name = serializers.CharField( source="performed_by.get_full_name", read_only=True ) type_display = serializers.CharField( source="get_type_display", read_only=True ) class Meta: model = MaintenanceRecord fields = [ "id", "equipment", "equipment_name", "equipment_inventory", "date", "type", "type_display", "description", "cost", "performed_by", "performed_by_name", "next_maintenance_date", "created_at", ] read_only_fields = [ "id", "created_at", "equipment_name", "equipment_inventory", "performed_by_name", "type_display", ] class SupplierSerializer(serializers.ModelSerializer): """Сериализатор для поставщиков.""" class Meta: model = Supplier fields = [ "id", "name", "contact_person", "phone", "email", "address", "website", "inn", "rating", "is_active", "notes", "created_at", "updated_at", ] read_only_fields = ["id", "created_at", "updated_at"] class ProcurementNotificationReadResponseSerializer(serializers.Serializer): status = serializers.CharField() count = serializers.IntegerField() notification_ids = serializers.ListField( child=serializers.IntegerField(), ) class ProcurementOverviewStatsSerializer(serializers.Serializer): total_requests = serializers.IntegerField() pending_requests = serializers.IntegerField() approved_this_month = serializers.IntegerField() completed_this_month = serializers.IntegerField() total_spent_this_year = serializers.CharField() by_status = serializers.DictField(child=serializers.IntegerField()) by_urgency = serializers.DictField(child=serializers.IntegerField()) class ProcurementDepartmentStatsSerializer(serializers.Serializer): department = serializers.DictField() total_requests = serializers.IntegerField() total_spent = serializers.CharField()