Files
CRM_AGENTES_CARGA/backend/api/v1/modules/crm/accounts/service.py
Jair Cedillo f4ef6a037d feat(fin,crm): catálogo c_UsoCFDI y claves fiscales del receptor
Cierra las decisiones pendientes 1 y 5. Agrega sat.cfdi_uses con su endpoint de
solo lectura y amarra la ficha del cliente a los catálogos del SAT con
crm.accounts.tax_regime_id y cfdi_use_id.

Las columnas de texto libre tax_regime y cfdi_use se conservan intactas: la
migración hace un backfill conservador que solo resuelve lo inequívoco (la clave
del catálogo, o la descripción exacta sin distinguir mayúsculas ni espacios) y
deja en NULL lo que no case, porque deducir el régimen de un receptor a partir
de texto libre provoca CFDI rechazados. La UI muestra el texto anterior junto al
selector para que el usuario elija la clave que corresponde.

El selector de régimen se acota al tipo de persona de la cuenta, y el service
valida ambas claves contra el catálogo.

sync_catalogs ahora omite los catálogos cuya tabla todavía no existe: al correr
el historial desde cero, la migración anterior la invoca antes de que se creen
los catálogos agregados después.

Las claves de c_UsoCFDI quedan pendientes de validación con el área Fiscal antes
de producción, igual que el subset de c_ClaveProdServ; no se cargaron las
banderas de persona física/moral ni la compatibilidad por régimen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:50:26 -05:00

109 lines
3.3 KiB
Python

from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime
from .dto import AccountCreate, AccountUpdate
from .models import Account
def _validate_sat_refs(db: Session, data: dict) -> None:
"""Verifica las claves del SAT del receptor antes de guardar la cuenta."""
for field, model, msg in [
("tax_regime_id", TaxRegime, "El régimen fiscal indicado no existe en el catálogo del SAT"),
("cfdi_use_id", CfdiUse, "El uso de CFDI indicado no existe en el catálogo del SAT"),
]:
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 get_accounts(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
account_status: str | None = None,
record_type: str | None = None,
) -> list[Account]:
query = db.query(Account).filter(
Account.tenant_id == tenant_id,
Account.company_id == company_id,
Account.deleted_at.is_(None),
)
if search:
pattern = f"%{search}%"
query = query.filter(
Account.name.ilike(pattern)
| Account.trade_name.ilike(pattern)
| Account.rfc.ilike(pattern)
)
if account_status:
query = query.filter(Account.status == account_status)
if record_type:
query = query.filter(Account.record_type == record_type)
return query.order_by(Account.name.asc()).all()
def get_account(db: Session, account_id: int, tenant_id: int, company_id: int) -> Account:
account = (
db.query(Account)
.filter(
Account.id == account_id,
Account.tenant_id == tenant_id,
Account.company_id == company_id,
Account.deleted_at.is_(None),
)
.first()
)
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cliente no encontrado")
return account
def create_account(
db: Session, payload: AccountCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Account:
data = payload.model_dump()
_validate_sat_refs(db, data)
account = Account(
**data,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(account)
db.commit()
db.refresh(account)
return account
def update_account(
db: Session,
account_id: int,
payload: AccountUpdate,
tenant_id: int,
company_id: int,
user_id: str | None = None,
) -> Account:
account = get_account(db, account_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_sat_refs(db, data)
for field, value in data.items():
setattr(account, field, value)
account.updated_by = user_id
db.commit()
db.refresh(account)
return account
def delete_account(db: Session, account_id: int, tenant_id: int, company_id: int) -> None:
account = get_account(db, account_id, tenant_id, company_id)
# Soft delete: conserva el histórico comercial del cliente
account.deleted_at = datetime.now(timezone.utc)
db.commit()