feat(crm): catálogos Clientes/Prospectos (enriquecido) y Proveedores + direcciones/documentos

Alinea el dominio al spec de catálogos:
- accounts (Clientes/Prospectos): tipo registro/persona, CURP, clasificación
  comercial, bloque fiscal (régimen, CFDI, pago, crédito), auditoría, notas internas
- suppliers (Proveedores): clasificación múltiple, cobertura, países/puertos/
  aeropuertos/aduanas (JSON), fiscal
- addresses y documents: tablas compartidas con FK a cliente o proveedor
- contacts enriquecidos (extensión, whatsapp, área, flags "recibe…", supplier_id)
- migración a7b8c9d0e1f2 (ALTER + CREATE) con downgrade completo
- permisos supplier/address/document; seed de datos actualizado
- 39 tests pytest en verde

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-14 16:19:13 -06:00
parent 2ed6247f1e
commit b12af1a561
31 changed files with 1599 additions and 101 deletions

View File

@@ -1,4 +1,6 @@
from sqlalchemy import Integer, String, Text, text
from decimal import Decimal
from sqlalchemy import Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -6,35 +8,65 @@ from core.database import Base
class Account(Base, TenantScopedMixin, TimestampMixin):
"""Cuenta CRM: empresa cliente o prospecto.
"""Catálogo de Clientes / Prospectos.
Modela importadores, IMMEX, agencias aduanales, transportistas, etc.
Los campos aduaneros (RFC, patente) son opcionales para no forzar datos
en prospectos que aún no comparten información fiscal.
Un mismo registro puede ser Cliente o Prospecto (``record_type``). Concentra
datos generales, comerciales y fiscales; las direcciones, contactos y
documentos viven en tablas relacionadas (crm.addresses, crm.contacts,
crm.documents).
"""
__tablename__ = "accounts"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# Razón social (nombre legal) y nombre comercial
name: Mapped[str] = mapped_column(String(255), nullable=False)
trade_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# ----- Datos generales -----
name: Mapped[str] = mapped_column(String(255), nullable=False) # razón social
trade_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # nombre comercial
rfc: Mapped[str | None] = mapped_column(String(13), nullable=True, index=True)
# Tipo de cuenta: immex | agencia_aduanal | importador | exportador | transportista | otro
curp: Mapped[str | None] = mapped_column(String(18), nullable=True) # persona física
# Tipo de registro: cliente | prospecto
record_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'cliente'"), index=True)
# Tipo de persona: fisica | moral
person_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
industry: Mapped[str | None] = mapped_column(String(120), nullable=True) # giro / industria
# Tipo operativo (immex | agencia_aduanal | importador | exportador | transportista | otro)
account_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
industry: Mapped[str | None] = mapped_column(String(120), nullable=True)
# Estatus: active | inactive
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
# ----- Información comercial -----
# Clasificación: importador | exportador | ambos
commercial_classification: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Medio de contacto preferido: llamada | correo | videollamada | whatsapp | otro
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
language: Mapped[str | None] = mapped_column(String(40), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
# ----- Información fiscal -----
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
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
credit_limit: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True)
credit_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
commercial_terms: Mapped[str | None] = mapped_column(Text, nullable=True) # condiciones comerciales
# ----- Datos aduaneros / ubicación rápida -----
patente_aduanal: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Ubicación de referencia (el detalle vive en crm.addresses)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
city: Mapped[str | None] = mapped_column(String(120), nullable=True)
state: Mapped[str | None] = mapped_column(String(120), nullable=True)
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
# Patente del agente aduanal (dato aduanero, no se traduce)
patente_aduanal: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Estado comercial: active | inactive | prospect
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
# Vendedor responsable (id de usuario Keycloak / sub)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# ----- Observaciones y auditoría -----
notes: Mapped[str | None] = mapped_column(Text, nullable=True) # comentarios generales
internal_notes: Mapped[str | None] = mapped_column(Text, nullable=True) # notas internas
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) # vendedor
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)