Merge pull request 'development' (#34) from development into main

Reviewed-on: ADUANASOFT/anexo76#34
This commit is contained in:
2025-12-29 19:57:38 +00:00
909 changed files with 85981 additions and 2178 deletions

5
.gitignore vendored
View File

@@ -15,18 +15,18 @@ downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.pnpm-store/
# Environment
.env
.env.local
backend/SCRIPTS/
# IDEs
.vscode/
.idea/
@@ -59,3 +59,4 @@ node_modules/
# Docker
*.dockerignore
postgres-data/

View File

@@ -1,12 +1,13 @@
# Anexo76
**Aplicación SaaS para gestión de comercio exterior conforme a Anexos 24, 31 y 22 del SAT**
**Aplicación SaaS para gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT**
Anexo76 es una plataforma multi-tenant diseñada para maquilas, empresas IMMEX y agentes aduanales, que permite gestionar inventarios, pedimentos y facturas de importación/exportación con control de licencias y cumplimiento normativo.
## 🏗️ Arquitectura
### Backend
- **Framework**: FastAPI 0.110+
- **Autenticación**: Keycloak (OpenID Connect)
- **Base de Datos**: PostgreSQL con SQLAlchemy
@@ -20,11 +21,13 @@ Anexo76 es una plataforma multi-tenant diseñada para maquilas, empresas IMMEX y
- `routes.py`: Endpoints API
### Frontend
- **Framework**: SvelteKit
- **Autenticación**: keycloak-js
- **UI**: Dashboard moderno y responsivo
### Infraestructura
- **Containerización**: Docker / Docker Compose
- **Orquestación**: Kubernetes (futuro)
- **Monitoreo**: Prometheus + Grafana
@@ -64,6 +67,7 @@ anexo76/
## 🚀 Inicio Rápido
### Requisitos Previos
- Docker y Docker Compose
- Python 3.11+ (para desarrollo local)
- Node.js 18+ (para desarrollo frontend)
@@ -89,6 +93,7 @@ docker-compose up -d
```
Esto iniciará:
- **PostgreSQL** en `localhost:5432`
- **Keycloak** en `localhost:8080`
- **Backend API** en `localhost:8000`
@@ -127,6 +132,21 @@ python -c "from core.database import init_db; init_db()"
- **Frontend**: http://localhost:5173
- **Keycloak Admin**: http://localhost:8080
## 🛠️ Produccion
```
docker build -t dev.aduanasoft.com/anexo76/backend:latest -f ./backend/Dockerfile ./backend
docker build -t dev.aduanasoft.com/anexo76/frontend:latest -f ./frontend/Dockerfile.prod ./frontend
```
Publica en el registro (ajusta el registry si corresponde):
```
docker login dev.aduanasoft.com
docker push dev.aduanasoft.com/anexo76/backend:latest
docker push dev.aduanasoft.com/anexo76/frontend:latest
```
## 🛠️ Desarrollo Local
### Backend
@@ -150,17 +170,20 @@ npm run dev
## 📦 Módulos Principales
### 1. **Auth** (`/v1/auth`)
- Login con Keycloak
- Refresh token
- Logout
- Información de usuario
### 2. **Tenants** (`/v1/tenants`)
- Creación y gestión de tenants
- Upgrade de BD compartida a dedicada
- Gestión de realms de Keycloak
### 3. **Licenses** (`/v1/licenses`)
- Control de planes (Free, Basic, Professional, Enterprise)
- Validación de licencias activas
- Tracking de uso (usuarios, storage, operaciones)
@@ -188,11 +211,13 @@ npm run dev
### Modelo Híbrido
**BD Compartida** (tenants pequeños/medianos):
- Tabla única con `tenant_id` como foreign key
- Row-level security
- Más económico para clientes con bajo volumen
**BD Dedicada** (tenants enterprise):
- Base de datos PostgreSQL independiente
- Máximo aislamiento y performance
- Configuración almacenada en `tenants.db_config`
@@ -219,16 +244,18 @@ service.upgrade_to_dedicated(tenant_id=123, db_config=db_config)
### Planes Disponibles
| Plan | Usuarios | Storage | Operaciones/mes | Features |
|------|----------|---------|-----------------|----------|
| Free | 5 | 10 GB | 1,000 | API básica |
| Basic | 20 | 50 GB | 10,000 | + Reportes |
| Professional | 100 | 200 GB | 50,000 | + Integraciones |
| Enterprise | Ilimitado | Ilimitado | Ilimitado | + Soporte dedicado + BD dedicada |
| Plan | Usuarios | Storage | Operaciones/mes | Features |
| ------------ | --------- | --------- | --------------- | -------------------------------- |
| Free | 5 | 10 GB | 1,000 | API básica |
| Basic | 20 | 50 GB | 10,000 | + Reportes |
| Professional | 100 | 200 GB | 50,000 | + Integraciones |
| Enterprise | Ilimitado | Ilimitado | Ilimitado | + Soporte dedicado + BD dedicada |
### Middleware de Validación
El `LicenseValidationMiddleware` verifica en cada request:
- ✅ Licencia activa
- ✅ No expirada
- ✅ Límites no excedidos
@@ -254,6 +281,7 @@ npm test
### Prometheus Metrics
El backend expone métricas en `/metrics`:
- Request duration
- Request count por endpoint
- Error rate
@@ -262,6 +290,7 @@ El backend expone métricas en `/metrics`:
### Logging
Logs estructurados con nivel configurable:
- INFO: Operaciones normales
- WARNING: Validaciones fallidas
- ERROR: Errores de sistema
@@ -282,6 +311,7 @@ Este proyecto es privado y propietario.
## 📞 Soporte
Para soporte técnico o consultas:
- Email: soporte@anexo76.com
- Documentación: https://docs.anexo76.com

View File

@@ -12,7 +12,7 @@ CORE_DB_USER=postgres
CORE_DB_PASSWORD=postgres
# Keycloak
KEYCLOAK_SERVER_URL=http://localhost:8080
KEYCLOAK_SERVER_URL=http://localhost:8080/kcauth
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=anexo76-backend
KEYCLOAK_CLIENT_SECRET=your-client-secret

View File

@@ -84,7 +84,8 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = ${DATABASE_URL}
sqlalchemy.url = postgresql://${CORE_DB_USER}:${CORE_DB_PASSWORD}@${CORE_DB_HOST}:${CORE_DB_PORT}/${CORE_DB_NAME}
[post_write_hooks]

View File

@@ -1,11 +1,14 @@
import importlib.util
import logging
import os
import sys
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from urllib.parse import quote_plus
from alembic import context
from core.config import settings
import logging
from core.database import Base
from sqlalchemy import engine_from_config, pool
logger = logging.getLogger(__name__)
@@ -13,6 +16,7 @@ logger = logging.getLogger(__name__)
# access to the values within the .ini file in use.
config = context.config
def get_database_url():
"""Obtiene la URL de la base de datos (PostgreSQL) desde variables de entorno o alembic.ini."""
# Intentar construir desde variables de entorno primero
@@ -41,6 +45,7 @@ def get_database_url():
return url
# Configurar la URL de la base de datos
database_url = get_database_url()
@@ -54,7 +59,7 @@ if os.environ.get("ALEMBIC_DEBUG"):
debug_url = before + "@" + after
except Exception:
debug_url = "postgresql://***:***@***"
logger.error("Error al ocultar la contraseña en la URL para debug.")
logger.error("Error al ocultar la contraseña en la URL para debug.")
config.set_main_option("sqlalchemy.url", database_url)
@@ -63,23 +68,20 @@ config.set_main_option("sqlalchemy.url", database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
import sys
import importlib.util
# Ajusta la ruta para que puedas importar core y módulos
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, BASE_DIR)
from core.database import Base
# Configuración de Alembic
config = context.config
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def import_models_from_dir(dir_path: str):
"""Importa recursivamente cualquier archivo models.py desde dir_path"""
"""Importa recursivamente cualquier archivo models.py desde dir_path y archivos en directorios models/"""
for root, dirs, files in os.walk(dir_path):
# Importar archivos models.py directos
if "models.py" in files:
module_path = os.path.join(root, "models.py")
# Convertir path en nombre de módulo compatible
@@ -90,12 +92,28 @@ def import_models_from_dir(dir_path: str):
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# Importar todos los archivos .py en directorios llamados "models"
if os.path.basename(root) == "models":
for file in files:
if file.endswith(".py") and not file.startswith("__"):
module_path = os.path.join(root, file)
rel_path = os.path.relpath(module_path, BASE_DIR)
module_name = rel_path.replace(os.sep, ".").replace(".py", "")
try:
spec = importlib.util.spec_from_file_location(
module_name, module_path
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
except Exception as e:
logger.warning(f"No se pudo importar {module_path}: {e}")
# Importar todos los models dentro de api/v1/modules y api/v1/modules/uploads
modules_dir = os.path.join(BASE_DIR, "api", "v1", "modules")
import_models_from_dir(modules_dir)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
@@ -132,10 +150,7 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
)
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
@@ -144,4 +159,4 @@ def run_migrations_online() -> None:
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""increase_port_description_length
Revision ID: 3a012dff0274
Revises: 7937209f9718
Create Date: 2025-12-24 10:24:49.927020
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3a012dff0274'
down_revision: Union[str, Sequence[str], None] = '7937209f9718'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass

View File

@@ -1,18 +1,21 @@
"""create material_types table
Revision ID: 531bf8cdae06
Revises:
Revises:
Create Date: 2025-10-19 18:23:39.613953
"""
# pylint: disable=no-member
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '531bf8cdae06'
revision: str = "531bf8cdae06"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -21,183 +24,148 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tenants',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('type', sa.Enum('SHARED', 'DEDICATED', name='tenanttype'), nullable=False),
sa.Column('keycloak_realm', sa.String(length=255), nullable=False),
sa.Column('db_config', sa.Text(), nullable=True),
sa.Column('contact_name', sa.String(length=255), nullable=True),
sa.Column('contact_email', sa.String(length=255), nullable=True),
sa.Column('contact_phone', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('keycloak_realm'),
schema='a76'
op.create_table(
"containers",
sa.Column("key", sa.String(length=3), nullable=False),
sa.Column("description", sa.String(length=500), nullable=False),
sa.PrimaryKeyConstraint("key", name="containers_pkey"),
schema="public",
)
op.create_index(op.f('ix_a76_tenants_id'), 'tenants', ['id'], unique=False, schema='a76')
op.create_index(op.f('ix_a76_tenants_name'), 'tenants', ['name'], unique=False, schema='a76')
op.create_index(op.f('ix_a76_tenants_slug'), 'tenants', ['slug'], unique=True, schema='a76')
op.create_table('containers',
sa.Column('key', sa.String(length=3), nullable=False),
sa.Column('description', sa.String(length=500), nullable=False),
sa.PrimaryKeyConstraint('key', name='containers_pkey'),
schema='public'
op.create_table(
"countries",
sa.Column("m3_key", sa.String(length=3), nullable=False),
sa.Column("mex_key", sa.String(length=2), nullable=False),
sa.Column("ame_key", sa.String(length=2), nullable=False),
sa.Column("description_es", sa.String(length=50), nullable=False),
sa.Column("description_en", sa.String(length=50), nullable=False),
sa.PrimaryKeyConstraint("m3_key", name="countries_pkey"),
schema="public",
)
op.create_table('countries',
sa.Column('m3_key', sa.String(length=3), nullable=False),
sa.Column('mex_key', sa.String(length=2), nullable=False),
sa.Column('ame_key', sa.String(length=2), nullable=False),
sa.Column('description_es', sa.String(length=50), nullable=False),
sa.Column('description_en', sa.String(length=50), nullable=False),
sa.PrimaryKeyConstraint('m3_key', name='countries_pkey'),
schema='public'
op.create_index(
"ak_country_ame", "countries", ["ame_key"], unique=True, schema="public"
)
op.create_index('ak_country_ame', 'countries', ['ame_key'], unique=True, schema='public')
op.create_table('currency_types',
sa.Column('code', sa.String(length=3), nullable=False),
sa.Column('currency_name', sa.String(length=15), nullable=False),
sa.Column('country_description', sa.String(length=50), nullable=False),
sa.PrimaryKeyConstraint('code', name='currency_types_pkey'),
schema='public'
op.create_table(
"currency_types",
sa.Column("code", sa.String(length=3), nullable=False),
sa.Column("currency_name", sa.String(length=15), nullable=False),
sa.Column("country_description", sa.String(length=50), nullable=False),
sa.PrimaryKeyConstraint("code", name="currency_types_pkey"),
schema="public",
)
op.create_table('customs_sections',
sa.Column('customs_code', sa.String(length=3), nullable=False),
sa.Column('section_name', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('customs_code', name='customs_code_pkey'),
schema='public'
op.create_table(
"customs_sections",
sa.Column("customs_code", sa.String(length=3), nullable=False),
sa.Column("section_name", sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint("customs_code", name="customs_code_pkey"),
schema="public",
)
op.create_table('customs_warehouses',
sa.Column('key', sa.String(length=3), nullable=False),
sa.Column('customs', sa.String(length=100), nullable=False),
sa.Column('fiscalized_warehouse', sa.String(length=1000), nullable=False),
sa.PrimaryKeyConstraint('key', 'customs', name='pk_customs_warehouse'),
schema='public'
op.create_table(
"customs_warehouses",
sa.Column("key", sa.String(length=3), nullable=False),
sa.Column("customs", sa.String(length=100), nullable=False),
sa.Column("fiscalized_warehouse", sa.String(length=1000), nullable=False),
sa.PrimaryKeyConstraint("key", "customs", name="pk_customs_warehouse"),
schema="public",
)
op.create_table('incoterms',
sa.Column('code', sa.String(length=5), nullable=False),
sa.Column('description_es', sa.String(length=256), nullable=False),
sa.Column('description_en', sa.String(length=256), nullable=False),
sa.PrimaryKeyConstraint('code', name='incoterms_pkey'),
schema='public'
op.create_table(
"incoterms",
sa.Column("code", sa.String(length=5), nullable=False),
sa.Column("description_es", sa.String(length=256), nullable=False),
sa.Column("description_en", sa.String(length=256), nullable=False),
sa.PrimaryKeyConstraint("code", name="incoterms_pkey"),
schema="public",
)
op.create_table('invoice_types',
sa.Column('key', sa.String(length=5), nullable=False),
sa.Column('description', sa.String(length=50), nullable=False),
sa.Column('note', sa.String(length=500), nullable=False),
sa.Column('type', sa.String(length=15), nullable=False),
sa.PrimaryKeyConstraint('key', name='invoice_types_pkey'),
schema='public'
op.create_table(
"invoice_types",
sa.Column("key", sa.String(length=5), nullable=False),
sa.Column("description", sa.String(length=50), nullable=False),
sa.Column("note", sa.String(length=500), nullable=False),
sa.Column("type", sa.String(length=15), nullable=False),
sa.Column("operation", sa.String(length=5), nullable=False),
sa.PrimaryKeyConstraint("key", name="invoice_types_pkey"),
schema="public",
)
op.create_table('material_types',
sa.Column('key', sa.String(length=10), nullable=False),
sa.Column('type', sa.String(length=15), nullable=False),
sa.Column('description', sa.String(length=256), nullable=False),
sa.PrimaryKeyConstraint('key', name='material_types_pkey'),
schema='public'
op.create_table(
"material_types",
sa.Column("key", sa.String(length=10), nullable=False),
sa.Column("type", sa.String(length=15), nullable=False),
sa.Column("description", sa.String(length=256), nullable=False),
sa.PrimaryKeyConstraint("key", name="material_types_pkey"),
schema="public",
)
op.create_table('payment_methods',
sa.Column('key', sa.String(length=2), nullable=False),
sa.Column('description', sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint('key', name='payment_methods_pkey'),
schema='public'
op.create_table(
"payment_methods",
sa.Column("key", sa.String(length=2), nullable=False),
sa.Column("description", sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint("key", name="payment_methods_pkey"),
schema="public",
)
op.create_table('pedimento_codes',
sa.Column('code', sa.String(length=3), nullable=False),
sa.Column('description', sa.String(length=250), nullable=False),
sa.PrimaryKeyConstraint('code', name='pedimento_codes_pkey'),
schema='public'
op.create_table(
"pedimento_codes",
sa.Column("code", sa.String(length=3), nullable=False),
sa.Column("description", sa.String(length=250), nullable=False),
sa.PrimaryKeyConstraint("code", name="pedimento_codes_pkey"),
schema="public",
)
op.create_table('pedimento_regimens',
sa.Column('code', sa.String(length=3), nullable=False),
sa.Column('description', sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint('code', name='pedimento_regimens_pkey'),
schema='public'
op.create_table(
"pedimento_regimens",
sa.Column("code", sa.String(length=3), nullable=False),
sa.Column("description", sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint("code", name="pedimento_regimens_pkey"),
schema="public",
)
op.create_table('sectors',
sa.Column('key', sa.String(length=8), nullable=False),
sa.Column('description', sa.String(length=150), nullable=False),
sa.Column('authorized', sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint('key', name='sectors_pkey'),
schema='public'
op.create_table(
"sectors",
sa.Column("key", sa.String(length=8), nullable=False),
sa.Column("description", sa.String(length=150), nullable=False),
sa.Column("authorized", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint("key", name="sectors_pkey"),
schema="public",
)
op.create_table('states',
sa.Column('m3_key', sa.String(length=3), nullable=False),
sa.Column('description', sa.String(length=50), nullable=False),
sa.Column('mex_key', sa.String(length=3), nullable=True),
sa.Column('ame_key', sa.String(length=2), nullable=True),
sa.PrimaryKeyConstraint('m3_key', 'description', name='states_pkey'),
schema='public'
op.create_table(
"states",
sa.Column("m3_key", sa.String(length=3), nullable=False),
sa.Column("description", sa.String(length=50), nullable=False),
sa.Column("mex_key", sa.String(length=3), nullable=True),
sa.Column("ame_key", sa.String(length=2), nullable=True),
sa.PrimaryKeyConstraint("m3_key", "description", name="states_pkey"),
schema="public",
)
op.create_table('transport_modes',
sa.Column('key', sa.String(length=3), nullable=False),
sa.Column('name', sa.String(length=30), nullable=False),
sa.PrimaryKeyConstraint('key', name='transport_modes_pkey'),
schema='public'
op.create_table(
"transport_modes",
sa.Column("key", sa.String(length=3), nullable=False),
sa.Column("name", sa.String(length=30), nullable=False),
sa.PrimaryKeyConstraint("key", name="transport_modes_pkey"),
schema="public",
)
op.create_table('transport_types',
sa.Column('transport_code', sa.String(length=2), nullable=False),
sa.Column('description', sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint('transport_code', name='transport_types_pkey'),
schema='public'
op.create_table(
"transport_types",
sa.Column("transport_code", sa.String(length=2), nullable=False),
sa.Column("description", sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint("transport_code", name="transport_types_pkey"),
schema="public",
)
op.create_table('valuation_methods',
sa.Column('key', sa.String(length=2), nullable=False),
sa.Column('description', sa.String(length=200), nullable=False),
sa.PrimaryKeyConstraint('key', name='valuation_methods_pkey'),
schema='public'
op.create_table(
"valuation_methods",
sa.Column("key", sa.String(length=2), nullable=False),
sa.Column("description", sa.String(length=200), nullable=False),
sa.PrimaryKeyConstraint("key", name="valuation_methods_pkey"),
schema="public",
)
op.create_table('license_usage',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('period_start', sa.DateTime(timezone=True), nullable=False),
sa.Column('period_end', sa.DateTime(timezone=True), nullable=False),
sa.Column('active_users', sa.Integer(), nullable=True),
sa.Column('storage_used_gb', sa.Integer(), nullable=True),
sa.Column('operations_count', sa.Integer(), nullable=True),
sa.Column('api_calls_count', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['a76.tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='a76'
)
op.create_index(op.f('ix_a76_license_usage_id'), 'license_usage', ['id'], unique=False, schema='a76')
op.create_index(op.f('ix_a76_license_usage_tenant_id'), 'license_usage', ['tenant_id'], unique=False, schema='a76')
op.create_table('licenses',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('plan', sa.Enum('FREE', 'BASIC', 'PROFESSIONAL', 'ENTERPRISE', name='licenseplan'), nullable=False),
sa.Column('status', sa.Enum('ACTIVE', 'EXPIRED', 'SUSPENDED', 'PENDING', 'CANCELLED', name='licensestatus'), nullable=False),
sa.Column('max_users', sa.Integer(), nullable=False),
sa.Column('max_storage_gb', sa.Integer(), nullable=False),
sa.Column('max_monthly_operations', sa.Integer(), nullable=False),
sa.Column('feature_api_access', sa.Boolean(), nullable=True),
sa.Column('feature_advanced_reports', sa.Boolean(), nullable=True),
sa.Column('feature_integrations', sa.Boolean(), nullable=True),
sa.Column('feature_dedicated_support', sa.Boolean(), nullable=True),
sa.Column('starts_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['a76.tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='a76'
)
op.create_index(op.f('ix_a76_licenses_id'), 'licenses', ['id'], unique=False, schema='a76')
op.create_index(op.f('ix_a76_licenses_tenant_id'), 'licenses', ['tenant_id'], unique=True, schema='a76')
op.create_table('code_pedimento_regimens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('pedimento_code', sa.String(length=3), nullable=False),
sa.Column('regimen_code', sa.String(length=3), nullable=False),
sa.Column('type_code', sa.String(length=1), nullable=True),
sa.ForeignKeyConstraint(['pedimento_code'], ['public.pedimento_codes.code'], name='fk_codeped'),
sa.ForeignKeyConstraint(['regimen_code'], ['public.pedimento_regimens.code'], name='fk_regimenped'),
sa.PrimaryKeyConstraint('id', name='clave_pedimento_regimens_pkey'),
schema='public'
op.create_table(
"code_pedimento_regimens",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("pedimento_code", sa.String(length=3), nullable=False),
sa.Column("regimen_code", sa.String(length=3), nullable=False),
sa.Column("type_code", sa.String(length=1), nullable=True),
sa.ForeignKeyConstraint(
["pedimento_code"], ["public.pedimento_codes.code"], name="fk_codeped"
),
sa.ForeignKeyConstraint(
["regimen_code"], ["public.pedimento_regimens.code"], name="fk_regimenped"
),
sa.PrimaryKeyConstraint("id", name="clave_pedimento_regimens_pkey"),
schema="public",
)
# ### end Alembic commands ###
@@ -205,32 +173,22 @@ def upgrade() -> None:
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('code_pedimento_regimens', schema='public')
op.drop_index(op.f('ix_a76_licenses_tenant_id'), table_name='licenses', schema='a76')
op.drop_index(op.f('ix_a76_licenses_id'), table_name='licenses', schema='a76')
op.drop_table('licenses', schema='a76')
op.drop_index(op.f('ix_a76_license_usage_tenant_id'), table_name='license_usage', schema='a76')
op.drop_index(op.f('ix_a76_license_usage_id'), table_name='license_usage', schema='a76')
op.drop_table('license_usage', schema='a76')
op.drop_table('valuation_methods', schema='public')
op.drop_table('transport_types', schema='public')
op.drop_table('transport_modes', schema='public')
op.drop_table('states', schema='public')
op.drop_table('sectors', schema='public')
op.drop_table('pedimento_regimens', schema='public')
op.drop_table('pedimento_codes', schema='public')
op.drop_table('payment_methods', schema='public')
op.drop_table('material_types', schema='public')
op.drop_table('invoice_types', schema='public')
op.drop_table('incoterms', schema='public')
op.drop_table('customs_warehouses', schema='public')
op.drop_table('customs_sections', schema='public')
op.drop_table('currency_types', schema='public')
op.drop_index('ak_country_ame', table_name='countries', schema='public')
op.drop_table('countries', schema='public')
op.drop_table('containers', schema='public')
op.drop_index(op.f('ix_a76_tenants_slug'), table_name='tenants', schema='a76')
op.drop_index(op.f('ix_a76_tenants_name'), table_name='tenants', schema='a76')
op.drop_index(op.f('ix_a76_tenants_id'), table_name='tenants', schema='a76')
op.drop_table('tenants', schema='a76')
op.drop_table("code_pedimento_regimens", schema="public")
op.drop_table("valuation_methods", schema="public")
op.drop_table("transport_types", schema="public")
op.drop_table("transport_modes", schema="public")
op.drop_table("states", schema="public")
op.drop_table("sectors", schema="public")
op.drop_table("pedimento_regimens", schema="public")
op.drop_table("pedimento_codes", schema="public")
op.drop_table("payment_methods", schema="public")
op.drop_table("material_types", schema="public")
op.drop_table("invoice_types", schema="public")
op.drop_table("incoterms", schema="public")
op.drop_table("customs_warehouses", schema="public")
op.drop_table("customs_sections", schema="public")
op.drop_table("currency_types", schema="public")
op.drop_index("ak_country_ame", table_name="countries", schema="public")
op.drop_table("countries", schema="public")
op.drop_table("containers", schema="public")
# ### end Alembic commands ###

View File

@@ -5,152 +5,293 @@ Revises: 531bf8cdae06
Create Date: 2025-10-19 18:23:55.258800
"""
# pylint: disable=no-member
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from api.v1.modules.public.reference_data.pedimento_codes.seed import seed as pedimento_codes_seed
from api.v1.modules.public.reference_data.pedimento_regimens.seed import seed as pedimento_regimens_seed
from api.v1.modules.public.reference_data.code_pedimento_regimens.seed import seed as code_pedimento_regimens_seed
from api.v1.modules.public.reference_data.containers.seed import seed as container_types_seed
from api.v1.modules.public.reference_data.code_pedimento_regimens.seed import (
seed as code_pedimento_regimens_seed,
)
from api.v1.modules.public.reference_data.containers.seed import (
seed as container_types_seed,
)
from api.v1.modules.public.reference_data.countries.seed import seed as countries_seed
from api.v1.modules.public.reference_data.currency_types.seed import seed as currency_types_seed
from api.v1.modules.public.reference_data.customs_sections.seed import seed as customs_sections_seed
from api.v1.modules.public.reference_data.customs_warehouses.seed import seed as customs_warehouses_seed
from api.v1.modules.public.reference_data.currency_types.seed import (
seed as currency_types_seed,
)
from api.v1.modules.public.reference_data.customs_sections.seed import (
seed as customs_sections_seed,
)
from api.v1.modules.public.reference_data.customs_warehouses.seed import (
seed as customs_warehouses_seed,
)
from api.v1.modules.public.reference_data.incoterms.seed import seed as incoterms_seed
from api.v1.modules.public.reference_data.invoice_types.seed import seed as invoice_types_seed
from api.v1.modules.public.reference_data.material_types.seed import seed as material_types_seed
from api.v1.modules.public.reference_data.payment_methods.seed import seed as payment_methods_seed
from api.v1.modules.public.reference_data.invoice_types.seed import (
seed as invoice_types_seed,
)
from api.v1.modules.public.reference_data.material_types.seed import (
seed as material_types_seed,
)
from api.v1.modules.public.reference_data.payment_methods.seed import (
seed as payment_methods_seed,
)
from api.v1.modules.public.reference_data.pedimento_codes.seed import (
seed as pedimento_codes_seed,
)
from api.v1.modules.public.reference_data.pedimento_regimens.seed import (
seed as pedimento_regimens_seed,
)
from api.v1.modules.public.reference_data.sectors.seed import seed as sectors_seed
from api.v1.modules.public.reference_data.transport_modes.seed import seed as transport_modes_seed
from api.v1.modules.public.reference_data.transport_types.seed import seed as transport_types_seed
from api.v1.modules.public.reference_data.valuation_methods.seed import seed as valuation_methods_seed
from api.v1.modules.public.reference_data.transport_modes.seed import (
seed as transport_modes_seed,
)
from api.v1.modules.public.reference_data.transport_types.seed import (
seed as transport_types_seed,
)
from api.v1.modules.public.reference_data.valuation_methods.seed import (
seed as valuation_methods_seed,
)
# revision identifiers, used by Alembic.
revision: str = '7937209f9718'
down_revision: Union[str, Sequence[str], None] = '531bf8cdae06'
revision: str = "7937209f9718"
down_revision: Union[str, Sequence[str], None] = "531bf8cdae06"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = '531bf8cdae06'
depends_on: Union[str, Sequence[str], None] = "531bf8cdae06"
def upgrade() -> None:
"""Upgrade schema."""
#Seeds
values_pc = ", ".join([f"('{code}', '{desc.replace(chr(39), chr(39)*2)}')" for code, desc in pedimento_codes_seed])
op.execute(f"""
# Seeds
values_pc = ", ".join(
[
f"('{code}', '{desc.replace(chr(39), chr(39)*2)}')"
for code, desc in pedimento_codes_seed
]
)
op.execute(
f"""
INSERT INTO pedimento_codes (code, description) VALUES
{values_pc}
ON CONFLICT (code) DO NOTHING;
""")
values_pr = ", ".join([f"('{code}', '{desc.replace(chr(39), chr(39)*2)}')" for code, desc in pedimento_regimens_seed])
op.execute(f"""
"""
)
values_pr = ", ".join(
[
f"('{code}', '{desc.replace(chr(39), chr(39)*2)}')"
for code, desc in pedimento_regimens_seed
]
)
op.execute(
f"""
INSERT INTO public.pedimento_regimens (code, description) VALUES
{values_pr}
ON CONFLICT (code) DO NOTHING;
""")
values_cpr = ", ".join([f"('{ped_code}', '{reg_code}', '{type_code}')" for ped_code, reg_code, type_code in code_pedimento_regimens_seed])
op.execute(f"""
"""
)
values_cpr = ", ".join(
[
f"('{ped_code}', '{reg_code}', '{type_code}')"
for ped_code, reg_code, type_code in code_pedimento_regimens_seed
]
)
op.execute(
f"""
INSERT INTO public.code_pedimento_regimens (pedimento_code, regimen_code, type_code) VALUES
{values_cpr}
""")
values_c = ", ".join([f"('{key}', '{desc.replace(chr(39), chr(39)*2)}')" for key, desc in container_types_seed])
op.execute(f"""
"""
)
values_c = ", ".join(
[
f"('{key}', '{desc.replace(chr(39), chr(39)*2)}')"
for key, desc in container_types_seed
]
)
op.execute(
f"""
INSERT INTO public.containers (key, description) VALUES
{values_c}
ON CONFLICT (key) DO NOTHING;
""")
values_country = ", ".join([f"('{m3_key}', '{mex_key}', '{ame_key}', '{desc_es.replace(chr(39), chr(39)*2)}', '{desc_en.replace(chr(39), chr(39)*2)}')" for m3_key, mex_key, ame_key, desc_es, desc_en in countries_seed])
op.execute(f"""
"""
)
values_country = ", ".join(
[
f"('{m3_key}', '{mex_key}', '{ame_key}', '{desc_es.replace(chr(39), chr(39)*2)}', '{desc_en.replace(chr(39), chr(39)*2)}')"
for m3_key, mex_key, ame_key, desc_es, desc_en in countries_seed
]
)
op.execute(
f"""
INSERT INTO public.countries (m3_key, mex_key, ame_key, description_es, description_en) VALUES
{values_country}
ON CONFLICT (m3_key) DO NOTHING;
""")
values_ct = ", ".join([f"('{code}', '{currency_name.replace(chr(39), chr(39)*2)}', '{country_desc.replace(chr(39), chr(39)*2)}')" for code, currency_name, country_desc in currency_types_seed])
op.execute(f"""
"""
)
values_ct = ", ".join(
[
f"('{code}', '{currency_name.replace(chr(39), chr(39)*2)}', '{country_desc.replace(chr(39), chr(39)*2)}')"
for code, currency_name, country_desc in currency_types_seed
]
)
op.execute(
f"""
INSERT INTO public.currency_types (code, currency_name, country_description) VALUES
{values_ct}
ON CONFLICT (code) DO NOTHING;
""")
values_cs = ", ".join([f"('{code}', '{name.replace(chr(39), chr(39)*2)}')" for code, name in customs_sections_seed])
op.execute(f"""
"""
)
values_cs = ", ".join(
[
f"('{code}', '{name.replace(chr(39), chr(39)*2)}')"
for code, name in customs_sections_seed
]
)
op.execute(
f"""
INSERT INTO public.customs_sections (customs_code, section_name) VALUES
{values_cs}
ON CONFLICT (customs_code) DO NOTHING;
""")
values_cw = ", ".join([f"('{key}', '{customs.replace(chr(39), chr(39)*2)}', '{fiscalized_warehouse.replace(chr(39), chr(39)*2)}')" for key, customs, fiscalized_warehouse in customs_warehouses_seed])
op.execute(f"""
"""
)
values_cw = ", ".join(
[
f"('{key}', '{customs.replace(chr(39), chr(39)*2)}', '{fiscalized_warehouse.replace(chr(39), chr(39)*2)}')"
for key, customs, fiscalized_warehouse in customs_warehouses_seed
]
)
op.execute(
f"""
INSERT INTO public.customs_warehouses (key, customs, fiscalized_warehouse)
VALUES
{values_cw}
ON CONFLICT (key, customs) DO NOTHING;
""")
values_incoterms = ", ".join([f"('{code}', '{desc_es.replace(chr(39), chr(39)*2)}', '{desc_en.replace(chr(39), chr(39)*2)}')" for code, desc_es, desc_en in incoterms_seed])
op.execute(f"""
"""
)
values_incoterms = ", ".join(
[
f"('{code}', '{desc_es.replace(chr(39), chr(39)*2)}', '{desc_en.replace(chr(39), chr(39)*2)}')"
for code, desc_es, desc_en in incoterms_seed
]
)
op.execute(
f"""
INSERT INTO public.incoterms (code, description_es, description_en) VALUES
{values_incoterms}
ON CONFLICT (code) DO NOTHING;
""")
values_it = ", ".join([f"('{key}', '{desc.replace(chr(39), chr(39)*2)}', '{note.replace(chr(39), chr(39)*2)}', '{type.replace(chr(39), chr(39)*2)}')" for key, desc, note, type in invoice_types_seed])
op.execute(f"""
INSERT INTO public.invoice_types (key, description, note, type) VALUES
"""
)
values_it = ", ".join(
[
f"('{key}', '{desc.replace(chr(39), chr(39)*2)}', '{note.replace(chr(39), chr(39)*2)}', '{type.replace(chr(39), chr(39)*2)}', '{operation.replace(chr(39), chr(39)*2)}')"
for key, desc, note, type, operation in invoice_types_seed
]
)
op.execute(
f"""
INSERT INTO public.invoice_types (key, description, note, type, operation) VALUES
{values_it}
ON CONFLICT (key) DO NOTHING;
""")
values_mt = ", ".join([f"('{key}', '{desc.replace(chr(39), chr(39)*2)}', '{category.replace(chr(39), chr(39)*2)}')" for key, desc, category in material_types_seed])
op.execute(f"""
"""
)
values_mt = ", ".join(
[
f"('{key}', '{desc.replace(chr(39), chr(39)*2)}', '{category.replace(chr(39), chr(39)*2)}')"
for key, desc, category in material_types_seed
]
)
op.execute(
f"""
INSERT INTO public.material_types (key, description, type) VALUES
{values_mt}
ON CONFLICT (key) DO NOTHING;
""")
"""
)
values_pm = ", ".join([f"('{key}', '{desc.replace(chr(39), chr(39)*2)}')" for key, desc in payment_methods_seed])
op.execute(f"""
values_pm = ", ".join(
[
f"('{key}', '{desc.replace(chr(39), chr(39)*2)}')"
for key, desc in payment_methods_seed
]
)
op.execute(
f"""
INSERT INTO public.payment_methods (key, description) VALUES
{values_pm}
ON CONFLICT (key) DO NOTHING;
""")
values_sectors = ", ".join([f"('{key}', '{desc.replace(chr(39), chr(39)*2)}', '{authorized}')" for key, desc, authorized in sectors_seed])
op.execute(f"""
"""
)
values_sectors = ", ".join(
[
f"('{key}', '{desc.replace(chr(39), chr(39)*2)}', '{authorized}')"
for key, desc, authorized in sectors_seed
]
)
op.execute(
f"""
INSERT INTO public.sectors (key, description, authorized) VALUES
{values_sectors}
ON CONFLICT (key) DO NOTHING;
""")
values_tm = ", ".join([f"('{key}', '{name.replace(chr(39), chr(39)*2)}')" for key, name in transport_modes_seed])
op.execute(f"""
"""
)
values_tm = ", ".join(
[
f"('{key}', '{name.replace(chr(39), chr(39)*2)}')"
for key, name in transport_modes_seed
]
)
op.execute(
f"""
INSERT INTO public.transport_modes (key, name) VALUES
{values_tm}
ON CONFLICT (key) DO NOTHING;
""")
values_tt = ", ".join([f"('{code}', '{desc.replace(chr(39), chr(39)*2)}')" for code, desc in transport_types_seed])
op.execute(f"""
"""
)
values_tt = ", ".join(
[
f"('{code}', '{desc.replace(chr(39), chr(39)*2)}')"
for code, desc in transport_types_seed
]
)
op.execute(
f"""
INSERT INTO public.transport_types (transport_code, description) VALUES
{values_tt}
ON CONFLICT (transport_code) DO NOTHING;
""")
values_vm = ", ".join([f"('{key}', '{desc.replace(chr(39), chr(39)*2)}')" for key, desc in valuation_methods_seed])
op.execute(f"""
"""
)
values_vm = ", ".join(
[
f"('{key}', '{desc.replace(chr(39), chr(39)*2)}')"
for key, desc in valuation_methods_seed
]
)
op.execute(
f"""
INSERT INTO public.valuation_methods (key, description) VALUES
{values_vm}
ON CONFLICT (key) DO NOTHING;
""")
"""
)
def downgrade() -> None:
"""Downgrade schema."""
op.execute("DELETE FROM public.valuation_methods;")
op.execute("DELETE FROM public.transport_types;")
op.execute("DELETE FROM public.transport_modes;")

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
class TimestampMixin:
"""Mixin for common timestamp fields"""
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=func.now(), onupdate=func.now()
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class TenantScopedMixin:
"""Mixin for tenant and company scoped entities"""
tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("core.tenants.id"), nullable=False, index=True)
company_id: Mapped[int] = mapped_column(Integer, ForeignKey("a76.company.id"), nullable=False, index=True)
class PedimentoRelatedMixin(TenantScopedMixin):
"""Mixin for entities related to pedimentos"""
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)

View File

@@ -0,0 +1,121 @@
from typing import Any, Callable, Generic, Type, TypeVar
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
ModelType = TypeVar("ModelType")
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
class CRUDRouterFactory(
Generic[ModelType, CreateSchemaType, UpdateSchemaType, ResponseSchemaType]
):
"""Factory to create standard CRUD routes"""
def __init__(
self,
model: Type[ModelType],
create_schema: Type[CreateSchemaType],
update_schema: Type[UpdateSchemaType],
response_schema: Type[ResponseSchemaType],
db_dependency: Callable,
auth_dependency: Callable,
prefix: str,
tags: list[str],
id_field: str = "key",
):
self.model = model
self.create_schema = create_schema
self.update_schema = update_schema
self.response_schema = response_schema
self.db_dependency = db_dependency
self.auth_dependency = auth_dependency
self.id_field = id_field
self.router = APIRouter(prefix=prefix, tags=tags)
self._register_routes()
def _register_routes(self):
"""Register all CRUD routes"""
@self.router.get("/", response_model=list[self.response_schema])
def list_items(
skip: int = 0,
limit: int = 100,
db: Session = Depends(self.db_dependency),
current_user: dict = Depends(self.auth_dependency),
):
items = db.query(self.model).offset(skip).limit(limit).all()
return items
@self.router.get(f"/{{{self.id_field}}}", response_model=self.response_schema)
def get_item(
db: Session = Depends(self.db_dependency),
current_user: dict = Depends(self.auth_dependency),
**kwargs,
):
item_id = kwargs.get(self.id_field)
obj = (
db.query(self.model)
.filter(getattr(self.model, self.id_field) == item_id)
.first()
)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
return obj
@self.router.post("/", response_model=self.response_schema)
def create_item(
data: Any,
db: Session = Depends(self.db_dependency),
current_user: dict = Depends(self.auth_dependency),
):
obj = self.model(**data.dict())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@self.router.put(f"/{{{self.id_field}}}", response_model=self.response_schema)
def update_item(
data: Any,
db: Session = Depends(self.db_dependency),
current_user: dict = Depends(self.auth_dependency),
**kwargs,
):
item_id = kwargs.get(self.id_field)
obj = (
db.query(self.model)
.filter(getattr(self.model, self.id_field) == item_id)
.first()
)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
for field, value in data.dict(exclude_unset=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@self.router.delete(f"/{{{self.id_field}}}", status_code=204)
def delete_item(
db: Session = Depends(self.db_dependency),
current_user: dict = Depends(self.auth_dependency),
**kwargs,
):
item_id = kwargs.get(self.id_field)
obj = (
db.query(self.model)
.filter(getattr(self.model, self.id_field) == item_id)
.first()
)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
db.delete(obj)
db.commit()
return None

View File

@@ -0,0 +1,31 @@
from decimal import Decimal
from typing import Optional
from pydantic import Field
class CurrencyMixin:
"""Mixin for currency-related fields"""
currency: Optional[str] = Field(None, max_length=3, description="Currency")
currency_factor: Optional[Decimal] = Field(None, description="Currency factor")
class AffectValueMixin:
"""Mixin for value affect flags"""
not_affect_usd_value: Optional[int] = Field(
None, description="Not affect USD value"
)
not_affect_customs_value: Optional[int] = Field(
None, description="Not affect customs value"
)
class UpdateFlagsMixin:
"""Mixin for update flags"""
update_vat: Optional[int] = Field(None, description="Update VAT")
update_advalorem: Optional[int] = Field(None, description="Update advalorem")
update_cc: Optional[int] = Field(None, description="Update CC")
update_ieps: Optional[int] = Field(None, description="Update IEPS")

View File

@@ -0,0 +1,457 @@
from typing import Any, Callable, Dict, Generic, Optional, Type, TypeVar, Union
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
# Type variables for generic types
ModelType = TypeVar("ModelType")
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
ServiceType = TypeVar("ServiceType")
class TenantCRUDRoutes(
Generic[CreateSchemaType, UpdateSchemaType,
ResponseSchemaType, ServiceType]
):
"""
Generic CRUD routes factory for tenant-scoped resources
Supports both parent resources (with list/pagination) and child resources (nested under parent).
Usage examples:
1. Parent resource with list (e.g., /pedimentos):
router = TenantCRUDRoutes(
service=PedimentosService,
create_schema=PedimentosCreate,
update_schema=PedimentosUpdate,
response_schema=PedimentosResponse,
prefix="/pedimentos",
tags=["Pedimentos"],
resource_name="Pedimento",
id_name="pedimento_id",
enable_list=True,
).router
2. Child resource (e.g., /pedimentos/{pedimento_id}/config-additional):
router = TenantCRUDRoutes(
service=PedimentoConfigAdditionalService,
create_schema=PedimentoConfigAdditionalCreate,
update_schema=PedimentoConfigAdditionalUpdate,
response_schema=PedimentoConfigAdditionalResponse,
prefix="/{pedimento_id}/config-additional",
tags=["Pedimento Config Additional"],
resource_name="Config additional",
parent_id_name="pedimento_id",
enable_list=False,
).router
3. Parent resource with string ID (e.g., /vehicles with vehicle_key):
router = TenantCRUDRoutes(
service=VehicleService,
create_schema=VehicleCreate,
update_schema=VehicleUpdate,
response_schema=VehicleResponse,
prefix="/vehicles",
tags=["Vehicles"],
resource_name="Vehicle",
id_name="vehicle_key",
id_type=str, # Specify string type for vehicle_key
enable_list=True,
).router
"""
def __init__(
self,
service: Type[ServiceType],
create_schema: Type[CreateSchemaType],
update_schema: Type[UpdateSchemaType],
response_schema: Type[ResponseSchemaType],
prefix: str,
tags: list[str],
resource_name: str = "Resource",
# For parent resources (e.g., "pedimento_id")
id_name: Optional[str] = None,
id_type: Type = int, # Type of the ID (int, str, etc.)
parent_id_name: Optional[
str
] = None, # For child resources (e.g., "pedimento_id")
db_dependency: Callable = get_core_db,
auth_dependency: Callable = get_current_user,
validate_parent_match: bool = True, # Validate parent_id matches in create
enable_list: bool = False, # Enable GET list endpoint with pagination
enable_filters: bool = False, # Enable custom filters in list endpoint
default_page_size: int = 50,
max_page_size: int = 100,
):
self.service = service
self.create_schema = create_schema
self.update_schema = update_schema
self.response_schema = response_schema
self.resource_name = resource_name
self.id_name = id_name or parent_id_name or "id"
self.id_type = id_type
self.parent_id_name = parent_id_name
self.db_dependency = db_dependency
self.auth_dependency = auth_dependency
self.validate_parent_match = validate_parent_match
self.enable_list = enable_list
self.enable_filters = enable_filters
self.default_page_size = default_page_size
self.max_page_size = max_page_size
self.router = APIRouter(prefix=prefix, tags=tags)
self._register_routes()
def _register_routes(self):
"""Register all CRUD routes"""
# LIST route (optional, for parent resources)
if self.enable_list:
if self.enable_filters:
@self.router.get(
"/",
response_model=Dict[str, Any],
summary=f"List {self.resource_name}s",
description=f"Get paginated list of {self.resource_name}s with optional filters",
)
async def list_resources(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(
self.default_page_size,
ge=1,
le=self.max_page_size,
description="Page size",
),
status: Optional[str] = Query(
None, description="Filter by status"),
operation_type: Optional[str] = Query(
None, description="Filter by operation type"),
invoice_type: Optional[str] = Query(
None, description="Filter by invoice type"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(
self.auth_dependency),
):
tenant_id = validate_access_to_resource(
db, company_id, current_user
)
skip = (page - 1) * page_size
filters = {}
if status:
filters["status"] = status
if operation_type:
filters["operation_type"] = operation_type
if invoice_type:
filters["invoice_type"] = invoice_type
items, total = self.service.get_all(
db, tenant_id, company_id, skip, page_size, filters
)
return {
"items": [
self.response_schema.model_validate(item) for item in items
],
"total": total,
"page": page,
"page_size": page_size,
}
else:
@self.router.get(
"/",
response_model=Dict[str, Any],
summary=f"List {self.resource_name}s",
description=f"Get paginated list of {self.resource_name}s",
)
async def list_resources(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(
self.default_page_size,
ge=1,
le=self.max_page_size,
description="Page size",
),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(
self.auth_dependency),
):
tenant_id = validate_access_to_resource(
db, company_id, current_user
)
skip = (page - 1) * page_size
items, total = self.service.get_all(
db, tenant_id, company_id, skip, page_size, None
)
return {
"items": [
self.response_schema.model_validate(item) for item in items
],
"total": total,
"page": page,
"page_size": page_size,
}
# GET single resource route
# For parent resources: GET /{id}
# For child resources: GET / (parent_id comes from path)
if self.parent_id_name:
# Child resource - single GET without ID in path
@self.router.get(
"/",
response_model=self.response_schema,
summary=f"Get {self.resource_name}",
description=f"Get {self.resource_name} by {self.parent_id_name}",
)
async def get_resource(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
**path_params,
):
tenant_id = validate_access_to_resource(
db, company_id, current_user)
parent_id = path_params.get(self.parent_id_name)
# Try method with 4 params (pedimento_id, tenant_id, company_id)
if hasattr(self.service, "get_by_pedimento_id"):
resource = self.service.get_by_pedimento_id(
db, parent_id, tenant_id, company_id
)
# Fallback to method with 3 params
elif hasattr(self.service, "get_by_id"):
resource = self.service.get_by_id(
db, parent_id, tenant_id, company_id
)
else:
resource = self.service.get(
db, parent_id, tenant_id, company_id)
if not resource:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return resource
else:
# Parent resource - GET by ID in path
@self.router.get(
f"/{{{self.id_name}}}",
response_model=self.response_schema,
summary=f"Get {self.resource_name} by ID",
description=f"Get a specific {self.resource_name} by {self.id_name}",
)
async def get_resource_by_id(
resource_id: Union[int, str] = Path(
..., alias=self.id_name, description=f"{self.resource_name} ID"
),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
tenant_id = validate_access_to_resource(
db, company_id, current_user)
resource = self.service.get_by_id(
db, resource_id, tenant_id, company_id
)
if not resource:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return resource
# POST route
if self.parent_id_name:
# Child resource - needs parent_id from path
# Create a closure to capture the schema type
create_schema = self.create_schema
@self.router.post(
"/",
response_model=self.response_schema,
status_code=201,
summary=f"Create {self.resource_name}",
description=f"Create a new {self.resource_name}",
)
async def create_child_resource(
company_id: int = Query(..., description="Company ID"),
data: create_schema = Body(...), # type: ignore
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
tenant_id = validate_access_to_resource(
db, company_id, current_user)
# For child resources, parent_id validation would go here
resource = self.service.create(db, data, tenant_id, company_id)
return resource
else:
# Parent resource - no parent_id needed
# Create a closure to capture the schema type
create_schema = self.create_schema
@self.router.post(
"/",
response_model=self.response_schema,
status_code=201,
summary=f"Create {self.resource_name}",
description=f"Create a new {self.resource_name}",
)
async def create_parent_resource(
company_id: int = Query(..., description="Company ID"),
data: create_schema = Body(...), # type: ignore
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
tenant_id = validate_access_to_resource(
db, company_id, current_user)
resource = self.service.create(db, data, tenant_id, company_id)
return resource
# PUT route
# For parent resources: PUT /{id}
# For child resources: PUT / (parent_id comes from path)
if self.parent_id_name:
# Child resource
# Create a closure to capture the schema type
update_schema = self.update_schema
@self.router.put(
"/",
response_model=self.response_schema,
summary=f"Update {self.resource_name}",
description=f"Update an existing {self.resource_name}",
)
async def update_resource(
company_id: int = Query(..., description="Company ID"),
data: update_schema = Body(...), # type: ignore
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
**path_params,
):
tenant_id = validate_access_to_resource(
db, company_id, current_user)
parent_id = path_params.get(self.parent_id_name)
resource = self.service.update(
db, parent_id, tenant_id, data, company_id
)
if not resource:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return resource
else:
# Parent resource
# Create a closure to capture the schema type
update_schema = self.update_schema
@self.router.put(
f"/{{{self.id_name}}}",
response_model=self.response_schema,
summary=f"Update {self.resource_name}",
description=f"Update an existing {self.resource_name} by {self.id_name}",
)
async def update_resource_by_id(
resource_id: Union[int, str] = Path(
..., alias=self.id_name, description=f"{self.resource_name} ID"
),
company_id: int = Query(..., description="Company ID"),
data: update_schema = Body(...), # type: ignore
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
f"""Update {self.resource_name}"""
tenant_id = validate_access_to_resource(
db, company_id, current_user)
resource = self.service.update(
db, resource_id, tenant_id, data, company_id
)
if not resource:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return resource
# DELETE route
# For parent resources: DELETE /{id}
# For child resources: DELETE / (parent_id comes from path)
if self.parent_id_name:
# Child resource
@self.router.delete(
"/",
status_code=204,
summary=f"Delete {self.resource_name}",
description=f"Delete an existing {self.resource_name}",
)
async def delete_resource(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
**path_params,
):
tenant_id = validate_access_to_resource(
db, company_id, current_user)
parent_id = path_params.get(self.parent_id_name)
success = self.service.delete(
db, parent_id, tenant_id, company_id)
if not success:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return None
else:
# Parent resource
@self.router.delete(
f"/{{{self.id_name}}}",
status_code=204,
summary=f"Delete {self.resource_name}",
description=f"Delete an existing {self.resource_name} by {self.id_name}",
)
async def delete_resource_by_id(
resource_id: Union[int, str] = Path(
..., alias=self.id_name, description=f"{self.resource_name} ID"
),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
tenant_id = validate_access_to_resource(
db, company_id, current_user)
success = self.service.delete(
db, resource_id, tenant_id, company_id)
if not success:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return None

View File

@@ -0,0 +1,34 @@
from decimal import Decimal
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
Boolean,
ForeignKeyConstraint,
Integer,
Numeric,
PrimaryKeyConstraint,
String,
)
from sqlalchemy.orm import Mapped, mapped_column
class QClasses(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "fa_classes" # QClases
__table_args__ = (
PrimaryKeyConstraint("id", name="qclases_pk"),
ForeignKeyConstraint(["class_id"], ["a76.classes.id"], name="fk_qclasses_classes"),
{"schema": "a24"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
class_id: Mapped[int] = mapped_column(Integer, nullable=False)
import_tariff_code: Mapped[str] = mapped_column(String(10)) # FRACCIONIMPO
import_tariff_type: Mapped[str] = mapped_column(String(6)) # TIPOFRACIMPO
export_tariff_code: Mapped[str] = mapped_column(String(10)) # FRACCIONEXPO
export_tariff_type: Mapped[str] = mapped_column(String(6)) # TIPOFRACEXPO
depreciation_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2)) # TASADEPRECIA
fda_code: Mapped[str] = mapped_column(String(20)) # FDA
eccn_code: Mapped[str] = mapped_column(String(20)) # ECCN
class_enabled: Mapped[bool] = mapped_column(Boolean) # HABILITADESHABILITACLASE

View File

@@ -0,0 +1,34 @@
from typing import Optional
from sqlalchemy import Boolean, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class LineItem(Base):
__tablename__ = "line_items"
__table_args__ = (
{"schema": "a24"}
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
item_id: Mapped[int] = mapped_column(ForeignKey("a76.items.id"))
# Asset information (SCAF specific)
asset_number: Mapped[Optional[str]] = mapped_column(String(25)) # ASSETNUMBER
asset_photo: Mapped[Optional[str]] = mapped_column(String(255)) # FOTOACTIVOFIJO
equipment_message: Mapped[Optional[str]] = mapped_column(String(40)) # EQI_MENSAJE
invoice_type_asset: Mapped[Optional[str]] = mapped_column(String(6)) # TIPOFACTURAASSET
return_import_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURAIMPORET
return_import_date: Mapped[Optional[int]] = mapped_column(Integer) # FECHAFACIMPORET
movement_type_import: Mapped[Optional[str]] = mapped_column(String(3)) # TIPOMOVIMPO
# Cross-references IN CASE OF IMPORT REPAIR
search_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURAEXPO (when line is import) / FACTURAIMPO (when line is export)
search_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEAEXPO (when line is import) / LINEAIMPO (when line is export)
# Search type
search_type: Mapped[Optional[str]] = mapped_column(String(10)) # TIPOBUSQUEDA
# Special flags
download: Mapped[Optional[bool]] = mapped_column(Boolean) # DESCARGA
own_equipment: Mapped[Optional[bool]] = mapped_column(Boolean) # EQUIPOPROPIO
omit_annex31: Mapped[Optional[bool]] = mapped_column(Boolean) # OMITITENANEXO31

View File

@@ -0,0 +1,20 @@
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column
class SClasses(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "inv_classes" # SClases
__table_args__ = (
ForeignKeyConstraint(
["class_id"], ["a76.clases.class_id"], name="fk_sclasses_classes"
),
PrimaryKeyConstraint("id", name="sclases_pk"),
{"schema": "a24"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
class_id: Mapped[int] = mapped_column(Integer, nullable=False) # Id de clase
stock_um: Mapped[str] = mapped_column(String(5)) # Unidad de medida para existencia
us_tariff_code: Mapped[str] = mapped_column(String(19)) # Fracción americana (USA)

View File

@@ -0,0 +1,3 @@
"""
Módulo de localización
"""

View File

@@ -0,0 +1,43 @@
"""
DTOs (Data Transfer Objects) para módulo de localización
"""
from typing import Optional
from pydantic import BaseModel, Field
class LocationCreateDTO(BaseModel):
"""DTO para crear una localización"""
code: str = Field(..., max_length=5, description="Location code")
description: Optional[str] = Field(
None, max_length=200, description="Location description"
)
class Config:
from_attributes = True
class LocationUpdateDTO(BaseModel):
"""DTO para actualizar una localización"""
code: Optional[str] = Field(
None, max_length=5, description="Location code")
description: Optional[str] = Field(
None, max_length=200, description="Location description"
)
class Config:
from_attributes = True
class LocationResponseDTO(BaseModel):
"""DTO para responder con datos de una localización"""
id: int
code: str
description: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,36 @@
"""
Modelos ORM para gestión de localización
"""
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import Integer, PrimaryKeyConstraint, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
class Location(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla Location - Localización
"""
__tablename__ = "location" # SLocalizacion
__table_args__ = (
PrimaryKeyConstraint("id", name="location_pkey"),
UniqueConstraint("code", name="location_code_unique"),
{"schema": "a24"},
)
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
# Location code (unique)
code: Mapped[str] = mapped_column(String(5), nullable=False, unique=True)
# Location description
description: Mapped[Optional[str]] = mapped_column(String(200))
def __repr__(self):
return f"<Location(id={self.id}, code={self.code}, description={self.description})>"

View File

@@ -0,0 +1,136 @@
"""
Rutas para gestión de localización
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO
from .models import Location
from .service import LocationService
router = APIRouter(prefix="/locations", tags=["locations"])
@router.get(
"",
response_model=dict,
summary="Get all locations",
)
async def get_all_locations(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
code: str = Query(None),
description: str = Query(None),
db: Session = Depends(get_core_db),
):
"""Get all locations with optional filtering and pagination"""
filters = {}
if code:
filters["code"] = code
if description:
filters["description"] = description
locations, total = LocationService.get_all(db, skip, limit, filters)
return {
"data": [LocationResponseDTO.model_validate(location) for location in locations],
"total": total,
"skip": skip,
"limit": limit,
}
@router.get(
"/{location_id}",
response_model=LocationResponseDTO,
summary="Get location by ID",
)
async def get_location(
location_id: int,
db: Session = Depends(get_core_db),
):
"""Get a location by its ID"""
location = LocationService.get_by_id(db, location_id)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return LocationResponseDTO.model_validate(location)
@router.get(
"/code/{code}",
response_model=LocationResponseDTO,
summary="Get location by code",
)
async def get_location_by_code(
code: str,
db: Session = Depends(get_core_db),
):
"""Get a location by its code"""
location = LocationService.get_by_code(db, code)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return LocationResponseDTO.model_validate(location)
@router.post(
"",
response_model=LocationResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Create location",
)
async def create_location(
location_data: LocationCreateDTO,
db: Session = Depends(get_core_db),
):
"""Create a new location"""
location = LocationService.create(db, location_data)
return LocationResponseDTO.model_validate(location)
@router.put(
"/{location_id}",
response_model=LocationResponseDTO,
summary="Update location",
)
async def update_location(
location_id: int,
location_data: LocationUpdateDTO,
db: Session = Depends(get_core_db),
):
"""Update a location"""
location = LocationService.update(db, location_id, location_data)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return LocationResponseDTO.model_validate(location)
@router.delete(
"/{location_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete location",
)
async def delete_location(
location_id: int,
db: Session = Depends(get_core_db),
):
"""Delete a location"""
success = LocationService.delete(db, location_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return None

View File

@@ -0,0 +1,136 @@
"""
Capa de servicio para lógica de negocio de localización
"""
import logging
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO
from .models import Location
logger = logging.getLogger(__name__)
class LocationService:
"""Servicio para gestión de localización"""
def __init__(self, db: Session):
self.db = db
@staticmethod
def get_all(
db: Session,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Location], int]:
"""Get all locations with pagination"""
query = db.query(Location)
if filters:
if filters.get("code"):
query = query.filter(
Location.code.ilike(f"%{filters['code']}%"))
if filters.get("description"):
query = query.filter(
Location.description.ilike(f"%{filters['description']}%")
)
total = query.count()
locations = query.offset(skip).limit(limit).all()
return locations, total
@staticmethod
def get_by_id(db: Session, location_id: int) -> Optional[Location]:
"""Get location by ID"""
return db.query(Location).filter(Location.id == location_id).first()
@staticmethod
def get_by_code(db: Session, code: str) -> Optional[Location]:
"""Get location by code"""
return db.query(Location).filter(Location.code == code).first()
@staticmethod
def create(db: Session, location_data: LocationCreateDTO) -> Location:
"""Create a new location"""
try:
db_location = Location(
**location_data.model_dump(exclude_unset=True))
db.add(db_location)
db.commit()
db.refresh(db_location)
return db_location
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating location: {str(e)}")
raise HTTPException(
status_code=400,
detail="Location code already exists",
)
except Exception as e:
db.rollback()
logger.error(f"Error creating location: {str(e)}")
raise HTTPException(
status_code=500, detail="Error creating location")
@staticmethod
def update(
db: Session, location_id: int, location_data: LocationUpdateDTO
) -> Optional[Location]:
"""Update a location"""
try:
db_location = db.query(Location).filter(
Location.id == location_id).first()
if not db_location:
return None
for key, value in location_data.model_dump(exclude_unset=True).items():
setattr(db_location, key, value)
db.commit()
db.refresh(db_location)
return db_location
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError updating location: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error updating location",
)
except Exception as e:
db.rollback()
logger.error(f"Error updating location: {str(e)}")
raise HTTPException(
status_code=500, detail="Error updating location")
@staticmethod
def delete(db: Session, location_id: int) -> bool:
"""Delete a location"""
try:
db_location = db.query(Location).filter(
Location.id == location_id).first()
if not db_location:
return False
db.delete(db_location)
db.commit()
return True
except Exception as e:
db.rollback()
logger.error(f"Error deleting location: {str(e)}")
raise HTTPException(
status_code=500, detail="Error deleting location")

View File

@@ -0,0 +1,7 @@
"""
Módulo de Class
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,150 @@
"""
DTOs (Data Transfer Objects) para módulo de clases SCAII y SCAF
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class ClassCreateDTO(BaseModel):
"""DTO para crear una clase"""
client_id: int = Field(..., description="Client key")
class_code: str = Field(..., max_length=8, description="Class code")
description_es: Optional[str] = Field(
None, max_length=500, description="Description in Spanish"
)
description_en: Optional[str] = Field(
None, max_length=500, description="Description in English"
)
material_key: Optional[str] = Field(
None,
max_length=10,
description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)",
)
unit_of_measure: Optional[str] = Field(
None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)"
)
fraction: Optional[str] = Field(
None, max_length=10, description="Mexican tariff fraction"
)
us_fraction: Optional[str] = Field(
None, max_length=16, description="US tariff fraction"
)
sub_key: Optional[str] = Field(
None, max_length=5, description="Sub classification key"
)
physical_review: Optional[int] = Field(
None, description="Physical review indicator"
)
iva_exempt_fraction: Optional[str] = Field(
None, max_length=4, description="IVA exempt fraction"
)
class Config:
from_attributes = True
class ClassUpdateDTO(BaseModel):
"""DTO para actualizar una clase"""
description_es: Optional[str] = Field(
None, max_length=500, description="Description in Spanish"
)
description_en: Optional[str] = Field(
None, max_length=500, description="Description in English"
)
material_key: Optional[str] = Field(
None,
max_length=10,
description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)",
)
unit_of_measure: Optional[str] = Field(
None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)"
)
fraction: Optional[str] = Field(
None, max_length=10, description="Mexican tariff fraction"
)
us_fraction: Optional[str] = Field(
None, max_length=16, description="US tariff fraction"
)
sub_key: Optional[str] = Field(
None, max_length=5, description="Sub classification key"
)
physical_review: Optional[int] = Field(
None, description="Physical review indicator"
)
iva_exempt_fraction: Optional[str] = Field(
None, max_length=4, description="IVA exempt fraction"
)
class Config:
from_attributes = True
class ClassResponseDTO(BaseModel):
"""DTO para respuesta de clase"""
id: int
tenant_id: int
company_id: int
client_id: int
class_code: str
description_es: Optional[str] = None
description_en: Optional[str] = None
material_key: Optional[str] = None
unit_of_measure: Optional[str] = None
fraction: Optional[str] = None
us_fraction: Optional[str] = None
sub_key: Optional[str] = None
physical_review: Optional[int] = None
iva_exempt_fraction: Optional[str] = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ClassBasicDTO(BaseModel):
"""DTO para información básica de clase"""
client_id: int
class_code: str
description_es: Optional[str] = None
description_en: Optional[str] = None
material_key: Optional[str] = None
fraction: Optional[str] = None
class Config:
from_attributes = True
class ClassListDTO(BaseModel):
"""DTO para lista de clases"""
classes: list[ClassBasicDTO]
total: int
page: int
size: int
class Config:
from_attributes = True
class ClassSearchDTO(BaseModel):
"""DTO para búsqueda de clases"""
client_id: Optional[int] = Field(None, description="Filter by client key")
class_code: Optional[str] = Field(None, description="Search by class code")
description: Optional[str] = Field(None, description="Search in descriptions")
material_key: Optional[str] = Field(None, description="Filter by material key")
fraction: Optional[str] = Field(None, description="Filter by tariff fraction")
physical_review: Optional[int] = Field(
None, description="Filter by physical review indicator"
)
class Config:
from_attributes = True

View File

@@ -0,0 +1,108 @@
"""
Modelos ORM para gestión de clases SCAII y SCAF
"""
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKey,
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
SmallInteger,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.public.reference_data.material_types.models import MaterialType
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
class Class(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla GClases - Información de clases en sistemas SCAII y SCAF
"""
__tablename__ = "classes"
__table_args__ = (
PrimaryKeyConstraint("id", name="classes_pkey"),
ForeignKeyConstraint(
["client_id"], ["a76.clients_and_providers.id"], name="fk_classes_client"
),
ForeignKeyConstraint(
["material_key"],
["public.material_types.key"],
name="fk_classes_material_type",
),
ForeignKeyConstraint(
["unit_of_measure", "tenant_id", "company_id"],
["a76.units_of_measure.code", "a76.units_of_measure.tenant_id",
"a76.units_of_measure.company_id"],
),
UniqueConstraint(
"tenant_id",
"company_id",
"client_id",
"class_code",
name="ufa_classes_client_id_class_code",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
client_id: Mapped[int] = mapped_column(Integer)
# Unique constraint compuesta
class_code: Mapped[str] = mapped_column(String(8)) # CLASE
# Basic information
description_es: Mapped[Optional[str]] = mapped_column(
String(500)) # DESCRIPCIONE
description_en: Mapped[Optional[str]] = mapped_column(
String(500)) # DESCRIPCIONI
# Material and measurement
material_key: Mapped[Optional[str]] = mapped_column(
String(10)
) # CLAVEMAT - homologated from TIPOMAT/TIPOMATEQUIPO
unit_of_measure: Mapped[Optional[str]] = mapped_column(
String(5)
) # UNIMED - homologated from UNIMEDIDA
# Tariff fractions
fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCION
us_fraction: Mapped[Optional[str]] = mapped_column(
String(16)
) # FRACCIONAME - US tariff fraction
# Additional classification
sub_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVESUB
physical_review: Mapped[Optional[int]] = mapped_column(
SmallInteger) # REVFISICA
iva_exempt_fraction: Mapped[Optional[str]] = mapped_column(
String(4)
) # FRACCIONEXENTAIVA
# Relationships
material_type: Mapped[Optional["MaterialType"]] = relationship(
foreign_keys=[material_key]
)
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
foreign_keys=[unit_of_measure]
)
# Inverse relationship with GParts that have this class
parts: Mapped[list["Part"]] = relationship(
primaryjoin="and_(Class.client_id == Part.client_id, Class.class_code == Part.part_class)",
foreign_keys="[Part.client_id, Part.part_class]",
viewonly=True,
back_populates="part_class_info",
)
def __repr__(self) -> str:
return f"<Class(client_id={self.client_id}, class_code='{self.class_code}', description='{self.description_es}')>"

View File

@@ -0,0 +1,24 @@
"""
Endpoints API para gestión de clases SCAII y SCAF
"""
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import ClassCreateDTO, ClassResponseDTO, ClassUpdateDTO
from .service import ClassService
# Create router with generic CRUD routes
router = TenantCRUDRoutes(
service=ClassService,
create_schema=ClassCreateDTO,
update_schema=ClassUpdateDTO,
response_schema=ClassResponseDTO,
prefix="/classes",
tags=["a76 / classes"],
resource_name="Class",
id_name="class_id",
enable_list=True,
enable_filters=True,
default_page_size=50,
max_page_size=100,
).router

View File

@@ -0,0 +1,482 @@
"""
Capa de servicio para lógica de negocio de clases SCAII y SCAF
"""
import logging
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from sqlalchemy import and_, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import (
ClassBasicDTO,
ClassCreateDTO,
ClassListDTO,
ClassResponseDTO,
ClassSearchDTO,
ClassUpdateDTO,
)
from .models import Class
logger = logging.getLogger(__name__)
class ClassService:
"""Servicio para gestión de clases SCAII y SCAF"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> tuple[List[Class], int]:
"""
Get all classes for a tenant with pagination and filters
"""
query = db.query(Class).filter(
Class.tenant_id == tenant_id, Class.company_id == company_id
)
if filters:
if filters.get("client_id"):
query = query.filter(Class.client_id == filters["client_id"])
if filters.get("class_code"):
query = query.filter(
Class.class_code.ilike(f"%{filters['class_code']}%")
)
if filters.get("description"):
description_pattern = f"%{filters['description']}%"
query = query.filter(
or_(
Class.description_es.ilike(description_pattern),
Class.description_en.ilike(description_pattern),
)
)
if filters.get("material_key"):
query = query.filter(
Class.material_key.ilike(f"%{filters['material_key']}%")
)
if filters.get("fraction"):
query = query.filter(Class.fraction.ilike(f"%{filters['fraction']}%"))
if filters.get("physical_review") is not None:
query = query.filter(
Class.physical_review == filters["physical_review"]
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session, class_id: int, tenant_id: int, company_id: int
) -> Optional[Class]:
"""Get a class by ID"""
return (
db.query(Class)
.filter(
Class.id == class_id,
Class.tenant_id == tenant_id,
Class.company_id == company_id,
)
.first()
)
@staticmethod
def create(
db: Session, class_data: ClassCreateDTO, tenant_id: int, company_id: int
) -> Class:
"""Create a new class"""
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
data_dict = class_data.model_dump()
# Check if class_code already exists for this tenant and company
existing = db.query(Class).filter(
Class.tenant_id == tenant_id,
Class.company_id == company_id,
Class.client_id == data_dict["client_id"],
Class.class_code == data_dict["class_code"]
).first()
if existing:
raise HTTPException(
status_code=400,
detail=f"Class with code '{data_dict['class_code']}' already exists for this tenant and company"
)
# Validate material_key exists if provided
if data_dict.get("material_key"):
from api.v1.modules.public.reference_data.material_types.models import MaterialType
material_exists = db.query(MaterialType).filter(
MaterialType.key == data_dict["material_key"]
).first()
if not material_exists:
# Set to None if material_key doesn't exist
data_dict["material_key"] = None
class_obj = Class(**data_dict)
class_obj.tenant_id = tenant_id
class_obj.company_id = company_id
try:
db.add(class_obj)
db.commit()
db.refresh(class_obj)
return class_obj
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=400,
detail=f"Failed to create class: {str(e.orig)}"
)
@staticmethod
def update(
db: Session,
class_id: int,
tenant_id: int,
class_data: ClassUpdateDTO,
company_id: int,
) -> Optional[Class]:
"""Update a class"""
class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id)
if not class_obj:
return None
update_data = class_data.model_dump(exclude_unset=True)
# Validate material_key exists if provided
if "material_key" in update_data and update_data["material_key"]:
from api.v1.modules.public.reference_data.material_types.models import MaterialType
material_exists = db.query(MaterialType).filter(
MaterialType.key == update_data["material_key"]
).first()
if not material_exists:
# Set to None if material_key doesn't exist
update_data["material_key"] = None
for field, value in update_data.items():
setattr(class_obj, field, value)
db.commit()
db.refresh(class_obj)
return class_obj
@staticmethod
def delete(db: Session, class_id: int, tenant_id: int, company_id: int) -> bool:
"""Delete a class"""
class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id)
if not class_obj:
return False
db.delete(class_obj)
db.commit()
return True
def __init__(self, db: Session):
self.db = db
def create_class(self, class_data: ClassCreateDTO) -> ClassResponseDTO:
"""
Crea una nueva clase en el sistema
Args:
class_data: Datos de la clase a crear
Returns:
ClassResponseDTO con información de la clase creada
Raises:
HTTPException: Si la clase ya existe o error en la creación
"""
try:
# Verificar que no exista la clase
existing = (
self.db.query(Class)
.filter(
and_(
Class.client_id == class_data.client_id,
Class.class_code == class_data.class_code,
)
)
.first()
)
if existing:
raise HTTPException(
status_code=400,
detail=f"Class with client_id '{class_data.client_id}' and class_code '{class_data.class_code}' already exists",
)
# Crear clase
db_class = Class(
client_id=class_data.client_id,
class_code=class_data.class_code,
description_spanish=class_data.description_spanish,
description_english=class_data.description_english,
material_key=class_data.material_key,
unit_of_measure=class_data.unit_of_measure,
fraction=class_data.fraction,
us_fraction=class_data.us_fraction,
sub_key=class_data.sub_key,
physical_review=class_data.physical_review,
iva_exempt_fraction=class_data.iva_exempt_fraction,
)
self.db.add(db_class)
self.db.commit()
self.db.refresh(db_class)
return ClassResponseDTO.model_validate(db_class)
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating class: {str(e)}")
raise HTTPException(
status_code=400,
detail="Class with this client_id and class_code already exists",
)
except HTTPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"Error creating class: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating class")
def get_class(self, client_id: int, class_code: str) -> Optional[ClassResponseDTO]:
"""
Obtiene una clase por clave compuesta
Args:
client_id: Clave del cliente
class_code: Código de clase
Returns:
ClassResponseDTO o None si no existe
"""
class_obj = (
self.db.query(Class)
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
.first()
)
if not class_obj:
return None
return ClassResponseDTO.model_validate(class_obj)
def list_classes(
self,
skip: int = 0,
limit: int = 100,
search_params: Optional[ClassSearchDTO] = None,
) -> ClassListDTO:
"""
Lista clases con filtros
Args:
skip: Número de registros a omitir
limit: Número máximo de registros a retornar
search_params: Parámetros de búsqueda
Returns:
ClassListDTO con la lista paginada
"""
query = self.db.query(Class)
# Aplicar filtros si se proporcionan
if search_params:
if search_params.client_id:
query = query.filter(Class.client_id == search_params.client_id)
if search_params.class_code:
query = query.filter(
Class.class_code.ilike(f"%{search_params.class_code}%")
)
if search_params.description:
description_pattern = f"%{search_params.description}%"
query = query.filter(
or_(
Class.description_spanish.ilike(description_pattern),
Class.description_english.ilike(description_pattern),
)
)
if search_params.material_key:
query = query.filter(
Class.material_key.ilike(f"%{search_params.material_key}%")
)
if search_params.fraction:
query = query.filter(
Class.fraction.ilike(f"%{search_params.fraction}%")
)
if search_params.physical_review is not None:
query = query.filter(
Class.physical_review == search_params.physical_review
)
# Contar total
total = query.count()
# Aplicar paginación
classes = query.offset(skip).limit(limit).all()
# Convertir a DTOs básicos
class_dtos = [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
return ClassListDTO(
classes=class_dtos,
total=total,
page=(skip // limit) + 1 if limit > 0 else 1,
size=len(class_dtos),
)
def update_class(
self, client_id: int, class_code: str, class_data: ClassUpdateDTO
) -> Optional[ClassResponseDTO]:
"""
Actualiza una clase
Args:
client_id: Clave del cliente
class_code: Código de clase
class_data: Datos a actualizar
Returns:
ClassResponseDTO actualizado o None si no existe
"""
class_obj = (
self.db.query(Class)
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
.first()
)
if not class_obj:
return None
try:
# Actualizar solo campos proporcionados
update_data = class_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(class_obj, field, value)
self.db.commit()
self.db.refresh(class_obj)
return ClassResponseDTO.model_validate(class_obj)
except Exception as e:
self.db.rollback()
logger.error(f"Error updating class {client_id}-{class_code}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating class")
def delete_class(self, client_id: int, class_code: str) -> bool:
"""
Elimina una clase
Args:
client_id: Clave del cliente
class_code: Código de clase
Returns:
True si se eliminó, False si no existe
"""
class_obj = (
self.db.query(Class)
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
.first()
)
if not class_obj:
return False
try:
self.db.delete(class_obj)
self.db.commit()
return True
except Exception as e:
self.db.rollback()
logger.error(f"Error deleting class {client_id}-{class_code}: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting class")
def search_by_fraction(self, fraction: str) -> List[ClassBasicDTO]:
"""Busca clases por fracción arancelaria"""
classes = (
self.db.query(Class).filter(Class.fraction.ilike(f"%{fraction}%")).all()
)
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
def search_by_client(
self, client_id: int, skip: int = 0, limit: int = 100
) -> List[ClassBasicDTO]:
"""Obtiene todas las clases de un cliente específico"""
classes = (
self.db.query(Class)
.filter(Class.client_id == client_id)
.offset(skip)
.limit(limit)
.all()
)
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
def search_by_material(self, material_key: str) -> List[ClassBasicDTO]:
"""Busca clases por clave de material"""
classes = (
self.db.query(Class)
.filter(Class.material_key.ilike(f"%{material_key}%"))
.all()
)
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
def get_classes_by_physical_review(
self, physical_review: int
) -> List[ClassBasicDTO]:
"""Obtiene clases por indicador de revisión física"""
classes = (
self.db.query(Class).filter(Class.physical_review == physical_review).all()
)
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
def get_classes_statistics(self) -> dict:
"""Obtiene estadísticas básicas de clases"""
total_classes = self.db.query(Class).count()
# Contar por clientes
clients_count = self.db.query(Class.client_id).distinct().count()
# Contar por revisión física
physical_review_stats = {}
for i in range(3): # Asumiendo valores 0, 1, 2
count = self.db.query(Class).filter(Class.physical_review == i).count()
physical_review_stats[f"physical_review_{i}"] = count
# Contar clases con fracciones
with_fraction = self.db.query(Class).filter(Class.fraction.isnot(None)).count()
with_us_fraction = (
self.db.query(Class).filter(Class.us_fraction.isnot(None)).count()
)
return {
"total_classes": total_classes,
"clients_with_classes": clients_count,
"classes_with_fraction": with_fraction,
"classes_with_us_fraction": with_us_fraction,
**physical_review_stats,
}
def get_classes_by_unit_measure(self, unit_of_measure: str) -> List[ClassBasicDTO]:
"""Obtiene clases por unidad de medida"""
classes = (
self.db.query(Class).filter(Class.unit_of_measure == unit_of_measure).all()
)
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]

View File

@@ -0,0 +1,36 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from .routes import router
app = FastAPI()
app.include_router(router)
client = TestClient(app)
@pytest.mark.usefixtures("client", "access_token")
def test_list_classes(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/classes/", headers=headers)
assert response.status_code == 200
assert "items" in response.json()
assert "page" in response.json()
assert "page_size" in response.json()
@pytest.mark.usefixtures("client", "access_token")
def test_get_class_not_found(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/classes/invalid_id", headers=headers)
assert response.status_code == 404
def test_create_class_forbidden():
response = client.post("/classes/", json={"name": "Test Class"})
assert response.status_code in (403, 405, 404)
def test_update_class_forbidden():
response = client.put("/classes/1", json={"name": "Updated Class"})
assert response.status_code in (403, 405, 404)

View File

@@ -0,0 +1,7 @@
"""
Módulo de Client & Provider
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,245 @@
"""
DTOs (Data Transfer Objects) para módulo de clientes y proveedores
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from decimal import Decimal
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
# DTOs para dirección
class ClientProviderAddressDTO(BaseModel):
"""DTO para dirección de cliente/proveedor"""
municipality: Optional[str] = Field(
None, max_length=150, description="Municipality"
)
streets: Optional[str] = Field(None, max_length=100, description="Streets")
neighborhood: Optional[str] = Field(None, max_length=40, description="Neighborhood")
interior_number: Optional[str] = Field(
None, max_length=20, description="Interior number"
)
exterior_number: Optional[str] = Field(
None, max_length=20, description="Exterior number"
)
postal_code: Optional[str] = Field(None, max_length=15, description="Postal code")
city: Optional[str] = Field(None, max_length=30, description="City")
state: Optional[str] = Field(None, max_length=30, description="State")
country: Optional[str] = Field(None, max_length=3, description="Country code")
phone: Optional[str] = Field(None, max_length=30, description="Phone number")
fax_number: Optional[str] = Field(None, max_length=30, description="Fax number")
email: Optional[str] = Field(None, max_length=100, description="Email address")
contact: Optional[str] = Field(None, max_length=50, description="Contact person")
reference: Optional[str] = Field(None, max_length=250, description="Reference")
class Config:
from_attributes = True
# DTOs para programas
class ClientProviderProgramsDTO(BaseModel):
"""DTO para programas de cliente/proveedor"""
program: Optional[str] = Field(None, max_length=7, description="Program")
program_number: Optional[str] = Field(
None, max_length=40, description="Program number"
)
prosec: Optional[int] = Field(None, description="PROSEC")
prosec_authorization: Optional[str] = Field(
None, max_length=20, description="PROSEC authorization"
)
secon_auth_date: Optional[int] = Field(None, description="SECON authorization date")
manufacturer_id: Optional[str] = Field(
None, max_length=25, description="Manufacturer ID"
)
tax_id: Optional[str] = Field(None, max_length=30, description="Tax ID")
broker: Optional[str] = Field(None, max_length=6, description="Broker")
import_broker: Optional[str] = Field(
None, max_length=6, description="Import broker"
)
transfer_key: Optional[str] = Field(None, max_length=8, description="Transfer key")
secon_authorization: Optional[str] = Field(
None, max_length=20, description="SECON authorization"
)
applied_proportion: Optional[Decimal] = Field(
None, description="Applied proportion"
)
is_certified_company: Optional[str] = Field(
None, max_length=1, description="Is certified company"
)
certified_company_registry: Optional[str] = Field(
None, max_length=40, description="Certified company registry"
)
donation_auth_number: Optional[str] = Field(
None, max_length=50, description="Donation authorization number"
)
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
tax_registry_number: Optional[str] = Field(
None, max_length=40, description="Tax registry number"
)
subassembly_service: Optional[int] = Field(None, description="Subassembly service")
autse_dates: Optional[int] = Field(None, description="AUTSE dates")
autse_number: Optional[str] = Field(
None, max_length=300, description="AUTSE number"
)
class Config:
from_attributes = True
# DTOs principales
class ClientProviderCreateDTO(BaseModel):
"""DTO para crear cliente/proveedor"""
type_nat_foreign: Optional[str] = Field(
None, max_length=1, description="Type national/foreign"
)
name: Optional[str] = Field(None, max_length=256, description="Name")
short_name: Optional[str] = Field(None, max_length=10, description="Short name")
rfc: Optional[str] = Field(None, max_length=30, description="RFC")
curp: Optional[str] = Field(None, max_length=19, description="CURP")
client_or_provider: Optional[Literal["client", "provider", "both"]] = Field(
None, description="Client or provider"
)
linking: Optional[str] = Field(None, max_length=1, description="Linking")
transform_subassembly: Optional[str] = Field(
None, max_length=1, description="Transform subassembly"
)
extra_information: Optional[str] = Field(
None, max_length=399, description="Extra information"
)
web_key: Optional[str] = Field(None, max_length=40, description="Web key")
responsible: Optional[str] = Field(
None, max_length=80, description="Responsible person"
)
position: Optional[str] = Field(None, max_length=30, description="Position")
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
is_national_provider: Optional[str] = Field(
None, max_length=2, description="Is national provider"
)
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = Field(
None, description="Address information"
)
programs: Optional[ClientProviderProgramsDTO] = Field(
None, description="Programs information"
)
class Config:
from_attributes = True
class ClientProviderUpdateDTO(BaseModel):
"""DTO para actualizar cliente/proveedor"""
type_nat_foreign: Optional[str] = Field(
None, max_length=1, description="Type national/foreign"
)
name: Optional[str] = Field(None, max_length=256, description="Name")
short_name: Optional[str] = Field(None, max_length=10, description="Short name")
rfc: Optional[str] = Field(None, max_length=30, description="RFC")
curp: Optional[str] = Field(None, max_length=19, description="CURP")
client_or_provider: Optional[Literal["client", "provider", "both"]] = Field(
None, description="Client or provider"
)
linking: Optional[str] = Field(None, max_length=1, description="Linking")
transform_subassembly: Optional[str] = Field(
None, max_length=1, description="Transform subassembly"
)
extra_information: Optional[str] = Field(
None, max_length=399, description="Extra information"
)
web_key: Optional[str] = Field(None, max_length=40, description="Web key")
responsible: Optional[str] = Field(
None, max_length=80, description="Responsible person"
)
position: Optional[str] = Field(None, max_length=30, description="Position")
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
is_national_provider: Optional[str] = Field(
None, max_length=2, description="Is national provider"
)
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = Field(
None, description="Address information"
)
programs: Optional[ClientProviderProgramsDTO] = Field(
None, description="Programs information"
)
class Config:
from_attributes = True
class ClientProviderResponseDTO(BaseModel):
"""DTO para respuesta de cliente/proveedor"""
id: int
type_nat_foreign: Optional[str] = None
name: Optional[str] = None
short_name: Optional[str] = None
rfc: Optional[str] = None
curp: Optional[str] = None
client_or_provider: Optional[str] = None
linking: Optional[str] = None
transform_subassembly: Optional[str] = None
extra_information: Optional[str] = None
web_key: Optional[str] = None
responsible: Optional[str] = None
position: Optional[str] = None
incoterm: Optional[str] = None
is_national_provider: Optional[str] = None
is_active: Optional[bool] = None
tenant_id: int
company_id: int
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = None
programs: Optional[ClientProviderProgramsDTO] = None
class Config:
from_attributes = True
# DTOs para respuestas específicas
class ClientProviderBasicDTO(BaseModel):
"""DTO para información básica de cliente/proveedor"""
client_id: str
name: Optional[str] = None
short_name: Optional[str] = None
rfc: Optional[str] = None
client_or_provider: Optional[str] = None
is_active: Optional[bool] = None
class Config:
from_attributes = True
class ClientProviderListDTO(BaseModel):
"""DTO para lista de clientes/proveedores"""
clients: list[ClientProviderBasicDTO]
total: int
page: int
size: int
class Config:
from_attributes = True
class ClientProviderPaginatedResponseDTO(BaseModel):
"""DTO para respuesta paginada de clientes/proveedores"""
items: List[ClientProviderResponseDTO]
total: int
page: int
page_size: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,159 @@
"""
Modelos ORM para gestión de clientes y proveedores
"""
from decimal import Decimal
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKey,
ForeignKeyConstraint,
Integer,
Numeric,
PrimaryKeyConstraint,
SmallInteger,
String,
Enum as PgEnum,
Boolean
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from enum import Enum
class ClientOrProviderEnum(str, Enum):
CLIENT = "client"
PROVIDER = "provider"
BOTH = "both"
class ClientProvider(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla GClientesPro - Información de clientes y proveedores
"""
__tablename__ = "clients_and_providers"
__table_args__ = (
PrimaryKeyConstraint("id", name="clients_and_providers_pkey"),
{"schema": "a76"},
)
# Primary key
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Basic information
type_nat_foreign: Mapped[Optional[str]] = mapped_column(String(1)) # TIPO NACIONAL/EXTRANJERO
name: Mapped[Optional[str]] = mapped_column(String(256))
short_name: Mapped[Optional[str]] = mapped_column(String(10))
rfc: Mapped[Optional[str]] = mapped_column(String(30))
curp: Mapped[Optional[str]] = mapped_column(String(19))
client_or_provider: Mapped[ClientOrProviderEnum] = mapped_column(PgEnum(ClientOrProviderEnum, name="entity_client_or_provider", create_type=True, native_enum=True),nullable=False)
linking: Mapped[Optional[str]] = mapped_column(String(1))
transform_subassembly: Mapped[Optional[str]] = mapped_column(String(1))
extra_information: Mapped[Optional[str]] = mapped_column(String(399))
web_key: Mapped[Optional[str]] = mapped_column(String(40))
responsible: Mapped[Optional[str]] = mapped_column(String(80))
position: Mapped[Optional[str]] = mapped_column(String(30))
incoterm: Mapped[Optional[str]] = mapped_column(String(19))
is_national_provider: Mapped[Optional[bool]] = mapped_column(Boolean)
is_active: Mapped[Optional[bool]] = mapped_column(Boolean)
# Relationships
address: Mapped[Optional["ClientProviderAddress"]] = relationship(
back_populates="clients_and_providers", uselist=False, cascade="all, delete-orphan"
)
programs: Mapped[Optional["ClientProviderPrograms"]] = relationship(
back_populates="clients_and_providers", uselist=False, cascade="all, delete-orphan"
)
class ClientProviderAddress(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla GClientesPro_Direccion - Dirección de clientes y proveedores
"""
__tablename__ = "clients_and_providers_address"
__table_args__ = (
PrimaryKeyConstraint("id", name="clients_and_providers_address_pkey"),
ForeignKeyConstraint(
["client_id"],
["a76.clients_and_providers.id"],
ondelete="CASCADE",
name="fk_clients_and_providers_address_client",
),
{"schema": "a76"},
)
# Primary key (foreign key)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
client_id: Mapped[int] = mapped_column(
Integer, ForeignKey("a76.clients_and_providers.id", ondelete="CASCADE")
)
# Address information
municipality: Mapped[Optional[str]] = mapped_column(String(150))
streets: Mapped[Optional[str]] = mapped_column(String(100))
neighborhood: Mapped[Optional[str]] = mapped_column(String(40))
interior_number: Mapped[Optional[str]] = mapped_column(String(20))
exterior_number: Mapped[Optional[str]] = mapped_column(String(20))
postal_code: Mapped[Optional[str]] = mapped_column(String(15))
city: Mapped[Optional[str]] = mapped_column(String(30))
state: Mapped[Optional[str]] = mapped_column(String(30))
country: Mapped[Optional[str]] = mapped_column(String(3))
phone: Mapped[Optional[str]] = mapped_column(String(30))
fax_number: Mapped[Optional[str]] = mapped_column(String(30))
email: Mapped[Optional[str]] = mapped_column(String(100))
contact: Mapped[Optional[str]] = mapped_column(String(50))
reference: Mapped[Optional[str]] = mapped_column(String(250))
# Relationship
clients_and_providers: Mapped["ClientProvider"] = relationship(back_populates="address")
class ClientProviderPrograms(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla GClientesPro_Programas - Programas de clientes y proveedores
"""
__tablename__ = "clients_and_providers_programs"
__table_args__ = (
PrimaryKeyConstraint("id", name="clients_and_providers_programs_pkey"),
ForeignKeyConstraint(
["client_id"],
["a76.clients_and_providers.id"],
ondelete="CASCADE",
name="fk_clients_and_providers_programs_client",
),
{"schema": "a76"},
)
# Primary key (foreign key)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
client_id: Mapped[int] = mapped_column(
Integer, ForeignKey("a76.clients_and_providers.id", ondelete="CASCADE")
)
# Program information
program: Mapped[Optional[str]] = mapped_column(String(7))
program_number: Mapped[Optional[str]] = mapped_column(String(40))
prosec: Mapped[Optional[int]] = mapped_column(SmallInteger)
prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20))
secon_auth_date: Mapped[Optional[int]] = mapped_column(Integer)
manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25))
tax_id: Mapped[Optional[str]] = mapped_column(String(30))
broker: Mapped[Optional[str]] = mapped_column(String(6))
import_broker: Mapped[Optional[str]] = mapped_column(String(6))
transfer_key: Mapped[Optional[str]] = mapped_column(String(8))
secon_authorization: Mapped[Optional[str]] = mapped_column(String(20))
applied_proportion: Mapped[Optional[Decimal]] = mapped_column(Numeric(7, 2))
is_certified_company: Mapped[Optional[str]] = mapped_column(String(1))
certified_company_registry: Mapped[Optional[str]] = mapped_column(String(40))
donation_auth_number: Mapped[Optional[str]] = mapped_column(String(50))
ctpat_svi: Mapped[Optional[str]] = mapped_column(String(100))
tax_registry_number: Mapped[Optional[str]] = mapped_column(String(40))
subassembly_service: Mapped[Optional[int]] = mapped_column(SmallInteger)
autse_dates: Mapped[Optional[int]] = mapped_column()
autse_number: Mapped[Optional[str]] = mapped_column(String(300))
# Relationship
clients_and_providers: Mapped["ClientProvider"] = relationship(back_populates="programs")

View File

@@ -0,0 +1,129 @@
"""
Endpoints API para gestión de clientes y proveedores
"""
from typing import List, Optional
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .models import ClientOrProviderEnum
from .dto import (
ClientProviderBasicDTO,
ClientProviderCreateDTO,
ClientProviderResponseDTO,
ClientProviderUpdateDTO,
ClientProviderPaginatedResponseDTO,
)
from .service import ClientProviderService
from .models import ClientProvider
# Create main router to add custom endpoints
router = APIRouter(prefix="/clients-providers")
@router.get("/", response_model=ClientProviderPaginatedResponseDTO)
async def get_clients_and_providers(
company_id: int = Query(..., description="Company ID"),
type: Optional[ClientOrProviderEnum] = Query(
None, description="Type of entity (client or provider)"
),
active: Optional[bool] = Query(None, description="Active status"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get clients and providers"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
query = db.query(ClientProvider).filter(
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
if type is not None:
query = query.filter(ClientProvider.client_or_provider == type)
if active is not None:
query = query.filter(ClientProvider.is_active == active)
total = query.count()
clients = query.offset(skip).limit(limit).all()
return {
"items": [ClientProviderResponseDTO.model_validate(c) for c in clients],
"total": total,
"page": (skip // limit) + 1,
"page_size": limit,
}
@router.get("/{client_id}/basic", response_model=ClientProviderBasicDTO)
async def get_clients_and_providers_basic_info(
client_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get basic information for a client/provider (without address and programs)"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
client = ClientProviderService.get_by_id(db, client_id, tenant_id, company_id)
if not client:
raise HTTPException(status_code=404, detail="Client/Provider not found")
return ClientProviderBasicDTO.model_validate(client)
@router.post("/", response_model=ClientProviderResponseDTO)
async def create_client_provider(
client_data: ClientProviderCreateDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new client/provider"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
return ClientProviderService.create(db, client_data, tenant_id, company_id)
@router.patch("/{client_id}", response_model=ClientProviderResponseDTO)
async def update_client_provider(
client_id: int,
client_data: ClientProviderUpdateDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Update a client/provider"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
client = ClientProviderService.update(
db, client_id, tenant_id, company_id, client_data
)
if not client:
raise HTTPException(status_code=404, detail="Client/Provider not found")
return client
@router.delete("/{client_id}", response_model=bool)
async def delete_client_provider(
client_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a client/provider"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
success = ClientProviderService.delete(db, client_id, tenant_id, company_id)
if not success:
raise HTTPException(status_code=404, detail="Client/Provider not found")
return success

View File

@@ -0,0 +1,480 @@
"""
Capa de servicio para lógica de negocio de clientes y proveedores
"""
import logging
from typing import List, Optional, Tuple, Dict, Any
from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from .dto import (
ClientProviderBasicDTO,
ClientProviderCreateDTO,
ClientProviderListDTO,
ClientProviderResponseDTO,
ClientProviderUpdateDTO,
)
from .models import ClientProvider, ClientProviderAddress, ClientProviderPrograms
logger = logging.getLogger(__name__)
class ClientProviderService:
"""Servicio para gestión de clientes y proveedores"""
def __init__(self, db: Session):
self.db = db
# Métodos para TenantCRUDRoutes
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[ClientProvider], int]:
"""Get all clients/providers for a tenant/company with pagination"""
query = db.query(ClientProvider).filter(
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
# Apply filters if provided
if filters:
if filters.get("search"):
search_pattern = f"%{filters['search']}%"
query = query.filter(
or_(
ClientProvider.name.ilike(search_pattern),
ClientProvider.short_name.ilike(search_pattern),
ClientProvider.rfc.ilike(search_pattern),
)
)
if filters.get("client_or_provider"):
query = query.filter(
ClientProvider.client_or_provider == filters["client_or_provider"]
)
if filters.get("status"):
enabled = 1 if filters["status"] == "enabled" else 0
query = query.filter(ClientProvider.is_active == enabled)
total = query.count()
clients = (
query.options(
joinedload(ClientProvider.address), joinedload(ClientProvider.programs)
)
.offset(skip)
.limit(limit)
.all()
)
return clients, total
@staticmethod
def get_by_id(
db: Session, client_id: int, tenant_id: int, company_id: int
) -> Optional[ClientProvider]:
"""Get client/provider by ID"""
return (
db.query(ClientProvider)
.options(
joinedload(ClientProvider.address), joinedload(ClientProvider.programs)
)
.filter(
ClientProvider.id == client_id,
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
.first()
)
@staticmethod
def create(
db: Session,
client_data: ClientProviderCreateDTO,
tenant_id: int,
company_id: int,
) -> ClientProvider:
"""Create a new client/provider"""
try:
# Create main client/provider
data_dict = client_data.model_dump(exclude={"address", "programs"})
db_client = ClientProvider(
**data_dict, tenant_id=tenant_id, company_id=company_id
)
db.add(db_client)
db.flush()
# Create address if provided
if client_data.address:
db_address = ClientProviderAddress(
tenant_id=tenant_id,
company_id=company_id,
client_id=db_client.id,
**client_data.address.model_dump(exclude_unset=True),
)
db.add(db_address)
# Create programs if provided
if client_data.programs:
db_programs = ClientProviderPrograms(
tenant_id=tenant_id,
company_id=company_id,
client_id=db_client.id,
**client_data.programs.model_dump(exclude_unset=True),
)
db.add(db_programs)
db.commit()
db.refresh(db_client)
return db_client
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating client/provider: {str(e)}")
raise HTTPException(
status_code=400, detail="Client/Provider already exists"
)
except Exception as e:
db.rollback()
logger.error(f"Error creating client/provider: {str(e)}")
raise HTTPException(
status_code=500, detail="Error creating client/provider"
)
@staticmethod
def update(
db: Session,
client_id: int,
tenant_id: int,
company_id: int,
client_data: ClientProviderUpdateDTO,
) -> Optional[ClientProvider]:
"""Update a client/provider"""
client = ClientProviderService.get_by_id(db, client_id, tenant_id, company_id)
if not client:
return None
try:
# Update main fields
update_data = client_data.model_dump(
exclude_unset=True, exclude={"address", "programs"}
)
for field, value in update_data.items():
setattr(client, field, value)
# Update address
if client_data.address:
if client.address:
address_data = client_data.address.model_dump(exclude_unset=True)
for field, value in address_data.items():
setattr(client.address, field, value)
else:
db_address = ClientProviderAddress(
client_id=client.id,
tenant_id=tenant_id,
**client_data.address.model_dump(exclude_unset=True),
)
db.add(db_address)
# Update programs
if client_data.programs:
if client.programs:
programs_data = client_data.programs.model_dump(exclude_unset=True)
for field, value in programs_data.items():
setattr(client.programs, field, value)
else:
db_programs = ClientProviderPrograms(
client_id=client.id,
tenant_id=tenant_id,
**client_data.programs.model_dump(exclude_unset=True),
)
db.add(db_programs)
db.commit()
db.refresh(client)
return client
except Exception as e:
db.rollback()
logger.error(f"Error updating client/provider {client_id}: {str(e)}")
raise HTTPException(
status_code=500, detail="Error updating client/provider"
)
@staticmethod
def delete(db: Session, client_id: int, tenant_id: int, company_id: int) -> bool:
"""Delete a client/provider"""
client = ClientProviderService.get_by_id(db, client_id, tenant_id, company_id)
if not client:
return False
try:
db.delete(client)
db.commit()
return True
except Exception as e:
db.rollback()
logger.error(f"Error deleting client/provider {client_id}: {str(e)}")
raise HTTPException(
status_code=500, detail="Error deleting client/provider"
)
# Legacy methods for custom endpoints
def create_clients_and_providers_legacy(
self, company_id: int, client_data: ClientProviderCreateDTO
) -> ClientProviderResponseDTO:
"""Legacy method for creating client/provider"""
try:
# Create main client/provider
data_dict = client_data.model_dump(exclude={"address", "programs"})
db_client = ClientProvider(**data_dict)
self.db.add(db_client)
self.db.flush()
# Create address if provided
if client_data.address:
db_address = ClientProviderAddress(
company_id=company_id,
client_id=db_client.id,
**client_data.address.model_dump(exclude_unset=True),
)
self.db.add(db_address)
# Create programs if provided
if client_data.programs:
db_programs = ClientProviderPrograms(
company_id=company_id,
client_id=db_client.id,
**client_data.programs.model_dump(exclude_unset=True),
)
self.db.add(db_programs)
self.db.commit()
return self._get_client_with_relations(db_client.id)
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating client/provider: {str(e)}")
raise HTTPException(
status_code=400, detail="Client/Provider with this ID already exists"
)
except HTTPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"Error creating client/provider: {str(e)}")
raise HTTPException(
status_code=500, detail="Error creating client/provider"
)
def get_clients_and_providers(
self, client_id: str
) -> Optional[ClientProviderResponseDTO]:
"""
Obtiene un cliente/proveedor por ID
Args:
client_id: ID del cliente/proveedor
Returns:
ClientProviderResponseDTO o None si no existe
"""
return self._get_client_with_relations(client_id)
def _get_client_with_relations(
self, client_id: str
) -> Optional[ClientProviderResponseDTO]:
"""Método privado para obtener cliente con relaciones"""
client = (
self.db.query(ClientProvider)
.options(
joinedload(ClientProvider.address), joinedload(ClientProvider.programs)
)
.filter(ClientProvider.client_id == client_id)
.first()
)
if not client:
return None
return ClientProviderResponseDTO.model_validate(client)
def list_clients_providers(
self,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
client_or_provider: Optional[str] = None,
enabled_only: bool = False,
) -> ClientProviderListDTO:
"""
Lista clientes/proveedores con filtros
Args:
skip: Número de registros a omitir
limit: Número máximo de registros a retornar
search: Texto de búsqueda (nombre, RFC, ID)
client_or_provider: Filtrar por tipo (client=Cliente, provider=Proveedor)
enabled_only: Si True, solo retorna activos
Returns:
ClientProviderListDTO con la lista paginada
"""
query = self.db.query(ClientProvider)
# Aplicar filtros
if search:
search_pattern = f"%{search}%"
query = query.filter(
or_(
ClientProvider.name.ilike(search_pattern),
ClientProvider.short_name.ilike(search_pattern),
ClientProvider.rfc.ilike(search_pattern),
ClientProvider.client_id.ilike(search_pattern),
)
)
if client_or_provider:
query = query.filter(
ClientProvider.client_or_provider == client_or_provider
)
if enabled_only:
query = query.filter(ClientProvider.is_active == 1)
# Contar total
total = query.count()
# Aplicar paginación
clients = query.offset(skip).limit(limit).all()
# Convertir a DTOs básicos
client_dtos = [
ClientProviderBasicDTO.model_validate(client) for client in clients
]
return ClientProviderListDTO(
clients=client_dtos,
total=total,
page=(skip // limit) + 1 if limit > 0 else 1,
size=len(client_dtos),
)
def update_clients_and_providers(
self, client_id: str, client_data: ClientProviderUpdateDTO
) -> Optional[ClientProviderResponseDTO]:
"""
Actualiza un cliente/proveedor
Args:
client_id: ID del cliente/proveedor a actualizar
client_data: Datos a actualizar
Returns:
ClientProviderResponseDTO actualizado o None si no existe
"""
client = (
self.db.query(ClientProvider)
.filter(ClientProvider.client_id == client_id)
.first()
)
if not client:
return None
try:
# Actualizar campos del cliente principal
update_data = client_data.model_dump(
exclude_unset=True, exclude={"address", "programs"}
)
for field, value in update_data.items():
setattr(client, field, value)
# Actualizar dirección
if client_data.address:
address = (
self.db.query(ClientProviderAddress)
.filter(ClientProviderAddress.client_id == client_id)
.first()
)
if address:
# Actualizar dirección existente
address_data = client_data.address.model_dump(exclude_unset=True)
for field, value in address_data.items():
setattr(address, field, value)
else:
# Crear nueva dirección
address = ClientProviderAddress(
client_id=client_id,
**client_data.address.model_dump(exclude_unset=True),
)
self.db.add(address)
# Actualizar programas
if client_data.programs:
programs = (
self.db.query(ClientProviderPrograms)
.filter(ClientProviderPrograms.client_id == client_id)
.first()
)
if programs:
# Actualizar programas existentes
programs_data = client_data.programs.model_dump(exclude_unset=True)
for field, value in programs_data.items():
setattr(programs, field, value)
else:
# Crear nuevos programas
programs = ClientProviderPrograms(
client_id=client_id,
**client_data.programs.model_dump(exclude_unset=True),
)
self.db.add(programs)
self.db.commit()
return self._get_client_with_relations(client_id)
except Exception as e:
self.db.rollback()
logger.error(f"Error updating client/provider {client_id}: {str(e)}")
raise HTTPException(
status_code=500, detail="Error updating client/provider"
)
def delete_clients_and_providers(self, client_id: str) -> bool:
"""
Elimina un cliente/proveedor
Args:
client_id: ID del cliente/proveedor a eliminar
Returns:
True si se eliminó, False si no existe
"""
client = (
self.db.query(ClientProvider)
.filter(ClientProvider.client_id == client_id)
.first()
)
if not client:
return False
try:
self.db.delete(client) # Las relaciones se eliminan en cascada
self.db.commit()
return True
except Exception as e:
self.db.rollback()
logger.error(f"Error deleting client/provider {client_id}: {str(e)}")
raise HTTPException(
status_code=500, detail="Error deleting client/provider"
)

View File

@@ -0,0 +1,40 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from .routes import router
app = FastAPI()
app.include_router(router)
client = TestClient(app)
@pytest.mark.usefixtures("client", "access_token")
def test_list_clients_and_providers(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/client_and_provider/", headers=headers)
assert response.status_code == 200
assert "items" in response.json()
assert "page" in response.json()
assert "page_size" in response.json()
@pytest.mark.usefixtures("client", "access_token")
def test_get_client_or_provider_not_found(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/client_and_provider/invalid_id", headers=headers)
assert response.status_code == 404
def test_create_client_or_provider_forbidden():
response = client.post(
"/client_and_provider/", json={"name": "Test Client/Provider"}
)
assert response.status_code in (403, 405, 404)
def test_update_client_or_provider_forbidden():
response = client.put(
"/client_and_provider/1", json={"name": "Updated Client/Provider"}
)
assert response.status_code in (403, 405, 404)

View File

@@ -0,0 +1,21 @@
"""
DTOs for CountryRuleOct.
"""
from pydantic import BaseModel
class CountryRuleOctBaseDTO(BaseModel):
permission: str
line: int
fraction: str
country_code: str
class CountryRuleOctCreateDTO(CountryRuleOctBaseDTO):
pass
class CountryRuleOctResponseDTO(CountryRuleOctBaseDTO):
class Config:
from_attributes = True

View File

@@ -0,0 +1,46 @@
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
class CountryRuleOct(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "country_rule_oct"
__table_args__ = (
PrimaryKeyConstraint("id", name="country_rule_oct_pkey"),
ForeignKeyConstraint(
["tenant_id", "company_id", "permission", "line", "fraction"],
[
"a76.fraction_rule_octave.tenant_id",
"a76.fraction_rule_octave.company_id",
"a76.fraction_rule_octave.permission",
"a76.fraction_rule_octave.line",
"a76.fraction_rule_octave.fraction",
],
ondelete="CASCADE",
name="fk_country_rule_oct_frac_octava",
),
UniqueConstraint(
"tenant_id",
"company_id",
"permission",
"line",
"fraction",
"country_code",
name="uq_country_rule_oct_permission_line_fraction_country",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
permission: Mapped[str] = mapped_column(String(20))
line: Mapped[int] = mapped_column()
fraction: Mapped[str] = mapped_column(String(10))
country_code: Mapped[str] = mapped_column(String(3))

View File

@@ -0,0 +1,98 @@
from typing import List
from core.database import get_core_db
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from .dto import CountryRuleOctCreateDTO, CountryRuleOctResponseDTO
from .services import CountryRuleOctService
router = APIRouter(prefix="/country-rule-oct")
@router.get("/", response_model=List[CountryRuleOctResponseDTO])
async def list_countries(
db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user)
):
"""
List all CountryRuleOct entries.
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
return db.query(CountryRuleOctService).all()
@router.get(
"/{permission}/{line}/{fraction}/{country_code}",
response_model=CountryRuleOctResponseDTO,
)
async def read_country_rule(
permission: str,
line: int,
fraction: str,
country_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get a specific CountryRuleOct by its composite key.
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
country = CountryRuleOctService.get_country_by_keys(
db, permission, line, fraction, country_code
)
if not country:
raise HTTPException(status_code=404, detail="CountryRuleOct not found")
return country
@router.post(
"/", response_model=CountryRuleOctResponseDTO, status_code=status.HTTP_201_CREATED
)
async def create_country_rule(
country_data: CountryRuleOctCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Create a new CountryRuleOct entry.
"""
return CountryRuleOctService.create_country_rule(db, country_data)
@router.delete(
"/{permission}/{line}/{fraction}/{country_code}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_country_rule(
permission: str,
line: int,
fraction: str,
country_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Delete a CountryRuleOct by its composite key.
"""
country = CountryRuleOctService.delete_country_rule(
db, permission, line, fraction, country_code
)
if not country:
raise HTTPException(status_code=404, detail="CountryRuleOct not found")

View File

@@ -0,0 +1,44 @@
"""
Service layer for CountryRuleOct.
"""
from sqlalchemy.orm import Session
from . import dto, models
class CountryRuleOctService:
@staticmethod
def get_country_by_keys(
db: Session, permission: str, line: int, fraction: str, country_code: str
):
return (
db.query(models.CountryRuleOct)
.filter(
models.CountryRuleOct.permission == permission,
models.CountryRuleOct.line == line,
models.CountryRuleOct.fraction == fraction,
models.CountryRuleOct.country_code == country_code,
)
.first()
)
@staticmethod
def create_country_rule(db: Session, country_data: dto.CountryRuleOctCreateDTO):
new_country = models.CountryRuleOct(**country_data.dict())
db.add(new_country)
db.commit()
db.refresh(new_country)
return new_country
@staticmethod
def delete_country_rule(
db: Session, permission: str, line: int, fraction: str, country_code: str
):
country = CountryRuleOctService.get_country_by_keys(
db, permission, line, fraction, country_code
)
if country:
db.delete(country)
db.commit()
return country

View File

@@ -0,0 +1,36 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from .routes import router
app = FastAPI()
app.include_router(router)
client = TestClient(app)
@pytest.mark.usefixtures("client", "access_token")
def test_list_country_rules(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/country-rule-oct/", headers=headers)
assert response.status_code == 200
assert "items" in response.json()
assert "page" in response.json()
assert "page_size" in response.json()
@pytest.mark.usefixtures("client", "access_token")
def test_get_country_rule_not_found(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/country-rule-oct/invalid_id", headers=headers)
assert response.status_code == 404
def test_create_country_rule_forbidden():
response = client.post("/country-rule-oct/", json={"rule": "Test Rule"})
assert response.status_code in (403, 405, 404)
def test_update_country_rule_forbidden():
response = client.put("/country-rule-oct/1", json={"rule": "Updated Rule"})
assert response.status_code in (403, 405, 404)

View File

@@ -0,0 +1,111 @@
from typing import Optional
from pydantic import BaseModel
class CustomsBrokerBaseDTO(BaseModel):
"""Base fields for CustomsBroker"""
type: Optional[str] = None
name: Optional[str] = None
address: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
phone: Optional[str] = None
fax: Optional[str] = None
email: Optional[str] = None
country: Optional[str] = None
tax_id: Optional[str] = None
personal_id: Optional[str] = None
position: Optional[str] = None
license: Optional[str] = None
company: Optional[str] = None
contact: Optional[str] = None
class CustomsBrokerCreateDTO(CustomsBrokerBaseDTO):
"""Schema for creating a new CustomsBroker"""
broker_key: str
class CustomsBrokerUpdateDTO(CustomsBrokerBaseDTO):
"""Schema for updating an existing CustomsBroker"""
pass
class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO):
"""Schema for CustomsBroker response"""
broker_key: str
tenant_id: int
company_id: int
class Config:
from_attributes = True
# Legacy DTO for backwards compatibility (if needed elsewhere)
class CustomsBrokerDTO(BaseModel):
type: Optional[str] = None
broker_key: str
name: Optional[str] = None
address: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
phone: Optional[str] = None
fax: Optional[str] = None
email: Optional[str] = None
country: Optional[str] = None
tax_id: Optional[str] = None
personal_id: Optional[str] = None
position: Optional[str] = None
license: Optional[str] = None
company: Optional[str] = None
contact: Optional[str] = None
tenant_id: str
company_id: str
class Config:
from_attributes = True
class CustomsBrokerVUCreateDTO(BaseModel):
certificate_path: Optional[str]
key_path: Optional[str]
access_key: Optional[str]
fiel_format: Optional[str]
signature_read_path: Optional[str]
archive_path: Optional[str]
fiel_access_key: Optional[str]
web_service_user: Optional[str]
web_service_access_key: Optional[str]
vu_email: Optional[str]
vu_figure_type: Optional[str]
xml_files_path: Optional[str]
query_tax_id: Optional[str]
doda_certificate_path: Optional[str]
doda_key_path: Optional[str]
doda_web_service_user: Optional[str]
doda_web_service_access_key: Optional[str]
doda_fiel_access_key: Optional[str]
doda_xml_files_path: Optional[str]
class Config:
from_attributes = True
class CustomsBrokerPersonnelDTO(BaseModel):
broker_key: str
line: int
name: Optional[str]
tax_id: Optional[str]
personal_id: Optional[str]
position: Optional[str]
license: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
middle_name: Optional[str]
email: Optional[str]
class Config:
from_attributes = True

View File

@@ -0,0 +1,94 @@
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String, UniqueConstraint, PrimaryKeyConstraint
from sqlalchemy.orm import relationship
class CustomsBroker(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "customs_brokers"
__table_args__ = (
PrimaryKeyConstraint("id", name="customs_brokers_pkey"),
UniqueConstraint("broker_key", "tenant_id", "company_id", name="uq_broker_key_tenant_company"),
{"schema": "a76"},
)
id = Column(Integer, primary_key=True)
type = Column(String(9), nullable=True)
broker_key = Column(String(5), nullable=False)
name = Column(String(80), nullable=True)
address = Column(String(1500), nullable=True)
postal_code = Column(String(15), nullable=True)
city = Column(String(30), nullable=True)
state = Column(String(30), nullable=True)
phone = Column(String(30), nullable=True)
fax = Column(String(30), nullable=True)
email = Column(String(100), nullable=True)
country = Column(String(3), nullable=True)
tax_id = Column(String(30), nullable=True)
personal_id = Column(String(20), nullable=True)
position = Column(String(30), nullable=True)
license = Column(String(4), nullable=True)
company = Column(String(200), nullable=True)
contact = Column(String(80), nullable=True)
vu = relationship(
"CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete"
)
personnel = relationship(
"CustomsBrokerPersonnel", back_populates="customs_broker", cascade="all, delete"
)
class CustomsBrokerVU(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "customs_brokers_vu"
__table_args__ = {"schema": "a76"}
customs_broker_id = Column(
Integer,
ForeignKey("a76.customs_brokers.id", ondelete="CASCADE"),
primary_key=True,
)
certificate_path = Column(String(1499), nullable=True)
key_path = Column(String(1499), nullable=True)
access_key = Column(String(50), nullable=True)
fiel_format = Column(String(19), nullable=True)
signature_read_path = Column(String(1499), nullable=True)
archive_path = Column(String(1499), nullable=True)
fiel_access_key = Column(String(50), nullable=True)
web_service_user = Column(String(100), nullable=True)
web_service_access_key = Column(String(100), nullable=True)
vu_email = Column(String(800), nullable=True)
vu_figure_type = Column(String(29), nullable=True)
xml_files_path = Column(String(1499), nullable=True)
query_tax_id = Column(String(30), nullable=True)
doda_certificate_path = Column(String(1499), nullable=True)
doda_key_path = Column(String(1499), nullable=True)
doda_web_service_user = Column(String(100), nullable=True)
doda_web_service_access_key = Column(String(100), nullable=True)
doda_fiel_access_key = Column(String(50), nullable=True)
doda_xml_files_path = Column(String(1499), nullable=True)
customs_broker = relationship("CustomsBroker", back_populates="vu")
class CustomsBrokerPersonnel(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "customs_brokers_personnel"
__table_args__ = {"schema": "a76"}
customs_broker_id = Column(
Integer,
ForeignKey("a76.customs_brokers.id", ondelete="CASCADE"),
primary_key=True,
)
line = Column(Integer, primary_key=True, nullable=False)
name = Column(String(80), nullable=True)
tax_id = Column(String(30), nullable=True)
personal_id = Column(String(20), nullable=True)
position = Column(String(30), nullable=True)
license = Column(String(4), nullable=True)
first_name = Column(String(80), nullable=True)
last_name = Column(String(80), nullable=True)
middle_name = Column(String(80), nullable=True)
email = Column(String(100), nullable=True)
customs_broker = relationship("CustomsBroker", back_populates="personnel")

View File

@@ -0,0 +1,85 @@
from typing import Dict, Any
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from . import dto, services
# Create main router
router = APIRouter()
# Create CRUD routes for CustomsBroker using TenantCRUDRoutes
customs_broker_crud = TenantCRUDRoutes(
service=services.CustomsBrokerService,
create_schema=dto.CustomsBrokerCreateDTO,
update_schema=dto.CustomsBrokerUpdateDTO,
response_schema=dto.CustomsBrokerResponseDTO,
prefix="/customs-brokers", # No prefix since it's already in the parent router
tags=[],
resource_name="Customs Broker",
id_name="broker_key",
id_type=str,
enable_list=True, # Enable list endpoint with pagination
)
# Include the CRUD routes
router.include_router(customs_broker_crud.router)
# Additional routes for child resources (CustomsBrokerVU and CustomsBrokerPersonnel)
# These remain as manual routes since they have different patterns
@router.put(
"/customs-broker-vu/{broker_key}",
response_model=dto.CustomsBrokerVUCreateDTO,
)
def update_customs_broker_vu(
broker_key: str,
vu_data: dto.CustomsBrokerVUCreateDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Verify the broker exists and belongs to the tenant/company
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if not broker:
raise HTTPException(status_code=404, detail="Customs Broker not found")
updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data)
if not updated_vu:
raise HTTPException(status_code=404, detail="Customs Broker VU not found")
return updated_vu
@router.put(
"/customs-broker-personnel/{broker_key}/{line}",
response_model=dto.CustomsBrokerPersonnelDTO,
)
def update_customs_broker_personnel(
broker_key: str,
line: int,
personnel_data: dto.CustomsBrokerPersonnelDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Verify the broker exists and belongs to the tenant/company
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if not broker:
raise HTTPException(status_code=404, detail="Customs Broker not found")
updated_personnel = services.CustomsBrokerPersonnelService.update_personnel(
db, broker_key, line, personnel_data
)
if not updated_personnel:
raise HTTPException(
status_code=404, detail="Customs Broker Personnel not found"
)
return updated_personnel

View File

@@ -0,0 +1,155 @@
from sqlalchemy.orm import Session
from . import dto, models
class CustomsBrokerService:
@staticmethod
def get_by_id(db: Session, broker_key: str, tenant_id: int, company_id: int):
"""Get a customs broker by broker_key with tenant/company validation"""
return (
db.query(models.CustomsBroker)
.filter(
models.CustomsBroker.broker_key == broker_key,
models.CustomsBroker.tenant_id == tenant_id,
models.CustomsBroker.company_id == company_id,
)
.first()
)
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: dict = None,
):
"""Get all customs brokers for a tenant/company with pagination"""
query = db.query(models.CustomsBroker).filter(
models.CustomsBroker.tenant_id == tenant_id,
models.CustomsBroker.company_id == company_id,
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def create(db: Session, broker_data: dto.CustomsBrokerCreateDTO, tenant_id: int, company_id: int):
"""Create a new customs broker"""
broker_dict = broker_data.model_dump()
broker_dict["tenant_id"] = tenant_id
broker_dict["company_id"] = company_id
new_broker = models.CustomsBroker(**broker_dict)
db.add(new_broker)
db.commit()
db.refresh(new_broker)
return new_broker
@staticmethod
def update(db: Session, broker_key: str, tenant_id: int, broker_data: dto.CustomsBrokerUpdateDTO, company_id: int):
"""Update an existing customs broker"""
broker = CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if broker:
for key, value in broker_data.model_dump(exclude_unset=True).items():
setattr(broker, key, value)
db.commit()
db.refresh(broker)
return broker
@staticmethod
def delete(db: Session, broker_key: str, tenant_id: int, company_id: int):
"""Delete a customs broker"""
broker = CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if broker:
db.delete(broker)
db.commit()
return True
return False
class CustomsBrokerVUService:
@staticmethod
def get_by_broker_key(db: Session, broker_key: str):
return (
db.query(models.CustomsBrokerVU)
.filter(models.CustomsBrokerVU.broker_key == broker_key)
.first()
)
@staticmethod
def create_vu(db: Session, vu_data: dto.CustomsBrokerVUCreateDTO):
new_vu = models.CustomsBrokerVU(**vu_data.dict())
db.add(new_vu)
db.commit()
db.refresh(new_vu)
return new_vu
@staticmethod
def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO):
vu = CustomsBrokerVUService.get_by_broker_key(db, broker_key)
if vu:
for key, value in vu_data.dict(exclude_unset=True).items():
setattr(vu, key, value)
db.commit()
db.refresh(vu)
return vu
@staticmethod
def delete_vu(db: Session, broker_key: str):
vu = CustomsBrokerVUService.get_by_broker_key(db, broker_key)
if vu:
db.delete(vu)
db.commit()
return vu
class CustomsBrokerPersonnelService:
@staticmethod
def get_by_broker_key_and_line(db: Session, broker_key: str, line: int):
return (
db.query(models.CustomsBrokerPersonnel)
.filter(
models.CustomsBrokerPersonnel.broker_key == broker_key,
models.CustomsBrokerPersonnel.line == line,
)
.first()
)
@staticmethod
def create_personnel(db: Session, personnel_data: dto.CustomsBrokerPersonnelDTO):
new_personnel = models.CustomsBrokerPersonnel(**personnel_data.dict())
db.add(new_personnel)
db.commit()
db.refresh(new_personnel)
return new_personnel
@staticmethod
def update_personnel(
db: Session,
broker_key: str,
line: int,
personnel_data: dto.CustomsBrokerPersonnelDTO,
):
personnel = CustomsBrokerPersonnelService.get_by_broker_key_and_line(
db, broker_key, line
)
if personnel:
for key, value in personnel_data.dict(exclude_unset=True).items():
setattr(personnel, key, value)
db.commit()
db.refresh(personnel)
return personnel
@staticmethod
def delete_personnel(db: Session, broker_key: str, line: int):
personnel = CustomsBrokerPersonnelService.get_by_broker_key_and_line(
db, broker_key, line
)
if personnel:
db.delete(personnel)
db.commit()
return personnel

View File

@@ -0,0 +1,28 @@
"""
DTOs for FractionRuleOctave.
"""
from typing import Optional
from pydantic import BaseModel
class FractionRuleOctaveBaseDTO(BaseModel):
PERMISSION: str
LINE: int
FRACTION: str
QUOTA_AMOUNT: Optional[float]
USED_AMOUNT: Optional[float]
QUOTA_VALUE: Optional[float]
USED_VALUE: Optional[float]
UNIT_COST_ME: Optional[float]
UNIT_MEASURE: Optional[str]
class FractionRuleOctaveCreateDTO(FractionRuleOctaveBaseDTO):
pass
class FractionRuleOctaveResponseDTO(FractionRuleOctaveBaseDTO):
class Config:
from_attributes = True

View File

@@ -0,0 +1,32 @@
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
class FractionRuleOctave(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "fraction_rule_octave"
__table_args__ = (
PrimaryKeyConstraint("id", name="fraction_rule_octave_pkey"),
UniqueConstraint(
"tenant_id",
"company_id",
"permission",
"line",
"fraction",
name="uq_fraction_rule_octave_permission_line_fraction",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
permission: Mapped[str] = mapped_column(String(20))
line: Mapped[int] = mapped_column(Integer)
fraction: Mapped[str] = mapped_column(String(10))

View File

@@ -0,0 +1,112 @@
from typing import List
from core.database import get_core_db
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from .dto import FractionRuleOctaveCreateDTO, FractionRuleOctaveResponseDTO
from .services import FractionRuleOctaveService
router = APIRouter(prefix="/fraction_rule_octave")
@router.get("/", response_model=List[FractionRuleOctaveResponseDTO])
async def list_fractions(
db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user)
):
"""
List all FractionRuleOctave entries.
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
return db.query(FractionRuleOctaveService).all()
@router.get(
"/{permission}/{line}/{fraction}", response_model=FractionRuleOctaveResponseDTO
)
async def read_fraction(
permission: str,
line: int,
fraction: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get a specific FractionRuleOctave by its composite key.
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
frac = FractionRuleOctaveService.get_fraction_by_permission_line(
db, permission, line, fraction
)
if not frac:
raise HTTPException(status_code=404, detail="FractionRuleOctave not found")
return frac
@router.post(
"/",
response_model=FractionRuleOctaveResponseDTO,
status_code=status.HTTP_201_CREATED,
)
async def create_frac(
frac_data: FractionRuleOctaveCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Create a new FractionRuleOctave entry.
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
return FractionRuleOctaveService.create_frac(db, frac_data)
@router.delete(
"/{permission}/{line}/{fraction}", status_code=status.HTTP_204_NO_CONTENT
)
async def delete_fraction(
permission: str,
line: int,
fraction: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Delete a FractionRuleOctave by its composite key.
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
frac = FractionRuleOctaveService.delete_fraction(db, permission, line, fraction)
if not frac:
raise HTTPException(status_code=404, detail="FractionRuleOctave not found")

View File

@@ -0,0 +1,41 @@
from sqlalchemy.orm import Session
from . import dto, models
"""
Service layer for FractionRuleOctave.
"""
class FractionRuleOctaveService:
@staticmethod
def get_fraction_by_permission_line(
db: Session, permission: str, line: int, fraction: str
):
return (
db.query(models.FractionRuleOctave)
.filter(
models.FractionRuleOctave.permission == permission,
models.FractionRuleOctave.line == line,
models.FractionRuleOctave.fraction == fraction,
)
.first()
)
@staticmethod
def create_frac(db: Session, frac_data: dto.FractionRuleOctaveCreateDTO):
new_frac = models.FractionRuleOctave(**frac_data.model_dump())
db.add(new_frac)
db.commit()
db.refresh(new_frac)
return new_frac
@staticmethod
def delete_fraction(db: Session, permission: str, line: int, fraction: str):
frac = FractionRuleOctaveService.get_fraction_by_permission_line(
db, permission, line, fraction
)
if frac:
db.delete(frac)
db.commit()
return frac

View File

@@ -0,0 +1,36 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from .routes import router
app = FastAPI()
app.include_router(router)
client = TestClient(app)
@pytest.mark.usefixtures("client", "access_token")
def test_list_fraction_rules(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/fraction_rule_octave/", headers=headers)
assert response.status_code == 200
assert "items" in response.json()
assert "page" in response.json()
assert "page_size" in response.json()
@pytest.mark.usefixtures("client", "access_token")
def test_get_fraction_rule_not_found(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/fraction_rule_octave/invalid_id", headers=headers)
assert response.status_code == 404
def test_create_fraction_rule_forbidden():
response = client.post("/fraction_rule_octave/", json={"rule": "Test Rule"})
assert response.status_code in (403, 405, 404)
def test_update_fraction_rule_forbidden():
response = client.put("/fraction_rule_octave/1", json={"rule": "Updated Rule"})
assert response.status_code in (403, 405, 404)

View File

@@ -0,0 +1,21 @@
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class ClassificationConceptBase(BaseModel):
classification: str = Field(..., max_length=30,
description="Classification")
class ClassificationConceptCreate(ClassificationConceptBase):
pass
class ClassificationConceptUpdate(BaseModel):
classification: Optional[str] = Field(None, max_length=30)
class ClassificationConceptResponse(ClassificationConceptBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,19 @@
from typing import Optional
from sqlalchemy import Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class ClassificationConcept(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "classification_concepts"
__table_args__ = (
UniqueConstraint("classification", name="uq_classification_concept"),
{"schema": "a76", "extend_existing": True},
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
classification: Mapped[str] = mapped_column(
String(30), nullable=False) # CLASIFICACION

View File

@@ -0,0 +1,14 @@
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import ClassificationConceptCreate, ClassificationConceptResponse, ClassificationConceptUpdate
from .service import ClassificationConceptService
router = TenantCRUDRoutes(
service=ClassificationConceptService,
create_schema=ClassificationConceptCreate,
update_schema=ClassificationConceptUpdate,
response_schema=ClassificationConceptResponse,
prefix="/classification-concepts",
tags=["a76.general_catalogs.classification_concepts"],
resource_name="Classification Concept",
enable_list=True,
).router

View File

@@ -0,0 +1,94 @@
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import logging
from .models import ClassificationConcept
from .dto import ClassificationConceptCreate, ClassificationConceptUpdate
logger = logging.getLogger(__name__)
class ClassificationConceptService:
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[ClassificationConcept], int]:
query = db.query(ClassificationConcept).filter(
ClassificationConcept.tenant_id == tenant_id,
ClassificationConcept.company_id == company_id
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session, id: int, tenant_id: int, company_id: int
) -> Optional[ClassificationConcept]:
return db.query(ClassificationConcept).filter(
ClassificationConcept.id == id,
ClassificationConcept.tenant_id == tenant_id,
ClassificationConcept.company_id == company_id
).first()
@staticmethod
def create(
db: Session, data: ClassificationConceptCreate, tenant_id: int, company_id: int
) -> ClassificationConcept:
db_obj = ClassificationConcept(
**data.model_dump(),
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def update(
db: Session, id: int, tenant_id: int, data: ClassificationConceptUpdate, company_id: int
) -> Optional[ClassificationConcept]:
db_obj = ClassificationConceptService.get_by_id(
db, id, tenant_id, company_id)
if not db_obj:
return None
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def delete(
db: Session, id: int, tenant_id: int, company_id: int
) -> bool:
db_obj = ClassificationConceptService.get_by_id(
db, id, tenant_id, company_id)
if not db_obj:
return False
try:
db.delete(db_obj)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"Error de integridad al eliminar clasificación de concepto {id}: {str(e)}")
raise HTTPException(
status_code=400,
detail="No se puede eliminar esta clasificación porque tiene registros relacionados (pedimentos, facturas, etc.). Primero debe eliminar o reasignar esos registros."
)

View File

@@ -0,0 +1,7 @@
"""
Módulo de Company
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,223 @@
"""
DTOs (Data Transfer Objects) para módulo de empresa
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class CompanyCreateDTO(BaseModel):
"""DTO para crear una empresa"""
name: Optional[str] = Field(None, max_length=255, description="Company name")
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
main_activity: Optional[str] = Field(
None, max_length=255, description="Main activity"
)
# Program information
program: Optional[str] = Field(None, max_length=10, description="Program")
program_number: Optional[str] = Field(
None, max_length=40, description="Program number"
)
prosec: Optional[int] = Field(None, description="PROSEC")
prosec_authorization: Optional[str] = Field(
None, max_length=20, description="PROSEC authorization"
)
# Identifiers
manufacturer_id: Optional[str] = Field(
None, max_length=25, description="Manufacturer ID"
)
broker_company: Optional[str] = Field(
None, max_length=10, description="Broker company"
)
# Responsible person
responsible: Optional[str] = Field(
None, max_length=80, description="Responsible person"
)
responsible_name: Optional[str] = Field(
None, max_length=20, description="Responsible first name"
)
responsible_last_name: Optional[str] = Field(
None, max_length=20, description="Responsible last name"
)
responsible_mother_last_name: Optional[str] = Field(
None, max_length=20, description="Responsible mother's last name"
)
responsible_rfc: Optional[str] = Field(
None, max_length=30, description="Responsible RFC"
)
position: Optional[str] = Field(
None, max_length=30, description="Responsible position"
)
# Configuration
logo: Optional[str] = Field(None, max_length=255, description="Company logo")
has_express_line: Optional[bool] = Field(None, description="Has express line")
order_format_type: Optional[str] = Field(
None, max_length=19, description="Order format type"
)
previous_code: Optional[int] = Field(None, description="Previous code")
is_service_company: Optional[bool] = Field(None, description="Is service company")
# Client and subassembly
client_name: Optional[str] = Field(None, max_length=300, description="Client name")
subassembly_mode: Optional[str] = Field(
None, max_length=7, description="Subassembly mode"
)
# Additional information
curp: Optional[str] = Field(None, max_length=19, description="CURP")
inter_db_name: Optional[str] = Field(
None, max_length=100, description="Inter DB name"
)
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
trusted_exporter_number: Optional[str] = Field(
None, max_length=50, description="Trusted exporter number"
)
prevalidator_key: Optional[str] = Field(
None, max_length=20, description="Prevalidator key"
)
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
class Config:
from_attributes = True
class CompanyUpdateDTO(BaseModel):
"""DTO para actualizar una empresa"""
name: Optional[str] = Field(None, max_length=255, description="Company name")
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
main_activity: Optional[str] = Field(
None, max_length=255, description="Main activity"
)
# Program information
program: Optional[str] = Field(None, max_length=10, description="Program")
program_number: Optional[str] = Field(
None, max_length=40, description="Program number"
)
prosec: Optional[int] = Field(None, description="PROSEC")
prosec_authorization: Optional[str] = Field(
None, max_length=20, description="PROSEC authorization"
)
# Identifiers
manufacturer_id: Optional[str] = Field(
None, max_length=25, description="Manufacturer ID"
)
broker_company: Optional[str] = Field(
None, max_length=10, description="Broker company"
)
# Responsible person
responsible: Optional[str] = Field(
None, max_length=80, description="Responsible person"
)
responsible_name: Optional[str] = Field(
None, max_length=20, description="Responsible first name"
)
responsible_last_name: Optional[str] = Field(
None, max_length=20, description="Responsible last name"
)
responsible_mother_last_name: Optional[str] = Field(
None, max_length=20, description="Responsible mother's last name"
)
responsible_rfc: Optional[str] = Field(
None, max_length=30, description="Responsible RFC"
)
position: Optional[str] = Field(
None, max_length=30, description="Responsible position"
)
# Configuration
logo: Optional[str] = Field(None, max_length=255, description="Company logo")
has_express_line: Optional[bool] = Field(None, description="Has express line")
order_format_type: Optional[str] = Field(
None, max_length=19, description="Order format type"
)
previous_code: Optional[int] = Field(None, description="Previous code")
is_service_company: Optional[bool] = Field(None, description="Is service company")
# Client and subassembly
client_name: Optional[str] = Field(None, max_length=300, description="Client name")
subassembly_mode: Optional[str] = Field(
None, max_length=7, description="Subassembly mode"
)
# Additional information
curp: Optional[str] = Field(None, max_length=19, description="CURP")
inter_db_name: Optional[str] = Field(
None, max_length=100, description="Inter DB name"
)
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
trusted_exporter_number: Optional[str] = Field(
None, max_length=50, description="Trusted exporter number"
)
prevalidator_key: Optional[str] = Field(
None, max_length=20, description="Prevalidator key"
)
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
class Config:
from_attributes = True
class CompanyResponseDTO(BaseModel):
"""DTO para respuesta de empresa"""
id: int
tenant_id: int
name: Optional[str] = None
rfc: Optional[str] = None
main_activity: Optional[str] = None
# Program information
program: Optional[str] = None
program_number: Optional[str] = None
prosec: Optional[int] = None
prosec_authorization: Optional[str] = None
# Identifiers
manufacturer_id: Optional[str] = None
broker_company: Optional[str] = None
# Responsible person
responsible: Optional[str] = None
responsible_name: Optional[str] = None
responsible_last_name: Optional[str] = None
responsible_mother_last_name: Optional[str] = None
responsible_rfc: Optional[str] = None
position: Optional[str] = None
# Configuration
logo: Optional[str] = None
has_express_line: Optional[bool] = None
order_format_type: Optional[str] = None
previous_code: Optional[int] = None
is_service_company: Optional[bool] = None
# Client and subassembly
client_name: Optional[str] = None
subassembly_mode: Optional[str] = None
# Additional information
curp: Optional[str] = None
inter_db_name: Optional[str] = None
ctpat_svi: Optional[str] = None
trusted_exporter_number: Optional[str] = None
prevalidator_key: Optional[str] = None
seventh_amendment: Optional[bool] = None
# Timestamps
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,78 @@
"""
Modelos ORM para gestión de empresa
"""
from typing import Optional
from api.v1.common.base_models import TimestampMixin
from core.database import Base
from sqlalchemy import (
Boolean,
ForeignKey,
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
SmallInteger,
String,
)
from sqlalchemy.orm import Mapped, mapped_column
class Company(Base, TimestampMixin):
"""
Modelo para la tabla Company - Información de la empresa
"""
__tablename__ = "company" #GEmpresa
__table_args__ = (
PrimaryKeyConstraint("id", name="company_pkey"),
{"schema": "a76"},
)
# Primary key
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("core.tenants.id"), nullable=False, index=True)
# Información básica de la empresa
name: Mapped[Optional[str]] = mapped_column(String(255))
rfc: Mapped[Optional[str]] = mapped_column(String(30))
main_activity: Mapped[Optional[str]] = mapped_column(String(255))
# Información del programa
program: Mapped[Optional[str]] = mapped_column(String(10))
program_number: Mapped[Optional[str]] = mapped_column(String(40))
prosec: Mapped[Optional[int]] = mapped_column(SmallInteger)
prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20))
# Identificadores
manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25))
broker_company: Mapped[Optional[str]] = mapped_column(String(10))
# Responsable
responsible: Mapped[Optional[str]] = mapped_column(String(80))
responsible_name: Mapped[Optional[str]] = mapped_column(String(20))
responsible_last_name: Mapped[Optional[str]] = mapped_column(String(20))
responsible_mother_last_name: Mapped[Optional[str]] = mapped_column(String(20))
responsible_rfc: Mapped[Optional[str]] = mapped_column(String(30))
position: Mapped[Optional[str]] = mapped_column(String(30))
# Configuración
logo: Mapped[Optional[str]] = mapped_column(String(255))
has_express_line: Mapped[Optional[bool]] = mapped_column(Boolean)
order_format_type: Mapped[Optional[str]] = mapped_column(String(19))
previous_code: Mapped[Optional[int]] = mapped_column(SmallInteger)
is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean)
# Cliente y submaquila
client_name: Mapped[Optional[str]] = mapped_column(String(300))
subassembly_mode: Mapped[Optional[str]] = mapped_column(String(7))
# Información adicional
curp: Mapped[Optional[str]] = mapped_column(String(19))
inter_db_name: Mapped[Optional[str]] = mapped_column(String(100))
ctpat_svi: Mapped[Optional[str]] = mapped_column(String(100))
trusted_exporter_number: Mapped[Optional[str]] = mapped_column(String(50))
prevalidator_key: Mapped[Optional[str]] = mapped_column(String(20))
seventh_amendment: Mapped[Optional[bool]] = mapped_column(
Boolean
) # FINALCONTADORAELECTRONICO renombrado

View File

@@ -0,0 +1,326 @@
"""
Rutas para gestión de empresa
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .....common.tenant_crud_routes import TenantCRUDRoutes
from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
from .models import Company
from .service import CompanyService
# Main router that includes base CRUD
router = APIRouter(prefix="/company")
@router.post(
"", # Se suma al prefix, queda POST /api/v1/a76/company
response_model=CompanyResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Create a new company",
)
async def create_company(
data: CompanyCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
service = CompanyService(db)
return service.create_company_manually(data, tenant_id=tenant_id)
@router.get(
"", # GET /api/v1/a76/company with pagination
response_model=dict,
summary="Get companies with pagination",
)
async def list_companies(
page: int = 1,
page_size: int = 50,
name: Optional[str] = None,
rfc: Optional[str] = None,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get paginated list of companies for current tenant with optional filters"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
skip = (page - 1) * page_size
filters = {}
if name:
filters["name"] = name
if rfc:
filters["rfc"] = rfc
service = CompanyService(db)
items, total = service.get_all(
db,
tenant_id,
company_id=0, # Not used for companies
skip=skip,
limit=page_size,
filters=filters if filters else None
)
total_pages = (total + page_size - 1) // page_size
return {
"items": [CompanyResponseDTO.model_validate(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
"pages": total_pages,
}
@router.get(
"/my-companies",
response_model=List[CompanyResponseDTO],
summary="Get all companies for current tenant",
)
async def get_my_companies(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get all companies that belong to the current user's tenant"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
service = CompanyService(db)
companies = service.get_companies_by_tenant(tenant_id)
return [CompanyResponseDTO.model_validate(company) for company in companies]
@router.get(
"/status/exists",
response_model=dict,
summary="Check if company exists for tenant",
)
async def check_company_exists(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Check if a company exists for the current tenant"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
service = CompanyService(db)
exists = service.exists_company(tenant_id)
return {"exists": exists}
@router.get(
"/info/basic/{company_id}",
response_model=dict,
summary="Get basic company info",
)
async def get_basic_info(
company_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get basic information about a company"""
tenant_id = current_user.get("tenant_id")
company_id_from_user = current_user.get("company_id")
# Validate access
validate_access_to_resource(
db, tenant_id, company_id_from_user, Company, company_id, "id"
)
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user)
if not company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
return {
"id": company.id,
"name": company.name,
"rfc": company.rfc,
"program": company.program,
}
@router.get(
"/info/responsible/{company_id}",
response_model=dict,
summary="Get responsible person info",
)
async def get_responsible_info(
company_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get responsible person information for a company"""
tenant_id = current_user.get("tenant_id")
company_id_from_user = current_user.get("company_id")
# Validate access
validate_access_to_resource(
db, tenant_id, company_id_from_user, Company, company_id, "id"
)
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user)
if not company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
return {
"responsible": company.responsible,
"responsible_name": company.responsible_name,
"responsible_last_name": company.responsible_last_name,
"responsible_mother_last_name": company.responsible_mother_last_name,
"responsible_rfc": company.responsible_rfc,
"position": company.position,
}
@router.get(
"/info/program/{company_id}",
response_model=dict,
summary="Get program information",
)
async def get_program_info(
company_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get program information for a company"""
tenant_id = current_user.get("tenant_id")
company_id_from_user = current_user.get("company_id")
# Validate access
validate_access_to_resource(
db, tenant_id, company_id_from_user, Company, company_id, "id"
)
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user)
if not company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
return {
"program": company.program,
"program_number": company.program_number,
"prosec": company.prosec,
"prosec_authorization": company.prosec_authorization,
}
@router.get(
"/{company_id}",
response_model=CompanyResponseDTO,
summary="Get company by ID",
)
async def get_company(
company_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get a specific company by ID"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
if not company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
return CompanyResponseDTO.model_validate(company)
@router.put(
"/{company_id}",
response_model=CompanyResponseDTO,
summary="Update company",
)
async def update_company(
company_id: int,
data: CompanyUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Update a company"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
updated_company = CompanyService.update(
db, company_id, tenant_id, 0, data
)
if not updated_company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
return CompanyResponseDTO.model_validate(updated_company)
@router.delete(
"/{company_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete company",
)
async def delete_company(
company_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a company"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
success = CompanyService.delete(db, company_id, tenant_id, 0)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
return None

View File

@@ -0,0 +1,200 @@
"""
Capa de servicio para lógica de negocio de empresa
"""
import logging
from typing import List, Optional, Tuple, Dict, Any
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
from .models import Company
logger = logging.getLogger(__name__)
class CompanyService:
"""Servicio para gestión de empresa"""
def __init__(self, db: Session):
self.db = db
# Métodos para TenantCRUDRoutes
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Company], int]:
"""Get all companies for a tenant with pagination"""
query = db.query(Company).filter(Company.tenant_id == tenant_id)
# Apply filters if provided
if filters:
if filters.get("name"):
query = query.filter(
Company.name.ilike(f"%{filters['name']}%")
)
if filters.get("rfc"):
query = query.filter(
Company.rfc.ilike(f"%{filters['rfc']}%")
)
total = query.count()
companies = query.offset(skip).limit(limit).all()
return companies, total
@staticmethod
def get_by_id(
db: Session, company_id: int, tenant_id: int, company_id_unused: int
) -> Optional[Company]:
"""Get company by ID"""
return (
db.query(Company)
.filter(
Company.id == company_id,
Company.tenant_id == tenant_id,
)
.first()
)
# ESTE ES EL MÉTODO VIEJO QUE CAUSABA PROBLEMAS (Lo dejamos por si acaso)
@staticmethod
def create(
db: Session,
company_data: CompanyCreateDTO,
tenant_id: int,
company_id: int,
) -> Company:
"""Create a new company (MÉTODO GENÉRICO - NO USAR PARA CREACIÓN MANUAL)"""
try:
db_company = Company(
**company_data.model_dump(exclude_unset=True),
tenant_id=tenant_id
)
db.add(db_company)
db.commit()
db.refresh(db_company)
return db_company
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating company: {str(e)}")
raise HTTPException(
status_code=400,
detail="Company already exists",
)
except Exception as e:
db.rollback()
logger.error(f"Error creating company: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating company")
@staticmethod
def update(
db: Session,
company_id: int,
tenant_id: int,
company_id_unused: int,
company_data: CompanyUpdateDTO,
) -> Optional[Company]:
"""Update a company"""
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_unused)
if not company:
return None
# Update only provided fields
update_data = company_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(company, field, value)
try:
db.commit()
db.refresh(company)
return company
except Exception as e:
db.rollback()
logger.error(f"Error updating company {company_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating company")
@staticmethod
def delete(
db: Session, company_id: int, tenant_id: int, company_id_unused: int
) -> bool:
"""Delete a company"""
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_unused)
if not company:
return False
try:
db.delete(company)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting company {company_id}: {str(e)}")
# Check if it's a foreign key constraint
if "foreign key constraint" in str(e).lower():
raise HTTPException(
status_code=400,
detail="No se puede eliminar la empresa porque tiene registros relacionados (facturas, conceptos, etc.)"
)
raise HTTPException(status_code=400, detail="Error al eliminar la empresa")
except Exception as e:
db.rollback()
logger.error(f"Error deleting company {company_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error al eliminar la empresa")
# Custom methods
def get_companies_by_tenant(self, tenant_id: int) -> List[Company]:
"""Get all companies for a tenant"""
return (
self.db.query(Company)
.filter(Company.tenant_id == tenant_id)
.order_by(Company.name)
.all()
)
def exists_company(self, tenant_id: int) -> bool:
"""Check if a company exists for a tenant"""
return (
self.db.query(Company)
.filter(Company.tenant_id == tenant_id)
.first()
is not None
)
def create_company_manually(self, data: CompanyCreateDTO, tenant_id: int) -> Company:
try:
# 1. Preparar datos
obj_data = data.model_dump(exclude_unset=True)
# 2. Crear objeto SQLAlchemy
db_obj = Company(**obj_data, tenant_id=tenant_id)
# 3. Guardar
self.db.add(db_obj)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating company manually: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error de integridad: Es posible que esta empresa ya exista.",
)
except Exception as e:
self.db.rollback()
logger.error(f"Error creating company manually: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error creando empresa: {str(e)}")

View File

@@ -0,0 +1,36 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from .routes import router
app = FastAPI()
app.include_router(router)
client = TestClient(app)
@pytest.mark.usefixtures("client", "access_token")
def test_list_companies(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/company/", headers=headers)
assert response.status_code == 200
assert "items" in response.json()
assert "page" in response.json()
assert "page_size" in response.json()
@pytest.mark.usefixtures("client", "access_token")
def test_get_company_not_found(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/company/invalid_id", headers=headers)
assert response.status_code == 404
def test_create_company_forbidden():
response = client.post("/company/", json={"name": "Test Company"})
assert response.status_code in (403, 405, 404)
def test_update_company_forbidden():
response = client.put("/company/1", json={"name": "Updated Company"})
assert response.status_code in (403, 405, 404)

View File

@@ -0,0 +1,46 @@
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class ConceptBase(BaseModel):
code: str = Field(..., max_length=15, description="Concept Code (CLAVE)")
company_id: int = Field(..., description="Company ID")
description: Optional[str] = Field(
None, max_length=120, description="Description")
description_en: Optional[str] = Field(
None, max_length=120, description="English Description")
detailed_description: Optional[str] = Field(
None, max_length=1000, description="Detailed Description")
priority: Optional[int] = Field(None, description="Priority")
priority_ame: Optional[int] = Field(None, description="American Priority")
first_total: Optional[bool] = Field(None, description="First Total")
type: Optional[str] = Field(None, max_length=9, description="Type")
is_printed: Optional[bool] = Field(None, description="Is Printed")
section: Optional[int] = Field(None, description="Section")
classification: Optional[str] = Field(
None, max_length=30, description="Classification")
class ConceptCreate(ConceptBase):
pass
class ConceptUpdate(BaseModel):
code: Optional[str] = Field(None, max_length=15)
description: Optional[str] = Field(None, max_length=120)
description_en: Optional[str] = Field(None, max_length=120)
detailed_description: Optional[str] = Field(None, max_length=1000)
priority: Optional[int] = None
priority_ame: Optional[int] = None
first_total: Optional[bool] = None
type: Optional[str] = Field(None, max_length=9)
is_printed: Optional[bool] = None
section: Optional[int] = None
classification: Optional[str] = Field(None, max_length=30)
class ConceptResponse(ConceptBase):
id: int
tenant_id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,58 @@
from typing import Optional
from sqlalchemy import Integer, String, UniqueConstraint, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from api.v1.modules.a76.general_catalogs.classification_concepts.models import ClassificationConcept
class Concept(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "concepts"
__table_args__ = (
UniqueConstraint("code", name="uq_concept_code"),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(
Integer, nullable=False) # IDEMPRESA
code: Mapped[str] = mapped_column(String(15), nullable=False) # CLAVE
description: Mapped[Optional[str]] = mapped_column(
String(120), nullable=True) # DESCRIPCION
description_en: Mapped[Optional[str]] = mapped_column(
String(120), nullable=True) # DESCRIPCIONINGLES
detailed_description: Mapped[Optional[str]] = mapped_column(
String(1000), nullable=True) # DESCRIPCIONDETALLADA
priority: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True) # PRIORIDAD
priority_ame: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True) # PRIORIDADAME
first_total: Mapped[Optional[bool]] = mapped_column(
Boolean, nullable=True) # PRIMERTOTAL
type: Mapped[Optional[str]] = mapped_column(
String(9), nullable=True) # TIPO
is_printed: Mapped[Optional[bool]] = mapped_column(
Boolean, nullable=True) # SEIMPRIME
section: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True) # SECCION
classification: Mapped[Optional[str]] = mapped_column(String(30), ForeignKey(
"a76.classification_concepts.classification"), nullable=True) # CLASIFICACION
classification_info: Mapped[Optional["ClassificationConcept"]] = relationship(
foreign_keys=[classification])

View File

@@ -0,0 +1,14 @@
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import ConceptCreate, ConceptResponse, ConceptUpdate
from .service import ConceptService
router = TenantCRUDRoutes(
service=ConceptService,
create_schema=ConceptCreate,
update_schema=ConceptUpdate,
response_schema=ConceptResponse,
prefix="/concepts",
tags=["a76.general_catalogs.concepts"],
resource_name="Concept",
enable_list=True,
).router

View File

@@ -0,0 +1,99 @@
from typing import List, Optional, Tuple, Dict, Any
import logging
from sqlalchemy.orm import Session
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from .models import Concept
from .dto import ConceptCreate, ConceptUpdate
logger = logging.getLogger(__name__)
class ConceptService:
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Concept], int]:
query = db.query(Concept).filter(
Concept.tenant_id == tenant_id,
Concept.company_id == company_id
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session, id: int, tenant_id: int, company_id: int
) -> Optional[Concept]:
return db.query(Concept).filter(
Concept.id == id,
Concept.tenant_id == tenant_id,
Concept.company_id == company_id
).first()
@staticmethod
def create(
db: Session, data: ConceptCreate, tenant_id: int, company_id: int
) -> Concept:
data_dict = data.model_dump()
data_dict['company_id'] = company_id
data_dict['tenant_id'] = tenant_id
db_obj = Concept(**data_dict)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def update(
db: Session, id: int, tenant_id: int, data: ConceptUpdate, company_id: int
) -> Optional[Concept]:
db_obj = ConceptService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def delete(
db: Session, id: int, tenant_id: int, company_id: int
) -> bool:
db_obj = ConceptService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
try:
db.delete(db_obj)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting concept {id}: {str(e)}")
if "foreign key constraint" in str(e).lower():
raise HTTPException(
status_code=400,
detail="No se puede eliminar el concepto porque tiene registros relacionados"
)
raise HTTPException(status_code=400, detail="Error al eliminar el concepto")
except Exception as e:
db.rollback()
logger.error(f"Error deleting concept {id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error al eliminar el concepto")

View File

@@ -0,0 +1,27 @@
from typing import Optional
from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict
class CustomsBrokerConceptBase(BaseModel):
broker_key: str = Field(..., max_length=5, description="Customs Broker Key (CLAVEAA)")
concept: str = Field(..., max_length=15, description="Concept")
amount: Optional[Decimal] = Field(None, description="Amount")
priority: Optional[int] = Field(None, description="Priority")
class CustomsBrokerConceptCreate(CustomsBrokerConceptBase):
pass
class CustomsBrokerConceptUpdate(BaseModel):
broker_key: Optional[str] = Field(None, max_length=5)
concept: Optional[str] = Field(None, max_length=15)
amount: Optional[Decimal] = None
priority: Optional[int] = None
class CustomsBrokerConceptResponse(CustomsBrokerConceptBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,31 @@
from typing import Optional
from decimal import Decimal
from sqlalchemy import Integer, String, UniqueConstraint, Numeric
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class CustomsBrokerConcept(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "customs_broker_concepts"
__table_args__ = (
UniqueConstraint("broker_key", "concept", "company_id", name="uq_broker_concept"),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(Integer, nullable=False)
broker_key: Mapped[str] = mapped_column(
String(5), nullable=False) # CLAVEAA
concept: Mapped[str] = mapped_column(
String(15), nullable=False) # CONCEPTO
amount: Mapped[Optional[Decimal]] = mapped_column(
Numeric(11, 2), nullable=True) # IMPORTE
priority: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True) # PRIORIDAD

View File

@@ -0,0 +1,14 @@
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import CustomsBrokerConceptCreate, CustomsBrokerConceptResponse, CustomsBrokerConceptUpdate
from .service import CustomsBrokerConceptService
router = TenantCRUDRoutes(
service=CustomsBrokerConceptService,
create_schema=CustomsBrokerConceptCreate,
update_schema=CustomsBrokerConceptUpdate,
response_schema=CustomsBrokerConceptResponse,
prefix="/customs-broker-concepts",
tags=["a76.general_catalogs.customs_broker_concepts"],
resource_name="Customs Broker Concept",
enable_list=True,
).router

View File

@@ -0,0 +1,101 @@
from typing import List, Optional, Tuple, Dict, Any
import logging
from sqlalchemy.orm import Session
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from .models import CustomsBrokerConcept
from .dto import CustomsBrokerConceptCreate, CustomsBrokerConceptUpdate
logger = logging.getLogger(__name__)
class CustomsBrokerConceptService:
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[CustomsBrokerConcept], int]:
query = db.query(CustomsBrokerConcept).filter(
CustomsBrokerConcept.tenant_id == tenant_id,
CustomsBrokerConcept.company_id == company_id
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session, id: int, tenant_id: int, company_id: int
) -> Optional[CustomsBrokerConcept]:
return db.query(CustomsBrokerConcept).filter(
CustomsBrokerConcept.id == id,
CustomsBrokerConcept.tenant_id == tenant_id,
CustomsBrokerConcept.company_id == company_id
).first()
@staticmethod
def create(
db: Session, data: CustomsBrokerConceptCreate, tenant_id: int, company_id: int
) -> CustomsBrokerConcept:
db_obj = CustomsBrokerConcept(
**data.model_dump(),
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def update(
db: Session, id: int, tenant_id: int, data: CustomsBrokerConceptUpdate, company_id: int
) -> Optional[CustomsBrokerConcept]:
db_obj = CustomsBrokerConceptService.get_by_id(
db, id, tenant_id, company_id)
if not db_obj:
return None
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def delete(
db: Session, id: int, tenant_id: int, company_id: int
) -> bool:
db_obj = CustomsBrokerConceptService.get_by_id(
db, id, tenant_id, company_id)
if not db_obj:
return False
try:
db.delete(db_obj)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting customs broker concept {id}: {str(e)}")
if "foreign key constraint" in str(e).lower():
raise HTTPException(
status_code=400,
detail="No se puede eliminar el concepto porque tiene registros relacionados"
)
raise HTTPException(status_code=400, detail="Error al eliminar el concepto")
except Exception as e:
db.rollback()
logger.error(f"Error deleting customs broker concept {id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error al eliminar el concepto")

View File

@@ -0,0 +1,3 @@
"""
Módulo de DODA (Documentos de Operación de Aduana)
"""

View File

@@ -0,0 +1,423 @@
"""
DTOs (Data Transfer Objects) para módulo de DODA
"""
from datetime import datetime
from decimal import Decimal
from typing import Optional, List
from pydantic import BaseModel, Field
# ============ DODA CONTAINER SEAL DTOS ============
class DodaContainerSealCreateDTO(BaseModel):
"""DTO para crear un candado de contenedor"""
seal_value: Optional[str] = Field(
None, max_length=21, description="Seal value")
class Config:
from_attributes = True
class DodaContainerSealResponseDTO(BaseModel):
"""DTO para responder con datos de un candado"""
id: int
doda_sys_id: int
seal_line: int
seal_value: Optional[str] = None
class Config:
from_attributes = True
# ============ DODA CONTAINER DTOS ============
class DodaContainerCreateDTO(BaseModel):
"""DTO para crear un contenedor"""
container_value: Optional[str] = Field(
None, max_length=20, description="Container value")
seals: Optional[str] = Field(None, max_length=254, description="Seals")
seals_detail: Optional[List[DodaContainerSealCreateDTO]] = Field(
None, description="Container seals"
)
class Config:
from_attributes = True
class DodaContainerUpdateDTO(BaseModel):
"""DTO para actualizar un contenedor"""
container_value: Optional[str] = Field(
None, max_length=20, description="Container value")
seals: Optional[str] = Field(None, max_length=254, description="Seals")
class Config:
from_attributes = True
class DodaContainerResponseDTO(BaseModel):
"""DTO para responder con datos de un contenedor"""
id: int
doda_sys_id: int
container_line: int
container_value: Optional[str] = None
seals: Optional[str] = None
seals_detail: Optional[List[DodaContainerSealResponseDTO]] = None
class Config:
from_attributes = True
# ============ DODA AMERICAN PEDIMENTO DTOS ============
class DodaAmericanPedimentoCreateDTO(BaseModel):
"""DTO para crear un pedimento americano"""
american_pedimento_type: Optional[str] = Field(
None, max_length=2, description="American pedimento type"
)
american_pedimento_value: Optional[str] = Field(
None, max_length=20, description="American pedimento value"
)
class Config:
from_attributes = True
class DodaAmericanPedimentoUpdateDTO(BaseModel):
"""DTO para actualizar un pedimento americano"""
american_pedimento_type: Optional[str] = Field(
None, max_length=2, description="American pedimento type"
)
american_pedimento_value: Optional[str] = Field(
None, max_length=20, description="American pedimento value"
)
class Config:
from_attributes = True
class DodaAmericanPedimentoResponseDTO(BaseModel):
"""DTO para responder con datos de un pedimento americano"""
id: int
doda_sys_id: int
american_pedimento_line: int
american_pedimento_type: Optional[str] = None
american_pedimento_value: Optional[str] = None
class Config:
from_attributes = True
# ============ DODA PEDIMENTO DTOS ============
class DodaPedimentoCreateDTO(BaseModel):
"""DTO para crear un pedimento DODA"""
authorization_patent: Optional[str] = Field(
None, max_length=10, description="Authorization patent"
)
document: Optional[str] = Field(
None, max_length=50, description="Document")
shipment: Optional[str] = Field(
None, max_length=11, description="Shipment")
cove: Optional[str] = Field(None, max_length=50, description="COVE")
umc: Optional[str] = Field(None, max_length=20, description="UMC")
effective_amount_usd: Optional[Decimal] = Field(
None, description="Effective amount USD")
difference_amount_usd: Optional[Decimal] = Field(
None, description="Difference amount USD")
dta_niu: Optional[str] = Field(None, max_length=20, description="DTA NIU")
article_7: Optional[bool] = Field(None, description="Article 7")
pedimento_sys_id: Optional[int] = Field(
None, description="Pedimento system ID")
invoice_line: Optional[int] = Field(None, description="Invoice line")
part_ii_line: Optional[int] = Field(None, description="Part II line")
pedimento_type: Optional[str] = Field(
None, max_length=20, description="Pedimento type")
zero_packaging_validation: Optional[bool] = Field(
None, description="Zero packaging validation"
)
class Config:
from_attributes = True
class DodaPedimentoUpdateDTO(BaseModel):
"""DTO para actualizar un pedimento DODA"""
authorization_patent: Optional[str] = Field(
None, max_length=10, description="Authorization patent"
)
document: Optional[str] = Field(
None, max_length=50, description="Document")
shipment: Optional[str] = Field(
None, max_length=11, description="Shipment")
cove: Optional[str] = Field(None, max_length=50, description="COVE")
umc: Optional[str] = Field(None, max_length=20, description="UMC")
effective_amount_usd: Optional[Decimal] = Field(
None, description="Effective amount USD")
difference_amount_usd: Optional[Decimal] = Field(
None, description="Difference amount USD")
dta_niu: Optional[str] = Field(None, max_length=20, description="DTA NIU")
article_7: Optional[bool] = Field(None, description="Article 7")
pedimento_sys_id: Optional[int] = Field(
None, description="Pedimento system ID")
invoice_line: Optional[int] = Field(None, description="Invoice line")
part_ii_line: Optional[int] = Field(None, description="Part II line")
pedimento_type: Optional[str] = Field(
None, max_length=20, description="Pedimento type")
zero_packaging_validation: Optional[bool] = Field(
None, description="Zero packaging validation"
)
class Config:
from_attributes = True
class DodaPedimentoResponseDTO(BaseModel):
"""DTO para responder con datos de un pedimento DODA"""
id: int
doda_sys_id: int
pedimento_line: int
authorization_patent: Optional[str] = None
document: Optional[str] = None
shipment: Optional[str] = None
cove: Optional[str] = None
umc: Optional[str] = None
effective_amount_usd: Optional[Decimal] = None
difference_amount_usd: Optional[Decimal] = None
dta_niu: Optional[str] = None
article_7: Optional[bool] = None
pedimento_sys_id: Optional[int] = None
invoice_line: Optional[int] = None
part_ii_line: Optional[int] = None
pedimento_type: Optional[str] = None
zero_packaging_validation: Optional[bool] = None
class Config:
from_attributes = True
# ============ MAIN DODA DTOS ============
class DodaCreateDTO(BaseModel):
"""DTO para crear un DODA"""
integration_number: Optional[str] = Field(
None, max_length=30, description="Integration number")
doda_date: Optional[int] = Field(None, description="DODA date")
doda_time: Optional[int] = Field(None, description="DODA time")
dispatch_customs: Optional[str] = Field(
None, max_length=3, description="Dispatch customs")
customs_sections: Optional[str] = Field(
None, max_length=3, description="Customs sections")
patent: Optional[str] = Field(None, max_length=4, description="Patent")
pedimentos: Optional[str] = Field(
None, max_length=80, description="Pedimentos")
caat: Optional[str] = Field(None, max_length=10, description="CAAT")
transport_identification: Optional[str] = Field(
None, max_length=20, description="Transport identification"
)
fast_id: Optional[str] = Field(None, max_length=20, description="FAST ID")
operation_type: Optional[str] = Field(
None, max_length=1, description="Operation type")
selected: Optional[bool] = Field(None, description="Selected")
user_selected: Optional[str] = Field(
None, max_length=30, description="User selected")
last_user: Optional[str] = Field(
None, max_length=30, description="Last user")
responsible: Optional[str] = Field(
None, max_length=14, description="Responsible")
carrier: Optional[str] = Field(None, max_length=8, description="Carrier")
shipments: Optional[str] = Field(
None, max_length=80, description="Shipments")
pedimento_type: Optional[str] = Field(
None, max_length=30, description="Pedimento type")
original_chain: Optional[str] = Field(
None, max_length=5000, description="Original chain")
serial_number: Optional[str] = Field(
None, max_length=21, description="Serial number")
electronic_signature: Optional[str] = Field(
None, max_length=2000, description="Electronic signature"
)
transaction_number: Optional[str] = Field(
None, max_length=30, description="Transaction number")
status: Optional[str] = Field(None, max_length=30, description="Status")
linq_sat_qr: Optional[str] = Field(
None, max_length=1000, description="LINQ SAT QR")
sat_certificate: Optional[str] = Field(
None, max_length=2001, description="SAT certificate")
sat_digital_seal: Optional[str] = Field(
None, description="SAT digital seal")
xml_doda_sent_path: Optional[str] = Field(
None, max_length=1000, description="XML DODA sent path")
xml_doda_response_path: Optional[str] = Field(
None, max_length=1000, description="XML DODA response path"
)
sat_original_chain: Optional[str] = Field(
None, description="SAT original chain")
customs_clearance: Optional[int] = Field(
None, description="Customs clearance")
unique_badge_number: Optional[str] = Field(
None, max_length=250, description="Unique badge number"
)
class Config:
from_attributes = True
class DodaUpdateDTO(BaseModel):
"""DTO para actualizar un DODA"""
integration_number: Optional[str] = Field(
None, max_length=30, description="Integration number")
doda_date: Optional[int] = Field(None, description="DODA date")
doda_time: Optional[int] = Field(None, description="DODA time")
dispatch_customs: Optional[str] = Field(
None, max_length=3, description="Dispatch customs")
customs_sections: Optional[str] = Field(
None, max_length=3, description="Customs sections")
patent: Optional[str] = Field(None, max_length=4, description="Patent")
pedimentos: Optional[str] = Field(
None, max_length=80, description="Pedimentos")
caat: Optional[str] = Field(None, max_length=10, description="CAAT")
transport_identification: Optional[str] = Field(
None, max_length=20, description="Transport identification"
)
fast_id: Optional[str] = Field(None, max_length=20, description="FAST ID")
operation_type: Optional[str] = Field(
None, max_length=1, description="Operation type")
selected: Optional[bool] = Field(None, description="Selected")
user_selected: Optional[str] = Field(
None, max_length=30, description="User selected")
last_user: Optional[str] = Field(
None, max_length=30, description="Last user")
responsible: Optional[str] = Field(
None, max_length=14, description="Responsible")
carrier: Optional[str] = Field(None, max_length=8, description="Carrier")
shipments: Optional[str] = Field(
None, max_length=80, description="Shipments")
pedimento_type: Optional[str] = Field(
None, max_length=30, description="Pedimento type")
original_chain: Optional[str] = Field(
None, max_length=5000, description="Original chain")
serial_number: Optional[str] = Field(
None, max_length=21, description="Serial number")
electronic_signature: Optional[str] = Field(
None, max_length=2000, description="Electronic signature"
)
transaction_number: Optional[str] = Field(
None, max_length=30, description="Transaction number")
status: Optional[str] = Field(None, max_length=30, description="Status")
linq_sat_qr: Optional[str] = Field(
None, max_length=1000, description="LINQ SAT QR")
sat_certificate: Optional[str] = Field(
None, max_length=2001, description="SAT certificate")
sat_digital_seal: Optional[str] = Field(
None, description="SAT digital seal")
xml_doda_sent_path: Optional[str] = Field(
None, max_length=1000, description="XML DODA sent path")
xml_doda_response_path: Optional[str] = Field(
None, max_length=1000, description="XML DODA response path"
)
sat_original_chain: Optional[str] = Field(
None, description="SAT original chain")
customs_clearance: Optional[int] = Field(
None, description="Customs clearance")
unique_badge_number: Optional[str] = Field(
None, max_length=250, description="Unique badge number"
)
class Config:
from_attributes = True
class DodaResponseDTO(BaseModel):
"""DTO para responder con datos de un DODA"""
id: int
integration_number: Optional[str] = None
doda_date: Optional[int] = None
doda_time: Optional[int] = None
dispatch_customs: Optional[str] = None
customs_sections: Optional[str] = None
patent: Optional[str] = None
pedimentos: Optional[str] = None
caat: Optional[str] = None
transport_identification: Optional[str] = None
fast_id: Optional[str] = None
operation_type: Optional[str] = None
selected: Optional[bool] = None
user_selected: Optional[str] = None
last_user: Optional[str] = None
responsible: Optional[str] = None
carrier: Optional[str] = None
shipments: Optional[str] = None
pedimento_type: Optional[str] = None
original_chain: Optional[str] = None
serial_number: Optional[str] = None
electronic_signature: Optional[str] = None
transaction_number: Optional[str] = None
status: Optional[str] = None
linq_sat_qr: Optional[str] = None
sat_certificate: Optional[str] = None
sat_digital_seal: Optional[str] = None
xml_doda_sent_path: Optional[str] = None
xml_doda_response_path: Optional[str] = None
sat_original_chain: Optional[str] = None
customs_clearance: Optional[int] = None
unique_badge_number: Optional[str] = None
containers: Optional[List[DodaContainerResponseDTO]] = None
american_pedimentos: Optional[List[DodaAmericanPedimentoResponseDTO]] = None
pedimentos_detail: Optional[List[DodaPedimentoResponseDTO]] = None
class Config:
from_attributes = True
class DodaDetailResponseDTO(BaseModel):
"""DTO detallado para responder con todos los datos de un DODA"""
id: int
integration_number: Optional[str] = None
doda_date: Optional[int] = None
doda_time: Optional[int] = None
dispatch_customs: Optional[str] = None
customs_sections: Optional[str] = None
patent: Optional[str] = None
pedimentos: Optional[str] = None
caat: Optional[str] = None
transport_identification: Optional[str] = None
fast_id: Optional[str] = None
operation_type: Optional[str] = None
selected: Optional[bool] = None
user_selected: Optional[str] = None
last_user: Optional[str] = None
responsible: Optional[str] = None
carrier: Optional[str] = None
shipments: Optional[str] = None
pedimento_type: Optional[str] = None
original_chain: Optional[str] = None
serial_number: Optional[str] = None
electronic_signature: Optional[str] = None
transaction_number: Optional[str] = None
status: Optional[str] = None
linq_sat_qr: Optional[str] = None
sat_certificate: Optional[str] = None
sat_digital_seal: Optional[str] = None
xml_doda_sent_path: Optional[str] = None
xml_doda_response_path: Optional[str] = None
sat_original_chain: Optional[str] = None
customs_clearance: Optional[int] = None
unique_badge_number: Optional[str] = None
containers: List[DodaContainerResponseDTO] = []
american_pedimentos: List[DodaAmericanPedimentoResponseDTO] = []
pedimentos_detail: List[DodaPedimentoResponseDTO] = []
class Config:
from_attributes = True

View File

@@ -0,0 +1,263 @@
"""
Modelos ORM para gestión de DODA (Documentos de Operación de Aduana)
"""
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
String,
Text,
LargeBinary,
Numeric,
Boolean,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
class Doda(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla Doda - Documentos de Operación de Aduana
"""
__tablename__ = "doda" # gDODA
__table_args__ = (
PrimaryKeyConstraint("id", name="doda_pkey"),
{"schema": "a76"},
)
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
# Integration and timestamps
integration_number: Mapped[Optional[str]] = mapped_column(String(30))
doda_date: Mapped[Optional[int]] = mapped_column(Integer)
doda_time: Mapped[Optional[int]] = mapped_column(Integer)
# Customs information
dispatch_customs: Mapped[Optional[str]] = mapped_column(String(3))
customs_sections: Mapped[Optional[str]] = mapped_column(String(3))
patent: Mapped[Optional[str]] = mapped_column(String(4))
pedimentos: Mapped[Optional[str]] = mapped_column(String(80))
caat: Mapped[Optional[str]] = mapped_column(String(10))
# Transport and identifiers
transport_identification: Mapped[Optional[str]] = mapped_column(String(20))
fast_id: Mapped[Optional[str]] = mapped_column(String(20))
operation_type: Mapped[Optional[str]] = mapped_column(String(1))
# Selection info
selected: Mapped[Optional[bool]] = mapped_column(Boolean)
user_selected: Mapped[Optional[str]] = mapped_column(String(30))
last_user: Mapped[Optional[str]] = mapped_column(String(30))
# Responsible parties
responsible: Mapped[Optional[str]] = mapped_column(String(14))
carrier: Mapped[Optional[str]] = mapped_column(String(8))
shipments: Mapped[Optional[str]] = mapped_column(String(80))
pedimento_type: Mapped[Optional[str]] = mapped_column(String(30))
# Digital signatures and certificates
original_chain: Mapped[Optional[str]] = mapped_column(String(5000))
serial_number: Mapped[Optional[str]] = mapped_column(String(21))
electronic_signature: Mapped[Optional[str]] = mapped_column(String(2000))
# Transaction info
transaction_number: Mapped[Optional[str]] = mapped_column(String(30))
status: Mapped[Optional[str]] = mapped_column(String(30))
# SAT information
linq_sat_qr: Mapped[Optional[str]] = mapped_column(String(1000))
sat_certificate: Mapped[Optional[str]] = mapped_column(String(2001))
sat_digital_seal: Mapped[Optional[Text]] = mapped_column(Text)
# XML Paths
xml_doda_sent_path: Mapped[Optional[str]] = mapped_column(String(1000))
xml_doda_response_path: Mapped[Optional[str]] = mapped_column(String(1000))
# SAT original chain
sat_original_chain: Mapped[Optional[Text]] = mapped_column(Text)
# Customs clearance
customs_clearance: Mapped[Optional[int]] = mapped_column(Integer)
unique_badge_number: Mapped[Optional[str]] = mapped_column(String(250))
# Relationships
containers: Mapped[list["DodaContainer"]] = relationship(
"DodaContainer", back_populates="doda", cascade="all, delete-orphan"
)
american_pedimentos: Mapped[list["DodaAmericanPedimento"]] = relationship(
"DodaAmericanPedimento", back_populates="doda", cascade="all, delete-orphan"
)
pedimentos_detail: Mapped[list["DodaPedimento"]] = relationship(
"DodaPedimento", back_populates="doda", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<Doda(id={self.id}, integration_number={self.integration_number}, status={self.status})>"
class DodaContainer(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla DodaContainer - Contenedores en DODA
"""
__tablename__ = "doda_containers" # gDoda_Contenedores
__table_args__ = (
PrimaryKeyConstraint("id", name="doda_containers_pkey"),
ForeignKeyConstraint(
["doda_id"], ["a76.doda.id"], name="fk_doda_containers_doda"
),
{"schema": "a76"},
)
# Primary key
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Foreign key and line number
doda_id: Mapped[int] = mapped_column(Integer, nullable=False)
container_line: Mapped[int] = mapped_column(Integer, nullable=False)
# Container information
container_value: Mapped[Optional[str]] = mapped_column(String(20))
seals: Mapped[Optional[str]] = mapped_column(String(254))
# Relationships
doda: Mapped["Doda"] = relationship("Doda", back_populates="containers")
seals_detail: Mapped[list["DodaContainerSeal"]] = relationship(
"DodaContainerSeal", back_populates="container", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<DodaContainer(id={self.id}, doda_id={self.doda_id}, container_line={self.container_line})>"
class DodaContainerSeal(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla DodaContainerSeal - Candados de Contenedores
"""
__tablename__ = "doda_container_seals" # gDoda_Contenedores_Candados
__table_args__ = (
PrimaryKeyConstraint("id", name="doda_container_seals_pkey"),
ForeignKeyConstraint(
["container_id"], ["a76.doda_containers.id"], name="fk_doda_container_seals_container"
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
container_id: Mapped[int] = mapped_column(Integer, nullable=False)
doda_id: Mapped[int] = mapped_column(Integer, nullable=False)
seal_line: Mapped[int] = mapped_column(Integer, nullable=False)
seal_value: Mapped[Optional[str]] = mapped_column(String(21))
container: Mapped["DodaContainer"] = relationship(
"DodaContainer", back_populates="seals_detail"
)
def __repr__(self):
return f"<DodaContainerSeal(id={self.id}, doda_id={self.doda_id}, seal_line={self.seal_line})>"
class DodaAmericanPedimento(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla DodaAmericanPedimento - Pedimentos Americanos en DODA
"""
__tablename__ = "doda_american_pedimentos" # gDoda_PedimentoAmericano
__table_args__ = (
PrimaryKeyConstraint("id", name="doda_american_pedimentos_pkey"),
ForeignKeyConstraint(
["doda_id"], ["a76.doda.id"], name="fk_doda_american_pedimentos_doda"
),
{"schema": "a76"},
)
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
# Foreign key and line number
doda_id: Mapped[int] = mapped_column(Integer, nullable=False)
american_pedimento_line: Mapped[int] = mapped_column(
Integer, nullable=False)
# American pedimento information
american_pedimento_type: Mapped[Optional[str]] = mapped_column(String(2))
american_pedimento_value: Mapped[Optional[str]] = mapped_column(String(20))
# Relationships
doda: Mapped["Doda"] = relationship(
"Doda", back_populates="american_pedimentos")
def __repr__(self):
return f"<DodaAmericanPedimento(id={self.id}, doda_id={self.doda_id}, american_pedimento_line={self.american_pedimento_line})>"
class DodaPedimento(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla DodaPedimento - Pedimentos en DODA
"""
__tablename__ = "doda_pedimentos" # gDoda_Pedimentos
__table_args__ = (
PrimaryKeyConstraint("id", name="doda_pedimentos_pkey"),
ForeignKeyConstraint(
["doda_id"], ["a76.doda.id"], name="fk_doda_pedimentos_doda"
),
{"schema": "a76"},
)
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
# Foreign key and line number
doda_id: Mapped[int] = mapped_column(Integer, nullable=False)
pedimento_line: Mapped[int] = mapped_column(Integer, nullable=False)
# Authorization and document info
authorization_patent: Mapped[Optional[str]] = mapped_column(String(10))
document: Mapped[Optional[str]] = mapped_column(String(50))
shipment: Mapped[Optional[str]] = mapped_column(String(11))
# Commercial information
cove: Mapped[Optional[str]] = mapped_column(String(50))
umc: Mapped[Optional[str]] = mapped_column(String(20))
# Financial information
effective_amount_usd: Mapped[Optional[Numeric]
] = mapped_column(Numeric(15, 2))
difference_amount_usd: Mapped[Optional[Numeric]
] = mapped_column(Numeric(15, 2))
# Additional identifiers
dta_niu: Mapped[Optional[str]] = mapped_column(String(20))
article_7: Mapped[Optional[bool]] = mapped_column(Boolean)
pedimento_id: Mapped[Optional[int]] = mapped_column(Integer)
invoice_line: Mapped[Optional[int]] = mapped_column(Integer)
part_ii_line: Mapped[Optional[int]] = mapped_column(Integer)
# Type and validation
pedimento_type: Mapped[Optional[str]] = mapped_column(String(20))
zero_packaging_validation: Mapped[Optional[bool]] = mapped_column(Boolean)
# Relationships
doda: Mapped["Doda"] = relationship(
"Doda", back_populates="pedimentos_detail")
def __repr__(self):
return f"<DodaPedimento(id={self.id}, doda_id={self.doda_id}, pedimento_line={self.pedimento_line})>"

View File

@@ -0,0 +1,199 @@
"""
Rutas para gestión de DODA (Documentos de Operación de Aduana)
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import (
DodaCreateDTO,
DodaResponseDTO,
DodaUpdateDTO,
DodaDetailResponseDTO,
DodaContainerCreateDTO,
DodaContainerResponseDTO,
DodaContainerUpdateDTO,
DodaAmericanPedimentoCreateDTO,
DodaAmericanPedimentoResponseDTO,
DodaAmericanPedimentoUpdateDTO,
DodaPedimentoCreateDTO,
DodaPedimentoResponseDTO,
DodaPedimentoUpdateDTO,
)
from .models import Doda
from .service import DodaService
from core.security import get_current_user, validate_access_to_resource
# Create CRUD router
crud_router = TenantCRUDRoutes(
service=DodaService,
create_schema=DodaCreateDTO,
update_schema=DodaUpdateDTO,
response_schema=DodaResponseDTO,
prefix="/doda",
tags=["doda"],
resource_name="DODA",
id_name="doda_id",
enable_list=True,
enable_filters=True,
).router
router = crud_router
# ============ CUSTOM ENDPOINTS ============
@router.get(
"/{doda_id}/detail",
response_model=DodaDetailResponseDTO,
summary="Get DODA by ID with all details",
)
async def get_doda_detail(
doda_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
doda = DodaService.get_by_id(db, doda_id, tenant_id, company_id)
if not doda:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="DODA not found",
)
return DodaDetailResponseDTO.model_validate(doda)
# ============ CONTAINERS ENDPOINTS ============
@router.get(
"/{doda_id}/containers",
response_model=List[DodaContainerResponseDTO],
summary="Get containers for DODA",
)
async def get_doda_containers(
doda_id: int,
db: Session = Depends(get_core_db),
):
"""Get all containers for a specific DODA"""
containers = DodaService.get_containers(db, doda_id)
return [DodaContainerResponseDTO.model_validate(c) for c in containers]
@router.post(
"/{doda_id}/containers",
response_model=DodaContainerResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Add container to DODA",
)
async def add_container(
doda_id: int,
container_data: DodaContainerCreateDTO,
db: Session = Depends(get_core_db),
):
"""Add a new container to a DODA"""
container = DodaService.add_container(db, doda_id, container_data)
if not container:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="DODA not found",
)
return DodaContainerResponseDTO.model_validate(container)
@router.put(
"/{doda_id}/containers/{container_line}",
response_model=DodaContainerResponseDTO,
summary="Update container",
)
async def update_container(
doda_id: int,
container_line: int,
container_data: DodaContainerUpdateDTO,
db: Session = Depends(get_core_db),
):
"""Update a container"""
container = DodaService.update_container(
db, doda_id, container_line, container_data
)
if not container:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Container not found",
)
return DodaContainerResponseDTO.model_validate(container)
# ============ AMERICAN PEDIMENTOS ENDPOINTS ============
@router.get(
"/{doda_id}/american-pedimentos",
response_model=List[DodaAmericanPedimentoResponseDTO],
summary="Get American pedimentos for DODA",
)
async def get_american_pedimentos(
doda_id: int,
db: Session = Depends(get_core_db),
):
"""Get all American pedimentos for a specific DODA"""
pedimentos = DodaService.get_american_pedimentos(db, doda_id)
return [DodaAmericanPedimentoResponseDTO.model_validate(p) for p in pedimentos]
@router.post(
"/{doda_id}/american-pedimentos",
response_model=DodaAmericanPedimentoResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Add American pedimento to DODA",
)
async def add_american_pedimento(
doda_id: int,
pedimento_data: DodaAmericanPedimentoCreateDTO,
db: Session = Depends(get_core_db),
):
"""Add a new American pedimento to a DODA"""
pedimento = DodaService.add_american_pedimento(db, doda_id, pedimento_data)
if not pedimento:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="DODA not found",
)
return DodaAmericanPedimentoResponseDTO.model_validate(pedimento)
# ============ PEDIMENTOS ENDPOINTS ============
@router.get(
"/{doda_id}/pedimentos",
response_model=List[DodaPedimentoResponseDTO],
summary="Get pedimentos for DODA",
)
async def get_doda_pedimentos(
doda_id: int,
db: Session = Depends(get_core_db),
):
"""Get all pedimentos for a specific DODA"""
pedimentos = DodaService.get_pedimentos(db, doda_id)
return [DodaPedimentoResponseDTO.model_validate(p) for p in pedimentos]
@router.post(
"/{doda_id}/pedimentos",
response_model=DodaPedimentoResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Add pedimento to DODA",
)
async def add_pedimento(
doda_id: int,
pedimento_data: DodaPedimentoCreateDTO,
db: Session = Depends(get_core_db),
):
"""Add a new pedimento to a DODA"""
pedimento = DodaService.add_pedimento(db, doda_id, pedimento_data)
if not pedimento:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="DODA not found",
)
return DodaPedimentoResponseDTO.model_validate(pedimento)

View File

@@ -0,0 +1,312 @@
"""
Capa de servicio para lógica de negocio de DODA
"""
import logging
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import (
DodaCreateDTO,
DodaResponseDTO,
DodaUpdateDTO,
DodaContainerCreateDTO,
DodaContainerUpdateDTO,
DodaAmericanPedimentoCreateDTO,
DodaAmericanPedimentoUpdateDTO,
DodaPedimentoCreateDTO,
DodaPedimentoUpdateDTO,
)
from .models import (
Doda,
DodaContainer,
DodaContainerSeal,
DodaAmericanPedimento,
DodaPedimento,
)
logger = logging.getLogger(__name__)
class DodaService:
"""Servicio para gestión de DODA"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Doda], int]:
"""Get all DODAs with pagination"""
query = db.query(Doda).filter(
Doda.tenant_id == tenant_id,
Doda.company_id == company_id
)
if filters:
if filters.get("integration_number"):
query = query.filter(
Doda.integration_number.ilike(
f"%{filters['integration_number']}%")
)
if filters.get("status"):
query = query.filter(
Doda.status.ilike(f"%{filters['status']}%"))
if filters.get("patent"):
query = query.filter(
Doda.patent.ilike(f"%{filters['patent']}%"))
total = query.count()
dodas = query.offset(skip).limit(limit).all()
return dodas, total
@staticmethod
def get_by_id(
db: Session, id: int, tenant_id: int, company_id: int
) -> Optional[Doda]:
"""Get DODA by ID"""
return db.query(Doda).filter(
Doda.id == id,
Doda.tenant_id == tenant_id,
Doda.company_id == company_id
).first()
@staticmethod
def create(
db: Session, doda_data: DodaCreateDTO, tenant_id: int, company_id: int
) -> Doda:
"""Create a new DODA"""
try:
db_doda = Doda(
**doda_data.model_dump(exclude_unset=True),
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_doda)
db.commit()
db.refresh(db_doda)
return db_doda
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating DODA: {str(e)}")
raise HTTPException(status_code=400, detail="Error creating DODA")
except Exception as e:
db.rollback()
logger.error(f"Error creating DODA: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating DODA")
@staticmethod
def update(
db: Session, id: int, tenant_id: int, doda_data: DodaUpdateDTO, company_id: int
) -> Optional[Doda]:
"""Update a DODA"""
try:
db_doda = DodaService.get_by_id(db, id, tenant_id, company_id)
if not db_doda:
return None
for key, value in doda_data.model_dump(exclude_unset=True).items():
setattr(db_doda, key, value)
db.commit()
db.refresh(db_doda)
return db_doda
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError updating DODA: {str(e)}")
raise HTTPException(status_code=400, detail="Error updating DODA")
except Exception as e:
db.rollback()
logger.error(f"Error updating DODA: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating DODA")
@staticmethod
def delete(
db: Session, id: int, tenant_id: int, company_id: int
) -> bool:
"""Delete a DODA"""
db_doda = DodaService.get_by_id(db, id, tenant_id, company_id)
if not db_doda:
return False
try:
db.delete(db_doda)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"Error de integridad al eliminar DODA {id}: {str(e)}")
raise HTTPException(
status_code=400,
detail="No se puede eliminar este DODA porque tiene registros relacionados. Primero debe eliminar o reasignar esos registros."
)
except Exception as e:
db.rollback()
logger.error(f"Error deleting DODA: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting DODA")
# ============ CONTAINERS ============
@staticmethod
def add_container(
db: Session, doda_id: int, container_data: DodaContainerCreateDTO
) -> Optional[DodaContainer]:
"""Add a container to a DODA"""
try:
doda = db.query(Doda).filter(Doda.id == doda_id).first()
if not doda:
return None
# Get max line number
max_line = (
db.query(DodaContainer)
.filter(DodaContainer.doda_id == doda_id)
.count()
)
db_container = DodaContainer(
doda_id=doda_id,
container_line=max_line + 1,
**{
k: v
for k, v in container_data.model_dump(exclude_unset=True).items()
if k != "seals_detail"
},
)
db.add(db_container)
db.commit()
db.refresh(db_container)
return db_container
except Exception as e:
db.rollback()
logger.error(f"Error adding container: {str(e)}")
raise HTTPException(
status_code=500, detail="Error adding container")
@staticmethod
def update_container(
db: Session,
doda_id: int,
container_line: int,
container_data: DodaContainerUpdateDTO,
) -> Optional[DodaContainer]:
"""Update a container"""
try:
db_container = (
db.query(DodaContainer)
.filter(
DodaContainer.doda_id == doda_id,
DodaContainer.container_line == container_line,
)
.first()
)
if not db_container:
return None
for key, value in container_data.model_dump(exclude_unset=True).items():
setattr(db_container, key, value)
db.commit()
db.refresh(db_container)
return db_container
except Exception as e:
db.rollback()
logger.error(f"Error updating container: {str(e)}")
raise HTTPException(
status_code=500, detail="Error updating container")
@staticmethod
def get_containers(db: Session, doda_id: int) -> List[DodaContainer]:
"""Get all containers for a DODA"""
return (
db.query(DodaContainer)
.filter(DodaContainer.doda_id == doda_id)
.all()
)
# ============ AMERICAN PEDIMENTOS ============
@staticmethod
def add_american_pedimento(
db: Session, doda_id: int, pedimento_data: DodaAmericanPedimentoCreateDTO
) -> Optional[DodaAmericanPedimento]:
"""Add an American pedimento to a DODA"""
try:
doda = db.query(Doda).filter(Doda.id == doda_id).first()
if not doda:
return None
max_line = (
db.query(DodaAmericanPedimento)
.filter(DodaAmericanPedimento.doda_id == doda_id)
.count()
)
db_pedimento = DodaAmericanPedimento(
doda_id=doda_id,
american_pedimento_line=max_line + 1,
**pedimento_data.model_dump(exclude_unset=True),
)
db.add(db_pedimento)
db.commit()
db.refresh(db_pedimento)
return db_pedimento
except Exception as e:
db.rollback()
logger.error(f"Error adding American pedimento: {str(e)}")
raise HTTPException(
status_code=500, detail="Error adding American pedimento"
)
@staticmethod
def get_american_pedimentos(db: Session, doda_id: int) -> List[DodaAmericanPedimento]:
"""Get all American pedimentos for a DODA"""
return (
db.query(DodaAmericanPedimento)
.filter(DodaAmericanPedimento.doda_id == doda_id)
.all()
)
# ============ PEDIMENTOS ============
@staticmethod
def add_pedimento(
db: Session, doda_id: int, pedimento_data: DodaPedimentoCreateDTO
) -> Optional[DodaPedimento]:
"""Add a pedimento to a DODA"""
try:
doda = db.query(Doda).filter(Doda.id == doda_id).first()
if not doda:
return None
max_line = (
db.query(DodaPedimento)
.filter(DodaPedimento.doda_id == doda_id)
.count()
)
db_pedimento = DodaPedimento(
doda_id=doda_id,
pedimento_line=max_line + 1,
**pedimento_data.model_dump(exclude_unset=True),
)
db.add(db_pedimento)
db.commit()
db.refresh(db_pedimento)
return db_pedimento
except Exception as e:
db.rollback()
logger.error(f"Error adding pedimento: {str(e)}")
raise HTTPException(
status_code=500, detail="Error adding pedimento")
@staticmethod
def get_pedimentos(db: Session, doda_id: int) -> List[DodaPedimento]:
"""Get all pedimentos for a DODA"""
return (
db.query(DodaPedimento).filter(
DodaPedimento.doda_id == doda_id).all()
)

View File

@@ -0,0 +1,3 @@
"""
Módulo de avisos electrónicos
"""

View File

@@ -0,0 +1,85 @@
"""
DTOs (Data Transfer Objects) para módulo de avisos electrónicos
"""
from typing import Optional
from pydantic import BaseModel, Field
class ElectronicNoticeCreateDTO(BaseModel):
"""DTO para crear un aviso electrónico"""
notice_number: Optional[str] = Field(
None, max_length=500, description="Notice number"
)
year: Optional[str] = Field(None, max_length=20, description="Year")
patent: Optional[str] = Field(None, max_length=4, description="Patent")
pedimento: Optional[str] = Field(
None, max_length=15, description="Pedimento")
file_sent: Optional[str] = Field(
None, max_length=1000, description="File sent")
file_response: Optional[str] = Field(
None, max_length=1000, description="File response"
)
status: Optional[str] = Field(None, max_length=100, description="Status")
invoice: Optional[str] = Field(None, max_length=50, description="Invoice")
validation_acknowledgment: Optional[str] = Field(
None, max_length=20, description="Validation acknowledgment"
)
fea: Optional[str] = Field(None, max_length=1000, description="FEA")
certificate_number: Optional[str] = Field(
None, max_length=50, description="Certificate number"
)
class Config:
from_attributes = True
class ElectronicNoticeUpdateDTO(BaseModel):
"""DTO para actualizar un aviso electrónico"""
notice_number: Optional[str] = Field(
None, max_length=500, description="Notice number"
)
year: Optional[str] = Field(None, max_length=20, description="Year")
patent: Optional[str] = Field(None, max_length=4, description="Patent")
pedimento: Optional[str] = Field(
None, max_length=15, description="Pedimento")
file_sent: Optional[str] = Field(
None, max_length=1000, description="File sent")
file_response: Optional[str] = Field(
None, max_length=1000, description="File response"
)
status: Optional[str] = Field(None, max_length=100, description="Status")
invoice: Optional[str] = Field(None, max_length=50, description="Invoice")
validation_acknowledgment: Optional[str] = Field(
None, max_length=20, description="Validation acknowledgment"
)
fea: Optional[str] = Field(None, max_length=1000, description="FEA")
certificate_number: Optional[str] = Field(
None, max_length=50, description="Certificate number"
)
class Config:
from_attributes = True
class ElectronicNoticeResponseDTO(BaseModel):
"""DTO para responder con datos de un aviso electrónico"""
id: int
notice_number: Optional[str] = None
year: Optional[str] = None
patent: Optional[str] = None
pedimento: Optional[str] = None
file_sent: Optional[str] = None
file_response: Optional[str] = None
status: Optional[str] = None
invoice: Optional[str] = None
validation_acknowledgment: Optional[str] = None
fea: Optional[str] = None
certificate_number: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,48 @@
"""
Modelos ORM para gestión de avisos electrónicos
"""
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column
class ElectronicNotice(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla ElectronicNotice - Avisos Electrónicos
"""
__tablename__ = "electronic_notices" # GAvisosElectronicos
__table_args__ = (
PrimaryKeyConstraint("id", name="electronic_notices_pkey"),
{"schema": "a76"},
)
# Primary key
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Notice identification
notice_number: Mapped[Optional[str]] = mapped_column(String(500))
year: Mapped[Optional[str]] = mapped_column(String(20))
patent: Mapped[Optional[str]] = mapped_column(String(4))
pedimento: Mapped[Optional[str]] = mapped_column(String(15))
# Files
file_sent: Mapped[Optional[str]] = mapped_column(String(1000))
file_response: Mapped[Optional[str]] = mapped_column(String(1000))
# Status and validation
status: Mapped[Optional[str]] = mapped_column(String(100))
invoice: Mapped[Optional[str]] = mapped_column(String(50))
validation_acknowledgment: Mapped[Optional[str]] = mapped_column(
String(20))
# Certificate information
fea: Mapped[Optional[str]] = mapped_column(String(1000))
certificate_number: Mapped[Optional[str]] = mapped_column(String(50))
def __repr__(self):
return f"<ElectronicNotice(id={self.id}, notice_number={self.notice_number}, status={self.status})>"

View File

@@ -0,0 +1,67 @@
"""
Rutas para gestión de avisos electrónicos
"""
from core.security import get_current_user, validate_access_to_resource
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import (
ElectronicNoticeCreateDTO,
ElectronicNoticeResponseDTO,
ElectronicNoticeUpdateDTO,
)
from .models import ElectronicNotice
from .service import ElectronicNoticeService
router = TenantCRUDRoutes(
service=ElectronicNoticeService,
create_schema=ElectronicNoticeCreateDTO,
update_schema=ElectronicNoticeUpdateDTO,
response_schema=ElectronicNoticeResponseDTO,
prefix="/electronic-notices",
tags=["electronic-notices"],
resource_name="Electronic Notice",
enable_list=True,
enable_filters=True,
).router
@router.get(
"/by-pedimento/{pedimento}",
response_model=List[ElectronicNoticeResponseDTO],
summary="Get notices by pedimento",
)
async def get_notices_by_pedimento(
pedimento: str,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get all electronic notices for a specific pedimento"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
notices = ElectronicNoticeService.get_by_pedimento(
db, pedimento, tenant_id, company_id)
return [ElectronicNoticeResponseDTO.model_validate(notice) for notice in notices]
@router.get(
"/by-status/{status}",
response_model=List[ElectronicNoticeResponseDTO],
summary="Get notices by status",
)
async def get_notices_by_status(
status: str,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get all electronic notices with a specific status"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
notices = ElectronicNoticeService.get_by_status(
db, status, tenant_id, company_id)
return [ElectronicNoticeResponseDTO.model_validate(notice) for notice in notices]

View File

@@ -0,0 +1,190 @@
"""
Capa de servicio para lógica de negocio de avisos electrónicos
"""
import logging
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import (
ElectronicNoticeCreateDTO,
ElectronicNoticeResponseDTO,
ElectronicNoticeUpdateDTO,
)
from .models import ElectronicNotice
logger = logging.getLogger(__name__)
class ElectronicNoticeService:
"""Servicio para gestión de avisos electrónicos"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[ElectronicNotice], int]:
"""Get all electronic notices with pagination"""
query = db.query(ElectronicNotice).filter(
ElectronicNotice.tenant_id == tenant_id,
ElectronicNotice.company_id == company_id
)
# Apply filters if provided
if filters:
if filters.get("notice_number"):
query = query.filter(
ElectronicNotice.notice_number.ilike(
f"%{filters['notice_number']}%"
)
)
if filters.get("status"):
query = query.filter(
ElectronicNotice.status.ilike(f"%{filters['status']}%")
)
if filters.get("pedimento"):
query = query.filter(
ElectronicNotice.pedimento.ilike(
f"%{filters['pedimento']}%")
)
total = query.count()
notices = query.offset(skip).limit(limit).all()
return notices, total
@staticmethod
def get_by_id(
db: Session, id: int, tenant_id: int, company_id: int
) -> Optional[ElectronicNotice]:
"""Get electronic notice by ID"""
return db.query(ElectronicNotice).filter(
ElectronicNotice.id == id,
ElectronicNotice.tenant_id == tenant_id,
ElectronicNotice.company_id == company_id
).first()
@staticmethod
def create(
db: Session, notice_data: ElectronicNoticeCreateDTO, tenant_id: int, company_id: int
) -> ElectronicNotice:
"""Create a new electronic notice"""
try:
db_notice = ElectronicNotice(
**notice_data.model_dump(exclude_unset=True),
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_notice)
db.commit()
db.refresh(db_notice)
return db_notice
except IntegrityError as e:
db.rollback()
logger.error(
f"IntegrityError creating electronic notice: {str(e)}")
raise HTTPException(
status_code=400,
detail="Electronic notice already exists",
)
except Exception as e:
db.rollback()
logger.error(f"Error creating electronic notice: {str(e)}")
raise HTTPException(
status_code=500, detail="Error creating electronic notice"
)
@staticmethod
def update(
db: Session, id: int, tenant_id: int, notice_data: ElectronicNoticeUpdateDTO, company_id: int
) -> Optional[ElectronicNotice]:
"""Update an electronic notice"""
try:
db_notice = ElectronicNoticeService.get_by_id(
db, id, tenant_id, company_id)
if not db_notice:
return None
for key, value in notice_data.model_dump(exclude_unset=True).items():
setattr(db_notice, key, value)
db.commit()
db.refresh(db_notice)
return db_notice
except IntegrityError as e:
db.rollback()
logger.error(
f"IntegrityError updating electronic notice: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error updating electronic notice",
)
except Exception as e:
db.rollback()
logger.error(f"Error updating electronic notice: {str(e)}")
raise HTTPException(
status_code=500, detail="Error updating electronic notice"
)
@staticmethod
def delete(
db: Session, id: int, tenant_id: int, company_id: int
) -> bool:
"""Delete an electronic notice"""
try:
db_notice = ElectronicNoticeService.get_by_id(
db, id, tenant_id, company_id)
if not db_notice:
return False
db.delete(db_notice)
db.commit()
return True
except Exception as e:
db.rollback()
logger.error(f"Error deleting electronic notice: {str(e)}")
raise HTTPException(
status_code=500, detail="Error deleting electronic notice"
)
@staticmethod
def get_by_pedimento(
db: Session, pedimento: str, tenant_id: int, company_id: int
) -> List[ElectronicNotice]:
"""Get all electronic notices by pedimento"""
return (
db.query(ElectronicNotice)
.filter(
ElectronicNotice.pedimento == pedimento,
ElectronicNotice.tenant_id == tenant_id,
ElectronicNotice.company_id == company_id
)
.all()
)
@staticmethod
def get_by_status(
db: Session, status: str, tenant_id: int, company_id: int
) -> List[ElectronicNotice]:
"""Get all electronic notices by status"""
return (
db.query(ElectronicNotice)
.filter(
ElectronicNotice.status == status,
ElectronicNotice.tenant_id == tenant_id,
ElectronicNotice.company_id == company_id
)
.all()
)

View File

@@ -0,0 +1,56 @@
from typing import Optional, List
from pydantic import BaseModel, Field, ConfigDict
# Equivalency Item DTOs
class EquivalencyItemBase(BaseModel):
original_field: str = Field(..., max_length=100,
description="Original Field (Unit of Measure)")
external_field: str = Field(..., max_length=100,
description="External Field")
class EquivalencyItemCreate(EquivalencyItemBase):
pass
class EquivalencyItemUpdate(BaseModel):
original_field: Optional[str] = Field(None, max_length=100)
external_field: Optional[str] = Field(None, max_length=100)
class EquivalencyItemResponse(EquivalencyItemBase):
id: int
equivalency_id: int
model_config = ConfigDict(from_attributes=True)
# Equivalency DTOs
class EquivalencyBase(BaseModel):
fraccion_mex: str = Field(..., max_length=10, description="Fraccion MX (Identifier)")
fraccion_us: str = Field(..., max_length=100, description="Fraccion US (External Field)")
description: Optional[str] = Field(
None, max_length=200, description="Description")
class EquivalencyCreate(EquivalencyBase):
pass
class EquivalencyUpdate(BaseModel):
fraccion_mex: Optional[str] = Field(None, max_length=10)
fraccion_us: Optional[str] = Field(None, max_length=100)
description: Optional[str] = Field(None, max_length=200)
class EquivalencyResponse(EquivalencyBase):
id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,55 @@
from typing import Optional, List
from sqlalchemy import Integer, String, ForeignKey, UniqueConstraint, ForeignKeyConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
class Equivalency(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "equivalencies"
__table_args__ = (
UniqueConstraint("identifier", "tenant_id", "company_id",
name="uq_equivalency_identifier"),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
identifier: Mapped[str] = mapped_column(String(10), nullable=False)
description: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True)
items: Mapped[List["EquivalencyItem"]] = relationship(
back_populates="equivalency", cascade="all, delete-orphan")
class EquivalencyItem(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "equivalency_items"
__table_args__ = (
UniqueConstraint("equivalency_id", "original_field",
"external_field", "tenant_id", "company_id", name="uq_equivalency_item_fields"),
ForeignKeyConstraint(
["original_field", "tenant_id", "company_id"],
["a76.units_of_measure.code", "a76.units_of_measure.tenant_id",
"a76.units_of_measure.company_id"]
),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
equivalency_id: Mapped[int] = mapped_column(
Integer, ForeignKey("a76.equivalencies.id"), nullable=False)
original_field: Mapped[str] = mapped_column(
String(100), nullable=False) # Relation to Unit of Measure
external_field: Mapped[str] = mapped_column(String(100), nullable=False)
equivalency: Mapped["Equivalency"] = relationship(back_populates="items")
unit_of_measure: Mapped["UnitOfMeasure"] = relationship()

View File

@@ -0,0 +1,99 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Dict, Any
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from . import service
from .models import Equivalency, EquivalencyItem
from .dto import (
EquivalencyCreate, EquivalencyResponse, EquivalencyUpdate,
EquivalencyItemCreate, EquivalencyItemResponse, EquivalencyItemUpdate
)
from .service import EquivalencyService, EquivalencyItemService
router = APIRouter(prefix="/equivalencies",
tags=["a76.general_catalogs.equivalencies"])
# Equivalency CRUD
equivalency_crud = TenantCRUDRoutes(
service=EquivalencyService,
create_schema=EquivalencyCreate,
update_schema=EquivalencyUpdate,
response_schema=EquivalencyResponse,
prefix="",
tags=["Equivalencies"],
resource_name="Equivalency",
enable_list=True,
)
# Equivalency Item CRUD
item_crud = TenantCRUDRoutes(
service=EquivalencyItemService,
create_schema=EquivalencyItemCreate,
update_schema=EquivalencyItemUpdate,
response_schema=EquivalencyItemResponse,
prefix="/items",
tags=["Equivalency Items"],
resource_name="EquivalencyItem",
enable_list=True,
)
# Custom endpoint for creating items nested under equivalency
@equivalency_crud.router.post(
"/{equivalency_id}/items",
response_model=EquivalencyItemResponse,
status_code=status.HTTP_201_CREATED,
summary="Create equivalency item",
)
async def create_equivalency_item(
equivalency_id: int,
data: EquivalencyItemCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Verify parent exists
parent = EquivalencyService.get_by_id(
db, equivalency_id, tenant_id, company_id)
if not parent:
raise HTTPException(status_code=404, detail="Equivalency not found")
# Create item
# We need to manually handle the creation because the DTO doesn't have equivalency_id
# and the service.create expects data to match the model or DTO.
# But service.create takes EquivalencyItemCreate which doesn't have equivalency_id.
# So we need to modify the data or handle it in service.
# Actually, I implemented EquivalencyItemService.create to take EquivalencyItemCreate.
# And it tries to create the model.
# But the model needs equivalency_id.
# So EquivalencyItemService.create will fail if I don't pass equivalency_id.
# I should update EquivalencyItemService.create to accept extra kwargs or handle this.
# Let's update the service call here to pass equivalency_id manually if I can't change the service signature easily.
# But wait, I can just instantiate the model here or update the service.
# I'll update the service to handle it.
# But for now, let's assume I can pass it in the data if I convert it to dict.
item_data = data.model_dump()
item_data['equivalency_id'] = equivalency_id
# I need to call a method that accepts this.
# EquivalencyItemService.create takes EquivalencyItemCreate.
# I should probably add a specific method for this or update create.
# Let's use a direct DB call here or add a method to service.
# Adding a method to service is cleaner.
return EquivalencyItemService.create_nested(db, equivalency_id, data, tenant_id, company_id)
router.include_router(equivalency_crud.router)
router.include_router(item_crud.router)

View File

@@ -0,0 +1,306 @@
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from .models import Equivalency, EquivalencyItem
from .dto import EquivalencyCreate, EquivalencyUpdate, EquivalencyItemCreate, EquivalencyItemUpdate
class EquivalencyService:
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None
) -> Tuple[List[Equivalency], int]:
query = db.query(Equivalency).filter(
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id
).options(joinedload(Equivalency.items))
if filters:
if 'fraccion_mex' in filters:
query = query.filter(Equivalency.identifier.ilike(f"%{filters['fraccion_mex']}%"))
if 'description' in filters:
query = query.filter(Equivalency.description.ilike(f"%{filters['description']}%"))
total = query.count()
items = query.offset(skip).limit(limit).all()
# Map internal fields to DTO fields
for item in items:
item.fraccion_mex = item.identifier
# Try to find the first item to get fraccion_us
if item.items:
item.fraccion_us = item.items[0].external_field
else:
item.fraccion_us = ""
return items, total
@staticmethod
def get_by_id(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> Optional[Equivalency]:
item = db.query(Equivalency).filter(
Equivalency.id == id,
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id
).options(joinedload(Equivalency.items)).first()
if item:
item.fraccion_mex = item.identifier
if item.items:
item.fraccion_us = item.items[0].external_field
else:
item.fraccion_us = ""
return item
@staticmethod
def create(
db: Session,
data: EquivalencyCreate,
tenant_id: int,
company_id: int
) -> Equivalency:
try:
# Create Parent
db_obj = Equivalency(
identifier=data.fraccion_mex,
description=data.description,
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.flush() # Flush to get ID
# Create Child Item (mapping fraccion_mex -> original_field, fraccion_us -> external_field)
item = EquivalencyItem(
equivalency_id=db_obj.id,
original_field=data.fraccion_mex, # Must exist in units_of_measure
external_field=data.fraccion_us,
tenant_id=tenant_id,
company_id=company_id
)
db.add(item)
db.commit()
db.refresh(db_obj)
# Map for response
db_obj.fraccion_mex = db_obj.identifier
db_obj.fraccion_us = item.external_field
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, 'orig') else str(e)
print(f"IntegrityError in create: {error_msg}")
if "units_of_measure" in error_msg:
raise HTTPException(
status_code=400,
detail=f"La Fracción MX '{data.fraccion_mex}' no es válida. Debe existir en el catálogo de Unidades de Medida."
)
if "uq_equivalency_identifier" in error_msg:
raise HTTPException(
status_code=400,
detail=f"Ya existe una equivalencia para la Fracción MX '{data.fraccion_mex}'."
)
raise HTTPException(status_code=400, detail=f"Error al guardar: {error_msg}")
except Exception as e:
db.rollback()
print(f"Error in create: {str(e)}")
raise e
@staticmethod
def update(
db: Session,
id: int,
tenant_id: int,
data: EquivalencyUpdate,
company_id: int
) -> Optional[Equivalency]:
db_obj = EquivalencyService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
try:
if data.fraccion_mex:
db_obj.identifier = data.fraccion_mex
if data.description:
db_obj.description = data.description
# Update Item
item = None
if db_obj.items:
item = db_obj.items[0]
if item:
if data.fraccion_us:
item.external_field = data.fraccion_us
if data.fraccion_mex:
item.original_field = data.fraccion_mex
else:
# Create if missing
if data.fraccion_us or data.fraccion_mex:
item = EquivalencyItem(
equivalency_id=db_obj.id,
original_field=data.fraccion_mex or db_obj.identifier,
external_field=data.fraccion_us or "",
tenant_id=tenant_id,
company_id=company_id
)
db.add(item)
db.commit()
db.refresh(db_obj)
# Map for response
db_obj.fraccion_mex = db_obj.identifier
if db_obj.items:
db_obj.fraccion_us = db_obj.items[0].external_field
else:
db_obj.fraccion_us = ""
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, 'orig') else str(e)
print(f"IntegrityError in update: {error_msg}")
if "units_of_measure" in error_msg:
raise HTTPException(
status_code=400,
detail=f"La Fracción MX '{data.fraccion_mex or db_obj.identifier}' no es válida. Debe existir en el catálogo de Unidades de Medida."
)
raise HTTPException(status_code=400, detail=f"Error al actualizar: {error_msg}")
except Exception as e:
db.rollback()
print(f"Error in update: {str(e)}")
raise e
@staticmethod
def delete(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> bool:
db_obj = EquivalencyService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True
class EquivalencyItemService:
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None
) -> Tuple[List[EquivalencyItem], int]:
query = db.query(EquivalencyItem).filter(
EquivalencyItem.tenant_id == tenant_id,
EquivalencyItem.company_id == company_id
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> Optional[EquivalencyItem]:
return db.query(EquivalencyItem).filter(
EquivalencyItem.id == id,
EquivalencyItem.tenant_id == tenant_id,
EquivalencyItem.company_id == company_id
).first()
@staticmethod
def create(
db: Session,
data: EquivalencyItemCreate,
tenant_id: int,
company_id: int
) -> EquivalencyItem:
db_obj = EquivalencyItem(
**data.model_dump(),
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def create_nested(
db: Session,
equivalency_id: int,
data: EquivalencyItemCreate,
tenant_id: int,
company_id: int
) -> EquivalencyItem:
db_obj = EquivalencyItem(
equivalency_id=equivalency_id,
original_field=data.original_field,
external_field=data.external_field,
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def update(
db: Session,
id: int,
tenant_id: int,
data: EquivalencyItemUpdate,
company_id: int
) -> Optional[EquivalencyItem]:
db_obj = EquivalencyItemService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
for key, value in data.model_dump(exclude_unset=True).items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def delete(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> bool:
db_obj = EquivalencyItemService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True

View File

@@ -0,0 +1,3 @@
"""
Módulo de catálogos de errores
"""

View File

@@ -0,0 +1,105 @@
"""
DTOs (Data Transfer Objects) para módulo de catálogos de errores
"""
from typing import Optional, List
from pydantic import BaseModel, Field
# ============ ERROR CLASSIFICATION DTOS ============
class ErrorClassificationCreateDTO(BaseModel):
"""DTO para crear una clasificación de error"""
code: str = Field(..., max_length=100, description="Classification code")
level: Optional[str] = Field(None, max_length=3, description="Level")
class Config:
from_attributes = True
class ErrorClassificationUpdateDTO(BaseModel):
"""DTO para actualizar una clasificación de error"""
level: Optional[str] = Field(None, max_length=3, description="Level")
class Config:
from_attributes = True
class ErrorClassificationResponseDTO(BaseModel):
"""DTO para responder con datos de una clasificación de error"""
id: int
code: str
level: Optional[str] = None
class Config:
from_attributes = True
class ErrorClassificationDetailResponseDTO(BaseModel):
"""DTO detallado para responder con datos de una clasificación y sus errores"""
id: int
code: str
level: Optional[str] = None
errors: List["ErrorCatalogResponseDTO"] = []
class Config:
from_attributes = True
# ============ ERROR CATALOG DTOS ============
class ErrorCatalogCreateDTO(BaseModel):
"""DTO para crear un error en el catálogo"""
code: str = Field(..., max_length=15, description="Error code")
description: Optional[str] = Field(
None, max_length=255, description="Error description"
)
classification_id: Optional[int] = Field(
None, description="Classification ID"
)
class Config:
from_attributes = True
class ErrorCatalogUpdateDTO(BaseModel):
"""DTO para actualizar un error en el catálogo"""
description: Optional[str] = Field(
None, max_length=255, description="Error description"
)
classification_id: Optional[int] = Field(
None, description="Classification ID"
)
class Config:
from_attributes = True
class ErrorCatalogResponseDTO(BaseModel):
"""DTO para responder con datos de un error en el catálogo"""
id: int
code: str
description: Optional[str] = None
classification_id: Optional[int] = None
class Config:
from_attributes = True
class ErrorCatalogDetailResponseDTO(BaseModel):
"""DTO detallado para responder con datos de un error y su clasificación"""
id: int
code: str
description: Optional[str] = None
classification_id: Optional[int] = None
classification: Optional[ErrorClassificationResponseDTO] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,82 @@
"""
Modelos ORM para gestión de catálogos de errores
"""
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
class ErrorClassification(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla ErrorClassification - Clasificación de Errores
"""
__tablename__ = "error_classifications" # GCatErroresClas
__table_args__ = (
PrimaryKeyConstraint("id", name="error_classifications_pkey"),
UniqueConstraint("code", name="error_classifications_code_unique"),
{"schema": "a76"},
)
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True
)
# Classification code (unique)
code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
# Classification information
level: Mapped[Optional[str]] = mapped_column(String(3))
# Relationships
errors: Mapped[list["ErrorCatalog"]] = relationship(
"ErrorCatalog", back_populates="classification", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<ErrorClassification(id={self.id}, code={self.code}, level={self.level})>"
class ErrorCatalog(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla ErrorCatalog - Catálogo de Errores
"""
__tablename__ = "error_catalogs" # GCatErrores
__table_args__ = (
PrimaryKeyConstraint("id", name="error_catalogs_pkey"),
UniqueConstraint("code", name="error_catalogs_code_unique"),
ForeignKeyConstraint(
["classification_id"],
["a76.error_classifications.id"],
name="fk_error_catalogs_classification",
),
{"schema": "a76"},
)
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True
)
# Error code (unique)
code: Mapped[str] = mapped_column(String(15), nullable=False, unique=True)
# Error information
description: Mapped[Optional[str]] = mapped_column(String(255))
# Foreign key to classification
classification_id: Mapped[Optional[int]] = mapped_column(Integer)
# Relationships
classification: Mapped[Optional["ErrorClassification"]] = relationship(
"ErrorClassification", back_populates="errors"
)
def __repr__(self):
return f"<ErrorCatalog(id={self.id}, code={self.code}, description={self.description}, classification_id={self.classification_id})>"

View File

@@ -0,0 +1,185 @@
"""
Rutas para gestión de catálogos de errores
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import (
ErrorClassificationCreateDTO,
ErrorClassificationResponseDTO,
ErrorClassificationUpdateDTO,
ErrorClassificationDetailResponseDTO,
ErrorCatalogCreateDTO,
ErrorCatalogResponseDTO,
ErrorCatalogUpdateDTO,
ErrorCatalogDetailResponseDTO,
)
from .models import ErrorClassification, ErrorCatalog
from .service import ErrorClassificationService, ErrorCatalogService
router = APIRouter(prefix="/error-catalogs", tags=["error-catalogs"])
# ============ ERROR CLASSIFICATIONS ENDPOINTS ============
classification_crud = TenantCRUDRoutes(
service=ErrorClassificationService,
create_schema=ErrorClassificationCreateDTO,
update_schema=ErrorClassificationUpdateDTO,
response_schema=ErrorClassificationResponseDTO,
prefix="/classifications",
tags=["error-classifications"],
resource_name="ErrorClassification",
enable_list=True,
)
# Add custom endpoints for classifications
@classification_crud.router.get(
"/code/{code}",
response_model=ErrorClassificationDetailResponseDTO,
summary="Get error classification by code with errors",
)
async def get_classification_by_code(
code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get an error classification by its code with all related errors"""
classification = ErrorClassificationService.get_by_code(
db,
code,
tenant_id=current_user["tenant_id"],
company_id=current_user["company_id"]
)
if not classification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Classification not found",
)
return ErrorClassificationDetailResponseDTO.model_validate(classification)
# Override get_by_id to return detail DTO
@classification_crud.router.get(
"/{id}",
response_model=ErrorClassificationDetailResponseDTO,
summary="Get error classification by ID with errors",
)
async def get_classification(
id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get an error classification by its ID with all related errors"""
classification = ErrorClassificationService.get_by_id(
db,
id,
tenant_id=current_user["tenant_id"],
company_id=current_user["company_id"]
)
if not classification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Classification not found",
)
return ErrorClassificationDetailResponseDTO.model_validate(classification)
router.include_router(classification_crud.router)
# ============ ERROR CATALOG ENDPOINTS ============
catalog_crud = TenantCRUDRoutes(
service=ErrorCatalogService,
create_schema=ErrorCatalogCreateDTO,
update_schema=ErrorCatalogUpdateDTO,
response_schema=ErrorCatalogResponseDTO,
prefix="",
tags=["error-catalogs"],
resource_name="ErrorCatalog",
enable_list=True,
)
# Add custom endpoints for catalogs
@catalog_crud.router.get(
"/code/{code}",
response_model=ErrorCatalogDetailResponseDTO,
summary="Get error by code",
)
async def get_error_by_code(
code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get an error by its code with classification details"""
error = ErrorCatalogService.get_by_code(
db,
code,
tenant_id=current_user["tenant_id"],
company_id=current_user["company_id"]
)
if not error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Error not found",
)
return ErrorCatalogDetailResponseDTO.model_validate(error)
@catalog_crud.router.get(
"/classification/{classification_id}",
response_model=List[ErrorCatalogResponseDTO],
summary="Get errors by classification",
)
async def get_errors_by_classification(
classification_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get all errors for a specific classification"""
errors = ErrorCatalogService.get_by_classification(
db,
classification_id,
tenant_id=current_user["tenant_id"],
company_id=current_user["company_id"]
)
return [ErrorCatalogResponseDTO.model_validate(error) for error in errors]
# Override get_by_id to return detail DTO
@catalog_crud.router.get(
"/{id}",
response_model=ErrorCatalogDetailResponseDTO,
summary="Get error by ID",
)
async def get_error(
id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get an error by its ID with classification details"""
error = ErrorCatalogService.get_by_id(
db,
id,
tenant_id=current_user["tenant_id"],
company_id=current_user["company_id"]
)
if not error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Error not found",
)
return ErrorCatalogDetailResponseDTO.model_validate(error)
router.include_router(catalog_crud.router)

View File

@@ -0,0 +1,354 @@
"""
Capa de servicio para lógica de negocio de catálogos de errores
"""
import logging
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import (
ErrorClassificationCreateDTO,
ErrorClassificationResponseDTO,
ErrorClassificationUpdateDTO,
ErrorCatalogCreateDTO,
ErrorCatalogResponseDTO,
ErrorCatalogUpdateDTO,
)
from .models import ErrorClassification, ErrorCatalog
logger = logging.getLogger(__name__)
class ErrorClassificationService:
"""Servicio para gestión de clasificaciones de errores"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[ErrorClassification], int]:
"""Get all error classifications with pagination"""
query = db.query(ErrorClassification).filter(
ErrorClassification.tenant_id == tenant_id,
ErrorClassification.company_id == company_id
)
if filters:
if filters.get("code"):
query = query.filter(
ErrorClassification.code.ilike(f"%{filters['code']}%")
)
if filters.get("level"):
query = query.filter(
ErrorClassification.level.ilike(f"%{filters['level']}%")
)
total = query.count()
classifications = query.offset(skip).limit(limit).all()
return classifications, total
@staticmethod
def get_by_code(
db: Session, code: str, tenant_id: int, company_id: int
) -> Optional[ErrorClassification]:
"""Get error classification by code"""
return (
db.query(ErrorClassification)
.filter(
ErrorClassification.code == code,
ErrorClassification.tenant_id == tenant_id,
ErrorClassification.company_id == company_id
)
.first()
)
@staticmethod
def get_by_id(
db: Session, id: int, tenant_id: int, company_id: int
) -> Optional[ErrorClassification]:
"""Get error classification by ID"""
return (
db.query(ErrorClassification)
.filter(
ErrorClassification.id == id,
ErrorClassification.tenant_id == tenant_id,
ErrorClassification.company_id == company_id
)
.first()
)
@staticmethod
def create(
db: Session, classification_data: ErrorClassificationCreateDTO, tenant_id: int, company_id: int
) -> ErrorClassification:
"""Create a new error classification"""
try:
db_classification = ErrorClassification(
**classification_data.model_dump(exclude_unset=True),
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_classification)
db.commit()
db.refresh(db_classification)
return db_classification
except IntegrityError as e:
db.rollback()
logger.error(
f"IntegrityError creating error classification: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error classification already exists",
)
except Exception as e:
db.rollback()
logger.error(f"Error creating error classification: {str(e)}")
raise HTTPException(
status_code=500, detail="Error creating error classification"
)
@staticmethod
def update(
db: Session, id: int, tenant_id: int, classification_data: ErrorClassificationUpdateDTO, company_id: int
) -> Optional[ErrorClassification]:
"""Update an error classification"""
try:
db_classification = ErrorClassificationService.get_by_id(
db, id, tenant_id, company_id)
if not db_classification:
return None
for key, value in classification_data.model_dump(exclude_unset=True).items():
setattr(db_classification, key, value)
db.commit()
db.refresh(db_classification)
return db_classification
except IntegrityError as e:
db.rollback()
logger.error(
f"IntegrityError updating error classification: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error updating error classification",
)
except Exception as e:
db.rollback()
logger.error(f"Error updating error classification: {str(e)}")
raise HTTPException(
status_code=500, detail="Error updating error classification"
)
@staticmethod
def delete(
db: Session, id: int, tenant_id: int, company_id: int
) -> bool:
"""Delete an error classification"""
try:
db_classification = ErrorClassificationService.get_by_id(
db, id, tenant_id, company_id)
if not db_classification:
return False
db.delete(db_classification)
db.commit()
return True
except Exception as e:
db.rollback()
logger.error(f"Error deleting error classification: {str(e)}")
raise HTTPException(
status_code=500, detail="Error deleting error classification"
)
class ErrorCatalogService:
"""Servicio para gestión de catálogos de errores"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[ErrorCatalog], int]:
"""Get all error catalogs with pagination"""
query = db.query(ErrorCatalog).filter(
ErrorCatalog.tenant_id == tenant_id,
ErrorCatalog.company_id == company_id
)
if filters:
if filters.get("code"):
query = query.filter(
ErrorCatalog.code.ilike(f"%{filters['code']}%"))
if filters.get("description"):
query = query.filter(
ErrorCatalog.description.ilike(
f"%{filters['description']}%")
)
if filters.get("classification_id"):
query = query.filter(
ErrorCatalog.classification_id == filters['classification_id']
)
total = query.count()
catalogs = query.offset(skip).limit(limit).all()
return catalogs, total
@staticmethod
def get_by_code(
db: Session, code: str, tenant_id: int, company_id: int
) -> Optional[ErrorCatalog]:
"""Get error catalog by code"""
return db.query(ErrorCatalog).filter(
ErrorCatalog.code == code,
ErrorCatalog.tenant_id == tenant_id,
ErrorCatalog.company_id == company_id
).first()
@staticmethod
def get_by_id(
db: Session, id: int, tenant_id: int, company_id: int
) -> Optional[ErrorCatalog]:
"""Get error catalog by ID"""
return db.query(ErrorCatalog).filter(
ErrorCatalog.id == id,
ErrorCatalog.tenant_id == tenant_id,
ErrorCatalog.company_id == company_id
).first()
@staticmethod
def get_by_classification(
db: Session, classification_id: int, tenant_id: int, company_id: int
) -> List[ErrorCatalog]:
"""Get all errors by classification"""
return (
db.query(ErrorCatalog)
.filter(
ErrorCatalog.classification_id == classification_id,
ErrorCatalog.tenant_id == tenant_id,
ErrorCatalog.company_id == company_id
)
.all()
)
@staticmethod
def create(
db: Session, error_data: ErrorCatalogCreateDTO, tenant_id: int, company_id: int
) -> ErrorCatalog:
"""Create a new error catalog"""
try:
# Validate classification exists if provided
if error_data.classification_id:
classification = (
db.query(ErrorClassification)
.filter(
ErrorClassification.id == error_data.classification_id,
ErrorClassification.tenant_id == tenant_id,
ErrorClassification.company_id == company_id
)
.first()
)
if not classification:
raise HTTPException(
status_code=400,
detail="Classification not found",
)
db_error = ErrorCatalog(
**error_data.model_dump(exclude_unset=True),
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_error)
db.commit()
db.refresh(db_error)
return db_error
except HTTPException:
raise
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating error catalog: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error already exists",
)
except Exception as e:
db.rollback()
logger.error(f"Error creating error catalog: {str(e)}")
raise HTTPException(
status_code=500, detail="Error creating error catalog"
)
@staticmethod
def update(
db: Session, id: int, tenant_id: int, error_data: ErrorCatalogUpdateDTO, company_id: int
) -> Optional[ErrorCatalog]:
"""Update an error catalog"""
try:
db_error = ErrorCatalogService.get_by_id(
db, id, tenant_id, company_id)
if not db_error:
return None
for key, value in error_data.model_dump(exclude_unset=True).items():
setattr(db_error, key, value)
db.commit()
db.refresh(db_error)
return db_error
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError updating error catalog: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error updating error catalog",
)
except Exception as e:
db.rollback()
logger.error(f"Error updating error catalog: {str(e)}")
raise HTTPException(
status_code=500, detail="Error updating error catalog"
)
@staticmethod
def delete(
db: Session, id: int, tenant_id: int, company_id: int
) -> bool:
"""Delete an error catalog"""
try:
db_error = ErrorCatalogService.get_by_id(
db, id, tenant_id, company_id)
if not db_error:
return False
db.delete(db_error)
db.commit()
return True
except Exception as e:
db.rollback()
logger.error(f"Error deleting error catalog: {str(e)}")
raise HTTPException(
status_code=500, detail="Error deleting error catalog"
)

View File

@@ -0,0 +1,31 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class ExchangeRateBaseDTO(BaseModel):
date: datetime = Field(..., description="Exchange rate date")
value: Optional[float] = Field(None, description="Exchange rate value")
local_currency: Optional[str] = Field(None, max_length=7, description="Local currency code")
foreign_currency: Optional[str] = Field(None, max_length=7, description="Foreign currency code")
class ExchangeRateCreateDTO(ExchangeRateBaseDTO):
"""Schema for creating an exchange rate"""
pass
class ExchangeRateUpdateDTO(ExchangeRateBaseDTO):
"""Schema for updating an exchange rate"""
date: Optional[datetime] = Field(None, description="Exchange rate date")
class ExchangeRateResponseDTO(ExchangeRateBaseDTO):
"""Schema for exchange rate response"""
id: int
company_id: int
tenant_id: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,38 @@
from datetime import datetime
from decimal import Decimal
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
DECIMAL,
DateTime,
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
class ExchangeRate(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "exchange_rate"
__table_args__ = (
PrimaryKeyConstraint("id", name="exchange_rate_pkey"),
UniqueConstraint(
"tenant_id", "company_id", "date", name="uq_exchange_rate_date_tenant"
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
date: Mapped[datetime] = mapped_column(DateTime)
value: Mapped[Optional[Decimal]] = mapped_column(DECIMAL(13, 6))
local_currency: Mapped[Optional[str]] = mapped_column(String(7))
foreign_currency: Mapped[Optional[str]] = mapped_column(String(7))

View File

@@ -0,0 +1,20 @@
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import ExchangeRateCreateDTO, ExchangeRateResponseDTO, ExchangeRateUpdateDTO
from .services import ExchangeRateService
# Create router using TenantCRUDRoutes factory
router = TenantCRUDRoutes(
service=ExchangeRateService,
create_schema=ExchangeRateCreateDTO,
update_schema=ExchangeRateUpdateDTO,
response_schema=ExchangeRateResponseDTO,
prefix="/exchange-rate",
tags=[],
resource_name="Exchange Rate",
id_name="id", # Using numeric ID
enable_list=True, # Enable GET /exchange-rate with pagination
enable_filters=False,
default_page_size=50,
max_page_size=100,
).router

View File

@@ -0,0 +1,114 @@
from typing import Optional, Tuple, List, Dict, Any
from sqlalchemy.orm import Session
from . import dto, models
class ExchangeRateService:
"""Service for ExchangeRate CRUD operations with tenant support"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[models.ExchangeRate], int]:
"""Get all exchange rates for a tenant/company with pagination"""
query = db.query(models.ExchangeRate).filter(
models.ExchangeRate.tenant_id == tenant_id,
models.ExchangeRate.company_id == company_id,
)
# Apply filters if provided
if filters:
if filters.get("date"):
query = query.filter(
models.ExchangeRate.date == filters["date"])
if filters.get("local_currency"):
query = query.filter(
models.ExchangeRate.local_currency == filters["local_currency"]
)
if filters.get("foreign_currency"):
query = query.filter(
models.ExchangeRate.foreign_currency == filters["foreign_currency"]
)
total = query.count()
exchange_rates = query.order_by(
models.ExchangeRate.date.desc()).offset(skip).limit(limit).all()
return exchange_rates, total
@staticmethod
def get_by_id(
db: Session, exchange_rate_id: int, tenant_id: int, company_id: int
) -> Optional[models.ExchangeRate]:
"""Get exchange rate by ID"""
return (
db.query(models.ExchangeRate)
.filter(
models.ExchangeRate.id == exchange_rate_id,
models.ExchangeRate.tenant_id == tenant_id,
models.ExchangeRate.company_id == company_id,
)
.first()
)
@staticmethod
def create(
db: Session,
exchange_rate_data: dto.ExchangeRateCreateDTO,
tenant_id: int,
company_id: int,
) -> models.ExchangeRate:
"""Create a new exchange rate"""
new_exchange_rate = models.ExchangeRate(
**exchange_rate_data.model_dump(), tenant_id=tenant_id, company_id=company_id
)
db.add(new_exchange_rate)
db.commit()
db.refresh(new_exchange_rate)
return new_exchange_rate
@staticmethod
def update(
db: Session,
exchange_rate_id: int,
exchange_rate_data: dto.ExchangeRateUpdateDTO,
tenant_id: int,
company_id: int,
) -> Optional[models.ExchangeRate]:
"""Update an exchange rate"""
exchange_rate = ExchangeRateService.get_by_id(
db, exchange_rate_id, tenant_id, company_id
)
if not exchange_rate:
return None
# Update fields
update_data = exchange_rate_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(exchange_rate, field, value)
db.commit()
db.refresh(exchange_rate)
return exchange_rate
@staticmethod
def delete(
db: Session, exchange_rate_id: int, tenant_id: int, company_id: int
) -> bool:
"""Delete an exchange rate"""
exchange_rate = ExchangeRateService.get_by_id(
db, exchange_rate_id, tenant_id, company_id
)
if not exchange_rate:
return False
db.delete(exchange_rate)
db.commit()
return True

View File

@@ -0,0 +1,36 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from .routes import router
app = FastAPI()
app.include_router(router)
client = TestClient(app)
@pytest.mark.usefixtures("client", "access_token")
def test_list_exchange_rates(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/exchange-rate/", headers=headers)
assert response.status_code == 200
assert "items" in response.json()
assert "page" in response.json()
assert "page_size" in response.json()
@pytest.mark.usefixtures("client", "access_token")
def test_get_exchange_rate_not_found(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/exchange-rate/invalid_id", headers=headers)
assert response.status_code == 404
def test_create_exchange_rate_forbidden():
response = client.post("/exchange-rate/", json={"rate": 1.23})
assert response.status_code in (403, 405, 404)
def test_update_exchange_rate_forbidden():
response = client.put("/exchange-rate/1", json={"rate": 1.45})
assert response.status_code in (403, 405, 404)

View File

@@ -0,0 +1,60 @@
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class IdentifierBase(BaseModel):
code: str = Field(..., max_length=2, description="Identifier Code (CLAVE)")
description: Optional[str] = Field(
None, max_length=1000, description="Description")
level: Optional[str] = Field(None, max_length=1, description="Level")
complement: Optional[str] = Field(
None, max_length=5000, description="Complement")
class IdentifierCreate(IdentifierBase):
pass
class IdentifierUpdate(BaseModel):
code: Optional[str] = Field(None, max_length=2)
description: Optional[str] = Field(None, max_length=1000)
level: Optional[str] = Field(None, max_length=1)
complement: Optional[str] = Field(None, max_length=5000)
class IdentifierResponse(IdentifierBase):
id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)
class IdentifierDetailBase(BaseModel):
invoice_consecutive: Optional[int] = Field(
None, description="Invoice Consecutive")
part_line: Optional[int] = Field(None, description="Part Line")
identifier_code: Optional[str] = Field(
None, max_length=2, description="Identifier Code")
module: Optional[str] = Field(None, max_length=20, description="Module")
complement1: Optional[str] = Field(
None, max_length=50, description="Complement 1")
complement2: Optional[str] = Field(
None, max_length=51, description="Complement 2")
complement3: Optional[str] = Field(
None, max_length=50, description="Complement 3")
class IdentifierDetailCreate(IdentifierDetailBase):
pass
class IdentifierDetailUpdate(BaseModel):
invoice_consecutive: Optional[int] = None
part_line: Optional[int] = None
identifier_code: Optional[str] = Field(None, max_length=2)
module: Optional[str] = Field(None, max_length=20)
complement1: Optional[str] = Field(None, max_length=50)
complement2: Optional[str] = Field(None, max_length=51)
complement3: Optional[str] = Field(None, max_length=50)
class IdentifierDetailResponse(IdentifierDetailBase):
id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,56 @@
from typing import Optional
from sqlalchemy import Integer, String, UniqueConstraint, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Identifier(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "identifiers"
__table_args__ = (
UniqueConstraint("code", name="uq_identifier_code"),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(2), nullable=False) # CLAVE
description: Mapped[Optional[str]] = mapped_column(
String(1000), nullable=True) # DESCRIPCION
level: Mapped[Optional[str]] = mapped_column(
String(1), nullable=True) # NIVEL
complement: Mapped[Optional[str]] = mapped_column(
String(5000), nullable=True) # COMPLEMENTO
details: Mapped[list["IdentifierDetail"]] = relationship(
back_populates="identifier")
class IdentifierDetail(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "identifier_details"
__table_args__ = (
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
invoice_consecutive: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True) # CONSECUTIVOFACTURA
part_line: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True) # LINEAPARTIDA
identifier_code: Mapped[Optional[str]] = mapped_column(
String(2), ForeignKey("a76.identifiers.code"), nullable=True) # ID
module: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True) # MODULO
complement1: Mapped[Optional[str]] = mapped_column(
String(50), nullable=True) # COMPLEMENTO1
complement2: Mapped[Optional[str]] = mapped_column(
String(51), nullable=True) # COMPLEMENTO2
complement3: Mapped[Optional[str]] = mapped_column(
String(50), nullable=True) # COMPLEMENTO3
identifier: Mapped["Identifier"] = relationship(back_populates="details")

View File

@@ -0,0 +1,36 @@
from fastapi import APIRouter
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import (
IdentifierCreate, IdentifierResponse, IdentifierUpdate,
IdentifierDetailCreate, IdentifierDetailResponse, IdentifierDetailUpdate
)
from .service import IdentifierService, IdentifierDetailService
router = APIRouter(prefix="/identifiers",
tags=["a76.general_catalogs.identifiers"])
# Identifier CRUD
identifier_crud = TenantCRUDRoutes(
create_schema=IdentifierCreate,
update_schema=IdentifierUpdate,
response_schema=IdentifierResponse,
service=IdentifierService,
prefix="",
tags=["Identifiers"],
resource_name="Identifier",
enable_list=True
)
# Identifier Detail CRUD
detail_crud = TenantCRUDRoutes(
create_schema=IdentifierDetailCreate,
update_schema=IdentifierDetailUpdate,
response_schema=IdentifierDetailResponse,
service=IdentifierDetailService,
prefix="/details",
tags=["Identifier Details"],
resource_name="Identifier Detail"
)
router.include_router(identifier_crud.router)
router.include_router(detail_crud.router)

View File

@@ -0,0 +1,196 @@
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import select
from .models import Identifier, IdentifierDetail
from .dto import IdentifierCreate, IdentifierUpdate, IdentifierDetailCreate, IdentifierDetailUpdate
class IdentifierService:
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None
) -> Tuple[List[Identifier], int]:
query = db.query(Identifier).filter(
Identifier.tenant_id == tenant_id,
Identifier.company_id == company_id
)
if filters:
# Add filters here if needed
pass
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> Optional[Identifier]:
return db.query(Identifier).filter(
Identifier.id == id,
Identifier.tenant_id == tenant_id,
Identifier.company_id == company_id
).first()
@staticmethod
def create(
db: Session,
data: IdentifierCreate,
tenant_id: int,
company_id: int
) -> Identifier:
# Exclude company_id from data as it is passed separately
data_dict = data.model_dump()
if 'company_id' in data_dict:
del data_dict['company_id']
db_obj = Identifier(
**data_dict,
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def update(
db: Session,
id: int,
tenant_id: int,
data: IdentifierUpdate,
company_id: int
) -> Optional[Identifier]:
db_obj = IdentifierService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def delete(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> bool:
db_obj = IdentifierService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True
class IdentifierDetailService:
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None
) -> Tuple[List[IdentifierDetail], int]:
query = db.query(IdentifierDetail).filter(
IdentifierDetail.tenant_id == tenant_id,
IdentifierDetail.company_id == company_id
)
if filters:
# Add filters here if needed
pass
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> Optional[IdentifierDetail]:
return db.query(IdentifierDetail).filter(
IdentifierDetail.id == id,
IdentifierDetail.tenant_id == tenant_id,
IdentifierDetail.company_id == company_id
).first()
@staticmethod
def create(
db: Session,
data: IdentifierDetailCreate,
tenant_id: int,
company_id: int
) -> IdentifierDetail:
# Exclude company_id from data as it is passed separately
data_dict = data.model_dump()
if 'company_id' in data_dict:
del data_dict['company_id']
db_obj = IdentifierDetail(
**data_dict,
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def update(
db: Session,
id: int,
data: IdentifierDetailUpdate,
tenant_id: int,
company_id: int
) -> Optional[IdentifierDetail]:
db_obj = IdentifierDetailService.get_by_id(
db, id, tenant_id, company_id)
if not db_obj:
return None
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def delete(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> bool:
db_obj = IdentifierDetailService.get_by_id(
db, id, tenant_id, company_id)
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True

View File

@@ -0,0 +1,25 @@
from typing import Optional
from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict
class INPCBase(BaseModel):
year: str = Field(..., max_length=4, description="Year (YYYY)")
month: str = Field(..., max_length=2, description="Month (MM)")
value: Optional[Decimal] = Field(None, description="INPC Value")
class INPCCreate(INPCBase):
pass
class INPCUpdate(BaseModel):
year: Optional[str] = Field(None, max_length=4)
month: Optional[str] = Field(None, max_length=2)
value: Optional[Decimal] = None
class INPCResponse(INPCBase):
id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,25 @@
from typing import Optional
from decimal import Decimal
from sqlalchemy import Integer, String, UniqueConstraint, Numeric
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class INPC(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "inpc"
__table_args__ = (
UniqueConstraint("year", "month", "tenant_id",
"company_id", name="uq_inpc_year_month"),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
year: Mapped[str] = mapped_column(String(4), nullable=False) # ANIO
month: Mapped[str] = mapped_column(String(2), nullable=False) # MES
value: Mapped[Optional[Decimal]] = mapped_column(
Numeric(19, 8), nullable=True) # VALOR

Some files were not shown because too many files have changed in this diff Show More