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>
This commit is contained in:
@@ -25,6 +25,9 @@ class AccountBase(BaseModel):
|
||||
# Fiscal
|
||||
tax_regime: str | None = Field(None, max_length=120)
|
||||
cfdi_use: str | None = Field(None, max_length=60)
|
||||
# Claves contra los catálogos del SAT; sustituyen al texto libre de arriba al timbrar.
|
||||
tax_regime_id: int | None = Field(None, description="c_RegimenFiscal del receptor")
|
||||
cfdi_use_id: int | None = Field(None, description="c_UsoCFDI del receptor")
|
||||
payment_method: str | None = Field(None, max_length=60)
|
||||
payment_form: str | None = Field(None, max_length=60)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
@@ -65,6 +68,8 @@ class AccountUpdate(BaseModel):
|
||||
website: str | None = Field(None, max_length=255)
|
||||
tax_regime: str | None = Field(None, max_length=120)
|
||||
cfdi_use: str | None = Field(None, max_length=60)
|
||||
tax_regime_id: int | None = None
|
||||
cfdi_use_id: int | None = None
|
||||
payment_method: str | None = Field(None, max_length=60)
|
||||
payment_form: str | None = Field(None, max_length=60)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Integer, Numeric, String, Text, text
|
||||
from sqlalchemy import ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime # noqa: F401 (resuelve las FK)
|
||||
from core.database import Base
|
||||
|
||||
|
||||
@@ -47,8 +48,17 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
|
||||
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# ----- Información fiscal -----
|
||||
# Régimen fiscal y uso de CFDI en texto libre: se conservan como capturó el usuario
|
||||
# para no perder lo ya registrado, pero lo que vale al timbrar son las FK de abajo.
|
||||
tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal
|
||||
cfdi_use: Mapped[str | None] = mapped_column(String(60), nullable=True) # uso de CFDI
|
||||
# Claves del receptor contra los catálogos del SAT (c_RegimenFiscal y c_UsoCFDI).
|
||||
tax_regime_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.tax_regimes.id"), nullable=True
|
||||
)
|
||||
cfdi_use_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.cfdi_uses.id"), nullable=True
|
||||
)
|
||||
payment_method: Mapped[str | None] = mapped_column(String(60), nullable=True) # método de pago
|
||||
payment_form: Mapped[str | None] = mapped_column(String(60), nullable=True) # forma de pago
|
||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True) # moneda
|
||||
|
||||
@@ -3,10 +3,24 @@ 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,
|
||||
@@ -53,8 +67,10 @@ def get_account(db: Session, account_id: int, tenant_id: int, company_id: int) -
|
||||
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(
|
||||
**payload.model_dump(),
|
||||
**data,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
created_by=user_id,
|
||||
@@ -75,7 +91,9 @@ def update_account(
|
||||
user_id: str | None = None,
|
||||
) -> Account:
|
||||
account = get_account(db, account_id, tenant_id, company_id)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user