"""Lógica del catálogo de conceptos de facturación. Todas las consultas filtran por ``tenant_id``, ``company_id`` y ``deleted_at IS NULL``: el catálogo es privado de cada empresa dentro de cada tenant. """ from datetime import datetime, timezone from fastapi import HTTPException, status from sqlalchemy import or_ from sqlalchemy.orm import Session from ..catalogs.models import ProductService, TaxObject, UnitOfMeasure from .dto import ConceptCreate, ConceptUpdate from .models import Concept def _check_sat_refs(db: Session, data: dict) -> None: """Verifica que las claves del SAT referidas existan antes de guardar.""" for field, model, msg in [ ("product_service_id", ProductService, "La clave de producto/servicio del SAT no existe"), ("unit_of_measure_id", UnitOfMeasure, "La unidad de medida del SAT no existe"), ("tax_object_id", TaxObject, "El objeto de impuesto del SAT no existe"), ]: value = data.get(field) if field in data and value is not None: if db.query(model.id).filter(model.id == value).first() is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg) def _check_unique( db: Session, tenant_id: int, company_id: int, code: str | None, product_service_id: int | None, exclude_id: int | None = None, ) -> None: """Aplica en el service las mismas reglas que los índices únicos parciales. Sin esto el conflicto llegaría al cliente como un IntegrityError crudo; aquí se traduce a un 409 con mensaje en español. """ base = db.query(Concept).filter( Concept.tenant_id == tenant_id, Concept.company_id == company_id, Concept.deleted_at.is_(None), ) if exclude_id is not None: base = base.filter(Concept.id != exclude_id) if code is not None and base.filter(Concept.code == code).first() is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Ya existe un concepto con la clave '{code}' en esta empresa", ) # Regla 1:1 — una clave ProdServ no puede repetirse entre conceptos de la empresa. if product_service_id is not None and base.filter( Concept.product_service_id == product_service_id ).first() is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="La clave de producto/servicio del SAT ya está asignada a otro concepto de esta empresa", ) def get_concepts( db: Session, tenant_id: int, company_id: int, search: str | None = None, active_only: bool | None = None, product_service_id: int | None = None, ) -> list[Concept]: q = db.query(Concept).filter( Concept.tenant_id == tenant_id, Concept.company_id == company_id, Concept.deleted_at.is_(None), ) if active_only is not None: q = q.filter(Concept.is_active.is_(active_only)) if product_service_id is not None: q = q.filter(Concept.product_service_id == product_service_id) if search: term = f"%{search.strip()}%" q = q.filter(or_(Concept.code.ilike(term), Concept.description.ilike(term))) return q.order_by(Concept.code.asc()).all() def get_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> Concept: obj = db.query(Concept).filter( Concept.id == concept_id, Concept.tenant_id == tenant_id, Concept.company_id == company_id, Concept.deleted_at.is_(None), ).first() if not obj: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado") return obj def create_concept( db: Session, payload: ConceptCreate, tenant_id: int, company_id: int, user_id: str | None = None ) -> Concept: data = payload.model_dump() _check_sat_refs(db, data) _check_unique(db, tenant_id, company_id, data["code"], data["product_service_id"]) obj = Concept(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) db.add(obj) db.commit() db.refresh(obj) return obj def update_concept( db: Session, concept_id: int, payload: ConceptUpdate, tenant_id: int, company_id: int, user_id: str | None = None, ) -> Concept: obj = get_concept(db, concept_id, tenant_id, company_id) data = payload.model_dump(exclude_unset=True) _check_sat_refs(db, data) _check_unique( db, tenant_id, company_id, data.get("code"), data.get("product_service_id"), exclude_id=obj.id, ) for field, value in data.items(): setattr(obj, field, value) obj.updated_by = user_id db.commit() db.refresh(obj) return obj def delete_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> None: """Baja lógica: libera la clave ProdServ y el código para un concepto nuevo.""" obj = get_concept(db, concept_id, tenant_id, company_id) obj.deleted_at = datetime.now(timezone.utc) db.commit()