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>
97 lines
3.1 KiB
Python
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)
|