diff --git a/backend/alembic/versions/e6f7a8b9c0d1_crm_catalog_items.py b/backend/alembic/versions/e6f7a8b9c0d1_crm_catalog_items.py new file mode 100644 index 0000000..ac46b23 --- /dev/null +++ b/backend/alembic/versions/e6f7a8b9c0d1_crm_catalog_items.py @@ -0,0 +1,90 @@ +"""crm catalog_items (catálogos de referencia) + columnas nuevas accounts/suppliers + +Revision ID: e6f7a8b9c0d1 +Revises: d5e6f7a8b9c0 +Create Date: 2026-07-22 00:00:00.000000 + +Soporta T2026-07-081 (Clientes/Prospectos) y T2026-07-082 (Proveedores): +catálogos de referencia SAT/ISO + propios del cliente, y campos faltantes +(observaciones comerciales, "otro" de medio de contacto y de clasificación). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "e6f7a8b9c0d1" +down_revision: Union[str, None] = "d5e6f7a8b9c0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + # ----- crm.catalog_items ----- + op.create_table( + "catalog_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("catalog", sa.String(length=60), nullable=False), + sa.Column("code", sa.String(length=64), nullable=False), + sa.Column("label", sa.String(length=255), nullable=False), + sa.Column("parent_catalog", sa.String(length=60), nullable=True), + sa.Column("parent_code", sa.String(length=64), nullable=True), + # NULL = catálogo global (Aduanasoft); con valor = catálogo del tenant. + sa.Column("tenant_id", sa.Integer(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("extra", sa.JSON(), nullable=True), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("updated_by", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.PrimaryKeyConstraint("id"), + schema=SCHEMA, + ) + op.create_index("ix_crm_catalog_items_id", "catalog_items", ["id"], schema=SCHEMA) + op.create_index("ix_crm_catalog_items_catalog", "catalog_items", ["catalog"], schema=SCHEMA) + op.create_index("ix_crm_catalog_items_tenant_id", "catalog_items", ["tenant_id"], schema=SCHEMA) + op.create_index( + "ix_crm_catalog_items_lookup", "catalog_items", ["catalog", "tenant_id", "is_active"], schema=SCHEMA + ) + # Unicidad de clave por catálogo: global (tenant NULL) y por tenant, separadas. + op.create_index( + "uq_crm_catalog_items_global", + "catalog_items", + ["catalog", "code"], + unique=True, + schema=SCHEMA, + postgresql_where=sa.text("tenant_id IS NULL"), + ) + op.create_index( + "uq_crm_catalog_items_tenant", + "catalog_items", + ["catalog", "code", "tenant_id"], + unique=True, + schema=SCHEMA, + postgresql_where=sa.text("tenant_id IS NOT NULL"), + ) + + # ----- columnas nuevas ----- + # Clientes/Prospectos: observaciones comerciales + "otro" del medio de contacto. + op.add_column("accounts", sa.Column("commercial_observations", sa.Text(), nullable=True), schema=SCHEMA) + op.add_column("accounts", sa.Column("preferred_contact_other", sa.String(length=120), nullable=True), schema=SCHEMA) + # Proveedores: "otro" de la clasificación múltiple. + op.add_column("suppliers", sa.Column("classification_other", sa.String(length=120), nullable=True), schema=SCHEMA) + + +def downgrade() -> None: + op.drop_column("suppliers", "classification_other", schema=SCHEMA) + op.drop_column("accounts", "preferred_contact_other", schema=SCHEMA) + op.drop_column("accounts", "commercial_observations", schema=SCHEMA) + + op.drop_index("uq_crm_catalog_items_tenant", table_name="catalog_items", schema=SCHEMA) + op.drop_index("uq_crm_catalog_items_global", table_name="catalog_items", schema=SCHEMA) + op.drop_index("ix_crm_catalog_items_lookup", table_name="catalog_items", schema=SCHEMA) + op.drop_index("ix_crm_catalog_items_tenant_id", table_name="catalog_items", schema=SCHEMA) + op.drop_index("ix_crm_catalog_items_catalog", table_name="catalog_items", schema=SCHEMA) + op.drop_index("ix_crm_catalog_items_id", table_name="catalog_items", schema=SCHEMA) + op.drop_table("catalog_items", schema=SCHEMA) diff --git a/backend/api/v1/modules/crm/accounts/dto.py b/backend/api/v1/modules/crm/accounts/dto.py index b7aa564..4951a14 100644 --- a/backend/api/v1/modules/crm/accounts/dto.py +++ b/backend/api/v1/modules/crm/accounts/dto.py @@ -18,10 +18,12 @@ class AccountBase(BaseModel): # Comercial commercial_classification: str | None = Field(None, max_length=20) preferred_contact_method: str | None = Field(None, max_length=20) + preferred_contact_other: str | None = Field(None, max_length=120) language: str | None = Field(None, max_length=40) email: EmailStr | None = None phone: str | None = Field(None, max_length=40) website: str | None = Field(None, max_length=255) + commercial_observations: str | None = None # observaciones generales # Fiscal tax_regime: str | None = Field(None, max_length=120) cfdi_use: str | None = Field(None, max_length=60) @@ -59,10 +61,12 @@ class AccountUpdate(BaseModel): status: str | None = Field(None, max_length=20) commercial_classification: str | None = Field(None, max_length=20) preferred_contact_method: str | None = Field(None, max_length=20) + preferred_contact_other: str | None = Field(None, max_length=120) language: str | None = Field(None, max_length=40) email: EmailStr | None = None phone: str | None = Field(None, max_length=40) website: str | None = Field(None, max_length=255) + commercial_observations: str | None = None tax_regime: str | None = Field(None, max_length=120) cfdi_use: str | None = Field(None, max_length=60) payment_method: str | None = Field(None, max_length=60) diff --git a/backend/api/v1/modules/crm/accounts/models.py b/backend/api/v1/modules/crm/accounts/models.py index 9f23d46..70c8dad 100644 --- a/backend/api/v1/modules/crm/accounts/models.py +++ b/backend/api/v1/modules/crm/accounts/models.py @@ -39,12 +39,15 @@ class Account(Base, TenantScopedMixin, TimestampMixin): # ----- 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 + # Medio de contacto preferido: llamada | correo | videoconferencia | whatsapp | otro preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True) + # Texto libre cuando el medio de contacto es "otro" + preferred_contact_other: Mapped[str | None] = mapped_column(String(120), 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) + commercial_observations: Mapped[str | None] = mapped_column(Text, nullable=True) # observaciones generales # ----- Información fiscal ----- tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal diff --git a/backend/api/v1/modules/crm/catalogs/dto.py b/backend/api/v1/modules/crm/catalogs/dto.py new file mode 100644 index 0000000..38d7df1 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/dto.py @@ -0,0 +1,45 @@ +"""Schemas (DTOs) de los catálogos de referencia del CRM.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class CatalogItemBase(BaseModel): + code: str = Field(..., max_length=64) + label: str = Field(..., max_length=255) + parent_catalog: str | None = Field(None, max_length=60) + parent_code: str | None = Field(None, max_length=64) + sort_order: int = 0 + is_active: bool = True + + +class CatalogItemCreate(CatalogItemBase): + pass + + +class CatalogItemUpdate(BaseModel): + """PATCH: todos los campos opcionales.""" + + code: str | None = Field(None, max_length=64) + label: str | None = Field(None, max_length=255) + parent_code: str | None = Field(None, max_length=64) + sort_order: int | None = None + is_active: bool | None = None + + +class CatalogItemResponse(CatalogItemBase): + model_config = ConfigDict(from_attributes=True) + + id: int + catalog: str + tenant_id: int | None + is_system: bool + + +class CatalogMeta(BaseModel): + """Metadata de un catálogo para la pantalla de administración.""" + + catalog: str + label: str + scope: str # 'global' | 'tenant' + is_system: bool + count: int diff --git a/backend/api/v1/modules/crm/catalogs/models.py b/backend/api/v1/modules/crm/catalogs/models.py new file mode 100644 index 0000000..f20acdd --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/models.py @@ -0,0 +1,54 @@ +"""Modelo de catálogos de referencia del CRM (T2026-07-081/082). + +Un único modelo genérico ``CatalogItem`` respalda todos los catálogos +(SAT/ISO y los propios del cliente). Cada fila pertenece a un catálogo +(``catalog``) e identifica una opción por ``code`` (clave) + ``label`` +(descripción que se visualiza). + +Alcance: +- ``tenant_id IS NULL`` → catálogo GLOBAL (Aduanasoft), compartido por todos. +- ``tenant_id`` con valor → catálogo del CLIENTE (ese tenant lo administra). + +Los catálogos dependientes (p. ej. Estado depende de País) usan +``parent_catalog`` + ``parent_code`` para filtrarse. +""" + +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from core.database import Base + + +class CatalogItem(Base): + __tablename__ = "catalog_items" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + catalog: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + code: Mapped[str] = mapped_column(String(64), nullable=False) + label: Mapped[str] = mapped_column(String(255), nullable=False) + + # Dependencia (Estado→País, Municipio→Estado, …) + parent_catalog: Mapped[str | None] = mapped_column(String(60), nullable=True) + parent_code: Mapped[str | None] = mapped_column(String(64), nullable=True) + + # NULL = global (Aduanasoft); con valor = catálogo propio del tenant (cliente). + tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + # Catálogos base SAT/ISO: no se pueden borrar (solo activar/desactivar). + is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + + extra: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/api/v1/modules/crm/catalogs/routes.py b/backend/api/v1/modules/crm/catalogs/routes.py index 8c75dfe..e389ee7 100644 --- a/backend/api/v1/modules/crm/catalogs/routes.py +++ b/backend/api/v1/modules/crm/catalogs/routes.py @@ -1,6 +1,10 @@ -"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10).""" +"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10). -from fastapi import APIRouter, Depends, Query +Incluye el CRUD de catálogos de referencia (T2026-07-081/082): SAT/ISO globales +(Aduanasoft) y catálogos propios de cada cliente (tenant). +""" + +from fastapi import APIRouter, Depends, Query, status from sqlalchemy.orm import Session from core.database import get_core_db @@ -8,7 +12,9 @@ from core.security import get_current_user from ..accounts.models import Account from ..suppliers.models import Supplier +from . import service as catalog_service from .data import INCOTERMS, PARTICIPANT_ROLES +from .dto import CatalogItemCreate, CatalogItemResponse, CatalogItemUpdate, CatalogMeta router = APIRouter() @@ -31,6 +37,76 @@ def list_participant_roles( return PARTICIPANT_ROLES +# ---------------------------------------------------------------------------- +# Catálogos de referencia (CRUD) — T2026-07-081/082 +# ---------------------------------------------------------------------------- + + +@router.get("/catalogs", response_model=list[CatalogMeta]) +def list_catalog_meta( + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Lista los catálogos disponibles (global + del tenant) con su conteo.""" + return catalog_service.list_meta(db, current_user["tenant_id"]) + + +@router.get("/catalogs/{catalog}", response_model=list[CatalogItemResponse]) +def list_catalog_items( + catalog: str, + company_id: int = Query(..., description="Company ID"), + parent_code: str | None = Query(None, description="Filtra dependientes (ej. Estado por País)"), + include_inactive: bool = Query(False), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Opciones de un catálogo (global + del tenant), activas y ordenadas.""" + return catalog_service.list_items( + db, catalog, current_user["tenant_id"], parent_code=parent_code, include_inactive=include_inactive + ) + + +@router.post( + "/catalogs/{catalog}", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED +) +def create_catalog_item( + catalog: str, + data: CatalogItemCreate, + company_id: int = Query(..., description="Company ID"), + scope: str | None = Query("tenant", description="'tenant' (cliente) o 'global' (Aduanasoft, hub_admin)"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Inserta una opción en un catálogo.""" + return catalog_service.create_item(db, catalog, data, current_user, scope=scope) + + +@router.patch("/catalogs/{catalog}/{item_id}", response_model=CatalogItemResponse) +def update_catalog_item( + catalog: str, + item_id: int, + data: CatalogItemUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Edita una opción de catálogo.""" + return catalog_service.update_item(db, catalog, item_id, data, current_user) + + +@router.delete("/catalogs/{catalog}/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_catalog_item( + catalog: str, + item_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Borra una opción de catálogo (los catálogos base del sistema no se borran).""" + catalog_service.delete_item(db, catalog, item_id, current_user) + + @router.get("/participants") def list_participants( company_id: int = Query(..., description="Company ID"), diff --git a/backend/api/v1/modules/crm/catalogs/seed.py b/backend/api/v1/modules/crm/catalogs/seed.py new file mode 100644 index 0000000..0827522 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/seed.py @@ -0,0 +1,68 @@ +"""Siembra de catálogos globales (Aduanasoft) del CRM. + +Idempotente: inserta solo las claves que aún no existen (tenant_id NULL). Se +puede correr múltiples veces sin duplicar. Para ejecutarlo en un entorno: + + docker compose exec backend python -m api.v1.modules.crm.catalogs.seed +""" + +import logging + +from sqlalchemy.orm import Session + +from .models import CatalogItem +from .seed_data import GLOBAL_CATALOGS + +logger = logging.getLogger(__name__) + + +def seed_global_catalogs(db: Session) -> dict: + """Inserta los catálogos globales que falten. Devuelve un resumen {catalog: nuevos}.""" + summary: dict[str, int] = {} + for catalog, meta in GLOBAL_CATALOGS.items(): + is_system = bool(meta.get("is_system", False)) + existing = { + row.code + for row in db.query(CatalogItem.code).filter( + CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None) + ) + } + added = 0 + for order, item in enumerate(meta["items"]): + if item["code"] in existing: + continue + db.add( + CatalogItem( + catalog=catalog, + code=item["code"], + label=item["label"], + parent_catalog=item.get("parent_catalog"), + parent_code=item.get("parent_code"), + tenant_id=None, + sort_order=order, + is_active=True, + is_system=is_system, + ) + ) + added += 1 + if added: + summary[catalog] = added + db.commit() + total = sum(summary.values()) + logger.info("seed_global_catalogs: %s nuevas filas en %s catálogos", total, len(summary)) + return summary + + +def _run() -> None: + from core.database import CoreSessionLocal + + db = CoreSessionLocal() + try: + result = seed_global_catalogs(db) + print("Catálogos sembrados (nuevos):", result or "0 (ya estaban todos)") + finally: + db.close() + + +if __name__ == "__main__": + _run() diff --git a/backend/api/v1/modules/crm/catalogs/seed_data.py b/backend/api/v1/modules/crm/catalogs/seed_data.py new file mode 100644 index 0000000..3ed8fd6 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/seed_data.py @@ -0,0 +1,2129 @@ +"""Datos semilla de los catálogos de referencia del CRM (T2026-07-081/082). + +Generado a partir de CATALOGOS CRM.xlsx (SAT/ISO) + catálogos estándar. Los +catálogos globales (Aduanasoft) se siembran con tenant_id NULL. Los catálogos +"cliente" (servicio/puerto/aeropuerto/aduana) los llena cada tenant desde la +pantalla de administración; aquí solo se declara su metadata (labels). +""" + +GLOBAL_CATALOGS = { + "tipo_registro": { + "label": "Tipo de registro", + "is_system": true, + "items": [ + { + "code": "cliente", + "label": "Cliente" + }, + { + "code": "prospecto", + "label": "Prospecto" + } + ] + }, + "tipo_persona": { + "label": "Tipo de persona", + "is_system": true, + "items": [ + { + "code": "fisica", + "label": "Persona física" + }, + { + "code": "moral", + "label": "Persona moral" + } + ] + }, + "estatus": { + "label": "Estatus", + "is_system": true, + "items": [ + { + "code": "active", + "label": "Activo" + }, + { + "code": "inactive", + "label": "Inactivo" + } + ] + }, + "giro": { + "label": "Giro o industria", + "is_system": false, + "items": [ + { + "code": "importadora", + "label": "Importadora" + }, + { + "code": "exportadora", + "label": "Exportadora" + }, + { + "code": "manufactura", + "label": "Manufactura" + }, + { + "code": "comercializadora", + "label": "Comercializadora" + }, + { + "code": "logistica", + "label": "Logística y transporte" + }, + { + "code": "agencia_aduanal", + "label": "Agencia aduanal" + }, + { + "code": "maquiladora", + "label": "Maquiladora / IMMEX" + }, + { + "code": "servicios", + "label": "Servicios" + }, + { + "code": "otro", + "label": "Otro" + } + ] + }, + "clasificacion_cliente": { + "label": "Clasificación del cliente", + "is_system": false, + "items": [ + { + "code": "importador", + "label": "Importador" + }, + { + "code": "exportador", + "label": "Exportador" + }, + { + "code": "importador_exportador", + "label": "Importador/Exportador" + } + ] + }, + "medio_contacto": { + "label": "Medio de contacto preferido", + "is_system": false, + "items": [ + { + "code": "llamada", + "label": "Llamada telefónica" + }, + { + "code": "correo", + "label": "Correo electrónico" + }, + { + "code": "videoconferencia", + "label": "Videoconferencia" + }, + { + "code": "whatsapp", + "label": "WhatsApp" + }, + { + "code": "otro", + "label": "Otro" + } + ] + }, + "idioma": { + "label": "Idioma", + "is_system": false, + "items": [ + { + "code": "es", + "label": "Español" + }, + { + "code": "en", + "label": "Inglés" + }, + { + "code": "zh", + "label": "Chino (mandarín)" + }, + { + "code": "pt", + "label": "Portugués" + }, + { + "code": "fr", + "label": "Francés" + }, + { + "code": "de", + "label": "Alemán" + }, + { + "code": "ja", + "label": "Japonés" + }, + { + "code": "ko", + "label": "Coreano" + }, + { + "code": "it", + "label": "Italiano" + }, + { + "code": "otro", + "label": "Otro" + } + ] + }, + "regimen_fiscal": { + "label": "Régimen fiscal", + "is_system": false, + "items": [ + { + "code": "fisica", + "label": "Persona física" + }, + { + "code": "moral", + "label": "Persona moral" + } + ] + }, + "uso_cfdi": { + "label": "Uso de CFDI (SAT)", + "is_system": true, + "items": [ + { + "code": "G01", + "label": "Adquisición de mercancías" + }, + { + "code": "G02", + "label": "Devoluciones, descuentos o bonificaciones" + }, + { + "code": "G03", + "label": "Gastos en general" + }, + { + "code": "I01", + "label": "Construcciones" + }, + { + "code": "I02", + "label": "Mobiliario y equipo de oficina por inversiones" + }, + { + "code": "I03", + "label": "Equipo de transporte" + }, + { + "code": "I04", + "label": "Equipo de cómputo y accesorios" + }, + { + "code": "I05", + "label": "Dados, troqueles, moldes, matrices y herramental" + }, + { + "code": "I06", + "label": "Comunicaciones telefónicas" + }, + { + "code": "I07", + "label": "Comunicaciones satelitales" + }, + { + "code": "I08", + "label": "Otra maquinaria y equipo" + }, + { + "code": "D01", + "label": "Honorarios médicos, dentales y gastos hospitalarios" + }, + { + "code": "D02", + "label": "Gastos médicos por incapacidad o discapacidad" + }, + { + "code": "D03", + "label": "Gastos funerales" + }, + { + "code": "D04", + "label": "Donativos" + }, + { + "code": "D05", + "label": "Intereses por créditos hipotecarios" + }, + { + "code": "D06", + "label": "Aportaciones voluntarias al SAR" + }, + { + "code": "D07", + "label": "Primas por seguros de gastos médicos" + }, + { + "code": "D08", + "label": "Gastos de transportación escolar obligatoria" + }, + { + "code": "D09", + "label": "Depósitos en cuentas para el ahorro" + }, + { + "code": "D10", + "label": "Pagos por servicios educativos (colegiaturas)" + }, + { + "code": "S01", + "label": "Sin efectos fiscales" + }, + { + "code": "CP01", + "label": "Pagos" + }, + { + "code": "CN01", + "label": "Nómina" + }, + { + "code": "P01", + "label": "Por definir" + } + ] + }, + "forma_pago": { + "label": "Forma de pago (SAT)", + "is_system": true, + "items": [ + { + "code": "1", + "label": "Efectivo" + }, + { + "code": "2", + "label": "Cheque nominativo" + }, + { + "code": "3", + "label": "Transferencia electrónica de fondos" + }, + { + "code": "4", + "label": "Tarjeta de crédito" + }, + { + "code": "5", + "label": "Monedero electrónico" + }, + { + "code": "6", + "label": "Dinero electrónico" + }, + { + "code": "8", + "label": "Vales de despensa" + }, + { + "code": "12", + "label": "Dación en pago" + }, + { + "code": "13", + "label": "Pago por subrogación" + }, + { + "code": "14", + "label": "Pago por consignación" + }, + { + "code": "15", + "label": "Condonación" + }, + { + "code": "17", + "label": "Compensación" + }, + { + "code": "23", + "label": "Novación" + }, + { + "code": "24", + "label": "Confusión" + }, + { + "code": "25", + "label": "Remisión de deuda" + }, + { + "code": "26", + "label": "Prescripción o caducidad" + }, + { + "code": "27", + "label": "A satisfacción del acreedor" + }, + { + "code": "28", + "label": "Tarjeta de débito" + }, + { + "code": "29", + "label": "Tarjeta de servicios" + }, + { + "code": "30", + "label": "Aplicación de anticipos" + }, + { + "code": "31", + "label": "Intermediario pagos" + }, + { + "code": "99", + "label": "Por definir" + } + ] + }, + "metodo_pago": { + "label": "Método de pago (SAT)", + "is_system": true, + "items": [ + { + "code": "PPD", + "label": "Pago en parcialidades o diferido" + }, + { + "code": "PUE", + "label": "Pago en una sola exhibición" + } + ] + }, + "moneda": { + "label": "Moneda (ISO 4217)", + "is_system": true, + "items": [ + { + "code": "CRC", + "label": "Colón costarricense" + }, + { + "code": "CUC", + "label": "Peso Convertible" + }, + { + "code": "CUP", + "label": "Peso Cubano" + }, + { + "code": "CVE", + "label": "Cabo Verde Escudo" + }, + { + "code": "CZK", + "label": "Corona checa" + }, + { + "code": "DJF", + "label": "Franco de Djibouti" + }, + { + "code": "DKK", + "label": "Corona danesa" + }, + { + "code": "DOP", + "label": "Peso Dominicano" + }, + { + "code": "DZD", + "label": "Dinar argelino" + }, + { + "code": "EGP", + "label": "Libra egipcia" + }, + { + "code": "ERN", + "label": "Nakfa" + }, + { + "code": "ETB", + "label": "Birr etíope" + }, + { + "code": "EUR", + "label": "Euro" + }, + { + "code": "FJD", + "label": "Dólar de Fiji" + }, + { + "code": "FKP", + "label": "Libra malvinense" + }, + { + "code": "GBP", + "label": "Libra Esterlina" + }, + { + "code": "GEL", + "label": "Lari" + }, + { + "code": "GHS", + "label": "Cedi de Ghana" + }, + { + "code": "GIP", + "label": "Libra de Gibraltar" + }, + { + "code": "GMD", + "label": "Dalasi" + }, + { + "code": "GNF", + "label": "Franco guineano" + }, + { + "code": "GTQ", + "label": "Quetzal" + }, + { + "code": "GYD", + "label": "Dólar guyanés" + }, + { + "code": "HKD", + "label": "Dolar De Hong Kong" + }, + { + "code": "HNL", + "label": "Lempira" + }, + { + "code": "HRK", + "label": "Kuna" + }, + { + "code": "HTG", + "label": "Gourde" + }, + { + "code": "HUF", + "label": "Florín" + }, + { + "code": "IDR", + "label": "Rupia" + }, + { + "code": "ILS", + "label": "Nuevo Shekel Israelí" + }, + { + "code": "INR", + "label": "Rupia india" + }, + { + "code": "IQD", + "label": "Dinar iraquí" + }, + { + "code": "IRR", + "label": "Rial iraní" + }, + { + "code": "ISK", + "label": "Corona islandesa" + }, + { + "code": "JMD", + "label": "Dólar Jamaiquino" + }, + { + "code": "JOD", + "label": "Dinar jordano" + }, + { + "code": "JPY", + "label": "Yen" + }, + { + "code": "KES", + "label": "Chelín keniano" + }, + { + "code": "KGS", + "label": "Som" + }, + { + "code": "KHR", + "label": "Riel" + }, + { + "code": "KMF", + "label": "Franco Comoro" + }, + { + "code": "KPW", + "label": "Corea del Norte ganó" + }, + { + "code": "KRW", + "label": "Won" + }, + { + "code": "KWD", + "label": "Dinar kuwaití" + }, + { + "code": "KYD", + "label": "Dólar de las Islas Caimán" + }, + { + "code": "KZT", + "label": "Tenge" + }, + { + "code": "LAK", + "label": "Kip" + }, + { + "code": "LBP", + "label": "Libra libanesa" + }, + { + "code": "LKR", + "label": "Rupia de Sri Lanka" + }, + { + "code": "LRD", + "label": "Dólar liberiano" + }, + { + "code": "LSL", + "label": "Loti" + }, + { + "code": "LYD", + "label": "Dinar libio" + }, + { + "code": "MAD", + "label": "Dirham marroquí" + }, + { + "code": "MDL", + "label": "Leu moldavo" + }, + { + "code": "MGA", + "label": "Ariary malgache" + }, + { + "code": "MKD", + "label": "Denar" + }, + { + "code": "MMK", + "label": "Kyat" + }, + { + "code": "MNT", + "label": "Tugrik" + }, + { + "code": "MOP", + "label": "Pataca" + }, + { + "code": "MRO", + "label": "Ouguiya" + }, + { + "code": "MUR", + "label": "Rupia de Mauricio" + }, + { + "code": "MVR", + "label": "Rupia" + }, + { + "code": "MWK", + "label": "Kwacha" + }, + { + "code": "MXN", + "label": "Peso Mexicano" + }, + { + "code": "MXV", + "label": "México Unidad de Inversión (UDI)" + }, + { + "code": "MYR", + "label": "Ringgit malayo" + }, + { + "code": "MZN", + "label": "Mozambique Metical" + }, + { + "code": "NAD", + "label": "Dólar de Namibia" + }, + { + "code": "NGN", + "label": "Naira" + }, + { + "code": "NIO", + "label": "Córdoba Oro" + }, + { + "code": "NOK", + "label": "Corona noruega" + }, + { + "code": "NPR", + "label": "Rupia nepalí" + }, + { + "code": "NZD", + "label": "Dólar de Nueva Zelanda" + }, + { + "code": "OMR", + "label": "Rial omaní" + }, + { + "code": "PAB", + "label": "Balboa" + }, + { + "code": "PEN", + "label": "Nuevo Sol" + }, + { + "code": "PGK", + "label": "Kina" + }, + { + "code": "PHP", + "label": "Peso filipino" + }, + { + "code": "PKR", + "label": "Rupia de Pakistán" + }, + { + "code": "PLN", + "label": "Zloty" + }, + { + "code": "PYG", + "label": "Guaraní" + }, + { + "code": "QAR", + "label": "Qatar Rial" + }, + { + "code": "RON", + "label": "Leu rumano" + }, + { + "code": "RSD", + "label": "Dinar serbio" + }, + { + "code": "RUB", + "label": "Rublo ruso" + }, + { + "code": "RWF", + "label": "Franco ruandés" + }, + { + "code": "SAR", + "label": "Riyal saudí" + }, + { + "code": "SBD", + "label": "Dólar de las Islas Salomón" + }, + { + "code": "SCR", + "label": "Rupia de Seychelles" + }, + { + "code": "SDG", + "label": "Libra sudanesa" + }, + { + "code": "SEK", + "label": "Corona sueca" + }, + { + "code": "SGD", + "label": "Dolar De Singapur" + }, + { + "code": "SHP", + "label": "Libra de Santa Helena" + }, + { + "code": "SLL", + "label": "Leona" + }, + { + "code": "SOS", + "label": "Chelín somalí" + }, + { + "code": "SRD", + "label": "Dólar de Suriname" + }, + { + "code": "SSP", + "label": "Libra sudanesa Sur" + }, + { + "code": "STD", + "label": "Dobra" + }, + { + "code": "SVC", + "label": "Colon El Salvador" + }, + { + "code": "SYP", + "label": "Libra Siria" + }, + { + "code": "SZL", + "label": "Lilangeni" + }, + { + "code": "THB", + "label": "Baht" + }, + { + "code": "TJS", + "label": "Somoni" + }, + { + "code": "TMT", + "label": "Turkmenistán nuevo manat" + }, + { + "code": "TND", + "label": "Dinar tunecino" + }, + { + "code": "TOP", + "label": "Pa'anga" + }, + { + "code": "TRY", + "label": "Lira turca" + }, + { + "code": "TTD", + "label": "Dólar de Trinidad y Tobago" + }, + { + "code": "TWD", + "label": "Nuevo dólar de Taiwán" + }, + { + "code": "TZS", + "label": "Shilling tanzano" + }, + { + "code": "UAH", + "label": "Hryvnia" + }, + { + "code": "UGX", + "label": "Shilling de Uganda" + }, + { + "code": "USD", + "label": "Dolar americano" + }, + { + "code": "USN", + "label": "Dólar estadounidense (día siguiente)" + }, + { + "code": "UYI", + "label": "Peso Uruguay en Unidades Indexadas (URUIURUI)" + }, + { + "code": "UYU", + "label": "Peso Uruguayo" + }, + { + "code": "UZS", + "label": "Uzbekistán Sum" + }, + { + "code": "VEF", + "label": "Bolívar" + }, + { + "code": "VND", + "label": "Dong" + }, + { + "code": "VUV", + "label": "Vatu" + }, + { + "code": "WST", + "label": "Tala" + }, + { + "code": "XAF", + "label": "Franco CFA BEAC" + }, + { + "code": "XAG", + "label": "Plata" + }, + { + "code": "XAU", + "label": "Oro" + }, + { + "code": "XBA", + "label": "Unidad de Mercados de Bonos Unidad Europea Composite (EURCO)" + }, + { + "code": "XBB", + "label": "Unidad Monetaria de Bonos de Mercados Unidad Europea (UEM-6)" + }, + { + "code": "XBC", + "label": "Mercados de Bonos Unidad Europea unidad de cuenta a 9 (UCE-9)" + }, + { + "code": "XBD", + "label": "Mercados de Bonos Unidad Europea unidad de cuenta a 17 (UCE-17)" + }, + { + "code": "XCD", + "label": "Dólar del Caribe Oriental" + }, + { + "code": "XDR", + "label": "DEG (Derechos Especiales de Giro)" + }, + { + "code": "XOF", + "label": "Franco CFA BCEAO" + }, + { + "code": "XPD", + "label": "Paladio" + }, + { + "code": "XPF", + "label": "Franco CFP" + }, + { + "code": "XPT", + "label": "Platino" + }, + { + "code": "XSU", + "label": "Sucre" + }, + { + "code": "XTS", + "label": "Códigos reservados específicamente para propósitos de prueba" + }, + { + "code": "XUA", + "label": "Unidad ADB de Cuenta" + }, + { + "code": "XXX", + "label": "Los códigos asignados para las transacciones en que intervenga ninguna moneda" + }, + { + "code": "YER", + "label": "Rial yemení" + }, + { + "code": "ZAR", + "label": "Rand" + }, + { + "code": "ZMW", + "label": "Kwacha zambiano" + }, + { + "code": "ZWL", + "label": "Zimbabwe Dólar" + }, + { + "code": "NULL", + "label": "NULL" + } + ] + }, + "pais": { + "label": "País (ISO 3166)", + "is_system": true, + "items": [ + { + "code": "ABW", + "label": "Aruba" + }, + { + "code": "AFG", + "label": "Afganistán" + }, + { + "code": "AGO", + "label": "Angola" + }, + { + "code": "AIA", + "label": "Anguila" + }, + { + "code": "ALA", + "label": "Islas Åland" + }, + { + "code": "ALB", + "label": "Albania" + }, + { + "code": "AND", + "label": "Andorra" + }, + { + "code": "ARE", + "label": "Emiratos Árabes Unidos (Los)" + }, + { + "code": "ARG", + "label": "Argentina" + }, + { + "code": "ARM", + "label": "Armenia" + }, + { + "code": "ASM", + "label": "Samoa Americana" + }, + { + "code": "ATA", + "label": "Antártida" + }, + { + "code": "ATF", + "label": "Territorios Australes Franceses (los)" + }, + { + "code": "ATG", + "label": "Antigua y Barbuda" + }, + { + "code": "AUS", + "label": "Australia" + }, + { + "code": "AUT", + "label": "Austria" + }, + { + "code": "AZE", + "label": "Azerbaiyán" + }, + { + "code": "BDI", + "label": "Burundi" + }, + { + "code": "BEL", + "label": "Bélgica" + }, + { + "code": "BEN", + "label": "Benín" + }, + { + "code": "BES", + "label": "Bonaire, San Eustaquio y Saba" + }, + { + "code": "BFA", + "label": "Burkina Faso" + }, + { + "code": "BGD", + "label": "Bangladés" + }, + { + "code": "BGR", + "label": "Bulgaria" + }, + { + "code": "BHR", + "label": "Baréin" + }, + { + "code": "BHS", + "label": "Bahamas (las)" + }, + { + "code": "BIH", + "label": "Bosnia y Herzegovina" + }, + { + "code": "BLM", + "label": "San Bartolomé" + }, + { + "code": "BLR", + "label": "Bielorrusia" + }, + { + "code": "BLZ", + "label": "Belice" + }, + { + "code": "BMU", + "label": "Bermudas" + }, + { + "code": "BOL", + "label": "Bolivia, Estado Plurinacional de" + }, + { + "code": "BRA", + "label": "Brasil" + }, + { + "code": "BRB", + "label": "Barbados" + }, + { + "code": "BRN", + "label": "Brunéi Darussalam" + }, + { + "code": "BTN", + "label": "Bután" + }, + { + "code": "BVT", + "label": "Isla Bouvet" + }, + { + "code": "BWA", + "label": "Botsuana" + }, + { + "code": "CAF", + "label": "República Centroafricana (la)" + }, + { + "code": "CAN", + "label": "Canadá" + }, + { + "code": "CCK", + "label": "Islas Cocos (Keeling)" + }, + { + "code": "CHE", + "label": "Suiza" + }, + { + "code": "CHL", + "label": "Chile" + }, + { + "code": "CHN", + "label": "China" + }, + { + "code": "CIV", + "label": "Côte d'Ivoire" + }, + { + "code": "CMR", + "label": "Camerún" + }, + { + "code": "COD", + "label": "Congo (la República Democrática del)" + }, + { + "code": "COG", + "label": "Congo" + }, + { + "code": "COK", + "label": "Islas Cook (las)" + }, + { + "code": "COL", + "label": "Colombia" + }, + { + "code": "COM", + "label": "Comoras" + }, + { + "code": "CPV", + "label": "Cabo Verde" + }, + { + "code": "CRI", + "label": "Costa Rica" + }, + { + "code": "CUB", + "label": "Cuba" + }, + { + "code": "CUW", + "label": "Curaçao" + }, + { + "code": "CXR", + "label": "Isla de Navidad" + }, + { + "code": "CYM", + "label": "Islas Caimán (las)" + }, + { + "code": "CYP", + "label": "Chipre" + }, + { + "code": "CZE", + "label": "República Checa (la)" + }, + { + "code": "DEU", + "label": "Alemania" + }, + { + "code": "DJI", + "label": "Yibuti" + }, + { + "code": "DMA", + "label": "Dominica" + }, + { + "code": "DNK", + "label": "Dinamarca" + }, + { + "code": "DOM", + "label": "República Dominicana (la)" + }, + { + "code": "DZA", + "label": "Argelia" + }, + { + "code": "ECU", + "label": "Ecuador" + }, + { + "code": "EGY", + "label": "Egipto" + }, + { + "code": "ERI", + "label": "Eritrea" + }, + { + "code": "ESH", + "label": "Sahara Occidental" + }, + { + "code": "ESP", + "label": "España" + }, + { + "code": "EST", + "label": "Estonia" + }, + { + "code": "ETH", + "label": "Etiopía" + }, + { + "code": "FIN", + "label": "Finlandia" + }, + { + "code": "FJI", + "label": "Fiyi" + }, + { + "code": "FLK", + "label": "Islas Malvinas [Falkland] (las)" + }, + { + "code": "FRA", + "label": "Francia" + }, + { + "code": "FRO", + "label": "Islas Feroe (las)" + }, + { + "code": "FSM", + "label": "Micronesia (los Estados Federados de)" + }, + { + "code": "GAB", + "label": "Gabón" + }, + { + "code": "GBR", + "label": "Reino Unido (el)" + }, + { + "code": "GEO", + "label": "Georgia" + }, + { + "code": "GGY", + "label": "Guernsey" + }, + { + "code": "GHA", + "label": "Ghana" + }, + { + "code": "GIB", + "label": "Gibraltar" + }, + { + "code": "GIN", + "label": "Guinea" + }, + { + "code": "GLP", + "label": "Guadalupe" + }, + { + "code": "GMB", + "label": "Gambia (La)" + }, + { + "code": "GNB", + "label": "Guinea-Bisáu" + }, + { + "code": "GNQ", + "label": "Guinea Ecuatorial" + }, + { + "code": "GRC", + "label": "Grecia" + }, + { + "code": "GRD", + "label": "Granada" + }, + { + "code": "GRL", + "label": "Groenlandia" + }, + { + "code": "GTM", + "label": "Guatemala" + }, + { + "code": "GUF", + "label": "Guayana Francesa" + }, + { + "code": "GUM", + "label": "Guam" + }, + { + "code": "GUY", + "label": "Guyana" + }, + { + "code": "HKG", + "label": "Hong Kong" + }, + { + "code": "HMD", + "label": "Isla Heard e Islas McDonald" + }, + { + "code": "HND", + "label": "Honduras" + }, + { + "code": "HRV", + "label": "Croacia" + }, + { + "code": "HTI", + "label": "Haití" + }, + { + "code": "HUN", + "label": "Hungría" + }, + { + "code": "IDN", + "label": "Indonesia" + }, + { + "code": "IMN", + "label": "Isla de Man" + }, + { + "code": "IND", + "label": "India" + }, + { + "code": "IOT", + "label": "Territorio Británico del Océano Índico (el)" + }, + { + "code": "IRL", + "label": "Irlanda" + }, + { + "code": "IRN", + "label": "Irán (la República Islámica de)" + }, + { + "code": "IRQ", + "label": "Irak" + }, + { + "code": "ISL", + "label": "Islandia" + }, + { + "code": "ISR", + "label": "Israel" + }, + { + "code": "ITA", + "label": "Italia" + }, + { + "code": "JAM", + "label": "Jamaica" + }, + { + "code": "JEY", + "label": "Jersey" + }, + { + "code": "JOR", + "label": "Jordania" + }, + { + "code": "JPN", + "label": "Japón" + }, + { + "code": "KAZ", + "label": "Kazajistán" + }, + { + "code": "KEN", + "label": "Kenia" + }, + { + "code": "KGZ", + "label": "Kirguistán" + }, + { + "code": "KHM", + "label": "Camboya" + }, + { + "code": "KIR", + "label": "Kiribati" + }, + { + "code": "KNA", + "label": "San Cristóbal y Nieves" + }, + { + "code": "KOR", + "label": "Corea (la República de)" + }, + { + "code": "KWT", + "label": "Kuwait" + }, + { + "code": "LAO", + "label": "Lao, (la) República Democrática Popular" + }, + { + "code": "LBN", + "label": "Líbano" + }, + { + "code": "LBR", + "label": "Liberia" + }, + { + "code": "LBY", + "label": "Libia" + }, + { + "code": "LCA", + "label": "Santa Lucía" + }, + { + "code": "LIE", + "label": "Liechtenstein" + }, + { + "code": "LKA", + "label": "Sri Lanka" + }, + { + "code": "LSO", + "label": "Lesoto" + }, + { + "code": "LTU", + "label": "Lituania" + }, + { + "code": "LUX", + "label": "Luxemburgo" + }, + { + "code": "LVA", + "label": "Letonia" + }, + { + "code": "MAC", + "label": "Macao" + }, + { + "code": "MAF", + "label": "San Martín (parte francesa)" + }, + { + "code": "MAR", + "label": "Marruecos" + }, + { + "code": "MCO", + "label": "Mónaco" + }, + { + "code": "MDA", + "label": "Moldavia (la República de)" + }, + { + "code": "MDG", + "label": "Madagascar" + }, + { + "code": "MDV", + "label": "Maldivas" + }, + { + "code": "MEX", + "label": "México" + }, + { + "code": "MHL", + "label": "Islas Marshall (las)" + }, + { + "code": "MKD", + "label": "Macedonia (la antigua República Yugoslava de)" + }, + { + "code": "MLI", + "label": "Malí" + }, + { + "code": "MLT", + "label": "Malta" + }, + { + "code": "MMR", + "label": "Myanmar" + }, + { + "code": "MNE", + "label": "Montenegro" + }, + { + "code": "MNG", + "label": "Mongolia" + }, + { + "code": "MNP", + "label": "Islas Marianas del Norte (las)" + }, + { + "code": "MOZ", + "label": "Mozambique" + }, + { + "code": "MRT", + "label": "Mauritania" + }, + { + "code": "MSR", + "label": "Montserrat" + }, + { + "code": "MTQ", + "label": "Martinica" + }, + { + "code": "MUS", + "label": "Mauricio" + }, + { + "code": "MWI", + "label": "Malaui" + }, + { + "code": "MYS", + "label": "Malasia" + }, + { + "code": "MYT", + "label": "Mayotte" + }, + { + "code": "NAM", + "label": "Namibia" + }, + { + "code": "NCL", + "label": "Nueva Caledonia" + }, + { + "code": "NER", + "label": "Níger (el)" + }, + { + "code": "NFK", + "label": "Isla Norfolk" + }, + { + "code": "NGA", + "label": "Nigeria" + }, + { + "code": "NIC", + "label": "Nicaragua" + }, + { + "code": "NIU", + "label": "Niue" + }, + { + "code": "NLD", + "label": "Países Bajos (los)" + }, + { + "code": "NOR", + "label": "Noruega" + }, + { + "code": "NPL", + "label": "Nepal" + }, + { + "code": "NRU", + "label": "Nauru" + }, + { + "code": "NZL", + "label": "Nueva Zelanda" + }, + { + "code": "OMN", + "label": "Omán" + }, + { + "code": "PAK", + "label": "Pakistán" + }, + { + "code": "PAN", + "label": "Panamá" + }, + { + "code": "PCN", + "label": "Pitcairn" + }, + { + "code": "PER", + "label": "Perú" + }, + { + "code": "PHL", + "label": "Filipinas (las)" + }, + { + "code": "PLW", + "label": "Palaos" + }, + { + "code": "PNG", + "label": "Papúa Nueva Guinea" + }, + { + "code": "POL", + "label": "Polonia" + }, + { + "code": "PRI", + "label": "Puerto Rico" + }, + { + "code": "PRK", + "label": "Corea (la República Democrática Popular de)" + }, + { + "code": "PRT", + "label": "Portugal" + }, + { + "code": "PRY", + "label": "Paraguay" + }, + { + "code": "PSE", + "label": "Palestina, Estado de" + }, + { + "code": "PYF", + "label": "Polinesia Francesa" + }, + { + "code": "QAT", + "label": "Catar" + }, + { + "code": "REU", + "label": "Reunión" + }, + { + "code": "ROU", + "label": "Rumania" + }, + { + "code": "RUS", + "label": "Rusia, (la) Federación de" + }, + { + "code": "RWA", + "label": "Ruanda" + }, + { + "code": "SAU", + "label": "Arabia Saudita" + }, + { + "code": "SDN", + "label": "Sudán (el)" + }, + { + "code": "SEN", + "label": "Senegal" + }, + { + "code": "SGP", + "label": "Singapur" + }, + { + "code": "SGS", + "label": "Georgia del sur y las islas sandwich del sur" + }, + { + "code": "SHN", + "label": "Santa Helena, Ascensión y Tristán de Acuña" + }, + { + "code": "SJM", + "label": "Svalbard y Jan Mayen" + }, + { + "code": "SLB", + "label": "Islas Salomón (las)" + }, + { + "code": "SLE", + "label": "Sierra leona" + }, + { + "code": "NULL", + "label": "NULL" + } + ] + }, + "estado": { + "label": "Estado / Provincia", + "is_system": true, + "items": [ + { + "code": "AGU", + "label": "Aguascalientes", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "BCN", + "label": "Baja California", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "BCS", + "label": "Baja California Sur", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "CAM", + "label": "Campeche", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "CHP", + "label": "Chiapas", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "CHH", + "label": "Chihuahua", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "CMX", + "label": "Ciudad de México", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "COA", + "label": "Coahuila", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "COL", + "label": "Colima", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "DUR", + "label": "Durango", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "GUA", + "label": "Guanajuato", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "GRO", + "label": "Guerrero", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "HID", + "label": "Hidalgo", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "JAL", + "label": "Jalisco", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "MEX", + "label": "Estado de México", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "MIC", + "label": "Michoacán", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "MOR", + "label": "Morelos", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "NAY", + "label": "Nayarit", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "NLE", + "label": "Nuevo León", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "OAX", + "label": "Oaxaca", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "PUE", + "label": "Puebla", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "QUE", + "label": "Querétaro", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "ROO", + "label": "Quintana Roo", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "SLP", + "label": "San Luis Potosí", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "SIN", + "label": "Sinaloa", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "SON", + "label": "Sonora", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "TAB", + "label": "Tabasco", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "TAM", + "label": "Tamaulipas", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "TLA", + "label": "Tlaxcala", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "VER", + "label": "Veracruz", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "YUC", + "label": "Yucatán", + "parent_catalog": "pais", + "parent_code": "MEX" + }, + { + "code": "ZAC", + "label": "Zacatecas", + "parent_catalog": "pais", + "parent_code": "MEX" + } + ] + }, + "tipo_domicilio": { + "label": "Tipo de domicilio", + "is_system": false, + "items": [ + { + "code": "fiscal", + "label": "Fiscal" + }, + { + "code": "oficina", + "label": "Oficina" + }, + { + "code": "sucursal", + "label": "Sucursal" + }, + { + "code": "bodega", + "label": "Bodega" + }, + { + "code": "patio", + "label": "Patio" + }, + { + "code": "terminal", + "label": "Terminal" + }, + { + "code": "almacen", + "label": "Almacén" + } + ] + }, + "area": { + "label": "Área / Departamento", + "is_system": false, + "items": [ + { + "code": "ventas", + "label": "Ventas" + }, + { + "code": "operaciones", + "label": "Operaciones" + }, + { + "code": "facturacion", + "label": "Facturación" + }, + { + "code": "cobranza", + "label": "Cobranza" + }, + { + "code": "servicio_cliente", + "label": "Servicio al cliente" + } + ] + }, + "cobertura": { + "label": "Cobertura", + "is_system": true, + "items": [ + { + "code": "nacional", + "label": "Nacional" + }, + { + "code": "internacional", + "label": "Internacional" + } + ] + }, + "clasificacion_proveedor": { + "label": "Clasificación del proveedor", + "is_system": false, + "items": [ + { + "code": "naviera", + "label": "Naviera" + }, + { + "code": "aerolinea", + "label": "Aerolínea" + }, + { + "code": "transportista_terrestre", + "label": "Transportista Terrestre" + }, + { + "code": "ferrocarril", + "label": "Ferrocarril" + }, + { + "code": "agente_aduanal", + "label": "Agente Aduanal" + }, + { + "code": "agente_carga", + "label": "Agente de Carga" + }, + { + "code": "agente_corresponsal", + "label": "Agente Corresponsal" + }, + { + "code": "almacen", + "label": "Almacén" + }, + { + "code": "aseguradora", + "label": "Aseguradora" + }, + { + "code": "paqueteria", + "label": "Paquetería" + }, + { + "code": "otro", + "label": "Otro" + } + ] + } +} + +TENANT_CATALOG_LABELS = { + "servicio": "Servicios que ofrece", + "puerto": "Puertos donde opera", + "aeropuerto": "Aeropuertos donde opera", + "aduana": "Aduanas donde opera" +} diff --git a/backend/api/v1/modules/crm/catalogs/service.py b/backend/api/v1/modules/crm/catalogs/service.py new file mode 100644 index 0000000..8f62977 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/service.py @@ -0,0 +1,168 @@ +"""Lógica de negocio de los catálogos de referencia del CRM.""" + +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session + +from core.security import is_hub_admin + +from .dto import CatalogItemCreate, CatalogItemUpdate, CatalogMeta +from .models import CatalogItem +from .seed_data import GLOBAL_CATALOGS, TENANT_CATALOG_LABELS + +# Metadata de catálogos (labels y si el cliente puede llenarlos). +CATALOG_LABELS: dict[str, str] = {k: v["label"] for k, v in GLOBAL_CATALOGS.items()} +CATALOG_LABELS.update(TENANT_CATALOG_LABELS) +# Catálogos que administra el cliente (tenant). El resto son globales (Aduanasoft). +TENANT_CATALOG_KEYS = set(TENANT_CATALOG_LABELS.keys()) +KNOWN_CATALOGS = set(CATALOG_LABELS.keys()) + + +def _require_known(catalog: str) -> None: + if catalog not in KNOWN_CATALOGS: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catálogo '{catalog}' no existe") + + +def list_meta(db: Session, tenant_id: int) -> list[CatalogMeta]: + """Lista todos los catálogos disponibles con su conteo (global + del tenant).""" + out: list[CatalogMeta] = [] + for key, label in CATALOG_LABELS.items(): + is_tenant = key in TENANT_CATALOG_KEYS + count = ( + db.query(CatalogItem) + .filter( + CatalogItem.catalog == key, + or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id), + ) + .count() + ) + out.append( + CatalogMeta( + catalog=key, + label=label, + scope="tenant" if is_tenant else "global", + is_system=bool(GLOBAL_CATALOGS.get(key, {}).get("is_system", False)), + count=count, + ) + ) + return out + + +def list_items( + db: Session, + catalog: str, + tenant_id: int, + parent_code: str | None = None, + include_inactive: bool = False, +) -> list[CatalogItem]: + _require_known(catalog) + q = db.query(CatalogItem).filter( + CatalogItem.catalog == catalog, + or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id), + ) + if not include_inactive: + q = q.filter(CatalogItem.is_active.is_(True)) + if parent_code: + q = q.filter(CatalogItem.parent_code == parent_code) + return q.order_by(CatalogItem.sort_order, CatalogItem.label).all() + + +def _resolve_write_scope(catalog: str, scope: str | None, current_user: dict) -> int | None: + """Devuelve el tenant_id a usar al escribir (None = global) y valida permisos. + + - scope 'global' → solo hub_admin puede tocar catálogos globales (Aduanasoft). + - scope 'tenant' (default) → se guarda en el tenant del usuario. + """ + wants_global = scope == "global" + if wants_global: + if not is_hub_admin(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Solo un administrador de Aduanasoft puede editar catálogos globales.", + ) + return None + return int(current_user["tenant_id"]) + + +def create_item( + db: Session, catalog: str, data: CatalogItemCreate, current_user: dict, scope: str | None = None +) -> CatalogItem: + _require_known(catalog) + target_tenant = _resolve_write_scope(catalog, scope, current_user) + + # No duplicar por (catalog, code, tenant_id) + exists = ( + db.query(CatalogItem) + .filter( + CatalogItem.catalog == catalog, + CatalogItem.code == data.code, + CatalogItem.tenant_id.is_(None) if target_tenant is None else CatalogItem.tenant_id == target_tenant, + ) + .first() + ) + if exists: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Ya existe la clave '{data.code}' en el catálogo '{catalog}'.", + ) + + item = CatalogItem( + catalog=catalog, + code=data.code, + label=data.label, + parent_catalog=data.parent_catalog, + parent_code=data.parent_code, + tenant_id=target_tenant, + sort_order=data.sort_order, + is_active=data.is_active, + is_system=False, + created_by=current_user.get("sub"), + updated_by=current_user.get("sub"), + ) + db.add(item) + db.commit() + db.refresh(item) + return item + + +def _get_writable(db: Session, catalog: str, item_id: int, current_user: dict) -> CatalogItem: + _require_known(catalog) + item = db.query(CatalogItem).filter(CatalogItem.id == item_id, CatalogItem.catalog == catalog).first() + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado") + if item.tenant_id is None: + # Global (Aduanasoft): solo hub_admin. + if not is_hub_admin(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Solo un administrador de Aduanasoft puede editar este catálogo global.", + ) + elif item.tenant_id != int(current_user["tenant_id"]): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado") + return item + + +def update_item( + db: Session, catalog: str, item_id: int, data: CatalogItemUpdate, current_user: dict +) -> CatalogItem: + item = _get_writable(db, catalog, item_id, current_user) + payload: dict[str, Any] = data.model_dump(exclude_unset=True) + for field, value in payload.items(): + setattr(item, field, value) + item.updated_by = current_user.get("sub") + db.commit() + db.refresh(item) + return item + + +def delete_item(db: Session, catalog: str, item_id: int, current_user: dict) -> None: + item = _get_writable(db, catalog, item_id, current_user) + if item.is_system: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Un catálogo base del sistema no se puede borrar; puedes desactivarlo.", + ) + db.delete(item) + db.commit() diff --git a/backend/api/v1/modules/crm/suppliers/dto.py b/backend/api/v1/modules/crm/suppliers/dto.py index 8a0d9b6..70c49ff 100644 --- a/backend/api/v1/modules/crm/suppliers/dto.py +++ b/backend/api/v1/modules/crm/suppliers/dto.py @@ -13,6 +13,7 @@ class SupplierBase(BaseModel): person_type: str | None = Field(None, max_length=10) status: str = Field("active", max_length=20) classifications: list[str] = Field(default_factory=list) + classification_other: str | None = Field(None, max_length=120) # Comercial services_offered: str | None = None coverage: str | None = Field(None, max_length=20) @@ -52,6 +53,7 @@ class SupplierUpdate(BaseModel): person_type: str | None = Field(None, max_length=10) status: str | None = Field(None, max_length=20) classifications: list[str] | None = None + classification_other: str | None = Field(None, max_length=120) services_offered: str | None = None coverage: str | None = Field(None, max_length=20) countries: list[str] | None = None diff --git a/backend/api/v1/modules/crm/suppliers/models.py b/backend/api/v1/modules/crm/suppliers/models.py index 3108be4..65cb9d6 100644 --- a/backend/api/v1/modules/crm/suppliers/models.py +++ b/backend/api/v1/modules/crm/suppliers/models.py @@ -30,6 +30,8 @@ class Supplier(Base, TenantScopedMixin, TimestampMixin): # Clasificación (múltiple): naviera, aerolinea, transportista_terrestre, ferrocarril, # agente_aduanal, agente_carga, agente_corresponsal, almacen, aseguradora, paqueteria, otro classifications: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list) + # Texto libre cuando la clasificación incluye "otro" + classification_other: Mapped[str | None] = mapped_column(String(120), nullable=True) # ----- Información comercial ----- services_offered: Mapped[str | None] = mapped_column(Text, nullable=True)