fin.concepts es el catálogo de conceptos facturables de cada empresa, ligado a una clave de producto/servicio del SAT. La relación es 1:1 por empresa: si dos conceptos compartieran la misma clave, al timbrar no habría forma de saber qué descripción corresponde. La unicidad se garantiza por índice único parcial (WHERE deleted_at IS NULL) y se valida además en el service para devolver 409 con mensaje en español en vez de un IntegrityError crudo. La baja lógica libera la clave y el código. Las respuestas traen los objetos del catálogo ya resueltos (selectin) para que el frontend no dispare N+1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
"""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()
|