Files
CRM_AGENTES_CARGA/backend/api/v1/modules/fin/concepts/routes.py
Jair Cedillo 9cf142add6 feat(fin): CRUD de conceptos con relación 1:1 a clave ProdServ
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>
2026-08-07 16:57:39 -05:00

97 lines
3.1 KiB
Python

"""Endpoints del catálogo de conceptos de facturación (CRUD por empresa)."""
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from api.v1.modules.core.permissions.dependencies import PermissionChecker
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import ConceptCreate, ConceptResponse, ConceptUpdate
router = APIRouter()
def _uid(current_user: dict) -> str | None:
return current_user.get("sub") or current_user.get("id")
@router.get(
"/concepts",
response_model=list[ConceptResponse],
dependencies=[Depends(PermissionChecker(["fin.concept.view"]))],
)
def list_concepts(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None, description="Búsqueda por clave o descripción"),
active_only: bool | None = Query(None, description="Filtra por conceptos activos o inactivos"),
product_service_id: int | None = Query(None, description="Filtra por clave ProdServ del SAT"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_concepts(
db, current_user["tenant_id"], company_id, search, active_only, product_service_id
)
@router.get(
"/concepts/{concept_id}",
response_model=ConceptResponse,
dependencies=[Depends(PermissionChecker(["fin.concept.view"]))],
)
def get_concept(
concept_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_concept(db, concept_id, current_user["tenant_id"], company_id)
@router.post(
"/concepts",
response_model=ConceptResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(PermissionChecker(["fin.concept.create"]))],
)
def create_concept(
payload: ConceptCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.create_concept(db, payload, current_user["tenant_id"], company_id, _uid(current_user))
@router.patch(
"/concepts/{concept_id}",
response_model=ConceptResponse,
dependencies=[Depends(PermissionChecker(["fin.concept.edit"]))],
)
def update_concept(
concept_id: int,
payload: ConceptUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.update_concept(
db, concept_id, payload, current_user["tenant_id"], company_id, _uid(current_user)
)
@router.delete(
"/concepts/{concept_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(PermissionChecker(["fin.concept.delete"]))],
)
def delete_concept(
concept_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Baja lógica del concepto (``deleted_at``)."""
service.delete_concept(db, concept_id, current_user["tenant_id"], company_id)