import logging
from decimal import Decimal, InvalidOperation

from django.conf import settings
from django.db import transaction
from django.http import FileResponse, Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.utils import timezone
import django_filters
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view, inline_serializer
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.serializers import BooleanField, CharField, DecimalField, IntegerField, ListField
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet

from apps.boletas.models import Boleta
from apps.catalogos.models import Bodega, Castigo
from apps.recepciones.models import (
    Bonificacion,
    Deduccion,
    Flete,
    FotoRecepcion,
    Muestreo,
    PesoFlete,
    PesoRecepcion,
    Recepcion,
    RecepcionLog,
)
from apps.recepciones.integraciones import enviar_entrada_sap, registrar_volumen_sica
from apps.recepciones.pdf import (
    build_boleta_entrada_pdf_bytes,
    build_flete_boleta_entrada_pdf_bytes,
)
from apps.recepciones.permissions import (
    CanCloseRecepcion,
    CanCreateRecepcion,
    CanDeleteRecepcion,
    CanEditRecepcion,
    CanProcessRecepcion,
    CanRegisterPeso,
    CanReopenRecepcion,
    CanResolveDispute,
    CanValidateRecepcion,
    CanViewRecepcion,
)
from apps.recepciones.serializers import (
    AgregarBoletaSerializer,
    CerrarRecepcionSerializer,
    DistribuirPesoSerializer,
    FleteSerializer,
    FotoRecepcionCreateSerializer,
    IdentificarBoletaSerializer,
    MuestreoCreateSerializer,
    MuestreoSerializer,
    PesoCreateSerializer,
    PesoFleteSerializer,
    PesoPorQrCreateSerializer,
    PesoRecepcionSerializer,
    ReabrirRecepcionSerializer,
    RecepcionCreateSerializer,
    RecepcionIdentificarResponseSerializer,
    RecepcionSerializer,
    RecepcionUpdateSerializer,
    ResolverDisputaRecepcionSerializer,
)
from apps.recepciones.utils import compute_quality_deductions
from utils.audit import AuditFieldsViewSetMixin
from utils.office import extract_office_id
from utils.pagination import StandardResultsSetPagination


ErrorDetailSerializer = inline_serializer(
    name="ErrorDetail",
    fields={
        "detail": CharField(),
    },
)

logger = logging.getLogger(__name__)


def _payload_loggable(data):
    """Copia el payload quitando datos sensibles antes de loguearlo."""
    payload = dict(data or {})
    payload.pop("access_token", None)
    return payload


def _log_peso(accion, payload_in, payload_out=None, status_code=None):
    logger.info(
        "%s | payload_in=%s | payload_out=%s | status=%s",
        accion,
        _payload_loggable(payload_in),
        payload_out,
        status_code,
    )


def _actualizar_estado_boleta(boleta, estado, user):
    if boleta.estado == estado:
        return
    boleta.estado = estado
    boleta.updated_by = user
    boleta.save(update_fields=["estado", "updated_by", "updated_at"])


def _registrar_peso_flete(request, flete, validated_data, tipo, estado_siguiente):
    """Registra un peso lleno/vacío en un flete y sincroniza sus recepciones."""
    accion = f"flete {flete.pk} peso-{tipo}"
    if flete.pesos.filter(tipo=tipo).exists():
        detail = {"detail": f"Ya existe un peso {tipo} para este flete."}
        _log_peso(accion, request.data, detail, 422)
        return Response(detail, status=422)

    if tipo == PesoFlete.TIPO_VACIO:
        peso_lleno = flete.pesos.filter(tipo=PesoFlete.TIPO_LLENO).first()
        if not peso_lleno:
            detail = {"detail": "Debe existir un peso lleno antes de registrar el peso vacío."}
            _log_peso(accion, request.data, detail, 422)
            return Response(detail, status=422)
        if validated_data["valor"] == peso_lleno.valor:
            detail = {"detail": "El peso vacío no puede ser igual al peso lleno."}
            _log_peso(accion, request.data, detail, 422)
            return Response(detail, status=422)

        muestreo = getattr(flete, "muestreo", None)
        if not muestreo:
            detail = {"detail": "Falta orden de muestreo."}
            _log_peso(accion, request.data, detail, 422)
            return Response(detail, status=422)
        if muestreo.estado != "completado":
            detail = {"detail": "Debe completarse el muestreo antes de registrar el peso vacío."}
            _log_peso(accion, request.data, detail, 422)
            return Response(detail, status=422)
        if flete.estado != Recepcion.ESTADO_MUESTREO_COMPLETADO:
            detail = {"detail": "El flete debe estar en estado muestreo_completado para registrar el peso vacío."}
            _log_peso(accion, request.data, detail, 422)
            return Response(detail, status=422)

    data = dict(validated_data)
    bascula = data.pop("bascula", None)
    for extra_field in ("bascula_id", "access_token", "qr", "folio", "zafra_id"):
        data.pop(extra_field, None)

    if bascula is not None and bascula.oficina_id is not None and bascula.oficina_id != flete.oficina_id:
        detail = {"bascula_id": "La báscula no pertenece a la oficina del flete."}
        _log_peso(accion, request.data, detail, 400)
        raise ValidationError(detail)

    peso = PesoFlete.objects.create(
        flete=flete,
        tipo=tipo,
        bascula=bascula,
        **data,
    )

    user = request.user if getattr(request.user, "is_authenticated", False) else None
    flete.estado = estado_siguiente
    update_fields = ["estado", "updated_at"]
    if user is not None:
        flete.updated_by = user
        update_fields.append("updated_by")
    flete.save(update_fields=update_fields)
    flete.recepciones.exclude(estado=estado_siguiente).update(estado=estado_siguiente)

    payload = PesoFleteSerializer(peso).data
    if tipo == PesoFlete.TIPO_VACIO:
        peso_lleno = flete.pesos.filter(tipo=PesoFlete.TIPO_LLENO).first()
        payload["peso_bruto"] = Decimal(peso_lleno.valor) - Decimal(peso.valor)
    _log_peso(accion, request.data, payload, status.HTTP_201_CREATED)
    return Response(payload, status=status.HTTP_201_CREATED)


class RecepcionFilter(django_filters.FilterSet):
    created_date = django_filters.DateFilter(field_name="created_at", lookup_expr="date")
    created_date_gte = django_filters.DateFilter(field_name="created_at", lookup_expr="date__gte")
    created_date_lte = django_filters.DateFilter(field_name="created_at", lookup_expr="date__lte")

    class Meta:
        model = Recepcion
        fields = {
            "estado": ["exact"],
            "boleta__zafra": ["exact"],
            "boleta__productor": ["exact"],
            "boleta__contrato": ["exact"],
        }


class FotoRecepcionDownloadView(APIView):
    permission_classes = [CanProcessRecepcion]

    @extend_schema(
        tags=["Recepciones"],
        summary="Descargar foto de recepción",
        description="Devuelve el archivo asociado a una foto (binario).",
        responses={
            200: OpenApiResponse(response=OpenApiTypes.BINARY, description="Archivo"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    def get(self, request, pk: int):
        office_id = extract_office_id(request)
        if office_id is None:
            raise ValidationError({"office_id": "office_id es requerido."})

        try:
            foto = FotoRecepcion.objects.select_related("recepcion").get(pk=pk)
        except FotoRecepcion.DoesNotExist as e:
            raise Http404() from e

        if foto.recepcion_id and getattr(foto.recepcion, "oficina_id", None) != office_id:
            raise Http404()

        if not foto.imagen:
            raise Http404()

        resp = FileResponse(foto.imagen.open("rb"), content_type="application/octet-stream")
        resp["Content-Disposition"] = f"inline; filename=\"{foto.imagen.name.split('/')[-1]}\""
        return resp


@extend_schema_view(
    list=extend_schema(tags=["Recepciones"]),
    retrieve=extend_schema(tags=["Recepciones"]),
    create=extend_schema(tags=["Recepciones"]),
    update=extend_schema(
        tags=["Recepciones"],
        request=RecepcionUpdateSerializer,
        responses=RecepcionUpdateSerializer,
    ),
    partial_update=extend_schema(
        tags=["Recepciones"],
        request=RecepcionUpdateSerializer,
        responses=RecepcionSerializer,
    ),
    destroy=extend_schema(tags=["Recepciones"]),
)
class RecepcionViewSet(AuditFieldsViewSetMixin, ModelViewSet):
    queryset = (
        Recepcion.objects.select_related(
            "boleta",
            "bodega_destino",
            "boleta__productor",
            "boleta__contrato",
            "boleta__variedad",
            "boleta__zafra",
            "boleta__campo",
            "boleta__linea",
        )
        .prefetch_related("pesos", "fotos", "deducciones", "bonificaciones")
        .all()
        .order_by("-id")
    )

    pagination_class = StandardResultsSetPagination
    filterset_class = RecepcionFilter
    search_fields = [
        "boleta__placas_camion",
        "boleta__productor__nombre",
        "boleta__productor__rfc",
        "=boleta__folio",
    ]
    ordering_fields = ["id", "created_at", "updated_at"]

    def get_queryset(self):
        queryset = self.queryset
        if self.action == "list" and not self.request.query_params.get("estado"):
            queryset = queryset.exclude(
                estado__in=[
                    Recepcion.ESTADO_VALIDADA,
                    Recepcion.ESTADO_EDITADA,
                    Recepcion.ESTADO_RECHAZADA,
                ]
            )
        user = getattr(self.request, "user", None)
        if user and user.groups.filter(name="Externo").exists():
            queryset = queryset.filter(bodega_destino__es_externo=True)
        return queryset

    def perform_create(self, serializer):
        office_id = extract_office_id(self.request)
        if office_id is None:
            raise ValidationError({"office_id": "office_id es requerido."})

        boleta = serializer.validated_data.get("boleta")
        if boleta is not None:
            boleta_office_id = getattr(boleta, "oficina_id", None)
            if boleta_office_id is None:
                boleta.oficina_id = office_id
                boleta.updated_by = self.request.user
                boleta.save(update_fields=["oficina", "updated_by", "updated_at"])
            elif boleta_office_id != office_id:
                raise ValidationError({"boleta": "La boleta no pertenece a la oficina indicada."})

        user = getattr(self.request, "user", None)
        with transaction.atomic():
            recepcion = serializer.save(oficina_id=office_id, created_by=user, updated_by=user)
            boleta = getattr(recepcion, "boleta", None)
            if boleta and recepcion.estado != Recepcion.ESTADO_CERRADA and boleta.estado != Boleta.ESTADO_ASIGNADA:
                boleta.estado = Boleta.ESTADO_ASIGNADA
                boleta.updated_by = user
                boleta.save(update_fields=["estado", "updated_by", "updated_at"])

    def get_permissions(self):
        if self.action in {"list", "retrieve"}:
            return [CanViewRecepcion()]
        if self.action in {"create"}:
            return [CanCreateRecepcion()]
        if self.action in {"update", "partial_update"}:
            return [CanEditRecepcion()]
        if self.action in {"destroy"}:
            return [CanDeleteRecepcion()]
        if self.action in {"identificar"}:
            return [CanCreateRecepcion()]
        if self.action in {"peso_lleno", "peso_vacio", "peso_lleno_por_qr", "peso_vacio_por_qr"}:
            return [CanRegisterPeso()]
        if self.action in {"generar_muestreo", "calcular_deducciones"}:
            return [CanEditRecepcion()]
        if self.action in {"cerrar"}:
            return [CanCloseRecepcion()]
        if self.action in {"validar"}:
            return [CanValidateRecepcion()]
        if self.action in {"reabrir"}:
            return [CanReopenRecepcion()]
        if self.action in {"resolver_disputa"}:
            return [CanResolveDispute()]
        if self.action in {"fotos"}:
            return [CanEditRecepcion()]
        return [CanProcessRecepcion()]

    def get_serializer_class(self):
        if self.action == "create":
            return RecepcionCreateSerializer
        if self.action in {"update", "partial_update"}:
            return RecepcionUpdateSerializer
        return RecepcionSerializer

    def _aplicar_flujo_rechazo(self, recepcion, comentario=""):
        """
        Aplica el flujo de rechazo:
        - Sin contrato: pasa directamente a RECHAZADA y fija fecha de cierre.
        - Con contrato: pasa a EN_DISPUTA para revisión de Molino/Administrador.
        """
        tiene_contrato = bool(recepcion.boleta.contrato_id)
        if tiene_contrato:
            recepcion.estado = Recepcion.ESTADO_EN_DISPUTA
        else:
            recepcion.estado = Recepcion.ESTADO_RECHAZADA
            recepcion.fecha_cierre = recepcion.fecha_cierre or timezone.now()
        if comentario:
            recepcion.observaciones = (recepcion.observaciones or "") + ("\n" + comentario)
        return recepcion.estado

    def _get_recepcion_for_peso(self, request, pk):
        """
        Obtiene la recepción para registrar un peso. Si la petición viene de un
        usuario autenticado se respeta el filtrado normal por oficina; si viene
        de una báscula (sin autenticación, validada por access_token) se busca
        directamente por pk, ya que la báscula no necesariamente conoce el
        header X-Office-Id.
        """
        user = getattr(request, "user", None)
        if user and getattr(user, "is_authenticated", False):
            return self.get_object()
        return get_object_or_404(Recepcion, pk=pk)

    def partial_update(self, request, *args, **kwargs):
        instance = self.get_object()
        serializer = self.get_serializer(instance, data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)

        had_camion_data = any(
            k in serializer.validated_data
            for k in {
                "placas_camion",
                "linea_id",
                "linea_nombre",
                "marca_unidad",
                "modelo_unidad",
                "chofer_nombre",
                "chofer_telefono",
                "chofer_firma_en_dispositivo",
                "origen_trigo",
                "observaciones",
            }
        )
        bodega_destino = serializer.validated_data.get("bodega_destino")
        bodega_destino_id = getattr(bodega_destino, "pk", None) if bodega_destino is not None else None

        self.perform_update(serializer)
        instance.refresh_from_db()

        if had_camion_data and instance.estado == Recepcion.ESTADO_IDENTIFICACION:
            instance.estado = Recepcion.ESTADO_CAMION_REGISTRADO
            instance.updated_by = request.user
            instance.save(update_fields=["estado", "updated_by", "updated_at"])
            if instance.flete_id and instance.flete.estado == Recepcion.ESTADO_IDENTIFICACION:
                instance.flete.estado = Recepcion.ESTADO_CAMION_REGISTRADO
                instance.flete.updated_by = request.user
                instance.flete.save(update_fields=["estado", "updated_by", "updated_at"])
                instance.flete.recepciones.exclude(
                    estado=Recepcion.ESTADO_CAMION_REGISTRADO
                ).update(
                    estado=Recepcion.ESTADO_CAMION_REGISTRADO,
                    updated_by=request.user,
                    updated_at=timezone.now(),
                )

        if bodega_destino_id is not None and instance.flete_id:
            instance.flete.recepciones.exclude(
                bodega_destino_id=bodega_destino_id
            ).update(
                bodega_destino_id=bodega_destino_id,
                updated_by=request.user,
                updated_at=timezone.now(),
            )

        if instance.flete_id and had_camion_data:
            # Propaga los datos del camión del flete a todas las boletas
            # asociadas, ya que todas comparten el mismo camión.
            flete = instance.flete
            camion_data = {
                "placas_camion": flete.placas_camion,
                "linea_id": flete.linea_id,
                "linea_nombre": flete.linea_nombre,
                "marca_unidad": flete.marca_unidad,
                "modelo_unidad": flete.modelo_unidad,
                "chofer_nombre": flete.chofer_nombre,
                "chofer_telefono": flete.chofer_telefono,
                "chofer_firma_en_dispositivo": flete.chofer_firma_en_dispositivo,
                # El origen del trigo no se sincroniza porque puede variar por boleta.
            }
            for recepcion_flete in flete.recepciones.select_related("boleta").all():
                boleta = recepcion_flete.boleta
                for campo, valor in camion_data.items():
                    setattr(boleta, campo, valor)
                boleta.updated_by = request.user
                boleta.save(
                    update_fields=list(camion_data.keys()) + ["updated_by", "updated_at"]
                )

        out = RecepcionSerializer(instance, context={"request": request}).data
        return Response(out)

    @extend_schema(
        tags=["Recepciones"],
        summary="Identificar boleta e iniciar recepción",
        description="Identifica una boleta por QR o por folio (y opcionalmente zafra_id). Si la recepción no existe se crea y se devuelve el flag created=true.",
        request=IdentificarBoletaSerializer,
        responses={
            200: OpenApiResponse(response=RecepcionIdentificarResponseSerializer),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="Boleta no encontrada"),
        },
    )
    @action(detail=False, methods=["post"], url_path="identificar")
    def identificar(self, request):
        office_id = extract_office_id(request)
        if office_id is None:
            raise ValidationError({"office_id": "office_id es requerido."})

        serializer = IdentificarBoletaSerializer(data=request.data, context={"request": request})
        serializer.is_valid(raise_exception=True)
        bodega_destino = serializer.validated_data.get("bodega_destino")
        bodega_destino_id = getattr(bodega_destino, "pk", None)

        try:
            boleta = serializer.resolve_boleta()
        except Exception as e:
            if hasattr(e, "detail"):
                detail = e.detail
                if isinstance(detail, dict) and detail.get("detail") == "Boleta no encontrada.":
                    return Response(
                        {
                            "detail": "Boleta no encontrada. Puede crear una nueva usando el endpoint de boletas.",
                        },
                        status=status.HTTP_404_NOT_FOUND,
                    )
            raise

        boleta_office_id = getattr(boleta, "oficina_id", None)
        if boleta_office_id is None:
            boleta.oficina_id = office_id
            boleta.updated_by = request.user
            boleta.save(update_fields=["oficina", "updated_by", "updated_at"])
        elif boleta_office_id != office_id:
            raise ValidationError({"detail": "La boleta no pertenece a la oficina indicada."})

        with transaction.atomic():
            recepcion, created = Recepcion.objects.get_or_create(
                boleta=boleta,
                defaults={
                    "oficina_id": office_id,
                    "bodega_destino_id": bodega_destino_id,
                    "created_by": request.user,
                    "updated_by": request.user,
                },
            )
            if not created:
                if recepcion.oficina_id is None:
                    recepcion.oficina_id = office_id
                    recepcion.updated_by = request.user
                    recepcion.save(update_fields=["oficina", "updated_by", "updated_at"])
                elif recepcion.oficina_id != office_id:
                    raise ValidationError({"detail": "La recepción no pertenece a la oficina indicada."})

                if recepcion.updated_by_id != request.user.id:
                    recepcion.updated_by = request.user
                    recepcion.save(update_fields=["updated_by", "updated_at"])

                if bodega_destino_id is not None and recepcion.bodega_destino_id != bodega_destino_id:
                    recepcion.bodega_destino_id = bodega_destino_id
                    recepcion.updated_by = request.user
                    recepcion.save(update_fields=["bodega_destino", "updated_by", "updated_at"])

            if recepcion.estado != Recepcion.ESTADO_CERRADA and boleta.estado != Boleta.ESTADO_ASIGNADA:
                boleta.estado = Boleta.ESTADO_ASIGNADA
                boleta.updated_by = request.user
                boleta.save(update_fields=["estado", "updated_by", "updated_at"])

        data = RecepcionSerializer(recepcion, context={"request": request}).data
        data["created"] = created
        return Response(data, status=status.HTTP_200_OK)

    @extend_schema(
        tags=["Recepciones"],
        summary="Agregar boleta a un flete",
        description=(
            "Toma una recepción existente y le agrega una segunda (o posterior) boleta "
            "compartiendo el mismo flete. La primera vez crea el flete a partir de la recepción "
            "actual; las siguientes veces reutilizan el flete existente. Todas las boletas "
            "deben pertenecer a la misma oficina y variedad."
        ),
        request=AgregarBoletaSerializer,
        responses={
            200: OpenApiResponse(response=FleteSerializer),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="agregar-boleta")
    def agregar_boleta(self, request, pk=None):
        recepcion_principal = self.get_object()

        if recepcion_principal.estado not in {
            Recepcion.ESTADO_IDENTIFICACION,
            Recepcion.ESTADO_CAMION_REGISTRADO,
        }:
            raise ValidationError(
                {"estado": "Solo se pueden agregar boletas en estados iniciales."}
            )

        serializer = AgregarBoletaSerializer(data=request.data, context={"request": request})
        serializer.is_valid(raise_exception=True)
        bodega_destino = serializer.validated_data.get("bodega_destino")
        bodega_destino_id = getattr(bodega_destino, "pk", None)

        try:
            boleta = serializer.resolve_boleta()
        except Exception as e:
            if hasattr(e, "detail"):
                detail = e.detail
                if isinstance(detail, dict) and detail.get("detail") == "Boleta no encontrada.":
                    return Response(
                        {"detail": "Boleta no encontrada."},
                        status=status.HTTP_404_NOT_FOUND,
                    )
            raise

        office_id = extract_office_id(request)
        boleta_office_id = getattr(boleta, "oficina_id", None)
        if boleta_office_id is None:
            boleta.oficina_id = office_id
            boleta.updated_by = request.user
            boleta.save(update_fields=["oficina", "updated_by", "updated_at"])
        elif boleta_office_id != office_id:
            raise ValidationError({"detail": "La boleta no pertenece a la oficina indicada."})

        if hasattr(boleta, "recepcion"):
            raise ValidationError({"detail": "La boleta ya tiene una recepción asociada."})

        boleta_principal = recepcion_principal.boleta

        with transaction.atomic():
            flete = recepcion_principal.flete
            if flete is not None and flete.oficina_id != office_id:
                raise ValidationError({"detail": "El flete no pertenece a la oficina indicada."})

            if flete is None:
                flete = Flete.objects.create(
                    oficina_id=office_id,
                    variedad_id=boleta_principal.variedad_id or boleta.variedad_id,
                    estado=recepcion_principal.estado,
                    placas_camion=boleta_principal.placas_camion,
                    linea_id=boleta_principal.linea_id,
                    linea_nombre=boleta_principal.linea_nombre,
                    marca_unidad=boleta_principal.marca_unidad,
                    modelo_unidad=boleta_principal.modelo_unidad,
                    chofer_nombre=boleta_principal.chofer_nombre,
                    chofer_telefono=boleta_principal.chofer_telefono,
                    chofer_firma_en_dispositivo=boleta_principal.chofer_firma_en_dispositivo,
                    origen_trigo=boleta_principal.origen_trigo,
                    created_by=request.user,
                    updated_by=request.user,
                )
                recepcion_principal.flete = flete
                recepcion_principal.updated_by = request.user
                recepcion_principal.save(update_fields=["flete", "updated_by", "updated_at"])

            variedad_flete = flete.variedad_id
            if variedad_flete is None:
                flete.variedad_id = boleta.variedad_id
                flete.updated_by = request.user
                flete.save(update_fields=["variedad", "updated_by", "updated_at"])
            elif boleta.variedad_id != variedad_flete:
                raise ValidationError(
                    {"detail": "Todas las boletas del flete deben ser de la misma variedad."}
                )

            # Sincroniza los datos del camión del flete en la boleta nueva, ya
            # que todas las boletas del mismo flete comparten el mismo camión.
            # El origen del trigo no se copia porque puede variar por boleta.
            boleta.placas_camion = flete.placas_camion
            boleta.linea_id = flete.linea_id
            boleta.linea_nombre = flete.linea_nombre
            boleta.marca_unidad = flete.marca_unidad
            boleta.modelo_unidad = flete.modelo_unidad
            boleta.chofer_nombre = flete.chofer_nombre
            boleta.chofer_telefono = flete.chofer_telefono
            boleta.chofer_firma_en_dispositivo = flete.chofer_firma_en_dispositivo
            boleta.updated_by = request.user
            boleta.save(update_fields=[
                "placas_camion",
                "linea",
                "linea_nombre",
                "marca_unidad",
                "modelo_unidad",
                "chofer_nombre",
                "chofer_telefono",
                "chofer_firma_en_dispositivo",
                "updated_by",
                "updated_at",
            ])

            nueva_recepcion = Recepcion.objects.create(
                boleta=boleta,
                flete=flete,
                oficina_id=office_id,
                bodega_destino_id=bodega_destino_id or recepcion_principal.bodega_destino_id,
                created_by=request.user,
                updated_by=request.user,
            )

            if boleta.estado != Boleta.ESTADO_ASIGNADA:
                boleta.estado = Boleta.ESTADO_ASIGNADA
                boleta.updated_by = request.user
                boleta.save(update_fields=["estado", "updated_by", "updated_at"])

        return Response(FleteSerializer(flete, context={"request": request}).data, status=status.HTTP_200_OK)

    @extend_schema(
        tags=["Recepciones"],
        summary="Subir foto a una recepción",
        description="Sube una imagen (multipart/form-data) y la asocia a la recepción.",
        request=FotoRecepcionCreateSerializer,
        responses={
            201: OpenApiResponse(
                response=inline_serializer(
                    name="FotoRecepcionCreateResponse",
                    fields={
                        "foto": inline_serializer(
                            name="FotoRecepcionCreateResponseFoto",
                            fields={
                                "id": IntegerField(),
                                "tipo": CharField(),
                            },
                        )
                    },
                )
            ),
            422: OpenApiResponse(description="Error de validación"),
            403: OpenApiResponse(description="Sin permisos"),
        },
    )
    @action(
        detail=True,
        methods=["post"],
        url_path="fotos",
        parser_classes=[MultiPartParser, FormParser],
    )
    def fotos(self, request, pk=None):
        recepcion = self.get_object()
        serializer = FotoRecepcionCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        foto = serializer.save(recepcion=recepcion)
        out = {
            "foto": {
                "id": foto.id,
                "tipo": foto.tipo,
            }
        }
        return Response(out, status=status.HTTP_201_CREATED)

    @extend_schema(
        tags=["Recepciones"],
        summary="Registrar peso lleno",
        description=(
            "Registra el peso lleno de la recepción. Si la recepción pertenece a un flete, "
            "registra el peso lleno compartido del flete. Solo puede existir uno por recepción/flete. "
            "Puede ser registrado por un usuario autenticado, o de forma automática por una báscula "
            "enviando `bascula_id` y `access_token`."
        ),
        request=PesoCreateSerializer,
        responses={
            201: OpenApiResponse(response=PesoRecepcionSerializer),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="peso-lleno")
    def peso_lleno(self, request, pk=None):
        _log_peso(f"recepcion {pk} peso-lleno", request.data)
        recepcion = self._get_recepcion_for_peso(request, pk)
        serializer = PesoCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        return self._registrar_peso_lleno(request, recepcion, serializer.validated_data)

    @extend_schema(
        tags=["Recepciones"],
        summary="Registrar peso lleno (por QR/folio)",
        description=(
            "Variante de `peso-lleno` pensada para básculas: en vez de conocer el `recepcion_id`, "
            "identifica la recepción a partir del `qr` (o `folio`+`zafra_id`) leído de la boleta. "
            "Si la recepción pertenece a un flete, registra el peso lleno compartido del flete. "
            "Requiere `bascula_id` y `access_token`, y la recepción debe existir previamente "
            "(creada por el flujo normal de identificación)."
        ),
        operation_id="recepciones_peso_lleno_por_qr_create",
        request=PesoPorQrCreateSerializer,
        responses={
            201: OpenApiResponse(response=PesoRecepcionSerializer),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="Boleta o recepción no encontrada"),
        },
    )
    @action(detail=False, methods=["post"], url_path="peso-lleno")
    def peso_lleno_por_qr(self, request):
        _log_peso("recepcion peso-lleno por QR", request.data)
        serializer = PesoPorQrCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        recepcion = serializer.resolve_recepcion()
        return self._registrar_peso_lleno(request, recepcion, serializer.validated_data)

    def _registrar_peso_lleno(self, request, recepcion, validated_data):
        if recepcion.flete_id:
            return _registrar_peso_flete(
                request,
                recepcion.flete,
                validated_data,
                PesoFlete.TIPO_LLENO,
                Recepcion.ESTADO_PESO_LLENO,
            )

        if recepcion.pesos.filter(tipo=PesoRecepcion.TIPO_LLENO).exists():
            detail = {"detail": "Ya existe un peso lleno para esta recepción."}
            _log_peso(f"recepcion {recepcion.pk} peso-lleno", request.data, detail, 422)
            return Response(detail, status=422)

        data = dict(validated_data)
        bascula = data.pop("bascula", None)
        for extra_field in ("bascula_id", "access_token", "qr", "folio", "zafra_id"):
            data.pop(extra_field, None)

        if bascula is not None and bascula.oficina_id is not None and bascula.oficina_id != recepcion.oficina_id:
            detail = {"bascula_id": "La báscula no pertenece a la oficina de la recepción."}
            _log_peso(f"recepcion {recepcion.pk} peso-lleno", request.data, detail, 400)
            raise ValidationError(detail)

        peso = PesoRecepcion.objects.create(
            recepcion=recepcion,
            tipo=PesoRecepcion.TIPO_LLENO,
            bascula=bascula,
            **data,
        )

        user = request.user if getattr(request.user, "is_authenticated", False) else None
        recepcion.estado = Recepcion.ESTADO_PESO_LLENO
        update_fields = ["estado", "updated_at"]
        if user is not None:
            recepcion.updated_by = user
            update_fields.append("updated_by")
        recepcion.save(update_fields=update_fields)

        payload = PesoRecepcionSerializer(peso).data
        _log_peso(f"recepcion {recepcion.pk} peso-lleno", request.data, payload, status.HTTP_201_CREATED)
        return Response(payload, status=status.HTTP_201_CREATED)

    @extend_schema(
        tags=["Recepciones"],
        summary="Generar orden de muestreo",
        description="Crea la orden de muestreo asociada a la recepción. Solo puede existir una por recepción.",
        request=MuestreoCreateSerializer,
        responses={
            201: OpenApiResponse(response=MuestreoSerializer),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error de validación"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="generar-muestreo")
    def generar_muestreo(self, request, pk=None):
        recepcion = self.get_object()

        if hasattr(recepcion, "muestreo"):
            return Response(
                {"detail": "La recepción ya tiene muestreo."},
                status=status.HTTP_422_UNPROCESSABLE_ENTITY,
            )

        serializer = MuestreoCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        tecnico_id = serializer.validated_data.get("tecnico_id")
        muestreo = Muestreo.objects.create(
            recepcion=recepcion,
            oficina_id=recepcion.oficina_id,
            tipo_muestreo=serializer.validated_data["tipo_muestreo"],
            tecnico_asignado_id=tecnico_id,
        )

        recepcion.estado = Recepcion.ESTADO_MUESTREO_PENDIENTE
        recepcion.updated_by = request.user
        recepcion.save(update_fields=["estado", "updated_by", "updated_at"])

        return Response(MuestreoSerializer(muestreo).data, status=status.HTTP_201_CREATED)

    @extend_schema(
        tags=["Recepciones"],
        summary="Registrar peso vacío",
        description=(
            "Registra el peso vacío. Si la recepción pertenece a un flete, registra el peso vacío "
            "compartido del flete. Requiere que exista previamente el peso lleno y devuelve además "
            "el peso_bruto calculado. Puede ser registrado por un usuario autenticado, o de forma "
            "automática por una báscula enviando `bascula_id` y `access_token`."
        ),
        request=PesoCreateSerializer,
        responses={
            201: OpenApiResponse(
                response=inline_serializer(
                    name="PesoVacioResponse",
                    fields={
                        "id": IntegerField(),
                        "tipo": CharField(),
                        "valor": DecimalField(max_digits=10, decimal_places=2),
                        "es_automatico": BooleanField(),
                        "motivo_manual": CharField(allow_blank=True, required=False),
                        "created_at": CharField(),
                        "peso_bruto": DecimalField(max_digits=12, decimal_places=2),
                    },
                )
            ),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="peso-vacio")
    def peso_vacio(self, request, pk=None):
        _log_peso(f"recepcion {pk} peso-vacio", request.data)
        recepcion = self._get_recepcion_for_peso(request, pk)
        serializer = PesoCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        return self._registrar_peso_vacio(request, recepcion, serializer.validated_data)

    @extend_schema(
        tags=["Recepciones"],
        summary="Registrar peso vacío (por QR/folio)",
        description=(
            "Variante de `peso-vacio` pensada para básculas: en vez de conocer el `recepcion_id`, "
            "identifica la recepción a partir del `qr` (o `folio`+`zafra_id`) leído de la boleta. "
            "Si la recepción pertenece a un flete, registra el peso vacío compartido del flete. "
            "Requiere `bascula_id` y `access_token`, y la recepción debe existir previamente."
        ),
        operation_id="recepciones_peso_vacio_por_qr_create",
        request=PesoPorQrCreateSerializer,
        responses={
            201: OpenApiResponse(
                response=inline_serializer(
                    name="PesoVacioPorQrResponse",
                    fields={
                        "id": IntegerField(),
                        "tipo": CharField(),
                        "valor": DecimalField(max_digits=10, decimal_places=2),
                        "es_automatico": BooleanField(),
                        "motivo_manual": CharField(allow_blank=True, required=False),
                        "created_at": CharField(),
                        "peso_bruto": DecimalField(max_digits=12, decimal_places=2),
                    },
                )
            ),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="Boleta o recepción no encontrada"),
        },
    )
    @action(detail=False, methods=["post"], url_path="peso-vacio")
    def peso_vacio_por_qr(self, request):
        _log_peso("recepcion peso-vacio por QR", request.data)
        serializer = PesoPorQrCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        recepcion = serializer.resolve_recepcion()
        return self._registrar_peso_vacio(request, recepcion, serializer.validated_data)

    def _registrar_peso_vacio(self, request, recepcion, validated_data):
        if recepcion.flete_id:
            return _registrar_peso_flete(
                request,
                recepcion.flete,
                validated_data,
                PesoFlete.TIPO_VACIO,
                Recepcion.ESTADO_PESO_VACIO,
            )

        accion = f"recepcion {recepcion.pk} peso-vacio"
        if recepcion.pesos.filter(tipo=PesoRecepcion.TIPO_VACIO).exists():
            detail = {"detail": "Ya existe un peso vacío para esta recepción."}
            _log_peso(accion, request.data, detail, 422)
            return Response(detail, status=422)

        muestreo = getattr(recepcion, "muestreo", None)
        if not muestreo:
            detail = {"detail": "Falta orden de muestreo."}
            _log_peso(accion, request.data, detail, 422)
            return Response(detail, status=422)
        if muestreo.estado != "completado":
            detail = {"detail": "Debe completarse el muestreo antes de registrar el peso vacío."}
            _log_peso(accion, request.data, detail, 422)
            return Response(detail, status=422)
        if recepcion.estado != Recepcion.ESTADO_MUESTREO_COMPLETADO:
            detail = {"detail": "La recepción debe estar en estado muestreo_completado para registrar el peso vacío."}
            _log_peso(accion, request.data, detail, 422)
            return Response(detail, status=422)

        peso_lleno = recepcion.pesos.filter(tipo=PesoRecepcion.TIPO_LLENO).first()
        if not peso_lleno:
            detail = {"detail": "Debe existir un peso lleno antes de registrar el peso vacío."}
            _log_peso(accion, request.data, detail, 422)
            return Response(detail, status=422)
        if validated_data["valor"] == peso_lleno.valor:
            detail = {"detail": "El peso vacío no puede ser igual al peso lleno."}
            _log_peso(accion, request.data, detail, 422)
            return Response(detail, status=422)

        data = dict(validated_data)
        bascula = data.pop("bascula", None)
        for extra_field in ("bascula_id", "access_token", "qr", "folio", "zafra_id"):
            data.pop(extra_field, None)

        if bascula is not None and bascula.oficina_id is not None and bascula.oficina_id != recepcion.oficina_id:
            detail = {"bascula_id": "La báscula no pertenece a la oficina de la recepción."}
            _log_peso(accion, request.data, detail, 400)
            raise ValidationError(detail)

        peso = PesoRecepcion.objects.create(
            recepcion=recepcion,
            tipo=PesoRecepcion.TIPO_VACIO,
            bascula=bascula,
            **data,
        )

        bruto = Decimal(peso_lleno.valor) - Decimal(peso.valor)

        user = request.user if getattr(request.user, "is_authenticated", False) else None
        recepcion.estado = Recepcion.ESTADO_PESO_VACIO
        update_fields = ["estado", "updated_at"]
        if user is not None:
            recepcion.updated_by = user
            update_fields.append("updated_by")
        recepcion.save(update_fields=update_fields)

        payload = PesoRecepcionSerializer(peso).data
        payload["peso_bruto"] = bruto
        _log_peso(accion, request.data, payload, status.HTTP_201_CREATED)
        return Response(payload, status=status.HTTP_201_CREATED)

    @extend_schema(
        tags=["Recepciones"],
        summary="Calcular deducciones",
        description=(
            "Calcula deducciones (kg/ton) y bonificaciones ($/ton) usando los resultados de laboratorio y la tabla de parámetros (castigos). "
            "La bonificación se calcula sobre el peso neto (después de deducciones)."
        ),
        request=None,
        responses={
            200: OpenApiResponse(
                response=inline_serializer(
                    name="CalcularDeduccionesResponse",
                    fields={
                        "detail": CharField(),
                        "recepcion_id": IntegerField(),
                        "peso_bruto": DecimalField(max_digits=12, decimal_places=2),
                        "peso_neto": DecimalField(max_digits=12, decimal_places=2),
                        "toneladas_bruto": DecimalField(max_digits=12, decimal_places=4),
                        "toneladas_neto": DecimalField(max_digits=12, decimal_places=4),
                        "rechazo": BooleanField(),
                        "rechazos": ListField(
                            child=inline_serializer(
                                name="CalcularDeduccionesRechazo",
                                fields={
                                    "parametro": CharField(),
                                    "valor": CharField(),
                                },
                            )
                        ),
                        "deducciones": ListField(
                            child=inline_serializer(
                                name="CalcularDeduccionesItem",
                                fields={
                                    "parametro_id": IntegerField(),
                                    "parametro_nombre": CharField(),
                                    "kg_por_tonelada": DecimalField(max_digits=10, decimal_places=2),
                                    "kg_total": DecimalField(max_digits=12, decimal_places=2),
                                },
                            )
                        ),
                        "bonificaciones": ListField(
                            child=inline_serializer(
                                name="CalcularBonificacionesItem",
                                fields={
                                    "parametro_id": IntegerField(),
                                    "parametro_nombre": CharField(),
                                    "monto_por_tonelada": DecimalField(max_digits=12, decimal_places=2),
                                    "monto_total": DecimalField(max_digits=12, decimal_places=2),
                                },
                            )
                        ),
                        "total_bonificado": DecimalField(max_digits=12, decimal_places=2),
                    },
                )
            ),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error de validación"),
        },
    )
    @action(detail=True, methods=["post"], url_path="calcular-deducciones")
    def calcular_deducciones(self, request, pk=None):
        recepcion = self.get_object()
        return Response(self._calcular_deducciones_recepcion(recepcion, request.user))

    def _calcular_deducciones_recepcion(self, recepcion, user):
        camion = recepcion.flete if recepcion.flete_id else recepcion.boleta
        faltantes = {}
        if recepcion.bodega_destino_id is None:
            faltantes["bodega_destino"] = "La bodega destino es requerida."
        if recepcion.boleta.campo_id is None:
            faltantes["campo"] = "El campo de origen del trigo es requerido."
        for field, message in (
            ("placas_camion", "Las placas del camión son requeridas."),
            ("chofer_telefono", "El teléfono del chofer es requerido."),
            ("marca_unidad", "La marca de la unidad es requerida."),
            ("chofer_nombre", "El nombre del chofer es requerido."),
            ("modelo_unidad", "El modelo de la unidad es requerido."),
        ):
            if not str(getattr(camion, field, "") or "").strip():
                faltantes[field] = message
        if not getattr(camion, "linea_id", None) and not str(
            getattr(camion, "linea_nombre", "") or ""
        ).strip():
            faltantes["linea"] = "La línea es requerida."
        if faltantes:
            raise ValidationError(faltantes)

        if recepcion.flete_id:
            flete = recepcion.flete
            peso_lleno = flete.pesos.filter(tipo=PesoFlete.TIPO_LLENO).first()
            peso_vacio = flete.pesos.filter(tipo=PesoFlete.TIPO_VACIO).first()
            if not peso_lleno or not peso_vacio:
                errors = {}
                if not peso_lleno:
                    errors["peso_lleno"] = "Falta peso lleno del flete."
                if not peso_vacio:
                    errors["peso_vacio"] = "Falta peso vacío del flete."
                raise ValidationError(errors)

            if recepcion.peso_neto_asignado is None:
                raise ValidationError(
                    {"peso_neto_asignado": "Debe distribuir el peso neto del flete antes de calcular deducciones."}
                )

            bruto = Decimal(recepcion.peso_neto_asignado)
            muestreo = getattr(flete, "muestreo", None)
        else:
            peso_lleno = recepcion.pesos.filter(tipo=PesoRecepcion.TIPO_LLENO).first()
            peso_vacio = recepcion.pesos.filter(tipo=PesoRecepcion.TIPO_VACIO).first()
            if not peso_lleno or not peso_vacio:
                errors = {}
                if not peso_lleno:
                    errors["peso_lleno"] = "Falta peso lleno."
                if not peso_vacio:
                    errors["peso_vacio"] = "Falta peso vacío."
                raise ValidationError(errors)

            bruto = Decimal(peso_lleno.valor) - Decimal(peso_vacio.valor)
            muestreo = getattr(recepcion, "muestreo", None)

        if bruto <= 0:
            raise ValidationError({"peso_bruto": "El peso bruto debe ser mayor a 0."})

        if not muestreo:
            raise ValidationError({"muestreo": "Falta orden de muestreo."})

        resultado = getattr(muestreo, "resultado_laboratorio", None)
        if not resultado:
            raise ValidationError({"resultado_laboratorio": "Falta resultado de laboratorio."})

        parametros = resultado.parametros.select_related("parametro").all()

        parametro_ids = [int(pr.parametro_id) for pr in parametros if getattr(pr, "valor", None) is not None]
        castigos_por_parametro: dict[int, list] = {}
        if parametro_ids:
            for c in (
                Castigo.objects.filter(parametro_id__in=parametro_ids)
                .select_related("parametro")
                .order_by("parametro_id", "limite_superior")
            ):
                castigos_por_parametro.setdefault(int(c.parametro_id), []).append(c)

        try:
            computed = compute_quality_deductions(
                parametros_resultado=parametros,
                peso_bruto_kg=bruto,
                castigos_por_parametro=castigos_por_parametro,
            )
        except (InvalidOperation, TypeError, ValueError) as e:
            raise ValidationError({"resultado_laboratorio": "Valores de laboratorio inválidos."}) from e

        parametros_considerados = computed.get("parametros_considerados") or []
        if not parametros_considerados:
            raise ValidationError(
                {
                    "resultado_laboratorio": (
                        "No hay parámetros configurados para calcular deducciones/bonificaciones. "
                        "Configure la tabla (castigos) para los parámetros capturados e intente de nuevo."
                    )
                }
            )

        rechazos = computed["rechazos"]

        toneladas_bruto = computed["toneladas_bruto"]
        toneladas_neto = computed["toneladas_neto"]
        peso_neto = computed["peso_neto_kg"]

        ded_list = []
        for d in computed["deducciones"]:
            ded_list.append(
                {
                    "parametro_id": d.parametro_id,
                    "parametro_nombre": d.parametro_nombre,
                    "kg_por_tonelada": d.kg_por_tonelada,
                    "kg_total": d.kg_total,
                }
            )

        bon_list = []
        for b in computed["bonificaciones"]:
            bon_list.append(
                {
                    "parametro_id": b.parametro_id,
                    "parametro_nombre": b.parametro_nombre,
                    "monto_por_tonelada": b.monto_por_tonelada,
                    "monto_total": b.monto_total,
                }
            )
        total_bonificado = computed.get("total_bonificado") or Decimal("0")

        with transaction.atomic():
            recepcion.deducciones.all().delete()
            recepcion.bonificaciones.all().delete()
            recepcion.rechazo_calidad = bool(rechazos)
            recepcion.rechazos_calidad = rechazos
            recepcion.calidad_calculada = True
            recepcion.updated_by = user
            update_fields = [
                "rechazo_calidad",
                "rechazos_calidad",
                "calidad_calculada",
                "updated_by",
                "updated_at",
            ]
            if rechazos:
                self._aplicar_flujo_rechazo(recepcion, "RECHAZO por calidad.")
                update_fields.extend(["estado", "observaciones"])
                if recepcion.estado == Recepcion.ESTADO_RECHAZADA:
                    update_fields.append("fecha_cierre")
            recepcion.save(update_fields=update_fields)
            if recepcion.estado == Recepcion.ESTADO_RECHAZADA:
                _actualizar_estado_boleta(recepcion.boleta, Boleta.ESTADO_RECHAZADA, user)

            if not rechazos:
                Deduccion.objects.bulk_create(
                    [
                        Deduccion(
                            recepcion=recepcion,
                            parametro_id=item["parametro_id"],
                            kg_por_tonelada=item["kg_por_tonelada"],
                            valor_deducido=item["kg_total"],
                            tipo_deduccion="fijo",
                        )
                        for item in ded_list
                        if Decimal(item["kg_total"]) > 0
                    ]
                )

                Bonificacion.objects.bulk_create(
                    [
                        Bonificacion(
                            recepcion=recepcion,
                            parametro_id=item["parametro_id"],
                            monto_por_tonelada=item["monto_por_tonelada"],
                            monto_total=item["monto_total"],
                        )
                        for item in bon_list
                        if Decimal(item["monto_total"]) > 0
                    ]
                )

        if rechazos:
            estado_calculo = "rechazo"
            detail = "RECHAZO por calidad."
        elif bon_list:
            estado_calculo = "bonificaciones"
            detail = "Bonificaciones calculadas."
        elif ded_list:
            estado_calculo = "deducciones"
            detail = "Deducciones calculadas."
        else:
            estado_calculo = "sin_deducciones"
            detail = "Sin deducciones aplicables."

        return {
            "detail": detail,
            "calculo_calidad": {
                "estado": estado_calculo,
                "parametros_considerados": parametros_considerados,
                "conteo_deducciones": len(ded_list),
                "conteo_rechazos": len(rechazos),
                "conteo_bonificaciones": len(bon_list),
            },
            "recepcion_id": recepcion.id,
            "peso_bruto": bruto,
            "peso_neto": peso_neto,
            "toneladas_bruto": toneladas_bruto,
            "toneladas_neto": toneladas_neto,
            "rechazo": bool(rechazos),
            "rechazos": rechazos,
            "deducciones": ded_list,
            "bonificaciones": bon_list,
            "total_bonificado": total_bonificado,
        }

    @extend_schema(
        tags=["Recepciones"],
        summary="Cerrar recepción",
        description="Cierra la recepción validando que existan pesos y muestreo. Puede responder 409 si hay alerta por diferencia vs. peso teórico (requiere decision).",
        request=CerrarRecepcionSerializer,
        responses={
            200: OpenApiResponse(response=RecepcionSerializer),
            202: OpenApiResponse(response=RecepcionSerializer),
            409: OpenApiResponse(
                response=inline_serializer(
                    name="CerrarRecepcionAlertaResponse",
                    fields={
                        "alerta": inline_serializer(
                            name="CerrarRecepcionAlerta",
                            fields={
                                "tipo": CharField(),
                                "mensaje": CharField(),
                                "opciones": ListField(child=CharField()),
                            },
                        )
                    },
                )
            ),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="cerrar")
    def cerrar(self, request, pk=None):
        recepcion = self.get_object()
        serializer = CerrarRecepcionSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        if recepcion.bodega_destino_id is None:
            return Response({"detail": "Falta bodega destino."}, status=422)

        if recepcion.boleta.campo_id is None:
            raise ValidationError(
                {"campo": "El campo de origen del trigo es requerido antes de cerrar la recepción."}
            )

        if not recepcion.pesos.filter(tipo=PesoRecepcion.TIPO_LLENO).exists():
            return Response({"detail": "Falta peso lleno."}, status=422)
        if not recepcion.pesos.filter(tipo=PesoRecepcion.TIPO_VACIO).exists():
            return Response({"detail": "Falta peso vacío."}, status=422)
        if not hasattr(recepcion, "muestreo"):
            return Response({"detail": "Falta orden de muestreo."}, status=422)

        muestreo = recepcion.muestreo
        if muestreo.estado != "completado":
            return Response({"detail": "El muestreo aún no está listo."}, status=422)
        # Verificar que se haya calculado el cálculo de calidad (deducciones, rechazo o
        # cálculo explicito sin deducciones aplicables).
        if not (
            recepcion.deducciones.exists()
            or recepcion.rechazo_calidad
            or recepcion.calidad_calculada
        ):
            return Response({"detail": "Debe calcular las deducciones antes de cerrar la recepción."}, status=422)

        if recepcion.estado == Recepcion.ESTADO_EN_DISPUTA:
            return Response(
                {"detail": "La recepción está en disputa. Resuelva la disputa antes de cerrar."},
                status=422,
            )

        if recepcion.boleta.chofer_firma_en_dispositivo and not serializer.validated_data.get("firma_chofer"):
            return Response(
                {"detail": "Se requiere firma del chofer."},
                status=422,
            )

        data = serializer.validated_data

        lleno = recepcion.pesos.get(tipo=PesoRecepcion.TIPO_LLENO).valor
        vacio = recepcion.pesos.get(tipo=PesoRecepcion.TIPO_VACIO).valor
        bruto = Decimal(lleno) - Decimal(vacio)

        neto = bruto
        for d in recepcion.deducciones.all():
            if d.tipo_deduccion == "fijo":
                neto -= Decimal(d.valor_deducido)
            elif d.tipo_deduccion == "porcentaje":
                neto -= (bruto * Decimal(d.valor_deducido)) / Decimal("100")

        boleta = recepcion.boleta
        alerta_pct = getattr(settings, "RECEPCIONES_ALERTA_PCT", 0.05)

        if boleta.peso_estimado and Decimal(boleta.peso_estimado) > 0:
            diff_pct = (abs(neto - Decimal(boleta.peso_estimado)) / Decimal(boleta.peso_estimado))
            if float(diff_pct) > float(alerta_pct) and not data.get("decision"):
                return Response(
                    {
                        "alerta": {
                            "tipo": "diferencia_peso",
                            "mensaje": f"La diferencia con el peso teórico es del {round(float(diff_pct) * 100, 2)}%. ¿Qué desea hacer?",
                            "opciones": ["aceptar_con_comentario", "rechazar", "escalar"],
                        }
                    },
                    status=status.HTTP_409_CONFLICT,
                )

        with transaction.atomic():
            firmas = dict(recepcion.firmas or {})
            if data.get("firma_receptor"):
                firmas["firma_receptor"] = data["firma_receptor"]
            if data.get("firma_chofer"):
                firmas["firma_chofer"] = data["firma_chofer"]

            decision = data.get("decision")
            comentario = data.get("comentario", "")

            if decision == "rechazar":
                self._aplicar_flujo_rechazo(recepcion, comentario)
                recepcion.firmas = firmas
                recepcion.updated_by = request.user
                update_fields = ["estado", "observaciones", "firmas", "updated_by", "updated_at"]
                if recepcion.estado == Recepcion.ESTADO_RECHAZADA:
                    recepcion.fecha_cierre = recepcion.fecha_cierre or timezone.now()
                    update_fields.append("fecha_cierre")
                recepcion.save(update_fields=update_fields)
                if recepcion.estado == Recepcion.ESTADO_RECHAZADA:
                    _actualizar_estado_boleta(boleta, Boleta.ESTADO_RECHAZADA, request.user)
                out = RecepcionSerializer(recepcion, context={"request": request}).data
                out["peso_bruto"] = bruto
                out["peso_neto"] = neto
                return Response(out)

            if decision == "escalar":
                recepcion.estado = Recepcion.ESTADO_ESCALADA
                recepcion.alerta = {
                    "tipo": "diferencia_peso",
                    "decision": decision,
                    "comentario": comentario,
                }
                recepcion.firmas = firmas
                recepcion.updated_by = request.user
                recepcion.save(update_fields=["estado", "alerta", "firmas", "updated_by", "updated_at"])
                out = RecepcionSerializer(recepcion, context={"request": request}).data
                out["peso_bruto"] = bruto
                out["peso_neto"] = neto
                return Response(out, status=status.HTTP_202_ACCEPTED)

            if recepcion.rechazo_calidad and recepcion.estado not in {
                Recepcion.ESTADO_EN_DISPUTA,
                Recepcion.ESTADO_RECHAZADA,
            }:
                self._aplicar_flujo_rechazo(recepcion, comentario)
                recepcion.firmas = firmas
                recepcion.updated_by = request.user
                update_fields = ["estado", "observaciones", "firmas", "updated_by", "updated_at"]
                if recepcion.estado == Recepcion.ESTADO_RECHAZADA:
                    recepcion.fecha_cierre = recepcion.fecha_cierre or timezone.now()
                    update_fields.append("fecha_cierre")
                recepcion.save(update_fields=update_fields)
                if recepcion.estado == Recepcion.ESTADO_RECHAZADA:
                    _actualizar_estado_boleta(boleta, Boleta.ESTADO_RECHAZADA, request.user)
                out = RecepcionSerializer(recepcion, context={"request": request}).data
                out["peso_bruto"] = bruto
                out["peso_neto"] = neto
                return Response(out)

            recepcion.alerta = {}
            recepcion.firmas = firmas
            if comentario:
                recepcion.observaciones = (recepcion.observaciones or "") + ("\n" + comentario)
            recepcion.cerrar()
            recepcion.updated_by = request.user
            recepcion.save(
                update_fields=[
                    "estado",
                    "fecha_cierre",
                    "observaciones",
                    "firmas",
                    "alerta",
                    "updated_by",
                    "updated_at",
                ]
            )

            boleta.estado = Boleta.ESTADO_CERRADA
            boleta.updated_by = request.user
            boleta.save(update_fields=["estado", "updated_by", "updated_at"])

        out = RecepcionSerializer(recepcion, context={"request": request}).data
        out["peso_bruto"] = bruto
        out["peso_neto"] = neto
        return Response(out)

    @extend_schema(
        tags=["Recepciones"],
        summary="Resolver disputa de recepción",
        description=(
            "Permite a un usuario con permiso de Molino/Administrador resolver una "
            "recepción en estado EN_DISPUTA. 'autorizar' la deja en CERRADA_EN_DISPUTA; "
            "'rechazar' la deja en RECHAZADA_EN_DISPUTA. Ambas quedan disponibles para validación."
        ),
        request=ResolverDisputaRecepcionSerializer,
        responses={
            200: OpenApiResponse(response=RecepcionSerializer),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="resolver-disputa")
    def resolver_disputa(self, request, pk=None):
        recepcion = self.get_object()
        if recepcion.estado != Recepcion.ESTADO_EN_DISPUTA:
            raise ValidationError({"estado": "Solo se pueden resolver recepciones en estado EN_DISPUTA."})

        serializer = ResolverDisputaRecepcionSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        data = serializer.validated_data
        decision = data["decision"]
        comentario = data.get("comentario", "")

        if decision == "autorizar":
            recepcion.estado = Recepcion.ESTADO_CERRADA_EN_DISPUTA
        else:
            recepcion.estado = Recepcion.ESTADO_RECHAZADA_EN_DISPUTA
        if comentario:
            recepcion.observaciones = (recepcion.observaciones or "") + ("\n" + comentario)
        recepcion.fecha_cierre = timezone.now()
        recepcion.updated_by = request.user
        recepcion.save(
            update_fields=["estado", "observaciones", "fecha_cierre", "updated_by", "updated_at"]
        )
        estado_boleta = (
            Boleta.ESTADO_RECIBIDA
            if recepcion.estado == Recepcion.ESTADO_CERRADA_EN_DISPUTA
            else Boleta.ESTADO_RECHAZADA
        )
        _actualizar_estado_boleta(recepcion.boleta, estado_boleta, request.user)

        return Response(RecepcionSerializer(recepcion, context={"request": request}).data)

    @extend_schema(
        tags=["Recepciones"],
        summary="Validar recepción",
        description=(
            "Valida una recepción cerrada, reabierta o resuelta de una disputa, "
            "desencadenando la integración dummy con SAP (entrada al almacén) y SICA (volumen entregado). "
            "La primera validación deja la recepción en estado VALIDADA; las validaciones "
            "subsecuentes (tras reabrir y editar) la dejan en EDITADA."
        ),
        responses={
            200: OpenApiResponse(response=RecepcionSerializer),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="validar")
    def validar(self, request, pk=None):
        recepcion = self.get_object()

        estados_validables = {
            Recepcion.ESTADO_CERRADA,
            Recepcion.ESTADO_BORRADOR,
            Recepcion.ESTADO_CERRADA_EN_DISPUTA,
            Recepcion.ESTADO_RECHAZADA_EN_DISPUTA,
        }
        if recepcion.estado not in estados_validables:
            raise ValidationError(
                {"estado": "Solo se pueden validar recepciones en estado CERRADA, BORRADOR, CERRADA_EN_DISPUTA o RECHAZADA_EN_DISPUTA."}
            )

        if recepcion.estado == Recepcion.ESTADO_RECHAZADA_EN_DISPUTA:
            bodega_rechazo = Bodega.objects.filter(
                oficina_id=recepcion.oficina_id, es_rechazo=True
            ).first()
            if not bodega_rechazo or not bodega_rechazo.codigo_sap:
                raise ValidationError(
                    {"bodega_rechazo": "No existe una bodega de rechazo con código SAP para la oficina de la recepción."}
                )
            sap_result = enviar_entrada_sap(recepcion, codigo_sap_bodega=bodega_rechazo.codigo_sap)
        else:
            bodega_destino = getattr(recepcion, "bodega_destino", None)
            sap_result = enviar_entrada_sap(
                recepcion,
                codigo_sap_bodega=getattr(bodega_destino, "codigo_sap", None) or "",
            )
        sica_result = registrar_volumen_sica(recepcion)

        if not sap_result.success or not sica_result.success:
            errores = []
            if not sap_result.success:
                errores.append(f"SAP: {sap_result.mensaje}")
            if not sica_result.success:
                errores.append(f"SICA: {sica_result.mensaje}")
            raise ValidationError({"integracion": errores})

        boleta = recepcion.boleta
        estado_anterior = recepcion.estado

        with transaction.atomic():
            es_revalidacion = recepcion.fecha_validacion is not None
            if es_revalidacion:
                recepcion.marcar_editada()
                accion = RecepcionLog.ACCION_EDITADA
            else:
                recepcion.marcar_validada()
                accion = RecepcionLog.ACCION_VALIDADA

            recepcion.updated_by = request.user
            recepcion._skip_estado_log = True
            recepcion.save(
                update_fields=[
                    "estado",
                    "fecha_validacion",
                    "fecha_edicion",
                    "updated_by",
                    "updated_at",
                ]
            )

            estado_boleta = (
                Boleta.ESTADO_RECHAZADA
                if estado_anterior == Recepcion.ESTADO_RECHAZADA_EN_DISPUTA
                else Boleta.ESTADO_CERRADA
            )
            if boleta.estado != estado_boleta:
                boleta.estado = estado_boleta
                boleta.updated_by = request.user
                boleta._skip_estado_log = True
                boleta.save(update_fields=["estado", "updated_by", "updated_at"])

            RecepcionLog.objects.create(
                recepcion=recepcion,
                usuario=request.user,
                accion=accion,
                estado_anterior=estado_anterior,
                estado_nuevo=recepcion.estado,
                detalle={
                    "sap": {"success": sap_result.success, "mensaje": sap_result.mensaje},
                    "sica": {"success": sica_result.success, "mensaje": sica_result.mensaje},
                },
            )

        return Response(RecepcionSerializer(recepcion, context={"request": request}).data)

    @extend_schema(
        tags=["Recepciones"],
        summary="Reabrir recepción validada/editada",
        description=(
            "Reabre una recepción en estado VALIDADA o EDITADA para permitir correcciones. "
            "Si pertenece a un flete, reabre el flete y todas sus recepciones. "
            "Las recepciones vuelven a BORRADOR y sus boletas a ASIGNADA."
        ),
        request=ReabrirRecepcionSerializer,
        responses={
            200: OpenApiResponse(response=RecepcionSerializer),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="reabrir")
    def reabrir(self, request, pk=None):
        recepcion = self.get_object()

        serializer = ReabrirRecepcionSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        motivo = serializer.validated_data["motivo"].strip()

        if recepcion.estado not in {Recepcion.ESTADO_VALIDADA, Recepcion.ESTADO_EDITADA}:
            raise ValidationError(
                {"estado": "Solo se pueden reabrir recepciones en estado VALIDADA o EDITADA."}
            )

        with transaction.atomic():
            recepciones = [recepcion]
            if recepcion.flete_id:
                flete = recepcion.flete
                recepciones = list(
                    flete.recepciones.select_for_update().select_related("boleta").all()
                )
                flete.reabrir_para_edicion()
                flete.updated_by = request.user
                flete.save(update_fields=["estado", "fecha_cierre", "updated_by", "updated_at"])

            for item in recepciones:
                estado_anterior = item.estado
                item.reabrir_para_edicion()
                item.updated_by = request.user
                item._skip_estado_log = True
                item.save(update_fields=["estado", "fecha_cierre", "updated_by", "updated_at"])

                if item.boleta.estado in {Boleta.ESTADO_RECIBIDA, Boleta.ESTADO_CERRADA}:
                    item.boleta.estado = Boleta.ESTADO_ASIGNADA
                    item.boleta.updated_by = request.user
                    item.boleta._skip_estado_log = True
                    item.boleta.save(update_fields=["estado", "updated_by", "updated_at"])

                RecepcionLog.objects.create(
                    recepcion=item,
                    usuario=request.user,
                    accion=RecepcionLog.ACCION_REABIERTA,
                    estado_anterior=estado_anterior,
                    estado_nuevo=item.estado,
                    detalle={"motivo": motivo, "flete_id": item.flete_id},
                )

        recepcion.refresh_from_db()
        return Response(RecepcionSerializer(recepcion, context={"request": request}).data)

    @extend_schema(
        tags=["Recepciones"],
        summary="Descargar boleta de entrada PDF",
        description="Genera y descarga el PDF de la boleta de entrada para una recepción cerrada, validada o editada.",
        responses={
            200: OpenApiResponse(response=OpenApiTypes.BINARY, description="PDF de boleta de entrada"),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["get"], url_path="boleta-entrada-pdf")
    def boleta_entrada_pdf(self, request, pk=None):
        recepcion = self.get_object()
        estados_con_boleta = {
            Recepcion.ESTADO_CERRADA,
            Recepcion.ESTADO_VALIDADA,
            Recepcion.ESTADO_EDITADA,
        }
        if recepcion.estado not in estados_con_boleta:
            raise ValidationError(
                {"estado": "La recepción debe estar cerrada, validada o editada para generar la boleta de entrada."}
            )

        pdf_bytes = build_boleta_entrada_pdf_bytes(recepcion)
        filename = f"boleta_entrada_{recepcion.boleta.folio}_{recepcion.id}.pdf"
        return HttpResponse(
            pdf_bytes,
            content_type="application/pdf",
            headers={"Content-Disposition": f'attachment; filename="{filename}"'},
        )


class FleteViewSet(AuditFieldsViewSetMixin, ModelViewSet):
    queryset = (
        Flete.objects.select_related("oficina", "variedad", "linea")
        .prefetch_related(
            "recepciones",
            "recepciones__boleta",
            "recepciones__deducciones__parametro",
            "recepciones__bonificaciones__parametro",
            "pesos",
        )
        .order_by("-id")
    )
    serializer_class = FleteSerializer
    pagination_class = StandardResultsSetPagination

    def get_permissions(self):
        if self.action in {"list", "retrieve"}:
            return [CanViewRecepcion()]
        if self.action == "calcular_deducciones":
            return [CanEditRecepcion()]
        return [CanProcessRecepcion()]

    def get_queryset(self):
        queryset = self.queryset
        user = getattr(self.request, "user", None)
        office_id = extract_office_id(self.request)
        if office_id is not None:
            queryset = queryset.filter(oficina_id=office_id)
        if user and user.groups.filter(name="Externo").exists():
            queryset = queryset.filter(
                recepciones__bodega_destino__es_externo=True
            ).distinct()
        return queryset

    @extend_schema(
        tags=["Fletes"],
        summary="Registrar peso lleno del flete",
        description="Registra el peso lleno compartido para todas las recepciones del flete.",
        request=PesoCreateSerializer,
        responses={
            201: OpenApiResponse(response=PesoFleteSerializer),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="peso-lleno")
    def peso_lleno(self, request, pk=None):
        _log_peso(f"flete {pk} peso-lleno", request.data)
        flete = self.get_object()
        serializer = PesoCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        return _registrar_peso_flete(
            request,
            flete,
            serializer.validated_data,
            PesoFlete.TIPO_LLENO,
            Recepcion.ESTADO_PESO_LLENO,
        )

    @extend_schema(
        tags=["Fletes"],
        summary="Registrar peso vacío del flete",
        description=(
            "Registra el peso vacío compartido para todas las recepciones del flete. "
            "Requiere que exista el peso lleno y que el muestreo esté completado."
        ),
        request=PesoCreateSerializer,
        responses={
            201: OpenApiResponse(
                response=inline_serializer(
                    name="PesoVacioFleteResponse",
                    fields={
                        "id": IntegerField(),
                        "tipo": CharField(),
                        "valor": DecimalField(max_digits=10, decimal_places=2),
                        "es_automatico": BooleanField(),
                        "motivo_manual": CharField(allow_blank=True, required=False),
                        "created_at": CharField(),
                        "peso_bruto": DecimalField(max_digits=12, decimal_places=2),
                    },
                )
            ),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="peso-vacio")
    def peso_vacio(self, request, pk=None):
        _log_peso(f"flete {pk} peso-vacio", request.data)
        flete = self.get_object()
        serializer = PesoCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        return _registrar_peso_flete(
            request,
            flete,
            serializer.validated_data,
            PesoFlete.TIPO_VACIO,
            Recepcion.ESTADO_PESO_VACIO,
        )

    @extend_schema(
        tags=["Fletes"],
        summary="Generar orden de muestreo del flete",
        description="Crea una única orden de muestreo compartida para todas las recepciones del flete.",
        request=MuestreoCreateSerializer,
        responses={
            201: OpenApiResponse(response=MuestreoSerializer),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error de validación"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="generar-muestreo")
    def generar_muestreo(self, request, pk=None):
        flete = self.get_object()

        if hasattr(flete, "muestreo"):
            return Response(
                {"detail": "El flete ya tiene muestreo."},
                status=status.HTTP_422_UNPROCESSABLE_ENTITY,
            )

        serializer = MuestreoCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        # La recepción principal es la primera que se agregó al flete y es a
        # la que se asocia formalmente el muestreo. De esta forma el flujo de
        # laboratorio (que espera muestreo.recepcion) sigue funcionando sin
        # cambios, mientras el muestreo sigue perteneciendo al flete para
        # compartirse con el resto de las recepciones.
        recepcion_principal = (
            flete.recepciones.order_by("id").select_related("boleta").first()
        )
        if recepcion_principal is None:
            return Response(
                {"detail": "El flete no tiene recepciones asociadas."},
                status=422,
            )

        tecnico_id = serializer.validated_data.get("tecnico_id")
        muestreo = Muestreo.objects.create(
            flete=flete,
            recepcion=recepcion_principal,
            oficina_id=flete.oficina_id,
            tipo_muestreo=serializer.validated_data["tipo_muestreo"],
            tecnico_asignado_id=tecnico_id,
        )

        flete.estado = Recepcion.ESTADO_MUESTREO_PENDIENTE
        flete.updated_by = request.user
        flete.save(update_fields=["estado", "updated_by", "updated_at"])
        flete.recepciones.exclude(
            estado=Recepcion.ESTADO_MUESTREO_PENDIENTE
        ).update(estado=Recepcion.ESTADO_MUESTREO_PENDIENTE)

        return Response(MuestreoSerializer(muestreo).data, status=status.HTTP_201_CREATED)

    @extend_schema(
        tags=["Fletes"],
        summary="Distribuir peso neto del flete entre recepciones",
        description=(
            "Asigna manualmente la cantidad neta pesada a cada recepción/boleta del flete. "
            "La suma de los kilos asignados debe coincidir con el peso bruto total del flete "
            "(peso lleno - peso vacío)."
        ),
        request=DistribuirPesoSerializer,
        responses={
            200: OpenApiResponse(response=FleteSerializer),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="distribuir-peso")
    def distribuir_peso(self, request, pk=None):
        flete = self.get_object()

        peso_lleno = flete.pesos.filter(tipo=PesoFlete.TIPO_LLENO).first()
        peso_vacio = flete.pesos.filter(tipo=PesoFlete.TIPO_VACIO).first()
        if not peso_lleno or not peso_vacio:
            raise ValidationError({"detail": "Faltan pesos lleno y vacío del flete para distribuir."})

        bruto = (Decimal(peso_lleno.valor) - Decimal(peso_vacio.valor)).quantize(Decimal("0.001"))
        if bruto <= 0:
            raise ValidationError({"peso_bruto": "El peso bruto del flete debe ser mayor a 0."})

        serializer = DistribuirPesoSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        recepciones = {r.id: r for r in flete.recepciones.all()}
        distribuciones = serializer.validated_data["distribuciones"]
        ids_enviados = {item["recepcion_id"] for item in distribuciones}

        if ids_enviados != set(recepciones.keys()):
            raise ValidationError(
                {"distribuciones": "Debe enviar una distribución para cada recepción del flete."}
            )

        total = Decimal("0")
        with transaction.atomic():
            for item in distribuciones:
                recepcion = recepciones[item["recepcion_id"]]
                peso = Decimal(item["peso_neto_asignado"]).quantize(Decimal("0.001"))
                if peso < 0:
                    raise ValidationError(
                        {"distribuciones": "El peso asignado no puede ser negativo."}
                    )
                recepcion.peso_neto_asignado = peso
                recepcion.updated_by = request.user
                recepcion.save(update_fields=["peso_neto_asignado", "updated_by", "updated_at"])
                total += peso

        total = total.quantize(Decimal("0.001"))
        if total != bruto:
            raise ValidationError(
                {
                    "distribuciones": (
                        f"La suma de la distribución ({total}) no coincide con el peso bruto del flete ({bruto})."
                    )
                }
            )

        return Response(FleteSerializer(flete, context={"request": request}).data)

    @extend_schema(
        tags=["Fletes"],
        summary="Calcular deducciones del flete",
        description=(
            "Calcula en una sola operación las deducciones y bonificaciones de todas "
            "las recepciones del flete, usando el análisis compartido y el peso asignado a cada boleta."
        ),
        request=None,
        responses={
            200: OpenApiResponse(
                response=inline_serializer(
                    name="CalcularDeduccionesFleteResponse",
                    fields={
                        "flete_id": IntegerField(),
                        "resultados": ListField(),
                        "flete": FleteSerializer(),
                    },
                )
            ),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="calcular-deducciones")
    def calcular_deducciones(self, request, pk=None):
        flete = self.get_object()
        recepciones = list(flete.recepciones.select_related("boleta").order_by("id"))
        if not recepciones:
            raise ValidationError({"detail": "El flete no tiene recepciones asociadas."})

        calculadora = RecepcionViewSet()
        with transaction.atomic():
            resultados = [
                calculadora._calcular_deducciones_recepcion(recepcion, request.user)
                for recepcion in recepciones
            ]

        flete.refresh_from_db()
        return Response(
            {
                "flete_id": flete.id,
                "resultados": resultados,
                "flete": FleteSerializer(flete, context={"request": request}).data,
            }
        )

    @extend_schema(
        tags=["Fletes"],
        summary="Cerrar flete",
        description=(
            "Cierra el flete y todas sus recepciones asociadas de forma unísona. "
            "Valida pesos, muestreo, distribución, deducciones y firma del chofer."
        ),
        request=CerrarRecepcionSerializer,
        responses={
            200: OpenApiResponse(response=FleteSerializer),
            202: OpenApiResponse(response=FleteSerializer),
            409: OpenApiResponse(
                response=inline_serializer(
                    name="CerrarFleteAlertaResponse",
                    fields={
                        "alerta": inline_serializer(
                            name="CerrarFleteAlerta",
                            fields={
                                "tipo": CharField(),
                                "mensaje": CharField(),
                                "opciones": ListField(child=CharField()),
                            },
                        )
                    },
                )
            ),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="cerrar")
    def cerrar(self, request, pk=None):
        flete = self.get_object()
        serializer = CerrarRecepcionSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        data = serializer.validated_data

        peso_lleno = flete.pesos.filter(tipo=PesoFlete.TIPO_LLENO).first()
        peso_vacio = flete.pesos.filter(tipo=PesoFlete.TIPO_VACIO).first()
        if not peso_lleno or not peso_vacio:
            return Response({"detail": "Faltan pesos lleno y vacío del flete."}, status=422)

        recepciones_sin_campo = flete.recepciones.filter(boleta__campo__isnull=True)
        if recepciones_sin_campo.exists():
            raise ValidationError(
                {"campo": "Todas las boletas del flete deben tener un campo de origen del trigo."}
            )

        muestreo = getattr(flete, "muestreo", None)
        if not muestreo or muestreo.estado != "completado":
            return Response({"detail": "El muestreo del flete debe estar completado."}, status=422)

        recepciones = list(flete.recepciones.select_related("boleta", "bodega_destino").all())
        if not recepciones:
            return Response({"detail": "El flete no tiene recepciones."}, status=422)

        for r in recepciones:
            if r.bodega_destino_id is None:
                return Response(
                    {"detail": f"La recepción {r.id} no tiene bodega destino."},
                    status=422,
                )
            if r.peso_neto_asignado is None or r.peso_neto_asignado <= 0:
                return Response(
                    {"detail": f"La recepción {r.id} no tiene peso neto asignado."},
                    status=422,
                )
            if not (r.deducciones.exists() or r.rechazo_calidad or r.calidad_calculada):
                return Response(
                    {"detail": f"La recepción {r.id} no tiene deducciones calculadas."},
                    status=422,
                )

        if flete.chofer_firma_en_dispositivo and not data.get("firma_chofer"):
            return Response({"detail": "Se requiere firma del chofer."}, status=422)

        bruto = Decimal(peso_lleno.valor) - Decimal(peso_vacio.valor)
        total_estimado = sum(
            Decimal(getattr(r.boleta, "peso_estimado", None) or 0) for r in recepciones
        )
        if total_estimado > 0:
            diff_pct = abs(bruto - total_estimado) / total_estimado
            alerta_pct = getattr(settings, "RECEPCIONES_ALERTA_PCT", 0.05)
            if float(diff_pct) > float(alerta_pct) and not data.get("decision"):
                return Response(
                    {
                        "alerta": {
                            "tipo": "diferencia_peso",
                            "mensaje": (
                                f"La diferencia con el peso teórico total es del "
                                f"{round(float(diff_pct) * 100, 2)}%. ¿Qué desea hacer?"
                            ),
                            "opciones": ["aceptar_con_comentario", "rechazar", "escalar"],
                        }
                    },
                    status=status.HTTP_409_CONFLICT,
                )

        decision = data.get("decision")
        comentario = data.get("comentario", "")
        firmas = dict(flete.firmas or {})
        if data.get("firma_receptor"):
            firmas["firma_receptor"] = data["firma_receptor"]
        if data.get("firma_chofer"):
            firmas["firma_chofer"] = data["firma_chofer"]

        if decision == "rechazar":
            with transaction.atomic():
                flete.estado = Recepcion.ESTADO_RECHAZADA
                flete.fecha_cierre = timezone.now()
                flete.firmas = firmas
                if comentario:
                    flete.observaciones = (flete.observaciones or "") + ("\n" + comentario)
                flete.updated_by = request.user
                flete.save(
                    update_fields=[
                        "estado",
                        "fecha_cierre",
                        "firmas",
                        "observaciones",
                        "updated_by",
                        "updated_at",
                    ]
                )
                for r in recepciones:
                    r.estado = Recepcion.ESTADO_RECHAZADA
                    r.fecha_cierre = flete.fecha_cierre
                    r.firmas = firmas
                    if comentario:
                        r.observaciones = (r.observaciones or "") + ("\n" + comentario)
                    r.updated_by = request.user
                    r.save(
                        update_fields=[
                            "estado",
                            "fecha_cierre",
                            "firmas",
                            "observaciones",
                            "updated_by",
                            "updated_at",
                        ]
                    )
                    _actualizar_estado_boleta(r.boleta, Boleta.ESTADO_RECHAZADA, request.user)
            return Response(FleteSerializer(flete, context={"request": request}).data)

        if decision == "escalar":
            with transaction.atomic():
                flete.estado = Recepcion.ESTADO_ESCALADA
                flete.alerta = {
                    "tipo": "diferencia_peso",
                    "decision": decision,
                    "comentario": comentario,
                }
                flete.firmas = firmas
                flete.updated_by = request.user
                flete.save(
                    update_fields=[
                        "estado",
                        "alerta",
                        "firmas",
                        "updated_by",
                        "updated_at",
                    ]
                )
                flete.recepciones.update(
                    estado=Recepcion.ESTADO_ESCALADA,
                    updated_by=request.user,
                    updated_at=timezone.now(),
                )
            return Response(FleteSerializer(flete, context={"request": request}).data, status=status.HTTP_202_ACCEPTED)

        with transaction.atomic():
            flete.alerta = {}
            flete.firmas = firmas
            if comentario:
                flete.observaciones = (flete.observaciones or "") + ("\n" + comentario)
            flete.estado = Recepcion.ESTADO_CERRADA
            flete.fecha_cierre = timezone.now()
            flete.updated_by = request.user
            flete.save(
                update_fields=[
                    "estado",
                    "fecha_cierre",
                    "observaciones",
                    "firmas",
                    "alerta",
                    "updated_by",
                    "updated_at",
                ]
            )
            for r in recepciones:
                r.estado = Recepcion.ESTADO_CERRADA
                r.fecha_cierre = flete.fecha_cierre
                r.firmas = firmas
                if comentario:
                    r.observaciones = (r.observaciones or "") + ("\n" + comentario)
                r.updated_by = request.user
                r.save(
                    update_fields=[
                        "estado",
                        "fecha_cierre",
                        "observaciones",
                        "firmas",
                        "updated_by",
                        "updated_at",
                    ]
                )
                if r.boleta.estado != Boleta.ESTADO_CERRADA:
                    r.boleta.estado = Boleta.ESTADO_CERRADA
                    r.boleta.updated_by = request.user
                    r.boleta.save(update_fields=["estado", "updated_by", "updated_at"])

        return Response(FleteSerializer(flete, context={"request": request}).data)

    @extend_schema(
        tags=["Fletes"],
        summary="Validar flete",
        description=(
            "Valida un flete cuyas recepciones estén listas para validar. "
            "Cada recepción se valida según su propio estado, permitiendo que un flete "
            "tenga boletas con distintas resoluciones de disputa. "
            "Envía una entrada a SAP y registra volumen en SICA por cada boleta/recepción del flete."
        ),
        responses={
            200: OpenApiResponse(response=FleteSerializer),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["post"], url_path="validar")
    def validar(self, request, pk=None):
        flete = self.get_object()

        estados_validables = {
            Recepcion.ESTADO_CERRADA,
            Recepcion.ESTADO_BORRADOR,
            Recepcion.ESTADO_CERRADA_EN_DISPUTA,
            Recepcion.ESTADO_RECHAZADA_EN_DISPUTA,
        }

        if not flete.pesos.filter(tipo=PesoFlete.TIPO_LLENO).exists():
            raise ValidationError({"peso_lleno": "Falta registrar el peso lleno del flete."})
        if not flete.pesos.filter(tipo=PesoFlete.TIPO_VACIO).exists():
            raise ValidationError({"peso_vacio": "Falta registrar el peso vacío del flete."})

        muestreo = getattr(flete, "muestreo", None)
        if not muestreo or muestreo.estado != "completado":
            raise ValidationError({"muestreo": "El muestreo del flete debe estar completado."})

        recepciones = list(flete.recepciones.select_related("boleta", "bodega_destino").all())
        if not recepciones:
            raise ValidationError({"recepciones": "El flete no tiene recepciones."})

        for r in recepciones:
            if r.estado not in estados_validables:
                raise ValidationError(
                    {"estado": f"La recepción {r.id} no está en estado válido para validar."}
                )

        resultados_integracion = []
        with transaction.atomic():
            es_revalidacion = flete.fecha_validacion is not None
            estado_anterior = flete.estado

            for r in recepciones:
                if r.estado == Recepcion.ESTADO_RECHAZADA_EN_DISPUTA:
                    bodega_rechazo = Bodega.objects.filter(
                        oficina_id=r.oficina_id, es_rechazo=True
                    ).first()
                    if not bodega_rechazo or not bodega_rechazo.codigo_sap:
                        raise ValidationError(
                            {"bodega_rechazo": "No existe una bodega de rechazo con código SAP para la oficina de la recepción."}
                        )
                    sap_result = enviar_entrada_sap(r, codigo_sap_bodega=bodega_rechazo.codigo_sap)
                else:
                    bodega_destino = getattr(r, "bodega_destino", None)
                    sap_result = enviar_entrada_sap(
                        r,
                        codigo_sap_bodega=getattr(bodega_destino, "codigo_sap", None) or "",
                    )
                sica_result = registrar_volumen_sica(r)

                if not sap_result.success or not sica_result.success:
                    errores = []
                    if not sap_result.success:
                        errores.append(f"SAP: {sap_result.mensaje}")
                    if not sica_result.success:
                        errores.append(f"SICA: {sica_result.mensaje}")
                    raise ValidationError({"integracion": errores})

                resultados_integracion.append(
                    {
                        "recepcion_id": r.id,
                        "sap": sap_result.mensaje,
                        "sica": sica_result.mensaje,
                    }
                )

                estado_boleta = (
                    Boleta.ESTADO_RECHAZADA
                    if r.estado == Recepcion.ESTADO_RECHAZADA_EN_DISPUTA
                    else Boleta.ESTADO_CERRADA
                )
                if r.boleta.estado != estado_boleta:
                    r.boleta.estado = estado_boleta
                    r.boleta.updated_by = request.user
                    r.boleta._skip_estado_log = True
                    r.boleta.save(update_fields=["estado", "updated_by", "updated_at"])

            if es_revalidacion:
                flete.estado = Recepcion.ESTADO_EDITADA
                flete.fecha_edicion = timezone.now()
            else:
                flete.estado = Recepcion.ESTADO_VALIDADA
                flete.fecha_validacion = timezone.now()
            flete.updated_by = request.user
            flete.save(
                update_fields=[
                    "estado",
                    "fecha_validacion",
                    "fecha_edicion",
                    "updated_by",
                    "updated_at",
                ]
            )

            for r in recepciones:
                if es_revalidacion:
                    r.estado = Recepcion.ESTADO_EDITADA
                    r.fecha_edicion = flete.fecha_edicion
                    update_fields_r = ["estado", "fecha_edicion", "updated_by", "updated_at"]
                else:
                    r.estado = Recepcion.ESTADO_VALIDADA
                    r.fecha_validacion = flete.fecha_validacion
                    update_fields_r = ["estado", "fecha_validacion", "updated_by", "updated_at"]
                r.updated_by = request.user
                r._skip_estado_log = True
                r.save(update_fields=update_fields_r)

                accion = (
                    RecepcionLog.ACCION_EDITADA
                    if es_revalidacion
                    else RecepcionLog.ACCION_VALIDADA
                )
                RecepcionLog.objects.create(
                    recepcion=r,
                    usuario=request.user,
                    accion=accion,
                    estado_anterior=estado_anterior,
                    estado_nuevo=r.estado,
                    detalle={"flete_id": flete.id},
                )

        return Response(FleteSerializer(flete, context={"request": request}).data)

    @extend_schema(
        tags=["Fletes"],
        summary="Descargar boleta de entrada PDF del flete",
        description="Genera y descarga un único PDF de boleta de entrada para un flete cerrado, validado o editado.",
        responses={
            200: OpenApiResponse(response=OpenApiTypes.BINARY, description="PDF de boleta de entrada del flete"),
            422: OpenApiResponse(response=ErrorDetailSerializer, description="Error"),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(response=ErrorDetailSerializer, description="No encontrado"),
        },
    )
    @action(detail=True, methods=["get"], url_path="boleta-entrada-pdf")
    def boleta_entrada_pdf(self, request, pk=None):
        flete = self.get_object()
        estados_con_boleta = {
            Recepcion.ESTADO_CERRADA,
            Recepcion.ESTADO_VALIDADA,
            Recepcion.ESTADO_EDITADA,
        }
        if flete.estado not in estados_con_boleta:
            raise ValidationError(
                {"estado": "El flete debe estar cerrado, validado o editado para generar la boleta de entrada."}
            )

        pdf_bytes = build_flete_boleta_entrada_pdf_bytes(flete)
        filename = f"boleta_entrada_flete_{flete.id}.pdf"
        return HttpResponse(
            pdf_bytes,
            content_type="application/pdf",
            headers={"Content-Disposition": f'attachment; filename="{filename}"'},
        )


def boleta_entrada_preview(request):
    """Vista temporal para previsualizar el template de boleta de entrada."""
    ctx = {
        "fecha": "06/JUL/2026",
        "hora": "09:18 AM",
        "productor_clave": "002",
        "productor_nombre": "ALMACENADORA DE GRANOS SAFINSA S.A. DE C.V.",
        "tipo_ejidalario_marcado": "",
        "tipo_propietario_marcado": "X",
        "origen": "VALLE DEL YAQUI",
        "variedad": "BORLAUG",
        "bodega": "VICTORIA 02",
        "camion": "INT 2025",
        "placas": "759DU7",
        "conductor": "PEDRO GONZALEZ",
        "observaciones": "MUESTRA 868 DESCARGA EN BODEGA VIC 44",
        "peso_bruto": "18,600.00 KG",
        "tara": "2,600.00 KG",
        "peso_neto": "16,000.00 KG",
        "total_deducciones": "888.00 KG",
        "total_bonificaciones": "$ 0.00",
        "peso_analizado": "15,112.00 KG",
        "analisis": [
            {"parametro": "PUNTA NEGRA", "valor": "3.20", "unidad": "%", "deduccion": "200.00 KG"},
            {"parametro": "PANZA BLANCA", "valor": "12.00", "unidad": "%", "deduccion": "96.00 KG"},
            {"parametro": "GRANO DANADO", "valor": "1.7", "unidad": "%", "deduccion": "32.00 KG"},
            {"parametro": "PESO ESPECIFICO", "valor": "78.00", "unidad": "K/HI", "deduccion": "96.00 KG"},
            {"parametro": "PROTEINA", "valor": "12.80", "unidad": "%", "deduccion": "148.48 KG"},
            {"parametro": "HUMEDAD", "valor": "12.10", "unidad": "%", "deduccion": "$ 0.00"},
            {"parametro": "IMPUREZA", "valor": "2.10", "unidad": "%", "deduccion": "315.52 KG"},
            {"parametro": "CARBON PARCIAL", "valor": "0.00", "unidad": "%", "deduccion": "0.00 KG"},
        ],
    }
    html = render_to_string("recepciones/boleta_entrada_pdf.html", ctx)
    return HttpResponse(html)
